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// BREP private tests: f2fe80f6e7b543ac