Skip to main content

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
7//! ([offset-unification-audit.md](../../../docs/developer/kernel-plans/offset-unification-audit.md))
8//! records four independent copies; a fifth (`blend/edge/keep.rs`) and two more
9//! in offset-shell turned up while this module was written. They are collected
10//! here.
11//!
12//! # What this module is NOT
13//!
14//! It is **not** [`crate::offset_surface`]. That function is a Greville *fit*:
15//! it samples the offset at the source basis's Greville parameters and
16//! re-interpolates. Two independent reasons a fit is the wrong shared seam are
17//! recorded in-tree — handing a fitted surface to the blend march's Newton loop
18//! would inject the fit's approximation error into a residual whose bar is
19//! `1e-11·(1+scale)` (`blending/blend/stations.rs`), and
20//! `edit/direct_edit/face_offset_sphere.rs` records that `offset_surface` was
21//! tried for the full-sphere push and *failed*, because the Greville fit
22//! degenerates at the poles and the result stops re-recognising as a sphere.
23//! The shared foundation is the **pointwise evaluator**; `offset_surface` is one
24//! of its consumers (a fit of it), not the other way round.
25//!
26//! # The sign convention
27//!
28//! There is exactly ONE here: **`distance` is signed ALONG the returned
29//! normal.** `point = source + distance · normal`. The kernel's other
30//! convention — `offset_surface`'s "positive distance moves *opposite* the
31//! face's outward normal" — is expressed by its adapter negating on the way in,
32//! at one labelled place, instead of by four unlabelled hand negations
33//! (audit §4.1).
34//!
35//! # The orientation conventions
36//!
37//! Orientation is an explicit parameter, never read off a `FaceRecord`, because
38//! the callers genuinely disagree (audit §4.2) and both readings are
39//! load-bearing. [`OffsetNormal`] has exactly the three that exist in the tree,
40//! and each is defined to be *bit-identical* to the formula it replaces — see
41//! its variant docs.
42
43use crate::{AnalyticSurface, NurbsSurface, Vec3};
44
45/// Which unit normal the offset rides, and how it is recovered where the
46/// parametric normal degenerates.
47///
48/// Every variant names an existing in-tree formula and is bit-identical to it.
49/// They differ ONLY where the parameter leaves the domain or the cross product
50/// `S_u × S_v` collapses; a shared evaluator that silently picked one for
51/// everybody would move behaviour at exactly the singular points the callers
52/// each decided about on purpose.
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum OffsetNormal {
55    /// The bare `S_u × S_v`, normalized, through the C¹ domain extension
56    /// (`deriv1_extended`). Orientation is the *caller's* business — the blend
57    /// march carries it in the sign of ρ instead (`signed_radii`), which is why
58    /// this variant must not apply `same_sense`.
59    ///
60    /// No singular recovery: where the cross product collapses this is an
61    /// error, deliberately, so the march keeps refusing exactly where it
62    /// refuses today.
63    ///
64    /// Bit-identical to `blending::blend::stations::raw_normal` (and to
65    /// `evaluate_extended` for the point).
66    Raw,
67    /// `NurbsSurface::normal` — the in-domain `derivatives(u, v, 1)` cross —
68    /// negated when `same_sense` is false. Clamped, not extended, and no
69    /// singular recovery.
70    ///
71    /// Bit-identical to the `let mut normal = surface.normal(u, v)?; if
72    /// !face.same_sense { normal = normal.scale(-1.0) }` block written out in
73    /// `face_offset_revolution.rs`, `face_offset_freeform.rs`,
74    /// `face_offset_torus.rs` and `offset_shell/smooth_sync.rs::face_normal`.
75    Face { same_sense: bool },
76    /// [`OffsetNormal::Face`] plus the singular-row recovery that
77    /// `offset_surface` depends on: nudge off a point where the normal is
78    /// undefined, and at a *collapsed parameter row* (a cone apex) walk deep
79    /// inward for the true per-ruling limit rather than accept the axis
80    /// direction the at-point cross degenerates to.
81    ///
82    /// Bit-identical to the former `offset::stable_face_normal`, whose body
83    /// this is. The blend march must NOT inherit it — the recovery would
84    /// change the residual at poles, so it stays opt-in (audit slice 1,
85    /// "keep it as an opt-in flag").
86    FaceStable { same_sense: bool },
87    /// The exact closed-form normal of the *recognised analytic carrier*,
88    /// oriented like [`OffsetNormal::Face`].
89    ///
90    /// This is the "exact per analytic surface type" lane: a sphere's normal is
91    /// `(p − centre)/r` — defined at the poles, where `S_u × S_v` vanishes — a
92    /// cylinder's is its radial direction, a cone's is the meridian
93    /// perpendicular (defined even AT the apex, from the ruling's own azimuth),
94    /// a torus's is `(p − tube centre)/r`. Where there is no exact form (the
95    /// general `Revolution`, and every free-form patch) it falls back to
96    /// [`OffsetNormal::Face`], so this variant is always at least as defined as
97    /// that one.
98    ///
99    /// `NurbsSurface::analytic` memoizes per instance, so recognition is paid
100    /// once per surface, not once per point.
101    ///
102    /// **No consumer rides this lane yet.** It exists as the exact half of the
103    /// evaluator's contract and as the diagnostic's comparator; switching a
104    /// caller onto it changes that caller's last digits and is a separate,
105    /// measured step. See [`offset_normal_diagnostic`].
106    ExactAnalytic { same_sense: bool },
107}
108
109impl OffsetNormal {
110    /// The `same_sense`-carrying variants, for a caller that has a face.
111    fn same_sense(self) -> bool {
112        match self {
113            OffsetNormal::Raw => true,
114            OffsetNormal::Face { same_sense }
115            | OffsetNormal::FaceStable { same_sense }
116            | OffsetNormal::ExactAnalytic { same_sense } => same_sense,
117        }
118    }
119}
120
121/// One evaluation of the offset: the source point, the unit normal the offset
122/// rides, and the offset point itself.
123///
124/// The normal is also the *offset surface's own* normal wherever the offset is
125/// regular — it flips only past the evolute, which is what
126/// `thicken::ensure_offsets_regular` (the kernel's only curvature gate) exists
127/// to refuse. This struct deliberately carries no other derivative data: the
128/// five consumers were audited and not one of them uses `S_u`/`S_v` for
129/// anything but the normal, and the blend march's Jacobian is finite-differenced
130/// on the residual rather than assembled from partials.
131#[derive(Clone, Copy, Debug)]
132pub struct OffsetSample {
133    /// `S(u, v)` on the source carrier.
134    pub source: Vec3,
135    /// The unit normal, in the requested orientation.
136    pub normal: Vec3,
137    /// `source + distance · normal`.
138    pub point: Vec3,
139}
140
141/// The pointwise offset evaluator, bound to one carrier and one orientation
142/// convention.
143///
144/// Construction is free — it stores three references and reads nothing — so a
145/// caller inside a Newton loop may build one per call without paying anything.
146/// `site` is a short stable label used only by [`offset_normal_diagnostic`].
147#[derive(Clone, Copy)]
148pub struct OffsetEvaluator<'a> {
149    site: &'static str,
150    surface: &'a NurbsSurface,
151    convention: OffsetNormal,
152}
153
154impl<'a> OffsetEvaluator<'a> {
155    pub fn new(site: &'static str, surface: &'a NurbsSurface, convention: OffsetNormal) -> Self {
156        Self {
157            site,
158            surface,
159            convention,
160        }
161    }
162
163    /// The unit normal alone, for callers that only need the direction (a
164    /// residual check, or an affine offset that shifts the whole control net by
165    /// one vector). Evaluates the point only on the lanes that need it.
166    pub fn normal(&self, u: f64, v: f64) -> Result<Vec3, String> {
167        let result = self.normal_with(self.convention, u, v);
168        offset_normal_diagnostic(self.site, self.surface, self.convention, u, v, &result);
169        result
170    }
171
172    /// The offset of this carrier at `(u, v)` by `distance` **along the
173    /// normal** (see the module's sign convention).
174    pub fn at(&self, u: f64, v: f64, distance: f64) -> Result<OffsetSample, String> {
175        let (source, normal) = self.evaluate(u, v)?;
176        Ok(OffsetSample {
177            source,
178            normal,
179            point: source.add(normal.scale(distance)),
180        })
181    }
182
183    fn evaluate(&self, u: f64, v: f64) -> Result<(Vec3, Vec3), String> {
184        let result = match self.convention {
185            // ONE evaluation for the point and the partials the normal needs —
186            // `deriv1_extended`'s point is bit-identical to `evaluate_extended`,
187            // which is what licenses `blend/edge/keep.rs` to drop its second
188            // evaluation of the same surface.
189            OffsetNormal::Raw => self
190                .surface
191                .deriv1_extended(u, v)
192                .and_then(|(point, su, sv)| Ok((point, su.cross(sv).normalized()?))),
193            convention => self
194                .surface
195                .evaluate(u, v)
196                .and_then(|point| Ok((point, self.normal_with(convention, u, v)?))),
197        };
198        let normal = result
199            .as_ref()
200            .map(|(_, normal)| *normal)
201            .map_err(Clone::clone);
202        offset_normal_diagnostic(self.site, self.surface, self.convention, u, v, &normal);
203        result
204    }
205
206    fn normal_with(&self, convention: OffsetNormal, u: f64, v: f64) -> Result<Vec3, String> {
207        match convention {
208            OffsetNormal::Raw => {
209                let (_, su, sv) = self.surface.deriv1_extended(u, v)?;
210                su.cross(sv).normalized()
211            }
212            OffsetNormal::Face { same_sense } => {
213                Ok(orient(self.surface.normal(u, v)?, same_sense))
214            }
215            OffsetNormal::FaceStable { same_sense } => {
216                stable_normal(self.surface, same_sense, u, v)
217            }
218            OffsetNormal::ExactAnalytic { same_sense } => {
219                let point = self.surface.evaluate(u, v)?;
220                match exact_analytic_normal(self.surface, u, v, point)? {
221                    Some(normal) => Ok(orient(normal, same_sense)),
222                    None => Ok(orient(self.surface.normal(u, v)?, same_sense)),
223                }
224            }
225        }
226    }
227}
228
229fn orient(normal: Vec3, same_sense: bool) -> Vec3 {
230    if same_sense {
231        normal
232    } else {
233        normal.scale(-1.0)
234    }
235}
236
237fn domains(surface: &NurbsSurface) -> Result<([f64; 2], [f64; 2]), String> {
238    Ok((surface.domain_u()?, surface.domain_v()?))
239}
240
241/// The body of the former `offset::stable_face_normal`, verbatim, with the
242/// `FaceRecord` replaced by the `same_sense` flag it read (audit §4.5: the
243/// `FaceRecord` requirement is a type mismatch, not a geometric one — it is why
244/// `thicken` fabricates a synthetic face with empty loops just to offset a bare
245/// surface).
246fn stable_normal(
247    surface: &NurbsSurface,
248    same_sense: bool,
249    u: f64,
250    v: f64,
251) -> Result<Vec3, String> {
252    let normal_at = |u, v| surface.normal(u, v).ok();
253    let mut normal = normal_at(u, v);
254    let ([u0, u1], [v0, v1]) = domains(surface)?;
255    if normal.is_none() {
256        let du = (u1 - u0) * 1e-5;
257        let dv = (v1 - v0) * 1e-5;
258        for (candidate_u, candidate_v) in [
259            ((u + du).clamp(u0, u1), v),
260            ((u - du).clamp(u0, u1), v),
261            (u, (v + dv).clamp(v0, v1)),
262            (u, (v - dv).clamp(v0, v1)),
263        ] {
264            normal = normal_at(candidate_u, candidate_v);
265            if normal.is_some() {
266                break;
267            }
268        }
269    }
270    // SINGULAR-ROW override: at a surface singularity where a whole
271    // parameter row collapses to one point (a cone apex), the at-point /
272    // nudged normal is the cross of a vanishing partial with noise — the
273    // AXIS direction instead of the ruling normal, which offsets the apex
274    // row straight down the axis and bends the fitted surface by exactly
275    // d·cos(half-angle). Detect the collapse by local point spread, walk
276    // DEEP inward for the true per-ruling limit, and replace the at-point
277    // value only when the two genuinely DISAGREE. A sphere/dome pole also
278    // reads as collapsed, but there the at-point normal (the axis) IS the
279    // limit — agreement keeps the exact baseline value.
280    //
281    // (`OffsetNormal::ExactAnalytic` answers this case in closed form for a
282    // recognised cone — the ruling's own azimuth, not a deep-inward probe —
283    // but no consumer rides that lane yet, so this stays the `offset_surface`
284    // behaviour it always was.)
285    let singular_here = {
286        let du = (u1 - u0) * 1e-4;
287        let dv = (v1 - v0) * 1e-4;
288        let here = surface.evaluate(u, v)?;
289        let along_u = surface
290            .evaluate((u + du).clamp(u0, u1), v)?
291            .sub(here)
292            .length()
293            .max(
294                surface
295                    .evaluate((u - du).clamp(u0, u1), v)?
296                    .sub(here)
297                    .length(),
298            );
299        let along_v = surface
300            .evaluate(u, (v + dv).clamp(v0, v1))?
301            .sub(here)
302            .length()
303            .max(
304                surface
305                    .evaluate(u, (v - dv).clamp(v0, v1))?
306                    .sub(here)
307                    .length(),
308            );
309        let scale = along_u.max(along_v);
310        scale > 0.0 && along_u.min(along_v) < scale * 1e-6
311    };
312    if singular_here {
313        let v_mid = (v0 + v1) * 0.5;
314        let u_mid = (u0 + u1) * 0.5;
315        let mut interior = None;
316        for fraction in [1e-3, 1e-2, 5e-2, 0.25] {
317            let candidate_v = v + (v_mid - v) * fraction;
318            let candidate_u = u + (u_mid - u) * fraction;
319            for (cu, cv) in [(u, candidate_v), (candidate_u, v), (candidate_u, candidate_v)] {
320                if let Some(candidate) = normal_at(cu, cv) {
321                    interior = Some(candidate);
322                    break;
323                }
324            }
325            if interior.is_some() {
326                break;
327            }
328        }
329        normal = match (normal, interior) {
330            (Some(at_point), Some(interior)) if at_point.dot(interior) > 1.0 - 1e-6 => {
331                Some(at_point)
332            }
333            (_, Some(interior)) => Some(interior),
334            (at_point, None) => at_point,
335        };
336    }
337    let normal =
338        normal.ok_or_else(|| "offset_surface: cannot determine surface normal".to_string())?;
339    Ok(orient(normal, same_sense))
340}
341
342// ---------------------------------------------------------------------------
343// The exact analytic lane
344// ---------------------------------------------------------------------------
345
346/// The exact unit normal of a recognised analytic carrier at `point`, in the
347/// carrier's own canonical orientation *calibrated to agree with `S_u × S_v`*.
348///
349/// Returns `Ok(None)` when there is no exact closed form for this carrier (the
350/// general `Revolution`, and every unrecognised free-form patch), so the caller
351/// can fall back.
352///
353/// **Sign.** The natural closed forms (a sphere's outward radial, a cone's
354/// meridian perpendicular) have no fixed sign relation to the patch's
355/// parametric normal — a `make_revolution` product's `S_u × S_v` points inward
356/// or outward depending on how the generatrix was oriented. So the exact
357/// direction is calibrated against the parametric normal once, at `(u, v)` if
358/// it is regular there and at the domain midpoint otherwise. If no regular
359/// probe exists anywhere tried, there is nothing to calibrate against and the
360/// lane declines.
361fn exact_analytic_normal(
362    surface: &NurbsSurface,
363    u: f64,
364    v: f64,
365    point: Vec3,
366) -> Result<Option<Vec3>, String> {
367    let Some(analytic) = surface.analytic() else {
368        return Ok(None);
369    };
370    let Some(direction) = exact_direction(surface, analytic, u, v, point)? else {
371        return Ok(None);
372    };
373    let ([u0, u1], [v0, v1]) = domains(surface)?;
374    // Calibration probes: here first (free agreement when the patch is regular
375    // at the query), then the domain midpoint, then two off-centre staggers
376    // that miss a seam or a pole row.
377    let probes = [
378        (u, v),
379        ((u0 + u1) * 0.5, (v0 + v1) * 0.5),
380        (u0 + (u1 - u0) * 0.37, v0 + (v1 - v0) * 0.41),
381        (u0 + (u1 - u0) * 0.63, v0 + (v1 - v0) * 0.59),
382    ];
383    for (pu, pv) in probes {
384        let Ok(parametric) = surface.normal(pu, pv) else {
385            continue;
386        };
387        let probe_point = if (pu, pv) == (u, v) {
388            point
389        } else {
390            surface.evaluate(pu, pv)?
391        };
392        let Some(probe_direction) = exact_direction(surface, analytic, pu, pv, probe_point)? else {
393            continue;
394        };
395        let alignment = probe_direction.dot(parametric);
396        // A probe whose two readings are near-orthogonal is not a calibration —
397        // it is a degenerate row read as noise. Demand a decisive sign.
398        if alignment.abs() < 0.5 {
399            continue;
400        }
401        return Ok(Some(if alignment > 0.0 {
402            direction
403        } else {
404            direction.scale(-1.0)
405        }));
406    }
407    Ok(None)
408}
409
410/// The closed-form normal direction (unit, canonical orientation, uncalibrated)
411/// of one analytic carrier at a point known to lie on it.
412fn exact_direction(
413    surface: &NurbsSurface,
414    analytic: &AnalyticSurface,
415    u: f64,
416    v: f64,
417    point: Vec3,
418) -> Result<Option<Vec3>, String> {
419    Ok(match analytic {
420        AnalyticSurface::Plane { u_dir, v_dir, .. } => u_dir.cross(*v_dir).normalized().ok(),
421        AnalyticSurface::Sphere { frame, .. } => {
422            // Exact AT the poles, which is precisely where `S_u × S_v`
423            // vanishes and the Greville fit that `face_offset_sphere.rs`
424            // rejected went wrong.
425            point.sub(frame.origin).normalized().ok()
426        }
427        AnalyticSurface::RuledRevolution {
428            frame,
429            rho0,
430            rho1,
431            height,
432        } => {
433            let radial = match radial_direction(surface, frame.origin, frame.axis, u, v, point)? {
434                Some(radial) => radial,
435                None => return Ok(None),
436            };
437            // Meridian generatrix runs (rho0, 0) → (rho1, height) in
438            // (radial, axial); the in-meridian perpendicular is
439            // (height, −(rho1 − rho0)) over its length.
440            let dr = rho1 - rho0;
441            let length = (dr * dr + height * height).sqrt();
442            if length <= 0.0 {
443                return Ok(None);
444            }
445            radial
446                .scale(*height / length)
447                .sub(frame.axis.scale(dr / length))
448                .normalized()
449                .ok()
450        }
451        AnalyticSurface::Torus {
452            frame,
453            major_radius,
454            minor_radius,
455        } => {
456            let radial = match radial_direction(surface, frame.origin, frame.axis, u, v, point)? {
457                Some(radial) => radial,
458                None => return Ok(None),
459            };
460            if *minor_radius <= 0.0 {
461                return Ok(None);
462            }
463            let tube_centre = frame.origin.add(radial.scale(*major_radius));
464            point.sub(tube_centre).normalized().ok()
465        }
466        // A general revolution's exact normal needs the generatrix tangent at
467        // the meridian station, which is a 1D projection — not closed form, and
468        // no cheaper than `S_u × S_v`. Decline rather than pretend.
469        AnalyticSurface::Revolution { .. } => None,
470    })
471}
472
473/// Unit radial direction of `point` about the axis.
474///
475/// On the axis itself (a cone apex, or a sphere/torus degeneracy) the point
476/// carries no azimuth — but the *parameter* still does, because `u` names the
477/// ruling. Recover it from the opposite end of the same `u` iso-line, which is
478/// the exact per-ruling limit the `FaceStable` recovery only approximates by
479/// walking inward.
480fn radial_direction(
481    surface: &NurbsSurface,
482    origin: Vec3,
483    axis: Vec3,
484    u: f64,
485    v: f64,
486    point: Vec3,
487) -> Result<Option<Vec3>, String> {
488    let radial_of = |point: Vec3| {
489        let relative = point.sub(origin);
490        relative.sub(axis.scale(relative.dot(axis)))
491    };
492    let here = radial_of(point);
493    let [v0, v1] = surface.domain_v()?;
494    let scale = point.sub(origin).length().max(1.0);
495    if here.length() > 1e-12 * scale {
496        return Ok(here.normalized().ok());
497    }
498    let far = if (v - v0).abs() >= (v1 - v).abs() {
499        v0
500    } else {
501        v1
502    };
503    let candidate = radial_of(surface.evaluate(u, far)?);
504    if candidate.length() <= 1e-12 * scale {
505        return Ok(None);
506    }
507    Ok(candidate.normalized().ok())
508}
509
510// ---------------------------------------------------------------------------
511// Migration diagnostic
512// ---------------------------------------------------------------------------
513
514/// Per-site agreement between the normal lanes, aggregated over one thread.
515///
516/// Enabled by `BREP_OFFSET_DIAG`; the value is a sampling stride (`1` = every
517/// call). Printing per call is useless here — one blend march makes hundreds of
518/// thousands of evaluations — so the comparison is accumulated and dumped when
519/// the thread ends, which for `cargo test` is once per test.
520///
521/// A site with `calls` but zero disagreement is evidence the corpus does not
522/// *exercise* the difference, not proof the lanes are equivalent; a site that
523/// never appears was never reached at all. Both readings matter, so the counts
524/// are reported alongside the magnitudes.
525#[derive(Default, Clone, Copy)]
526struct LaneStats {
527    compared: u64,
528    /// Calls where this lane errored but the selected lane did not.
529    lane_errors: u64,
530    /// Calls where this lane succeeded and the selected lane did not.
531    lane_rescues: u64,
532    /// Worst CHORD distance between this lane's unit normal and the selected
533    /// lane's, taken as an unsigned DIRECTION: `min(|a − b|, |a + b|)`.
534    ///
535    /// Chord, not `acos`: two bitwise identical unit vectors have a dot product
536    /// one ULP below 1.0 as often as not (three rounded products summed), and
537    /// `acos` turns that ULP into a flat 1.49e-8 rad floor that hides every
538    /// real signal beneath it.  The chord is `2·sin(θ/2)`, so for a small angle
539    /// it IS the angle in radians, and it is exactly 0 for identical vectors.
540    ///
541    /// Unsigned, because `Raw` carries no orientation while the `Face` lanes
542    /// apply `same_sense`: on a reversed face they are exact opposites, and
543    /// that is the CONVENTION difference (§4.2), not a geometry difference.
544    /// The flips are counted separately so neither reading is lost.
545    worst_chord: f64,
546    /// Calls where this lane's normal pointed OPPOSITE the selected lane's.
547    flipped: u64,
548    worst_u: f64,
549    worst_v: f64,
550}
551
552#[derive(Default, Clone, Copy)]
553struct SiteStats {
554    calls: u64,
555    /// Calls where the lane the caller SELECTED could not produce a normal.
556    errors: u64,
557    convention: Option<OffsetNormal>,
558    lanes: [LaneStats; 4],
559}
560
561const LANE_NAMES: [&str; 4] = ["raw", "face", "stable", "exact"];
562
563fn lane_of(index: usize, same_sense: bool) -> OffsetNormal {
564    match index {
565        0 => OffsetNormal::Raw,
566        1 => OffsetNormal::Face { same_sense },
567        2 => OffsetNormal::FaceStable { same_sense },
568        _ => OffsetNormal::ExactAnalytic { same_sense },
569    }
570}
571
572struct DiagnosticSink {
573    sites: std::collections::BTreeMap<&'static str, SiteStats>,
574}
575
576impl Drop for DiagnosticSink {
577    fn drop(&mut self) {
578        use std::fmt::Write as _;
579        for (site, stats) in &self.sites {
580            // Build the WHOLE line first and emit it with ONE `eprintln!`.
581            // Threads exit concurrently under `cargo test`, and a report
582            // assembled from a run of `eprint!`s interleaves mid-line with
583            // another thread's — the first version of this lost ~60% of its
584            // reports to exactly that, which looked like missing sites rather
585            // than shredded ones.
586            let mut line = format!(
587                "offset-diag site={site} convention={} calls={} errors={}",
588                stats
589                    .convention
590                    .map(|convention| format!("{convention:?}"))
591                    .unwrap_or_else(|| "?".to_string()),
592                stats.calls,
593                stats.errors
594            );
595            for (index, lane) in stats.lanes.iter().enumerate() {
596                if lane.compared == 0 && lane.lane_errors == 0 && lane.lane_rescues == 0 {
597                    continue;
598                }
599                let _ = write!(
600                    line,
601                    " | {}: n={} chord={:.3e} at=({:.6},{:.6}) flip={} lane_err={} rescue={}",
602                    LANE_NAMES[index],
603                    lane.compared,
604                    lane.worst_chord,
605                    lane.worst_u,
606                    lane.worst_v,
607                    lane.flipped,
608                    lane.lane_errors,
609                    lane.lane_rescues
610                );
611            }
612            eprintln!("{line}");
613        }
614    }
615}
616
617fn diagnostic_stride() -> u64 {
618    static STRIDE: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
619    *STRIDE.get_or_init(|| match std::env::var("BREP_OFFSET_DIAG") {
620        Ok(value) => value.trim().parse::<u64>().unwrap_or(1).max(1),
621        Err(_) => 0,
622    })
623}
624
625thread_local! {
626    static DIAGNOSTIC: std::cell::RefCell<DiagnosticSink> = std::cell::RefCell::new(DiagnosticSink {
627        sites: std::collections::BTreeMap::new(),
628    });
629}
630
631/// Compare every lane against the one the caller selected, and accumulate.
632///
633/// Off unless `BREP_OFFSET_DIAG` is set, and then the only cost on the hot path
634/// is one `OnceLock` read and a modulo.
635fn offset_normal_diagnostic(
636    site: &'static str,
637    surface: &NurbsSurface,
638    convention: OffsetNormal,
639    u: f64,
640    v: f64,
641    selected: &Result<Vec3, String>,
642) {
643    let stride = diagnostic_stride();
644    if stride == 0 {
645        return;
646    }
647    let sampled = DIAGNOSTIC.with(|sink| {
648        let mut sink = sink.borrow_mut();
649        let stats = sink.sites.entry(site).or_default();
650        stats.convention = Some(convention);
651        stats.calls += 1;
652        if selected.is_err() {
653            stats.errors += 1;
654        }
655        stats.calls % stride == 0
656    });
657    if !sampled {
658        return;
659    }
660    let evaluator = OffsetEvaluator::new(site, surface, convention);
661    let same_sense = convention.same_sense();
662    let mut readings = [(0usize, f64::NAN, false, false); 4];
663    for (index, reading) in readings.iter_mut().enumerate() {
664        let lane = lane_of(index, same_sense);
665        let value = evaluator.normal_with(lane, u, v);
666        *reading = match (selected, &value) {
667            (Ok(chosen), Ok(other)) => {
668                let aligned = chosen.sub(*other).length();
669                let opposed = chosen.add(*other).length();
670                (index, aligned.min(opposed), true, opposed < aligned)
671            }
672            (Ok(_), Err(_)) => (index, f64::NAN, false, false),
673            (Err(_), Ok(_)) => (index, f64::NAN, true, false),
674            (Err(_), Err(_)) => (index, f64::NAN, false, false),
675        };
676    }
677    DIAGNOSTIC.with(|sink| {
678        let mut sink = sink.borrow_mut();
679        let stats = sink.sites.entry(site).or_default();
680        for (index, chord, lane_ok, flipped) in readings {
681            let lane = &mut stats.lanes[index];
682            if chord.is_finite() {
683                lane.compared += 1;
684                if flipped {
685                    lane.flipped += 1;
686                }
687                if chord > lane.worst_chord {
688                    lane.worst_chord = chord;
689                    lane.worst_u = u;
690                    lane.worst_v = v;
691                }
692            } else if selected.is_err() && lane_ok {
693                lane.lane_rescues += 1;
694            } else if selected.is_ok() && !lane_ok {
695                lane.lane_errors += 1;
696            }
697        }
698    });
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704    use crate::{make_box_brep, make_cylinder_brep, make_sphere_brep, Vec3};
705
706    fn cylinder() -> crate::topology::BrepSolid {
707        make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 2.0, 5.0).unwrap()
708    }
709
710    fn sphere() -> crate::topology::BrepSolid {
711        make_sphere_brep(Vec3::default(), 3.0, Vec3::new(0.0, 0.0, 1.0)).unwrap()
712    }
713
714    fn surfaces(solid: &crate::topology::BrepSolid) -> Vec<(&NurbsSurface, bool)> {
715        solid
716            .shells
717            .iter()
718            .flat_map(|shell| &shell.faces)
719            .map(|face| (&face.surface, face.same_sense))
720            .collect()
721    }
722
723    /// The `Raw` lane's point must be bit-identical to `evaluate_extended`,
724    /// because that identity is what licenses `blend/edge/keep.rs` to drop its
725    /// second surface evaluation.
726    #[test]
727    fn the_raw_lane_point_is_bitwise_evaluate_extended() {
728        let solid = cylinder();
729        for (surface, _) in surfaces(&solid) {
730            let [u0, u1] = surface.domain_u().unwrap();
731            let [v0, v1] = surface.domain_v().unwrap();
732            let evaluator = OffsetEvaluator::new("test", surface, OffsetNormal::Raw);
733            for iu in 0..5 {
734                for iv in 0..5 {
735                    // Deliberately overshoot the domain on one corner so the
736                    // C¹ extension branch is exercised too.
737                    let u = u0 + (u1 - u0) * (iu as f64 - 0.1) / 4.0;
738                    let v = v0 + (v1 - v0) * (iv as f64 - 0.1) / 4.0;
739                    let expected = surface.evaluate_extended(u, v).unwrap();
740                    let Ok(sample) = evaluator.at(u, v, 0.5) else {
741                        continue;
742                    };
743                    assert_eq!(sample.source.x.to_bits(), expected.x.to_bits());
744                    assert_eq!(sample.source.y.to_bits(), expected.y.to_bits());
745                    assert_eq!(sample.source.z.to_bits(), expected.z.to_bits());
746                }
747            }
748        }
749    }
750
751    /// The `Face` lane must be bitwise the `surface.normal()` + `same_sense`
752    /// flip block the four push/shell call sites wrote out by hand.
753    #[test]
754    fn the_face_lane_is_bitwise_the_hand_written_block() {
755        let solid = sphere();
756        for (surface, same_sense) in surfaces(&solid) {
757            let [u0, u1] = surface.domain_u().unwrap();
758            let [v0, v1] = surface.domain_v().unwrap();
759            let evaluator = OffsetEvaluator::new("test", surface, OffsetNormal::Face { same_sense });
760            for iu in 0..7 {
761                for iv in 0..7 {
762                    let u = u0 + (u1 - u0) * (iu as f64 + 0.31) / 7.0;
763                    let v = v0 + (v1 - v0) * (iv as f64 + 0.43) / 7.0;
764                    let point = surface.evaluate(u, v).unwrap();
765                    let mut expected = surface.normal(u, v).unwrap();
766                    if !same_sense {
767                        expected = expected.scale(-1.0);
768                    }
769                    let sample = evaluator.at(u, v, -0.25).unwrap();
770                    assert_eq!(sample.normal.x.to_bits(), expected.x.to_bits());
771                    assert_eq!(sample.normal.y.to_bits(), expected.y.to_bits());
772                    assert_eq!(sample.normal.z.to_bits(), expected.z.to_bits());
773                    let offset = point.add(expected.scale(-0.25));
774                    assert_eq!(sample.point.x.to_bits(), offset.x.to_bits());
775                    assert_eq!(sample.point.y.to_bits(), offset.y.to_bits());
776                    assert_eq!(sample.point.z.to_bits(), offset.z.to_bits());
777                }
778            }
779        }
780    }
781
782    /// The `Raw` and `Face` lanes are NOT interchangeable outside the domain,
783    /// and the difference is large, not a rounding detail.
784    ///
785    /// This is the measured reason [`OffsetNormal`] keeps both conventions
786    /// instead of picking one: `Raw` follows the C¹ ruled/bilinear extension
787    /// (`deriv1_extended`, §3.15) while `Face` clamps into the domain, and the
788    /// blend march's Newton routinely probes past the domain edge. A shared
789    /// evaluator that silently normalized the two would have moved the march.
790    #[test]
791    fn the_raw_and_face_lanes_diverge_outside_the_domain() {
792        let solid = cylinder();
793        let mut worst = 0.0f64;
794        for (surface, same_sense) in surfaces(&solid) {
795            let [u0, u1] = surface.domain_u().unwrap();
796            let [v0, v1] = surface.domain_v().unwrap();
797            let raw = OffsetEvaluator::new("test", surface, OffsetNormal::Raw);
798            let face = OffsetEvaluator::new("test", surface, OffsetNormal::Face { same_sense });
799            for overshoot in [0.05, 0.25, 0.5] {
800                let u = u1 + (u1 - u0) * overshoot;
801                let v = (v0 + v1) * 0.5;
802                let (Ok(a), Ok(b)) = (raw.normal(u, v), face.normal(u, v)) else {
803                    continue;
804                };
805                // Unsigned, so a `same_sense: false` face does not read as a
806                // divergence when it is only the orientation convention.
807                worst = worst.max(a.sub(b).length().min(a.add(b).length()));
808            }
809        }
810        assert!(
811            worst > 1e-3,
812            "the two lanes agreed everywhere outside the domain (worst {worst:.3e}) — either the \
813             extension changed or this fixture stopped leaving the domain, and either way the \
814             corpus no longer pins why both conventions exist"
815        );
816    }
817
818    /// The exact lane must agree with `S_u × S_v` to rounding wherever the
819    /// parametric normal is defined — that agreement is what makes it a
820    /// candidate replacement rather than a different answer.
821    #[test]
822    fn the_exact_lane_agrees_with_the_parametric_normal_where_both_are_defined() {
823        for solid in [
824            make_box_brep(Vec3::default(), 4.0, 3.0, 2.0).unwrap(),
825            cylinder(),
826            sphere(),
827        ] {
828            for (surface, same_sense) in surfaces(&solid) {
829                if surface.analytic().is_none() {
830                    continue;
831                }
832                let [u0, u1] = surface.domain_u().unwrap();
833                let [v0, v1] = surface.domain_v().unwrap();
834                let exact =
835                    OffsetEvaluator::new("test", surface, OffsetNormal::ExactAnalytic { same_sense });
836                let face = OffsetEvaluator::new("test", surface, OffsetNormal::Face { same_sense });
837                for iu in 0..9 {
838                    for iv in 0..9 {
839                        let u = u0 + (u1 - u0) * (iu as f64 + 0.37) / 9.0;
840                        let v = v0 + (v1 - v0) * (iv as f64 + 0.29) / 9.0;
841                        let Ok(parametric) = face.normal(u, v) else {
842                            continue;
843                        };
844                        let closed = exact.normal(u, v).unwrap();
845                        // Chord, not `acos` — see `LaneStats::worst_chord`: the
846                        // dot product of two BITWISE IDENTICAL unit vectors is
847                        // routinely one ULP below 1.0, and `acos` inflates that
848                        // into a 1.49e-8 rad floor that no real agreement can
849                        // get under.
850                        let chord = closed.sub(parametric).length();
851                        assert!(
852                            chord < 1e-14,
853                            "exact normal disagrees by {chord:.3e} at ({u}, {v})"
854                        );
855                    }
856                }
857            }
858        }
859    }
860
861    /// And it must be DEFINED at a sphere pole, where the parametric normal is
862    /// not — the degeneracy `face_offset_sphere.rs` records as the reason the
863    /// shared Greville fit could not be used for the sphere push.
864    #[test]
865    fn the_exact_lane_is_defined_at_a_sphere_pole() {
866        let solid = sphere();
867        let mut checked = 0;
868        for (surface, same_sense) in surfaces(&solid) {
869            if !matches!(surface.analytic(), Some(AnalyticSurface::Sphere { .. })) {
870                continue;
871            }
872            let [u0, u1] = surface.domain_u().unwrap();
873            let [v0, v1] = surface.domain_v().unwrap();
874            let exact =
875                OffsetEvaluator::new("test", surface, OffsetNormal::ExactAnalytic { same_sense });
876            for v in [v0, v1] {
877                let u = (u0 + u1) * 0.5;
878                let normal = exact
879                    .normal(u, v)
880                    .unwrap_or_else(|error| panic!("pole normal at v={v}: {error}"));
881                assert!(
882                    (normal.length() - 1.0).abs() < 1e-12,
883                    "pole normal is not unit: {normal:?}"
884                );
885                // The pole normal IS the axis, up to the patch's orientation.
886                assert!(
887                    normal.x.abs() < 1e-9 && normal.y.abs() < 1e-9,
888                    "pole normal is not axial: {normal:?}"
889                );
890                checked += 1;
891            }
892        }
893        assert!(checked > 0, "no spherical carrier was reached");
894    }
895
896    /// The offset of a sphere by `d` along its own outward normal is the
897    /// concentric sphere of radius `r + d` — the exactness claim, checked
898    /// against the closed form rather than against another evaluation.
899    #[test]
900    fn offsetting_a_sphere_stays_concentric() {
901        let solid = sphere();
902        for (surface, same_sense) in surfaces(&solid) {
903            let Some(AnalyticSurface::Sphere { frame, radius }) = surface.analytic() else {
904                continue;
905            };
906            let (centre, radius) = (frame.origin, *radius);
907            let [u0, u1] = surface.domain_u().unwrap();
908            let [v0, v1] = surface.domain_v().unwrap();
909            let evaluator =
910                OffsetEvaluator::new("test", surface, OffsetNormal::ExactAnalytic { same_sense });
911            for iu in 0..5 {
912                for iv in 0..5 {
913                    let u = u0 + (u1 - u0) * iu as f64 / 4.0;
914                    let v = v0 + (v1 - v0) * iv as f64 / 4.0;
915                    let sample = evaluator.at(u, v, 0.75).unwrap();
916                    let outward = sample.normal.dot(sample.source.sub(centre)) > 0.0;
917                    let expected = if outward { radius + 0.75 } else { radius - 0.75 };
918                    let actual = sample.point.sub(centre).length();
919                    assert!(
920                        (actual - expected).abs() < 1e-12,
921                        "offset sphere radius {actual} vs {expected} at ({u}, {v})"
922                    );
923                }
924            }
925        }
926    }
927
928    /// A cone's exact normal is defined AT the apex row, from the ruling's own
929    /// azimuth — the case `FaceStable`'s deep-inward walk exists to approximate.
930    #[test]
931    fn the_exact_lane_is_defined_at_a_cone_apex() {
932        let generatrix = crate::interpolate_curve(
933            &[Vec3::new(2.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 4.0)],
934            1,
935            &[0.0, 1.0],
936        )
937        .unwrap();
938        let cone = crate::make_revolution(
939            Vec3::default(),
940            Vec3::new(0.0, 0.0, 1.0),
941            &generatrix,
942            std::f64::consts::TAU,
943        )
944        .unwrap();
945        assert!(matches!(
946            cone.analytic(),
947            Some(AnalyticSurface::RuledRevolution { .. })
948        ));
949        let [u0, u1] = cone.domain_u().unwrap();
950        let [_, v1] = cone.domain_v().unwrap();
951        let exact = OffsetEvaluator::new("test", &cone, OffsetNormal::ExactAnalytic { same_sense: true });
952        let flank = exact.normal((u0 + u1) * 0.5, 0.5).unwrap();
953        let apex = exact.normal((u0 + u1) * 0.5, v1).unwrap();
954        // Same ruling, so the same normal: a cone's normal is constant along
955        // each ruling, right up to the apex.
956        let chord = flank.sub(apex).length();
957        assert!(chord < 1e-14, "apex normal differs from its ruling by {chord:.3e}");
958        // And it is the meridian perpendicular: rho drops 2 over height 4, so
959        // the axial component is 2/sqrt(20).
960        let expected_axial = 2.0 / 20.0f64.sqrt();
961        assert!(
962            (apex.z.abs() - expected_axial).abs() < 1e-9,
963            "apex normal axial component {} vs {expected_axial}",
964            apex.z
965        );
966    }
967}