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
12//! `docs/developer/kernel-plans/occt-offset-algorithms.md` §6.1 asks for, and
13//! the analogue of OCCT's `BRepOffset_SimpleOffset::FillEdgeData`
14//! (`BRepOffset_SimpleOffset.cxx:296-310`), which sets the edge tolerance to
15//! exactly this distance measured with `BRepLib_ValidateEdge`.
16//! * [`measure_surface_fit_against_pointwise_offset`] — for a carrier built by
17//! collocation: `max_{u,v} ‖S_fit(u,v) − offset(u,v)‖`. Nothing in this tree
18//! has ever measured our Greville fit against the pointwise offset it
19//! interpolates; this does.
20//!
21//! Plus [`vertex_endpoint_gap`], the endpoint half of the edge → vertex
22//! propagation whose rule (and whose verdict on OCCT's 1.001 factor) lives on
23//! [`crate::vertex_tolerance_from_edges`].
24//!
25//! # Why the edge measurement is `adaptive_coedge_error` PLUS a span pass
26//!
27//! `brep/topology/validate.rs:453` already computes `max_s ‖S(q(s)) − c(t(s))‖`
28//! over an adaptive subdivision, seam-aware through `evaluate_extended`, and it
29//! is what [`crate::BrepSolid::validate`] itself runs on every coedge. Writing a
30//! second sampler from scratch would produce a second answer to one question —
31//! the failure the offset-unification audit spent five slices removing — so it
32//! is kept as the general backstop and its band-driven refinement is passed
33//! through exactly as validate passes `pcurve_limit`.
34//!
35//! **But it is not sufficient here, and this was measured, not assumed.** Its
36//! sample set is 32 uniform intervals bisected to depth 3, i.e. the DYADIC grid
37//! of multiples of 1/256. Every 3D curve `offset_face_carrier` builds comes from
38//! `mapped_pcurve_polyline`, whose adaptive subdivision bisects the same
39//! interval and therefore places its interpolation nodes on the dyadic grid too
40//! (up to 1024 of them at its depth cap, `offset/offset.rs:560`). Once the
41//! polyline is finer than 1/256, **every one of the sampler's points is an
42//! interpolation node**, where a degree-1 interpolant is exact by construction.
43//! Measured on a 1200-long cylinder's cap rim: `adaptive_coedge_error` answers
44//! `8.0e-14`; a dense scan of the same comparison answers `2.356e-3`. Ten orders
45//! of magnitude, and the aliasing gets WORSE the finer the curve. (The same
46//! blind spot is in `BrepSolid::validate`, which runs the same sampler over the
47//! same curves — recorded in the study doc, not fixed here.)
48//!
49//! So the measurement adds [`span_midpoint_error`]: one sample at the MIDPOINT
50//! of each of the curve's own knot spans. For a degree-1 interpolant — which is
51//! every curve this construction builds — the deviation from the smooth image is
52//! zero at the nodes and extremal inside the span, so a midpoint per span is not
53//! a heuristic, it is the right estimator for this construction class. The
54//! recorded deviation is the maximum of the two passes: a general backstop that
55//! can see between spans, and a span pass that cannot be aliased away.
56//!
57//! A caller transferring a HIGHER-degree curve (the general pcurve → 3D image
58//! curve of `occt-offset-algorithms.md` §7 item 1) needs both for the opposite
59//! reason: a cubic's worst point is not generally the span midpoint, so the
60//! adaptive pass carries that case and the span pass only floors it.
61//!
62//! Cost is bounded by the curve's own span count plus one validate pass — a
63//! ceiling this kernel already pays on every offset result.
64//!
65//! # The direction rule
66//!
67//! Everything here RECORDS. Nothing here widens a band. See
68//! [`crate::MeasuredTolerance`]'s "direction rule".
69
70use crate::topology::{adaptive_coedge_error, EdgeRecord};
71use crate::{MeasuredTolerance, NurbsCurve, NurbsSurface, OffsetEvaluator, OffsetNormal, Vec3};
72
73/// The grid used by [`measure_surface_fit_against_pointwise_offset`], and the
74/// two fractional offsets that keep its samples off the knot lines a
75/// collocation fit interpolates exactly.
76///
77/// Both are copied deliberately from the free-form push's dense residual gate
78/// (`edit/direct_edit/face_offset_freeform.rs:72-76`): 15x15 with `+0.31` /
79/// `+0.43` cell offsets. Sampling the Greville parameters themselves would
80/// report `0.0` by construction — the fit interpolates there — so the whole
81/// value of this measurement is in sampling BETWEEN them.
82const FIT_SAMPLES: usize = 15;
83const FIT_OFFSET_U: f64 = 0.31;
84const FIT_OFFSET_V: f64 = 0.43;
85
86/// `max_t ‖C_3d(t) − S(p(t))‖` — how far the edge's own 3D curve sits from the
87/// locus its pcurve traces on `surface`, recorded against `band`.
88///
89/// This is the measurement that makes a general pcurve → 3D transfer
90/// trustworthy: the transfer's whole claim is that the interpolated 3D curve
91/// reproduces the composed image, and this is that claim, measured rather than
92/// assumed. `occt-offset-algorithms.md` §7 items 1 and 6 both name it.
93///
94/// `forward` follows the coedge's own sense, as in `BrepSolid::validate`.
95pub fn measure_edge_against_pcurve_image(
96 surface: &NurbsSurface,
97 pcurve: &NurbsCurve,
98 edge: &EdgeRecord,
99 forward: bool,
100 band: f64,
101) -> Result<MeasuredTolerance, String> {
102 let adaptive = adaptive_coedge_error(surface, pcurve, &edge.curve, edge, forward, band)?;
103 let spans = span_midpoint_error(surface, pcurve, edge, forward)?;
104 Ok(MeasuredTolerance::new(adaptive.max(spans), band))
105}
106
107/// The same comparison as [`measure_edge_against_pcurve_image`], sampled once at
108/// the midpoint of every knot span of the edge's own 3D curve.
109///
110/// This is the anti-aliasing half described in the module doc: it samples where
111/// the curve is furthest from what it interpolates, by construction, and its
112/// sample set is derived from the curve's own knots rather than from a fixed
113/// grid that a dyadically-subdivided curve can hide behind.
114///
115/// Exposed so the general pcurve → 3D image-curve transfer can floor its own
116/// self-verification with it without re-deriving the span walk.
117pub fn span_midpoint_error(
118 surface: &NurbsSurface,
119 pcurve: &NurbsCurve,
120 edge: &EdgeRecord,
121 forward: bool,
122) -> Result<f64, String> {
123 let span = edge.t1 - edge.t0;
124 if !span.is_finite() || span.abs() <= 0.0 {
125 return Ok(0.0);
126 }
127 let [q0, q1] = pcurve.domain()?;
128 // Distinct interior breakpoints of the curve, clipped to the edge's own
129 // parameter range. Repeated knots (a degree-1 curve clamps its ends) collapse
130 // to one, so a span is a real interval and its midpoint a real interior point.
131 let low = edge.t0.min(edge.t1);
132 let high = edge.t0.max(edge.t1);
133 let mut breaks: Vec<f64> = vec![low];
134 for &knot in &edge.curve.knots {
135 if knot > low && knot < high && knot > *breaks.last().unwrap_or(&low) {
136 breaks.push(knot);
137 }
138 }
139 breaks.push(high);
140
141 let mut worst = 0.0f64;
142 for pair in breaks.windows(2) {
143 let midpoint = (pair[0] + pair[1]) * 0.5;
144 if !(midpoint > pair[0] && midpoint < pair[1]) {
145 continue;
146 }
147 // `adaptive_coedge_error`'s own fraction -> parameter map, inverted, so
148 // the two passes compare the SAME pairing of pcurve point to curve point.
149 let fraction = if forward {
150 (midpoint - edge.t0) / span
151 } else {
152 (edge.t1 - midpoint) / span
153 };
154 let uv = pcurve.evaluate(q0 + (q1 - q0) * fraction)?;
155 let on_surface = surface.evaluate_extended(uv.x, uv.y)?;
156 let on_curve = edge.curve.evaluate(midpoint)?;
157 worst = worst.max(on_surface.sub(on_curve).length());
158 }
159 Ok(worst)
160}
161
162/// `max_{u,v} ‖S_fit(u,v) − offset(u,v)‖` — how far a fitted offset carrier
163/// sits from the pointwise offset it was fitted to, recorded against `band`.
164///
165/// `distance` and `same_sense` are `offset_surface`'s, and the evaluator is
166/// constructed with the same convention that produced the fit's samples
167/// ([`OffsetNormal::FaceStable`], with the same single negation), so this
168/// measures the FIT and nothing else — not a sign disagreement, and not a
169/// second opinion about which way "outward" points.
170///
171/// Only meaningful when the carrier really is a pointwise fit over the same
172/// parameterisation. `offset_surface`'s planar/ruled extension and its
173/// apex-cone pinch retrim both move the sample grid off the pointwise offset on
174/// purpose; measuring one of those against a pointwise offset reports a
175/// designed divergence, not an error. `offset_surface_measured` decides that
176/// and does not call this in those cases.
177pub fn measure_surface_fit_against_pointwise_offset(
178 source: &NurbsSurface,
179 same_sense: bool,
180 fitted: &NurbsSurface,
181 distance: f64,
182 band: f64,
183) -> Result<MeasuredTolerance, String> {
184 let evaluator = OffsetEvaluator::new(
185 "measure_offset_fit",
186 source,
187 OffsetNormal::FaceStable { same_sense },
188 );
189 let [u0, u1] = source.domain_u()?;
190 let [v0, v1] = source.domain_v()?;
191 let mut worst = 0.0f64;
192 for iu in 0..FIT_SAMPLES {
193 let u = u0 + (u1 - u0) * (iu as f64 + FIT_OFFSET_U) / FIT_SAMPLES as f64;
194 for iv in 0..FIT_SAMPLES {
195 let v = v0 + (v1 - v0) * (iv as f64 + FIT_OFFSET_V) / FIT_SAMPLES as f64;
196 // `offset_surface`'s positive distance moves OPPOSITE the face
197 // normal while the evaluator's moves ALONG it; the negation is the
198 // fit's own, repeated here verbatim rather than re-derived.
199 let want = evaluator.at(u, v, -distance)?.point;
200 let got = fitted.evaluate(u, v)?;
201 worst = worst.max(got.sub(want).length());
202 }
203 }
204 Ok(MeasuredTolerance::new(worst, band))
205}
206
207/// `|p_V − c_E(t_end)|` — how far a curve end sits from the vertex point the
208/// topology says it meets.
209///
210/// The endpoint half of the edge → vertex propagation. On a freshly built
211/// carrier this is exactly zero for the edge that CLAIMED the vertex (the
212/// vertex was placed at that curve's end) and non-zero for every later edge
213/// that reuses it — which is the whole reason it is worth measuring.
214pub fn vertex_endpoint_gap(vertex_point: Vec3, curve_end: Vec3) -> f64 {
215 curve_end.sub(vertex_point).length()
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use crate::{make_cylinder_brep, offset_face_carrier_measured};
222
223 /// The regression that named this module's second sampler.
224 ///
225 /// `mapped_pcurve_polyline` subdivides `[0, 1]` by bisection, so a rim it
226 /// refines deeply carries interpolation nodes on the dyadic grid — the very
227 /// grid `adaptive_coedge_error` walks (32 intervals bisected to depth 3 =
228 /// multiples of 1/256). On a large part the polyline reaches its depth cap
229 /// and every one of the sampler's points lands on a node, where a degree-1
230 /// interpolant is exact. Measured before the span pass existed: `8.0e-14`
231 /// reported against a true `2.356e-3`.
232 #[test]
233 fn span_midpoints_see_the_chord_sag_the_dyadic_sampler_aliases_away() {
234 let solid = make_cylinder_brep(
235 crate::Vec3::default(),
236 crate::Vec3::new(0.0, 0.0, 1.0),
237 500.0,
238 1200.0,
239 )
240 .expect("cylinder");
241 let cap = solid
242 .shells
243 .iter()
244 .flat_map(|shell| &shell.faces)
245 .find(|face| {
246 matches!(
247 face.surface.analytic(),
248 Some(crate::AnalyticSurface::Plane { .. })
249 )
250 })
251 .expect("a planar cap")
252 .id;
253 let carrier = offset_face_carrier_measured(&solid, cap, 50.0, 0.0).expect("carrier");
254 let surface = &carrier.face.surface;
255 let coedge = &carrier.face.loops[0].coedges[0];
256 let edge = carrier
257 .edges
258 .iter()
259 .find(|edge| edge.id == coedge.edge_id)
260 .expect("the rim edge");
261
262 let band = 1.0;
263 let aliased = adaptive_coedge_error(
264 surface,
265 &coedge.pcurve,
266 &edge.curve,
267 edge,
268 coedge.forward,
269 band,
270 )
271 .expect("adaptive");
272 let spans =
273 span_midpoint_error(surface, &coedge.pcurve, edge, coedge.forward).expect("spans");
274 let recorded =
275 measure_edge_against_pcurve_image(surface, &coedge.pcurve, edge, coedge.forward, band)
276 .expect("measured");
277
278 assert!(
279 aliased < 1e-9,
280 "the dyadic sampler is expected to alias here; if this fails the \
281 aliasing is gone and the span pass may be reconsidered (got {aliased:.3e})"
282 );
283 assert!(
284 spans > 2e-3,
285 "the span pass must see the real chord sag (got {spans:.3e})"
286 );
287 assert_eq!(
288 recorded.deviation(),
289 aliased.max(spans),
290 "the recorded deviation is the worse of the two passes"
291 );
292 }
293
294 /// The measurement is a record, so an exact construction records exactly
295 /// zero and a `None` never masquerades as a clean result.
296 #[test]
297 fn an_exact_affine_carrier_records_a_zero_surface_fit() {
298 let solid = crate::make_box_brep(crate::Vec3::default(), 20.0, 20.0, 4.0).expect("box");
299 let face = solid.shells[0].faces[0].id;
300 let carrier = offset_face_carrier_measured(&solid, face, 0.25, 0.0).expect("carrier");
301 let deviation = carrier.deviation.expect("measured");
302 assert_eq!(deviation.lane, crate::OffsetSurfaceLane::Affine);
303 assert_eq!(deviation.surface.expect("a fit record").deviation(), 0.0);
304 assert!(deviation.exceedances().is_empty());
305 // The unmeasured entry point records nothing at all — no default that
306 // could be read as "measured, and clean".
307 assert!(crate::offset_face_carrier(&solid, face, 0.25, 0.0)
308 .expect("carrier")
309 .deviation
310 .is_none());
311 }
312}