brep_kernel/offset/point.rs
1//! The ONE definition of "the offset of a surface at a parameter".
2//!
3//! Push-face, offset-shell, thicken and the blend march are all surface
4//! *offsetting* problems, and each of them had written the same three lines by
5//! hand: evaluate the carrier, take a unit normal, step `distance` along it.
6//! The audit records four independent copies; a fifth (`blend/edge/keep.rs`)
7//! and two more in offset-shell turned up while this module was written. They
8//! are collected here.
9//!
10//! # What this module is NOT
11//!
12//! It is **not** [`crate::offset_surface`]. That function is a Greville *fit*:
13//! it samples the offset at the source basis's Greville parameters and
14//! re-interpolates. Two independent reasons a fit is the wrong shared seam are
15//! recorded in-tree — handing a fitted surface to the blend march's Newton loop
16//! would inject the fit's approximation error into a residual whose bar is
17//! `1e-11·(1+scale)` (`blending/blend/stations.rs`), and
18//! `edit/direct_edit/face_offset_sphere.rs` records that `offset_surface` was
19//! tried for the full-sphere push and *failed*, because the Greville fit
20//! degenerates at the poles and the result stops re-recognising as a sphere.
21//! The shared foundation is the **pointwise evaluator**; `offset_surface` is one
22//! of its consumers (a fit of it), not the other way round.
23//!
24//! # The sign convention
25//!
26//! There is exactly ONE here: **`distance` is signed ALONG the returned
27//! normal.** `point = source + distance · normal`. The kernel's other
28//! convention — `offset_surface`'s "positive distance moves *opposite* the
29//! face's outward normal" — is expressed by its adapter negating on the way in,
30//! at one labelled place, instead of by four unlabelled hand negations
31//! (audit §4.1).
32//!
33//! # The orientation conventions
34//!
35//! Orientation is an explicit parameter, never read off a `FaceRecord`, because
36//! the callers genuinely disagree (audit §4.2) and both readings are
37//! load-bearing. [`OffsetNormal`] has exactly the three that exist in the tree,
38//! and each is defined to be *bit-identical* to the formula it replaces — see
39//! its variant docs.
40
41use crate::{AnalyticSurface, NurbsSurface, Vec3};
42
43/// Which unit normal the offset rides, and how it is recovered where the
44/// parametric normal degenerates.
45///
46/// Every variant names an existing in-tree formula and is bit-identical to it.
47/// They differ ONLY where the parameter leaves the domain or the cross product
48/// `S_u × S_v` collapses; a shared evaluator that silently picked one for
49/// everybody would move behaviour at exactly the singular points the callers
50/// each decided about on purpose.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum OffsetNormal {
53 /// The bare `S_u × S_v`, normalized, through the C¹ domain extension
54 /// (`deriv1_extended`). Orientation is the *caller's* business — the blend
55 /// march carries it in the sign of ρ instead (`signed_radii`), which is why
56 /// this variant must not apply `same_sense`.
57 ///
58 /// Not [`OffsetNormal::FaceStable`]'s recovery — no nudge off a point and
59 /// no walk along a collapsed row. Where the cross product vanishes this
60 /// reads the ONE-SIDED LIMIT of the same cross product from inside the
61 /// domain (`raw_unit_normal`), the surface twin of
62 /// `NurbsCurve::unit_tangent`: an extruded face inherits a stationary
63 /// parameter of its profile curve as an isoline where `S_u` vanishes,
64 /// though the face is perfectly regular there. Only a carrier with no
65 /// regular point in reach refuses, by name. The POINT past such a boundary
66 /// moves along the same limit (`raw_extended_point`) where the ruled
67 /// extension would stand still. Near such an isoline the vanishing partial
68 /// is re-read from the control net's differences (`well_rounded_partials`),
69 /// whose rounding shrinks with it, so the normal keeps its direction up to
70 /// the isoline instead of turning with the basis derivatives' rounding.
71 ///
72 /// Bit-identical to `blending::blend::stations::raw_normal` (and to
73 /// `evaluate_extended` for the point) wherever `S_u × S_v` is non-zero.
74 Raw,
75 /// `NurbsSurface::normal` — the in-domain `derivatives(u, v, 1)` cross —
76 /// negated when `same_sense` is false. Clamped, not extended, and no
77 /// singular recovery.
78 ///
79 /// Bit-identical to the `let mut normal = surface.normal(u, v)?; if
80 /// !face.same_sense { normal = normal.scale(-1.0) }` block written out in
81 /// `face_offset_revolution.rs`, `face_offset_freeform.rs`,
82 /// `face_offset_torus.rs` and `offset_shell/smooth_sync.rs::face_normal`.
83 Face { same_sense: bool },
84 /// [`OffsetNormal::Face`] plus the singular-row recovery that
85 /// `offset_surface` depends on: nudge off a point where the normal is
86 /// undefined, and at a *collapsed parameter row* (a cone apex) walk deep
87 /// inward for the true per-ruling limit rather than accept the axis
88 /// direction the at-point cross degenerates to.
89 ///
90 /// Bit-identical to the former `offset::stable_face_normal`, whose body
91 /// this is. The blend march must NOT inherit it — the recovery would
92 /// change the residual at poles, so it stays opt-in (audit slice 1,
93 /// "keep it as an opt-in flag").
94 FaceStable { same_sense: bool },
95 /// The exact closed-form normal of the *recognised analytic carrier*,
96 /// oriented like [`OffsetNormal::Face`].
97 ///
98 /// This is the "exact per analytic surface type" lane: a sphere's normal is
99 /// `(p − centre)/r` — defined at the poles, where `S_u × S_v` vanishes — a
100 /// cylinder's is its radial direction, a cone's is the meridian
101 /// perpendicular (defined even AT the apex, from the ruling's own azimuth),
102 /// a torus's is `(p − tube centre)/r`. Where there is no exact form (the
103 /// general `Revolution`, and every free-form patch) it falls back to
104 /// [`OffsetNormal::Face`], so this variant is always at least as defined as
105 /// that one.
106 ///
107 /// `NurbsSurface::analytic` memoizes per instance, so recognition is paid
108 /// once per surface, not once per point.
109 ///
110 /// **No consumer rides this lane yet.** It exists as the exact half of the
111 /// evaluator's contract and as the diagnostic's comparator; switching a
112 /// caller onto it changes that caller's last digits and is a separate,
113 /// measured step. See `offset_normal_diagnostic`.
114 ExactAnalytic { same_sense: bool },
115}
116
117impl OffsetNormal {
118 /// The `same_sense`-carrying variants, for a caller that has a face.
119 fn same_sense(self) -> bool {
120 match self {
121 OffsetNormal::Raw => true,
122 OffsetNormal::Face { same_sense }
123 | OffsetNormal::FaceStable { same_sense }
124 | OffsetNormal::ExactAnalytic { same_sense } => same_sense,
125 }
126 }
127}
128
129/// One evaluation of the offset: the source point, the unit normal the offset
130/// rides, and the offset point itself.
131///
132/// The normal is also the *offset surface's own* normal wherever the offset is
133/// regular — it flips only past the evolute, which is what the region-scoped
134/// scan in `offset/regularity.rs` (the kernel's only curvature gate, shared by
135/// `thicken`'s sheet refusal and offset-shell's carrier-collapse lane) exists
136/// to refuse, over the trim rather than over the whole carrier. This struct
137/// deliberately carries no other derivative data: the
138/// five consumers were audited and not one of them uses `S_u`/`S_v` for
139/// anything but the normal, and the blend march's Jacobian is finite-differenced
140/// on the residual rather than assembled from partials.
141#[derive(Clone, Copy, Debug)]
142pub struct OffsetSample {
143 /// `S(u, v)` on the source carrier.
144 pub source: Vec3,
145 /// The unit normal, in the requested orientation.
146 pub normal: Vec3,
147 /// `source + distance · normal`.
148 pub point: Vec3,
149}
150
151/// The pointwise offset evaluator, bound to one carrier and one orientation
152/// convention.
153///
154/// Construction is free — it stores three references and reads nothing — so a
155/// caller inside a Newton loop may build one per call without paying anything.
156/// `site` is a short stable label used only by `offset_normal_diagnostic`.
157#[derive(Clone, Copy)]
158pub struct OffsetEvaluator<'a> {
159 site: &'static str,
160 surface: &'a NurbsSurface,
161 convention: OffsetNormal,
162}
163
164impl<'a> OffsetEvaluator<'a> {
165 pub fn new(site: &'static str, surface: &'a NurbsSurface, convention: OffsetNormal) -> Self {
166 Self {
167 site,
168 surface,
169 convention,
170 }
171 }
172
173 /// The unit normal alone, for callers that only need the direction (a
174 /// residual check, or an affine offset that shifts the whole control net by
175 /// one vector). Evaluates the point only on the lanes that need it.
176 pub fn normal(&self, u: f64, v: f64) -> Result<Vec3, String> {
177 let result = self.normal_with(self.convention, u, v);
178 offset_normal_diagnostic(self.site, self.surface, self.convention, u, v, &result);
179 result
180 }
181
182 /// Whether the carrier evaluated at `(u, v)` is the REAL surface, or its
183 /// fiction — the question [`Self::at`] cannot answer for itself.
184 ///
185 /// `OffsetNormal::Raw` evaluates through `deriv1_extended`, which
186 /// continues an OPEN direction along its boundary tangent plane without
187 /// bound instead of refusing. A solve with no solution on the real
188 /// carrier therefore converges on that continuation and reports a
189 /// residual of zero, because the extended point IS a genuine root of the
190 /// extended system. Callers must ask here, at convergence.
191 ///
192 /// Being outside the domain is NOT by itself the fault, and testing for
193 /// that alone over-refuses badly. Three cases:
194 ///
195 /// * a CLOSED direction wraps (`rem_euclid`) back onto the real surface,
196 /// so it is always faithful — which is why a full cylinder cannot
197 /// exhibit the problem and a trimmed arc can;
198 /// * an open direction along which the surface is RULED extends exactly.
199 /// A plane is a bounded bilinear patch in this kernel, and its
200 /// continuation is still that plane; a cylinder's axial direction
201 /// continues as the same cylinder. A re-entrant corner touching a
202 /// wall's carrier beyond its patch is real geometry and must keep
203 /// working;
204 /// * an open direction with non-zero NORMAL CURVATURE is where the ruled
205 /// continuation leaves the surface, and that is the fiction.
206 ///
207 /// So the test is geometric, not a domain comparison and not keyed on
208 /// [`NurbsSurface::analytic`] — an analytic-keyed guard silently no-ops on
209 /// every fitted patch. The ruled extension drops the second-order term, so
210 /// over an excursion `d` it departs the true surface by about
211 /// `½·d²·|S_ii·n|`, where `S_ii·n` is the second-fundamental-form
212 /// coefficient along the exceeded direction (Golovanov §4.12's `b11`/`b22`,
213 /// the same quantities the marcher's step budget uses). Faithful when that
214 /// departure is within `tolerance`.
215 ///
216 /// The bound is QUADRATIC in the excursion, so a parameter a rounding bit
217 /// past the boundary is faithful by many orders and a real excursion onto
218 /// a curved carrier is unfaithful by many orders. The exact threshold
219 /// barely matters; passing the caller's own convergence bar keeps the
220 /// answer honest to the accuracy it claims.
221 pub fn on_real_carrier(&self, u: f64, v: f64, tolerance: f64) -> Result<bool, String> {
222 let [u0, u1] = self.surface.domain_u()?;
223 let [v0, v1] = self.surface.domain_v()?;
224 let (closed_u, closed_v) = self.surface.closed_directions()?;
225 let out = |value: f64, lo: f64, hi: f64, closed: bool| -> f64 {
226 if closed {
227 0.0
228 } else if value < lo {
229 value - lo
230 } else if value > hi {
231 value - hi
232 } else {
233 0.0
234 }
235 };
236 let du_out = out(u, u0, u1, closed_u);
237 let dv_out = out(v, v0, v1, closed_v);
238 if du_out == 0.0 && dv_out == 0.0 {
239 return Ok(true);
240 }
241 let derivatives = self.surface.derivatives(u - du_out, v - dv_out, 2)?;
242 let normal = match derivatives[1][0].cross(derivatives[0][1]).normalized() {
243 Ok(normal) => normal,
244 // A degenerate anchor: see `degenerate_anchor_on_real_carrier`.
245 Err(_) => {
246 return self.degenerate_anchor_on_real_carrier(u, v, du_out, dv_out, tolerance)
247 }
248 };
249 let departure = |step: f64, second: Vec3| 0.5 * step * step * second.dot(normal).abs();
250 if du_out != 0.0 && departure(du_out, derivatives[2][0]) > tolerance {
251 return Ok(false);
252 }
253 if dv_out != 0.0 && departure(dv_out, derivatives[0][2]) > tolerance {
254 return Ok(false);
255 }
256 Ok(true)
257 }
258
259 /// [`Self::on_real_carrier`] where `S_u × S_v` vanishes at the anchor.
260 ///
261 /// Where the anchor lies on a STATIONARY boundary isoline
262 /// ([`stationary_boundary`]) that the point has left across, the carrier is
263 /// regular and has a normal (the `Raw` lane's one-sided limit), and the
264 /// continuation past it is [`raw_extended_point`]'s: straight along the
265 /// isoline's limit direction, `speed·|excursion|` long. The second-order
266 /// departure `½·d²·|S_ii·n|` cannot measure it, because `S_ii` is the
267 /// parameterization's, not the carrier's. So the departure is measured on
268 /// the carrier itself: the point the same distance back INSIDE the patch,
269 /// through the [`StationaryChart`], sits off the boundary's tangent plane by
270 /// what a regular continuation would depart from the straight one, to
271 /// leading order (the mirror image of `½·κ_n·s²`). Faithful when that is
272 /// within `tolerance`. An excursion longer than the patch mirrors to its far
273 /// boundary and scales that departure by the square of the ratio.
274 ///
275 /// Every other degenerate anchor — a pole, an apex, a collapsed row — has no
276 /// normal to measure against, and the faithfulness of its extension cannot
277 /// be established, so it answers `false`, as it always has. A surface that
278 /// does not leave its boundary to any order refuses by name
279 /// ([`Self::stationary_chart`]).
280 fn degenerate_anchor_on_real_carrier(
281 &self,
282 u: f64,
283 v: f64,
284 du_out: f64,
285 dv_out: f64,
286 tolerance: f64,
287 ) -> Result<bool, String> {
288 let (anchor_u, anchor_v) = (u - du_out, v - dv_out);
289 let charts = [
290 (du_out != 0.0)
291 .then(|| self.stationary_chart(true, anchor_u, anchor_v))
292 .transpose()?
293 .flatten(),
294 (dv_out != 0.0)
295 .then(|| self.stationary_chart(false, anchor_u, anchor_v))
296 .transpose()?
297 .flatten(),
298 ];
299 if charts.iter().all(Option::is_none) {
300 return Ok(false);
301 }
302 let (anchor_point, su, sv) = self.surface.deriv1(anchor_u, anchor_v)?;
303 let Ok(normal) = raw_unit_normal(self.surface, anchor_u, anchor_v, su, sv) else {
304 return Ok(false);
305 };
306 let derivatives = self.surface.derivatives(anchor_u, anchor_v, 2)?;
307 let ([u0, u1], [v0, v1]) = domains(self.surface)?;
308 for (along_u, out, chart) in [(true, du_out, charts[0]), (false, dv_out, charts[1])] {
309 if out == 0.0 {
310 continue;
311 }
312 let departure = match chart {
313 Some(chart) => {
314 let excursion = chart.regular(if along_u { u } else { v });
315 let (lo, hi) = if along_u { (u0, u1) } else { (v0, v1) };
316 let far = if chart.outward > 0.0 { lo } else { hi };
317 // The patch mirrors at most back to its far boundary; a longer
318 // excursion scales that departure by the square of the ratio,
319 // the order a regular continuation departs at.
320 let reach = -chart.regular(far);
321 let (inside, growth) = if excursion <= reach {
322 (chart.parameter(-excursion), 1.0)
323 } else {
324 (far, (excursion / reach).powi(2))
325 };
326 let mirrored = if along_u {
327 self.surface.evaluate(inside, anchor_v)?
328 } else {
329 self.surface.evaluate(anchor_u, inside)?
330 };
331 growth * mirrored.sub(anchor_point).dot(normal).abs()
332 }
333 None => {
334 let second = if along_u { derivatives[2][0] } else { derivatives[0][2] };
335 0.5 * out * out * second.dot(normal).abs()
336 }
337 };
338 if departure > tolerance {
339 return Ok(false);
340 }
341 }
342 Ok(true)
343 }
344
345 /// The offset of this carrier at `(u, v)` by `distance` **along the
346 /// normal** (see the module's sign convention).
347 pub fn at(&self, u: f64, v: f64, distance: f64) -> Result<OffsetSample, String> {
348 let (source, normal) = self.evaluate(u, v)?;
349 Ok(OffsetSample {
350 source,
351 normal,
352 point: source.add(normal.scale(distance)),
353 })
354 }
355
356 fn evaluate(&self, u: f64, v: f64) -> Result<(Vec3, Vec3), String> {
357 let result = match self.convention {
358 // ONE evaluation for the point and the partials the normal needs —
359 // `deriv1_extended`'s point is bit-identical to `evaluate_extended`,
360 // which is what licenses `blend/edge/keep.rs` to drop its second
361 // evaluation of the same surface.
362 OffsetNormal::Raw => self
363 .surface
364 .deriv1_extended(u, v)
365 .and_then(|(point, su, sv)| {
366 let normal = raw_unit_normal(self.surface, u, v, su, sv)?;
367 Ok((raw_extended_point(self.surface, u, v, point, su, sv)?, normal))
368 }),
369 convention => self
370 .surface
371 .evaluate(u, v)
372 .and_then(|point| Ok((point, self.normal_with(convention, u, v)?))),
373 };
374 let normal = result
375 .as_ref()
376 .map(|(_, normal)| *normal)
377 .map_err(Clone::clone);
378 offset_normal_diagnostic(self.site, self.surface, self.convention, u, v, &normal);
379 result
380 }
381
382 fn normal_with(&self, convention: OffsetNormal, u: f64, v: f64) -> Result<Vec3, String> {
383 match convention {
384 OffsetNormal::Raw => {
385 let (_, su, sv) = self.surface.deriv1_extended(u, v)?;
386 raw_unit_normal(self.surface, u, v, su, sv)
387 }
388 OffsetNormal::Face { same_sense } => {
389 Ok(orient(self.surface.normal(u, v)?, same_sense))
390 }
391 OffsetNormal::FaceStable { same_sense } => {
392 stable_normal(self.surface, same_sense, u, v)
393 }
394 OffsetNormal::ExactAnalytic { same_sense } => {
395 let point = self.surface.evaluate(u, v)?;
396 match exact_analytic_normal(self.surface, u, v, point)? {
397 Some(normal) => Ok(orient(normal, same_sense)),
398 None => Ok(orient(self.surface.normal(u, v)?, same_sense)),
399 }
400 }
401 }
402 }
403}
404
405fn orient(normal: Vec3, same_sense: bool) -> Vec3 {
406 if same_sense {
407 normal
408 } else {
409 normal.scale(-1.0)
410 }
411}
412
413fn domains(surface: &NurbsSurface) -> Result<([f64; 2], [f64; 2]), String> {
414 Ok((surface.domain_u()?, surface.domain_v()?))
415}
416
417/// The `Raw` lane's unit normal from the partials `deriv1_extended` returned at
418/// `(u, v)`: `S_u × S_v` normalized — the same vector, to the bit, wherever it
419/// is non-zero — or its one-sided limit where it vanishes.
420///
421/// Why a limit and not a refusal: a surface can be stationary in its
422/// PARAMETERIZATION where its geometry is regular. The standard source is an
423/// extrusion (or any ruled face) of a profile curve whose first derivative
424/// vanishes at a vertex — the involute flank's Hermite fit, a cubic with two
425/// coincident poles — which carries that parameter as a whole isoline where
426/// `S_u = 0`. The blend march evaluates its support's normal on exactly that
427/// isoline when it rolls along the rim edge, and the face's normal is still the
428/// limit from inside, as a curve's direction of travel is at its cusp
429/// (`NurbsCurve::unit_tangent`, whose ladder this mirrors). The limit is also
430/// the side the march's orientation came from: `signed_radii` reads the sign of
431/// ρ at a regular interior point, and a one-sided probe from inside the domain
432/// keeps that sense, where a probe past a stationary boundary would not exist.
433///
434/// The probe origin is the in-domain parameter `deriv1_extended` anchors on —
435/// wrapped in a closed direction, clamped past an open boundary — and the
436/// ladder walks first along the parameter whose partial collapsed, then along
437/// the other (a pole row collapses `S_u` along the whole row, so only a step in
438/// `v` leaves it). Only a carrier with no regular point within a tenth of its
439/// domain refuses, and the refusal names where.
440///
441/// `BREP_CUSP_TANGENT_RESCUE=0` restores the bare `normalized()` refusal, the
442/// same hatch the curve tangent carries.
443fn raw_unit_normal(
444 surface: &NurbsSurface,
445 u: f64,
446 v: f64,
447 su: Vec3,
448 sv: Vec3,
449) -> Result<Vec3, String> {
450 let (su, sv) = well_rounded_partials(surface, u, v, su, sv)?;
451 let error = match su.cross(sv).normalized() {
452 Ok(normal) => return Ok(normal),
453 Err(error) => error,
454 };
455 if std::env::var("BREP_CUSP_TANGENT_RESCUE").as_deref() == Ok("0") {
456 return Err(error);
457 }
458 let ([u0, u1], [v0, v1]) = domains(surface)?;
459 let (closed_u, closed_v) = surface.closed_directions()?;
460 let anchor = |value: f64, lo: f64, hi: f64, closed: bool| -> f64 {
461 if closed && (value < lo || value > hi) {
462 lo + (value - lo).rem_euclid(hi - lo)
463 } else {
464 value.clamp(lo, hi)
465 }
466 };
467 let (anchor_u, anchor_v) = (anchor(u, u0, u1, closed_u), anchor(v, v0, v1, closed_v));
468 let along_u_first = su.length() <= sv.length();
469 let directions = if along_u_first {
470 [(1.0, 0.0), (0.0, 1.0)]
471 } else {
472 [(0.0, 1.0), (1.0, 0.0)]
473 };
474 for scale in [1e-6, 1e-4, 1e-2, 1e-1] {
475 for (along_u, along_v) in directions {
476 let step_u = (u1 - u0) * scale * along_u;
477 let step_v = (v1 - v0) * scale * along_v;
478 for sign in [1.0, -1.0] {
479 let probe_u = anchor_u + sign * step_u;
480 let probe_v = anchor_v + sign * step_v;
481 if probe_u < u0 || probe_u > u1 || probe_v < v0 || probe_v > v1 {
482 continue;
483 }
484 if let Some(normal) = surface
485 .deriv1_extended(probe_u, probe_v)
486 .and_then(|(_, su, sv)| {
487 well_rounded_partials(surface, probe_u, probe_v, su, sv)
488 })
489 .ok()
490 .and_then(|(su, sv)| su.cross(sv).normalized().ok())
491 {
492 return Ok(normal);
493 }
494 }
495 }
496 }
497 Err(format!(
498 "offset normal: S_u x S_v vanishes at (u, v) = ({u}, {v}) and at every probe the \
499 one-sided limit can reach in [{u0}, {u1}] x [{v0}, {v1}], so this carrier has no \
500 normal to report there"
501 ))
502}
503
504/// The `Raw` lane's POINT past an open boundary: `deriv1_extended`'s point —
505/// the same point, to the bit — unless the partial ACROSS that boundary
506/// vanishes at the anchor, where the ruled extension `S(anchor) + d·S_u` does
507/// not move at all.
508///
509/// That is the stationary isoline [`raw_unit_normal`] reads its limit on: an
510/// extrusion of a profile whose cubic has coincident end poles is regular up
511/// to and across the boundary, and only its parameterization stops there. A
512/// solve that has to leave the domain across such a boundary — the blend
513/// march's overshoot stations, which seat the rolling ball past a vertex —
514/// then meets a Jacobian column that is exactly zero ("station Newton is
515/// singular"), though the continuation of the face exists and is the one its
516/// cusp-free twin extends onto. So the extension moves along the one-sided
517/// limit of the vanished partial's DIRECTION, probed from inside the domain on
518/// the ladder `raw_unit_normal` uses, at the isoline's mean speed across its
519/// parameter range, `|S(hi, ·) − S(lo, ·)| / (hi − lo)`.
520///
521/// The speed is a choice the geometry does not make — the continuation's
522/// locus is fixed by the direction alone — and this one is intrinsic to the
523/// carrier: on a ruled face over a straight profile it is the speed the
524/// cusp-free twin's own partial has, so the solved parameter past the boundary
525/// is the twin's.
526///
527/// Refuses by name only where the surface itself gives no direction: the
528/// vanished partial is zero at every probe the ladder reaches, a patch
529/// collapsed across a tenth of its domain, where `raw_unit_normal` refuses
530/// too. `BREP_CUSP_TANGENT_RESCUE=0` restores the stationary extension.
531fn raw_extended_point(
532 surface: &NurbsSurface,
533 u: f64,
534 v: f64,
535 point: Vec3,
536 su: Vec3,
537 sv: Vec3,
538) -> Result<Vec3, String> {
539 let ([u0, u1], [v0, v1]) = domains(surface)?;
540 if u >= u0 && u <= u1 && v >= v0 && v <= v1 {
541 return Ok(point);
542 }
543 if su.normalized().is_ok() && sv.normalized().is_ok() {
544 return Ok(point);
545 }
546 if std::env::var("BREP_CUSP_TANGENT_RESCUE").as_deref() == Ok("0") {
547 return Ok(point);
548 }
549 let (closed_u, closed_v) = surface.closed_directions()?;
550 let excursion = |value: f64, lo: f64, hi: f64, closed: bool| -> f64 {
551 if closed {
552 0.0
553 } else if value < lo {
554 value - lo
555 } else if value > hi {
556 value - hi
557 } else {
558 0.0
559 }
560 };
561 let du_out = excursion(u, u0, u1, closed_u);
562 let dv_out = excursion(v, v0, v1, closed_v);
563 let wrap = |value: f64, lo: f64, hi: f64, closed: bool| -> f64 {
564 if closed && (value < lo || value > hi) {
565 lo + (value - lo).rem_euclid(hi - lo)
566 } else {
567 value.clamp(lo, hi)
568 }
569 };
570 let (anchor_u, anchor_v) = (wrap(u, u0, u1, closed_u), wrap(v, v0, v1, closed_v));
571 let mut point = point;
572 for (along_u, out, partial) in [(true, du_out, su), (false, dv_out, sv)] {
573 if out == 0.0 || partial.normalized().is_ok() {
574 continue;
575 }
576 let (lo, hi) = if along_u { (u0, u1) } else { (v0, v1) };
577 let inward = -out.signum();
578 let direction = [1e-6, 1e-4, 1e-2, 1e-1].iter().find_map(|scale| {
579 let step = inward * (hi - lo) * scale;
580 let (probe_u, probe_v) = if along_u {
581 (anchor_u + step, anchor_v)
582 } else {
583 (anchor_u, anchor_v + step)
584 };
585 if probe_u < u0 || probe_u > u1 || probe_v < v0 || probe_v > v1 {
586 return None;
587 }
588 let (_, probe_su, probe_sv) = surface.deriv1(probe_u, probe_v).ok()?;
589 let (probe_su, probe_sv) =
590 well_rounded_partials(surface, probe_u, probe_v, probe_su, probe_sv).ok()?;
591 (if along_u { probe_su } else { probe_sv }).normalized().ok()
592 });
593 let Some(direction) = direction else {
594 return Err(format!(
595 "offset point: S_{} vanishes on the boundary of [{u0}, {u1}] x [{v0}, {v1}] at \
596 (u, v) = ({anchor_u}, {anchor_v}) and at every probe the one-sided limit can \
597 reach, so the surface has no direction to extend along toward ({u}, {v})",
598 if along_u { "u" } else { "v" }
599 ));
600 };
601 let speed = isoline_mean_speed(surface, along_u, anchor_u, anchor_v)?;
602 point = point.add(direction.scale(out * speed));
603 }
604 Ok(point)
605}
606
607/// The mean speed of the isoline through `(anchor_u, anchor_v)` along `along_u`
608/// across its whole parameter range, `|S(hi, ·) − S(lo, ·)| / (hi − lo)`: the
609/// speed [`raw_extended_point`] moves a point past a stationary boundary at, and
610/// the one [`StationaryChart`] joins it with.
611fn isoline_mean_speed(
612 surface: &NurbsSurface,
613 along_u: bool,
614 anchor_u: f64,
615 anchor_v: f64,
616) -> Result<f64, String> {
617 let ([u0, u1], [v0, v1]) = domains(surface)?;
618 let (lo, hi) = if along_u { (u0, u1) } else { (v0, v1) };
619 let (start, end) = if along_u {
620 (surface.evaluate(lo, anchor_v)?, surface.evaluate(hi, anchor_v)?)
621 } else {
622 (surface.evaluate(anchor_u, lo)?, surface.evaluate(anchor_u, hi)?)
623 };
624 Ok(end.sub(start).length() / (hi - lo))
625}
626
627/// The in-domain anchor `deriv1_extended` reads for `(u, v)`: wrapped in a
628/// closed direction, clamped past an open boundary.
629fn extension_anchor(surface: &NurbsSurface, u: f64, v: f64) -> Result<(f64, f64), String> {
630 let ([u0, u1], [v0, v1]) = domains(surface)?;
631 let (closed_u, closed_v) = surface.closed_directions()?;
632 let anchor = |value: f64, lo: f64, hi: f64, closed: bool| -> f64 {
633 if closed && (value < lo || value > hi) {
634 lo + (value - lo).rem_euclid(hi - lo)
635 } else {
636 value.clamp(lo, hi)
637 }
638 };
639 Ok((anchor(u, u0, u1, closed_u), anchor(v, v0, v1, closed_v)))
640}
641
642/// The open boundary nearest `(u, v)` in the direction `along_u`, when the
643/// partial ACROSS it vanishes there while the partial ALONG it does not: an
644/// isoline on which only the parameterization is stationary, the extrusion of a
645/// profile cubic with coincident end poles. `None` in a closed direction, past
646/// a regular boundary, and on a collapsed row (a pole or an apex, where the
647/// partial along the boundary vanishes too, so the boundary is a point and not
648/// an isoline).
649///
650/// The zero test is `Vec3::normalized`'s, the one [`raw_unit_normal`] and
651/// [`raw_extended_point`] already apply.
652fn stationary_boundary(
653 surface: &NurbsSurface,
654 along_u: bool,
655 u: f64,
656 v: f64,
657) -> Result<Option<f64>, String> {
658 let ([u0, u1], [v0, v1]) = domains(surface)?;
659 let (closed_u, closed_v) = surface.closed_directions()?;
660 if (along_u && closed_u) || (!along_u && closed_v) {
661 return Ok(None);
662 }
663 let (anchor_u, anchor_v) = extension_anchor(surface, u, v)?;
664 let (value, lo, hi) = if along_u { (u, u0, u1) } else { (v, v0, v1) };
665 let boundary = if (value - lo).abs() <= (hi - value).abs() { lo } else { hi };
666 let (at_u, at_v) = if along_u { (boundary, anchor_v) } else { (anchor_u, boundary) };
667 let (_, su, sv) = surface.deriv1(at_u, at_v)?;
668 let (across, along) = if along_u { (su, sv) } else { (sv, su) };
669 Ok((across.normalized().is_err() && along.normalized().is_ok()).then_some(boundary))
670}
671
672/// Below this fraction of the other partial's reach over its domain, a partial
673/// is small enough that its rounding may be its direction: only then is its
674/// boundary asked whether it is stationary. A gate on cost, not on the answer —
675/// the answer is [`stationary_boundary`]'s — and the bare partial's tilt at the
676/// gate is about ulp·|S|/|S_u| ≈ 1e-12 of a unit model, so switching there moves
677/// a normal by no more than that.
678const STATIONARY_PARTIAL_REACH: f64 = 1e-3;
679
680/// The partials `(S_u, S_v)` the `Raw` lane builds its normal from: the ones
681/// `deriv1_extended` returned — the same vectors, to the bit — unless one of
682/// them is small against the other and belongs to a [`stationary_boundary`],
683/// where it is re-read by [`NurbsSurface::partial_by_differences`] at the
684/// in-domain anchor.
685///
686/// Why: near such an isoline the partial vanishes like the distance to it, and
687/// the basis-derivative evaluation's rounding does not, so the normal
688/// `S_u × S_v` turns by `ulp·|S|/|S_u|`. The tombstone fixtures measured 3.5e-11
689/// at `|S_u| = 7.7e-5` and 3.7e-5 at `|S_u| = 1.7e-10`, and the one-sided limit's
690/// own probe (a millionth of the span in) 2.2e-10: all above the blend march's
691/// residual bar of `1e-11·(1 + scale)`, and inconsistent with each other across
692/// the isoline, so the station Newton at a stationary vertex could not settle.
693/// The difference form's rounding scales with the partial, so the direction
694/// survives to the isoline itself.
695///
696/// `BREP_CUSP_TANGENT_RESCUE=0` keeps the bare partials.
697fn well_rounded_partials(
698 surface: &NurbsSurface,
699 u: f64,
700 v: f64,
701 su: Vec3,
702 sv: Vec3,
703) -> Result<(Vec3, Vec3), String> {
704 let ([u0, u1], [v0, v1]) = domains(surface)?;
705 let reach_u = su.length_squared() * (u1 - u0) * (u1 - u0);
706 let reach_v = sv.length_squared() * (v1 - v0) * (v1 - v0);
707 let gate = STATIONARY_PARTIAL_REACH * STATIONARY_PARTIAL_REACH;
708 let small_u = reach_u <= gate * reach_v;
709 let small_v = reach_v <= gate * reach_u;
710 if !small_u && !small_v {
711 return Ok((su, sv));
712 }
713 if std::env::var("BREP_CUSP_TANGENT_RESCUE").as_deref() == Ok("0") {
714 return Ok((su, sv));
715 }
716 let (anchor_u, anchor_v) = extension_anchor(surface, u, v)?;
717 let mut partials = (su, sv);
718 if small_u && stationary_boundary(surface, true, u, v)?.is_some() {
719 if let Ok(partial) = surface.partial_by_differences(true, anchor_u, anchor_v) {
720 partials.0 = partial;
721 }
722 }
723 if small_v && stationary_boundary(surface, false, u, v)?.is_some() {
724 if let Ok(partial) = surface.partial_by_differences(false, anchor_u, anchor_v) {
725 partials.1 = partial;
726 }
727 }
728 Ok(partials)
729}
730
731/// A REGULAR parameter for one direction of a carrier near an open boundary on
732/// which that direction's partial vanishes ([`stationary_boundary`]).
733///
734/// There the carrier is regular and only its parameterization stops: the point
735/// leaves the boundary like `L·d^m` in the outward parameter distance `d`
736/// (`m` the order of the first non-vanishing partial across the boundary, 2 for
737/// a cubic with two coincident end poles). A Newton that solves for the
738/// parameter itself therefore meets a root of multiplicity `m` wherever its
739/// answer lies ON the isoline — the blend march's station at a stationary
740/// vertex — and converges linearly on a Jacobian column that vanishes with
741/// the step.
742///
743/// The chart's value `w` is
744///
745/// * `−L·|d|^m` inside the domain (`d ≤ 0`), which is the distance from the
746/// boundary along the carrier to leading order, so the point moves at unit
747/// speed in `w` right up to the isoline;
748/// * `speed·d` past it, where [`raw_extended_point`] moves the point along the
749/// one-sided limit at the isoline's mean speed — so in `w` the continuation
750/// also moves at unit speed, and the chart joins it C¹ at `w = 0`.
751///
752/// On a straight isoline the point is affine in `w` on both sides to the order
753/// of the carrier's next term; the Newton's column in `w` no longer vanishes.
754#[derive(Clone, Copy, Debug)]
755pub(crate) struct StationaryChart {
756 boundary: f64,
757 /// +1 when the boundary is the high end of the domain, −1 the low end.
758 outward: f64,
759 order: i32,
760 /// `L = |∂^m S| / m!` across the boundary.
761 coefficient: f64,
762 speed: f64,
763}
764
765impl StationaryChart {
766 /// The chart value of the carrier parameter `parameter`.
767 pub(crate) fn regular(&self, parameter: f64) -> f64 {
768 let outward = self.outward * (parameter - self.boundary);
769 if outward >= 0.0 {
770 self.speed * outward
771 } else {
772 -self.coefficient * (-outward).powi(self.order)
773 }
774 }
775
776 /// The carrier parameter of the chart value `regular`.
777 pub(crate) fn parameter(&self, regular: f64) -> f64 {
778 let outward = if regular >= 0.0 {
779 regular / self.speed
780 } else if self.order == 2 {
781 -(-regular / self.coefficient).sqrt()
782 } else {
783 -(-regular / self.coefficient).powf(1.0 / f64::from(self.order))
784 };
785 self.boundary + self.outward * outward
786 }
787}
788
789impl OffsetEvaluator<'_> {
790 /// The [`StationaryChart`] for the direction `along_u` at `(u, v)`, when that
791 /// direction's nearest open boundary is a stationary isoline; `None`
792 /// otherwise, and on every lane but `Raw`, whose point past the boundary is
793 /// what the chart joins.
794 ///
795 /// Refuses by name only where the surface itself is singular: the partial
796 /// across the boundary vanishes to every order the carrier's degree
797 /// reaches, so the patch does not leave the boundary at all.
798 /// `BREP_CUSP_TANGENT_RESCUE=0` answers `None`.
799 pub(crate) fn stationary_chart(
800 &self,
801 along_u: bool,
802 u: f64,
803 v: f64,
804 ) -> Result<Option<StationaryChart>, String> {
805 if self.convention != OffsetNormal::Raw
806 || std::env::var("BREP_CUSP_TANGENT_RESCUE").as_deref() == Ok("0")
807 {
808 return Ok(None);
809 }
810 let surface = self.surface;
811 let Some(boundary) = stationary_boundary(surface, along_u, u, v)? else {
812 return Ok(None);
813 };
814 let ([u0, u1], [v0, v1]) = domains(surface)?;
815 let hi = if along_u { u1 } else { v1 };
816 let (anchor_u, anchor_v) = extension_anchor(surface, u, v)?;
817 let (at_u, at_v) = if along_u { (boundary, anchor_v) } else { (anchor_u, boundary) };
818 let degree = if along_u { surface.degree_u } else { surface.degree_v };
819 let derivatives = surface.derivatives(at_u, at_v, degree)?;
820 let mut factorial = 1.0;
821 let mut found = None;
822 for order in 2..=degree {
823 factorial *= order as f64;
824 let partial = if along_u {
825 derivatives[order][0]
826 } else {
827 derivatives[0][order]
828 };
829 if partial.normalized().is_ok() {
830 found = Some((order, partial.length() / factorial));
831 break;
832 }
833 }
834 let Some((order, coefficient)) = found else {
835 return Err(format!(
836 "offset chart: S_{} vanishes to order {degree} across the boundary of \
837 [{u0}, {u1}] x [{v0}, {v1}] at (u, v) = ({at_u}, {at_v}), so the surface does \
838 not leave that boundary and has no regular parameter there",
839 if along_u { "u" } else { "v" }
840 ));
841 };
842 let speed = isoline_mean_speed(surface, along_u, anchor_u, anchor_v)?;
843 if !(speed > 0.0) {
844 return Ok(None);
845 }
846 Ok(Some(StationaryChart {
847 boundary,
848 outward: if boundary == hi { 1.0 } else { -1.0 },
849 order: order as i32,
850 coefficient,
851 speed,
852 }))
853 }
854}
855
856/// The body of the former `offset::stable_face_normal`, verbatim, with the
857/// `FaceRecord` replaced by the `same_sense` flag it read (audit §4.5: the
858/// `FaceRecord` requirement is a type mismatch, not a geometric one — it is why
859/// `thicken` fabricates a synthetic face with empty loops just to offset a bare
860/// surface).
861fn stable_normal(
862 surface: &NurbsSurface,
863 same_sense: bool,
864 u: f64,
865 v: f64,
866) -> Result<Vec3, String> {
867 let normal_at = |u, v| surface.normal(u, v).ok();
868 let mut normal = normal_at(u, v);
869 let ([u0, u1], [v0, v1]) = domains(surface)?;
870 if normal.is_none() {
871 let du = (u1 - u0) * 1e-5;
872 let dv = (v1 - v0) * 1e-5;
873 for (candidate_u, candidate_v) in [
874 ((u + du).clamp(u0, u1), v),
875 ((u - du).clamp(u0, u1), v),
876 (u, (v + dv).clamp(v0, v1)),
877 (u, (v - dv).clamp(v0, v1)),
878 ] {
879 normal = normal_at(candidate_u, candidate_v);
880 if normal.is_some() {
881 break;
882 }
883 }
884 }
885 // SINGULAR-ROW override: at a surface singularity where a whole
886 // parameter row collapses to one point (a cone apex), the at-point /
887 // nudged normal is the cross of a vanishing partial with noise — the
888 // AXIS direction instead of the ruling normal, which offsets the apex
889 // row straight down the axis and bends the fitted surface by exactly
890 // d·cos(half-angle). Detect the collapse by local point spread, walk
891 // DEEP inward for the true per-ruling limit, and replace the at-point
892 // value only when the two genuinely DISAGREE. A sphere/dome pole also
893 // reads as collapsed, but there the at-point normal (the axis) IS the
894 // limit — agreement keeps the exact baseline value.
895 //
896 // (`OffsetNormal::ExactAnalytic` answers this case in closed form for a
897 // recognised cone — the ruling's own azimuth, not a deep-inward probe —
898 // but no consumer rides that lane yet, so this stays the `offset_surface`
899 // behaviour it always was.)
900 let singular_here = {
901 let du = (u1 - u0) * 1e-4;
902 let dv = (v1 - v0) * 1e-4;
903 let here = surface.evaluate(u, v)?;
904 let along_u = surface
905 .evaluate((u + du).clamp(u0, u1), v)?
906 .sub(here)
907 .length()
908 .max(
909 surface
910 .evaluate((u - du).clamp(u0, u1), v)?
911 .sub(here)
912 .length(),
913 );
914 let along_v = surface
915 .evaluate(u, (v + dv).clamp(v0, v1))?
916 .sub(here)
917 .length()
918 .max(
919 surface
920 .evaluate(u, (v - dv).clamp(v0, v1))?
921 .sub(here)
922 .length(),
923 );
924 let scale = along_u.max(along_v);
925 scale > 0.0 && along_u.min(along_v) < scale * 1e-6
926 };
927 if singular_here {
928 let v_mid = (v0 + v1) * 0.5;
929 let u_mid = (u0 + u1) * 0.5;
930 let mut interior = None;
931 for fraction in [1e-3, 1e-2, 5e-2, 0.25] {
932 let candidate_v = v + (v_mid - v) * fraction;
933 let candidate_u = u + (u_mid - u) * fraction;
934 for (cu, cv) in [(u, candidate_v), (candidate_u, v), (candidate_u, candidate_v)] {
935 if let Some(candidate) = normal_at(cu, cv) {
936 interior = Some(candidate);
937 break;
938 }
939 }
940 if interior.is_some() {
941 break;
942 }
943 }
944 normal = match (normal, interior) {
945 (Some(at_point), Some(interior)) if at_point.dot(interior) > 1.0 - 1e-6 => {
946 Some(at_point)
947 }
948 (_, Some(interior)) => Some(interior),
949 (at_point, None) => at_point,
950 };
951 }
952 let normal =
953 normal.ok_or_else(|| "offset_surface: cannot determine surface normal".to_string())?;
954 Ok(orient(normal, same_sense))
955}
956
957// ---------------------------------------------------------------------------
958// The exact analytic lane
959// ---------------------------------------------------------------------------
960
961/// The exact unit normal of a recognised analytic carrier at `point`, in the
962/// carrier's own canonical orientation *calibrated to agree with `S_u × S_v`*.
963///
964/// Returns `Ok(None)` when there is no exact closed form for this carrier (the
965/// general `Revolution`, and every unrecognised free-form patch), so the caller
966/// can fall back.
967///
968/// **Sign.** The natural closed forms (a sphere's outward radial, a cone's
969/// meridian perpendicular) have no fixed sign relation to the patch's
970/// parametric normal — a `make_revolution` product's `S_u × S_v` points inward
971/// or outward depending on how the generatrix was oriented. So the exact
972/// direction is calibrated against the parametric normal once, at `(u, v)` if
973/// it is regular there and at the domain midpoint otherwise. If no regular
974/// probe exists anywhere tried, there is nothing to calibrate against and the
975/// lane declines.
976fn exact_analytic_normal(
977 surface: &NurbsSurface,
978 u: f64,
979 v: f64,
980 point: Vec3,
981) -> Result<Option<Vec3>, String> {
982 let Some(analytic) = surface.analytic() else {
983 return Ok(None);
984 };
985 let Some(direction) = exact_direction(surface, analytic, u, v, point)? else {
986 return Ok(None);
987 };
988 let ([u0, u1], [v0, v1]) = domains(surface)?;
989 // Calibration probes: here first (free agreement when the patch is regular
990 // at the query), then the domain midpoint, then two off-centre staggers
991 // that miss a seam or a pole row.
992 let probes = [
993 (u, v),
994 ((u0 + u1) * 0.5, (v0 + v1) * 0.5),
995 (u0 + (u1 - u0) * 0.37, v0 + (v1 - v0) * 0.41),
996 (u0 + (u1 - u0) * 0.63, v0 + (v1 - v0) * 0.59),
997 ];
998 for (pu, pv) in probes {
999 let Ok(parametric) = surface.normal(pu, pv) else {
1000 continue;
1001 };
1002 let probe_point = if (pu, pv) == (u, v) {
1003 point
1004 } else {
1005 surface.evaluate(pu, pv)?
1006 };
1007 let Some(probe_direction) = exact_direction(surface, analytic, pu, pv, probe_point)? else {
1008 continue;
1009 };
1010 let alignment = probe_direction.dot(parametric);
1011 // A probe whose two readings are near-orthogonal is not a calibration —
1012 // it is a degenerate row read as noise. Demand a decisive sign.
1013 if alignment.abs() < 0.5 {
1014 continue;
1015 }
1016 return Ok(Some(if alignment > 0.0 {
1017 direction
1018 } else {
1019 direction.scale(-1.0)
1020 }));
1021 }
1022 Ok(None)
1023}
1024
1025/// The closed-form normal direction (unit, canonical orientation, uncalibrated)
1026/// of one analytic carrier at a point known to lie on it.
1027fn exact_direction(
1028 surface: &NurbsSurface,
1029 analytic: &AnalyticSurface,
1030 u: f64,
1031 v: f64,
1032 point: Vec3,
1033) -> Result<Option<Vec3>, String> {
1034 Ok(match analytic {
1035 AnalyticSurface::Plane { u_dir, v_dir, .. } => u_dir.cross(*v_dir).normalized().ok(),
1036 AnalyticSurface::Sphere { frame, .. } => {
1037 // Exact AT the poles, which is precisely where `S_u × S_v`
1038 // vanishes and the Greville fit that `face_offset_sphere.rs`
1039 // rejected went wrong.
1040 point.sub(frame.origin).normalized().ok()
1041 }
1042 AnalyticSurface::RuledRevolution {
1043 frame,
1044 rho0,
1045 rho1,
1046 height,
1047 } => {
1048 let radial = match radial_direction(surface, frame.origin, frame.axis, u, v, point)? {
1049 Some(radial) => radial,
1050 None => return Ok(None),
1051 };
1052 // Meridian generatrix runs (rho0, 0) → (rho1, height) in
1053 // (radial, axial); the in-meridian perpendicular is
1054 // (height, −(rho1 − rho0)) over its length.
1055 let dr = rho1 - rho0;
1056 let length = (dr * dr + height * height).sqrt();
1057 if length <= 0.0 {
1058 return Ok(None);
1059 }
1060 radial
1061 .scale(*height / length)
1062 .sub(frame.axis.scale(dr / length))
1063 .normalized()
1064 .ok()
1065 }
1066 AnalyticSurface::Torus {
1067 frame,
1068 major_radius,
1069 minor_radius,
1070 } => {
1071 let radial = match radial_direction(surface, frame.origin, frame.axis, u, v, point)? {
1072 Some(radial) => radial,
1073 None => return Ok(None),
1074 };
1075 if *minor_radius <= 0.0 {
1076 return Ok(None);
1077 }
1078 let tube_centre = frame.origin.add(radial.scale(*major_radius));
1079 point.sub(tube_centre).normalized().ok()
1080 }
1081 // A general revolution's exact normal needs the generatrix tangent at
1082 // the meridian station, which is a 1D projection — not closed form, and
1083 // no cheaper than `S_u × S_v`. Decline rather than pretend.
1084 AnalyticSurface::Revolution { .. } => None,
1085 })
1086}
1087
1088/// Unit radial direction of `point` about the axis.
1089///
1090/// On the axis itself (a cone apex, or a sphere/torus degeneracy) the point
1091/// carries no azimuth — but the *parameter* still does, because `u` names the
1092/// ruling. Recover it from the opposite end of the same `u` iso-line, which is
1093/// the exact per-ruling limit the `FaceStable` recovery only approximates by
1094/// walking inward.
1095fn radial_direction(
1096 surface: &NurbsSurface,
1097 origin: Vec3,
1098 axis: Vec3,
1099 u: f64,
1100 v: f64,
1101 point: Vec3,
1102) -> Result<Option<Vec3>, String> {
1103 let radial_of = |point: Vec3| {
1104 let relative = point.sub(origin);
1105 relative.sub(axis.scale(relative.dot(axis)))
1106 };
1107 let here = radial_of(point);
1108 let [v0, v1] = surface.domain_v()?;
1109 let scale = point.sub(origin).length().max(1.0);
1110 if here.length() > 1e-12 * scale {
1111 return Ok(here.normalized().ok());
1112 }
1113 let far = if (v - v0).abs() >= (v1 - v).abs() {
1114 v0
1115 } else {
1116 v1
1117 };
1118 let candidate = radial_of(surface.evaluate(u, far)?);
1119 if candidate.length() <= 1e-12 * scale {
1120 return Ok(None);
1121 }
1122 Ok(candidate.normalized().ok())
1123}
1124
1125// ---------------------------------------------------------------------------
1126// Migration diagnostic
1127// ---------------------------------------------------------------------------
1128
1129/// Per-site agreement between the normal lanes, aggregated over one thread.
1130///
1131/// Enabled by `BREP_OFFSET_DIAG`; the value is a sampling stride (`1` = every
1132/// call). Printing per call is useless here — one blend march makes hundreds of
1133/// thousands of evaluations — so the comparison is accumulated and dumped when
1134/// the thread ends, which for `cargo test` is once per test.
1135///
1136/// A site with `calls` but zero disagreement is evidence the corpus does not
1137/// *exercise* the difference, not proof the lanes are equivalent; a site that
1138/// never appears was never reached at all. Both readings matter, so the counts
1139/// are reported alongside the magnitudes.
1140#[derive(Default, Clone, Copy)]
1141struct LaneStats {
1142 compared: u64,
1143 /// Calls where this lane errored but the selected lane did not.
1144 lane_errors: u64,
1145 /// Calls where this lane succeeded and the selected lane did not.
1146 lane_rescues: u64,
1147 /// Worst CHORD distance between this lane's unit normal and the selected
1148 /// lane's, taken as an unsigned DIRECTION: `min(|a − b|, |a + b|)`.
1149 ///
1150 /// Chord, not `acos`: two bitwise identical unit vectors have a dot product
1151 /// one ULP below 1.0 as often as not (three rounded products summed), and
1152 /// `acos` turns that ULP into a flat 1.49e-8 rad floor that hides every
1153 /// real signal beneath it. The chord is `2·sin(θ/2)`, so for a small angle
1154 /// it IS the angle in radians, and it is exactly 0 for identical vectors.
1155 ///
1156 /// Unsigned, because `Raw` carries no orientation while the `Face` lanes
1157 /// apply `same_sense`: on a reversed face they are exact opposites, and
1158 /// that is the CONVENTION difference (§4.2), not a geometry difference.
1159 /// The flips are counted separately so neither reading is lost.
1160 worst_chord: f64,
1161 /// Calls where this lane's normal pointed OPPOSITE the selected lane's.
1162 flipped: u64,
1163 worst_u: f64,
1164 worst_v: f64,
1165}
1166
1167#[derive(Default, Clone, Copy)]
1168struct SiteStats {
1169 calls: u64,
1170 /// Calls where the lane the caller SELECTED could not produce a normal.
1171 errors: u64,
1172 convention: Option<OffsetNormal>,
1173 lanes: [LaneStats; 4],
1174}
1175
1176const LANE_NAMES: [&str; 4] = ["raw", "face", "stable", "exact"];
1177
1178fn lane_of(index: usize, same_sense: bool) -> OffsetNormal {
1179 match index {
1180 0 => OffsetNormal::Raw,
1181 1 => OffsetNormal::Face { same_sense },
1182 2 => OffsetNormal::FaceStable { same_sense },
1183 _ => OffsetNormal::ExactAnalytic { same_sense },
1184 }
1185}
1186
1187struct DiagnosticSink {
1188 sites: std::collections::BTreeMap<&'static str, SiteStats>,
1189}
1190
1191impl Drop for DiagnosticSink {
1192 fn drop(&mut self) {
1193 use std::fmt::Write as _;
1194 for (site, stats) in &self.sites {
1195 // Build the WHOLE line first and emit it with ONE `eprintln!`.
1196 // Threads exit concurrently under `cargo test`, and a report
1197 // assembled from a run of `eprint!`s interleaves mid-line with
1198 // another thread's — the first version of this lost ~60% of its
1199 // reports to exactly that, which looked like missing sites rather
1200 // than shredded ones.
1201 let mut line = format!(
1202 "offset-diag site={site} convention={} calls={} errors={}",
1203 stats
1204 .convention
1205 .map(|convention| format!("{convention:?}"))
1206 .unwrap_or_else(|| "?".to_string()),
1207 stats.calls,
1208 stats.errors
1209 );
1210 for (index, lane) in stats.lanes.iter().enumerate() {
1211 if lane.compared == 0 && lane.lane_errors == 0 && lane.lane_rescues == 0 {
1212 continue;
1213 }
1214 let _ = write!(
1215 line,
1216 " | {}: n={} chord={:.3e} at=({:.6},{:.6}) flip={} lane_err={} rescue={}",
1217 LANE_NAMES[index],
1218 lane.compared,
1219 lane.worst_chord,
1220 lane.worst_u,
1221 lane.worst_v,
1222 lane.flipped,
1223 lane.lane_errors,
1224 lane.lane_rescues
1225 );
1226 }
1227 eprintln!("{line}");
1228 }
1229 }
1230}
1231
1232fn diagnostic_stride() -> u64 {
1233 static STRIDE: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
1234 *STRIDE.get_or_init(|| match std::env::var("BREP_OFFSET_DIAG") {
1235 Ok(value) => value.trim().parse::<u64>().unwrap_or(1).max(1),
1236 Err(_) => 0,
1237 })
1238}
1239
1240thread_local! {
1241 static DIAGNOSTIC: std::cell::RefCell<DiagnosticSink> = std::cell::RefCell::new(DiagnosticSink {
1242 sites: std::collections::BTreeMap::new(),
1243 });
1244}
1245
1246/// Compare every lane against the one the caller selected, and accumulate.
1247///
1248/// Off unless `BREP_OFFSET_DIAG` is set, and then the only cost on the hot path
1249/// is one `OnceLock` read and a modulo.
1250fn offset_normal_diagnostic(
1251 site: &'static str,
1252 surface: &NurbsSurface,
1253 convention: OffsetNormal,
1254 u: f64,
1255 v: f64,
1256 selected: &Result<Vec3, String>,
1257) {
1258 let stride = diagnostic_stride();
1259 if stride == 0 {
1260 return;
1261 }
1262 let sampled = DIAGNOSTIC.with(|sink| {
1263 let mut sink = sink.borrow_mut();
1264 let stats = sink.sites.entry(site).or_default();
1265 stats.convention = Some(convention);
1266 stats.calls += 1;
1267 if selected.is_err() {
1268 stats.errors += 1;
1269 }
1270 stats.calls % stride == 0
1271 });
1272 if !sampled {
1273 return;
1274 }
1275 let evaluator = OffsetEvaluator::new(site, surface, convention);
1276 let same_sense = convention.same_sense();
1277 let mut readings = [(0usize, f64::NAN, false, false); 4];
1278 for (index, reading) in readings.iter_mut().enumerate() {
1279 let lane = lane_of(index, same_sense);
1280 let value = evaluator.normal_with(lane, u, v);
1281 *reading = match (selected, &value) {
1282 (Ok(chosen), Ok(other)) => {
1283 let aligned = chosen.sub(*other).length();
1284 let opposed = chosen.add(*other).length();
1285 (index, aligned.min(opposed), true, opposed < aligned)
1286 }
1287 (Ok(_), Err(_)) => (index, f64::NAN, false, false),
1288 (Err(_), Ok(_)) => (index, f64::NAN, true, false),
1289 (Err(_), Err(_)) => (index, f64::NAN, false, false),
1290 };
1291 }
1292 DIAGNOSTIC.with(|sink| {
1293 let mut sink = sink.borrow_mut();
1294 let stats = sink.sites.entry(site).or_default();
1295 for (index, chord, lane_ok, flipped) in readings {
1296 let lane = &mut stats.lanes[index];
1297 if chord.is_finite() {
1298 lane.compared += 1;
1299 if flipped {
1300 lane.flipped += 1;
1301 }
1302 if chord > lane.worst_chord {
1303 lane.worst_chord = chord;
1304 lane.worst_u = u;
1305 lane.worst_v = v;
1306 }
1307 } else if selected.is_err() && lane_ok {
1308 lane.lane_rescues += 1;
1309 } else if selected.is_ok() && !lane_ok {
1310 lane.lane_errors += 1;
1311 }
1312 }
1313 });
1314}
1315