Skip to main content

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