brep_kernel/offset/measure.rs
1//! Measured tolerance for offset constructions — the deviation OBSERVED
2//! between a built entity and the geometry it was built to reproduce.
3//!
4//! # What this module is
5//!
6//! [`crate::MeasuredTolerance`] is the vocabulary; this is the offset family's
7//! measurement. Two quantities, one per construction kind:
8//!
9//! * [`measure_edge_against_pcurve_image`] — for a rim/trim edge built by
10//! sampling a pcurve through a surface and interpolating the images:
11//! `max_t ‖C_3d(t) − S(p(t))‖`. This is the number that makes the offset
12//! family's tolerance measured rather than predicted — the size-derived band
13//! stays the acceptance bar the caller gates on, and this observed deviation
14//! is what gets recorded — and the analogue of OCCT's
15//! `BRepOffset_SimpleOffset::FillEdgeData`
16//! (`BRepOffset_SimpleOffset.cxx:296-310`), which sets the edge tolerance to
17//! exactly this distance measured with `BRepLib_ValidateEdge`.
18//! * [`measure_surface_fit_against_pointwise_offset`] — for a carrier built by
19//! collocation: `max_{u,v} ‖S_fit(u,v) − offset(u,v)‖`. Nothing in this tree
20//! has ever measured our Greville fit against the pointwise offset it
21//! interpolates; this does.
22//!
23//! Plus [`vertex_endpoint_gap`], the endpoint half of the edge → vertex
24//! propagation whose rule (and whose verdict on OCCT's 1.001 factor) lives on
25//! [`crate::vertex_tolerance_from_edges`].
26//!
27//! # Why the edge measurement is `adaptive_coedge_error` PLUS a span pass
28//!
29//! `brep/topology/validate.rs:453` already computes `max_s ‖S(q(s)) − c(t(s))‖`
30//! over an adaptive subdivision, seam-aware through `evaluate_extended`, and it
31//! is what [`crate::BrepSolid::validate`] itself runs on every coedge. Writing a
32//! second sampler from scratch would produce a second answer to one question —
33//! the failure the offset-unification audit spent five slices removing — so it
34//! is kept as the general backstop and its band-driven refinement is passed
35//! through exactly as validate passes `pcurve_limit`.
36//!
37//! **But it is not sufficient here, and this was measured, not assumed.** Its
38//! sample set is 32 uniform intervals bisected to depth 3, i.e. the DYADIC grid
39//! of multiples of 1/256. Every 3D curve `offset_face_carrier` built when this
40//! was written came from `mapped_pcurve_polyline`, whose adaptive subdivision
41//! bisects the same interval and therefore places its interpolation nodes on
42//! the dyadic grid too (up to 1024 of them at its depth cap,
43//! `offset/offset.rs:560`). Once the polyline is finer than 1/256, **every one
44//! of the sampler's points is an interpolation node**, where a degree-1
45//! interpolant is exact by construction. (Since 2026-09-06 the carrier builds
46//! its edges through the `image_curve` ladder and reaches that polyline only
47//! as the ladder's fallback — `offset.rs::carrier_edge_curve` — but the
48//! fallback, and any degree-1 curve a caller transfers, is measured here the
49//! same way.)
50//! Measured on a 1200-long cylinder's cap rim: `adaptive_coedge_error` answers
51//! `8.0e-14`; a dense scan of the same comparison answers `2.356e-3`. Ten orders
52//! of magnitude, and the aliasing gets WORSE the finer the curve. (The same
53//! blind spot is in `BrepSolid::validate`, which runs the same sampler over the
54//! same curves — recorded in the study doc, not fixed here.)
55//!
56//! So the measurement adds [`span_midpoint_error`]: one sample at the MIDPOINT
57//! of each of the curve's own knot spans. For a degree-1 interpolant — the
58//! construction's fallback curve — the deviation from the smooth image is
59//! zero at the nodes and extremal inside the span, so a midpoint per span is not
60//! a heuristic, it is the right estimator for this construction class. The
61//! recorded deviation is the maximum of the two passes: a general backstop that
62//! can see between spans, and a span pass that cannot be aliased away.
63//!
64//! A caller transferring a HIGHER-degree curve (the general pcurve → 3D image
65//! curve the `image_curve` ladder interpolates as a cubic once neither its
66//! affine nor its iso tier applies) needs both for the opposite reason: a
67//! cubic's worst point is not generally the span midpoint, so the adaptive pass
68//! carries that case and the span pass only floors it.
69//!
70//! Cost is bounded by the curve's own span count plus one validate pass — a
71//! ceiling this kernel already pays on every offset result.
72//!
73//! # The direction rule
74//!
75//! Everything here RECORDS. Nothing here widens a band. See
76//! [`crate::MeasuredTolerance`]'s "direction rule".
77
78use crate::{KernelRefusal, KernelStage, OrRefuse};
79use crate::topology::{coedge_error_floored, EdgeRecord};
80use crate::{MeasuredTolerance, NurbsCurve, NurbsSurface, OffsetEvaluator, OffsetNormal, Vec3};
81
82/// The grid used by [`measure_surface_fit_against_pointwise_offset`], and the
83/// two fractional offsets that keep its samples off the knot lines a
84/// collocation fit interpolates exactly.
85///
86/// Both are copied deliberately from the free-form push's dense residual gate
87/// (`edit/direct_edit/face_offset_freeform.rs:72-76`): 15x15 with `+0.31` /
88/// `+0.43` cell offsets. Sampling the Greville parameters themselves would
89/// report `0.0` by construction — the fit interpolates there — so the whole
90/// value of this measurement is in sampling BETWEEN them.
91const FIT_SAMPLES: usize = 15;
92const FIT_OFFSET_U: f64 = 0.31;
93const FIT_OFFSET_V: f64 = 0.43;
94
95/// `max_t ‖C_3d(t) − S(p(t))‖` — how far the edge's own 3D curve sits from the
96/// locus its pcurve traces on `surface`, recorded against `band`.
97///
98/// This is the measurement that makes a general pcurve → 3D transfer
99/// trustworthy: the transfer's whole claim is that the interpolated 3D curve
100/// reproduces the composed image, and this is that claim, measured rather than
101/// assumed. It is equally what an iso SHORTCUT owes: `rim_welds.rs` accepts an
102/// iso on a structural degree/knot match, and this is the geometric check that
103/// would replace it.
104///
105/// `forward` follows the coedge's own sense, as in `BrepSolid::validate`.
106pub fn measure_edge_against_pcurve_image(
107 surface: &NurbsSurface,
108 pcurve: &NurbsCurve,
109 edge: &EdgeRecord,
110 forward: bool,
111 band: f64,
112) -> Result<MeasuredTolerance, KernelRefusal> {
113 let adaptive = coedge_error_floored(surface, pcurve, &edge.curve, edge, forward, band).or_refuse(KernelStage::Validate, "coedge_error_floored")?;
114 let spans = span_midpoint_error(surface, pcurve, edge, forward)?;
115 Ok(MeasuredTolerance::new(adaptive.max(spans), band))
116}
117
118/// The same comparison as [`measure_edge_against_pcurve_image`], sampled once at
119/// the midpoint of every knot span of the edge's own 3D curve.
120///
121/// This is the anti-aliasing half described in the module doc: it samples where
122/// the curve is furthest from what it interpolates, by construction, and its
123/// sample set is derived from the curve's own knots rather than from a fixed
124/// grid that a dyadically-subdivided curve can hide behind.
125///
126/// Exposed so the general pcurve → 3D image-curve transfer can floor its own
127/// self-verification with it without re-deriving the span walk.
128pub fn span_midpoint_error(
129 surface: &NurbsSurface,
130 pcurve: &NurbsCurve,
131 edge: &EdgeRecord,
132 forward: bool,
133) -> Result<f64, KernelRefusal> {
134 let span = edge.t1 - edge.t0;
135 if !span.is_finite() || span.abs() <= 0.0 {
136 return Ok(0.0);
137 }
138 let [q0, q1] = pcurve.domain().or_refuse(KernelStage::Validate, "domain")?;
139 // Distinct interior breakpoints of the curve, clipped to the edge's own
140 // parameter range. Repeated knots (a degree-1 curve clamps its ends) collapse
141 // to one, so a span is a real interval and its midpoint a real interior point.
142 let low = edge.t0.min(edge.t1);
143 let high = edge.t0.max(edge.t1);
144 let mut breaks: Vec<f64> = vec![low];
145 for &knot in &edge.curve.knots {
146 if knot > low && knot < high && knot > *breaks.last().unwrap_or(&low) {
147 breaks.push(knot);
148 }
149 }
150 breaks.push(high);
151
152 let mut worst = 0.0f64;
153 for pair in breaks.windows(2) {
154 let midpoint = (pair[0] + pair[1]) * 0.5;
155 if !(midpoint > pair[0] && midpoint < pair[1]) {
156 continue;
157 }
158 // `adaptive_coedge_error`'s own fraction -> parameter map, inverted, so
159 // the two passes compare the SAME pairing of pcurve point to curve point.
160 let fraction = if forward {
161 (midpoint - edge.t0) / span
162 } else {
163 (edge.t1 - midpoint) / span
164 };
165 let uv = pcurve.evaluate(q0 + (q1 - q0) * fraction).or_refuse(KernelStage::Validate, "evaluate")?;
166 let on_surface = surface.evaluate_extended(uv.x, uv.y).or_refuse(KernelStage::Validate, "evaluate_extended")?;
167 let on_curve = edge.curve.evaluate(midpoint).or_refuse(KernelStage::Validate, "evaluate")?;
168 worst = worst.max(on_surface.sub(on_curve).length());
169 }
170 Ok(worst)
171}
172
173/// `max_{u,v} ‖S_fit(u,v) − offset(u,v)‖` — how far a fitted offset carrier
174/// sits from the pointwise offset it was fitted to, recorded against `band`.
175///
176/// `distance` and `same_sense` are `offset_surface`'s, and the evaluator is
177/// constructed with the same convention that produced the fit's samples
178/// ([`OffsetNormal::FaceStable`], with the same single negation), so this
179/// measures the FIT and nothing else — not a sign disagreement, and not a
180/// second opinion about which way "outward" points.
181///
182/// Only meaningful when the carrier really is a pointwise fit over the same
183/// parameterisation. `offset_surface`'s planar/ruled extension and its
184/// apex-cone pinch retrim both move the sample grid off the pointwise offset on
185/// purpose; measuring one of those against a pointwise offset reports a
186/// designed divergence, not an error. `offset_surface_measured` decides that
187/// and does not call this in those cases.
188pub fn measure_surface_fit_against_pointwise_offset(
189 source: &NurbsSurface,
190 same_sense: bool,
191 fitted: &NurbsSurface,
192 distance: f64,
193 band: f64,
194) -> Result<MeasuredTolerance, KernelRefusal> {
195 let evaluator = OffsetEvaluator::new(
196 "measure_offset_fit",
197 source,
198 OffsetNormal::FaceStable { same_sense },
199 );
200 let [u0, u1] = source.domain_u().or_refuse(KernelStage::Validate, "domain_u")?;
201 let [v0, v1] = source.domain_v().or_refuse(KernelStage::Validate, "domain_v")?;
202 let mut worst = 0.0f64;
203 for iu in 0..FIT_SAMPLES {
204 let u = u0 + (u1 - u0) * (iu as f64 + FIT_OFFSET_U) / FIT_SAMPLES as f64;
205 for iv in 0..FIT_SAMPLES {
206 let v = v0 + (v1 - v0) * (iv as f64 + FIT_OFFSET_V) / FIT_SAMPLES as f64;
207 // `offset_surface`'s positive distance moves OPPOSITE the face
208 // normal while the evaluator's moves ALONG it; the negation is the
209 // fit's own, repeated here verbatim rather than re-derived.
210 let want = evaluator.at(u, v, -distance).or_refuse(KernelStage::Validate, "at")?.point;
211 let got = fitted.evaluate(u, v).or_refuse(KernelStage::Validate, "evaluate")?;
212 worst = worst.max(got.sub(want).length());
213 }
214 }
215 Ok(MeasuredTolerance::new(worst, band))
216}
217
218/// `|p_V − c_E(t_end)|` — how far a curve end sits from the vertex point the
219/// topology says it meets.
220///
221/// The endpoint half of the edge → vertex propagation. On a freshly built
222/// carrier this is exactly zero for the edge that CLAIMED the vertex (the
223/// vertex was placed at that curve's end) and non-zero for every later edge
224/// that reuses it — which is the whole reason it is worth measuring.
225pub fn vertex_endpoint_gap(vertex_point: Vec3, curve_end: Vec3) -> f64 {
226 curve_end.sub(vertex_point).length()
227}
228