Skip to main content

brep_kernel/offset/
offset.rs

1use crate::fit::solve_dense;
2use crate::topology::{BrepSolid, CoedgeRecord, EdgeRecord, FaceRecord, LoopRecord, VertexRecord};
3use crate::{
4    interpolate_curve, measure_edge_against_pcurve_image,
5    measure_surface_fit_against_pointwise_offset, offset_construction_band, solid_model_scale,
6    vertex_endpoint_gap, vertex_tolerance_from_edges, KnotVector, MeasuredTolerance, NurbsCurve,
7    NurbsSurface, OffsetEvaluator, OffsetNormal, Vec2, Vec3, Vec4,
8};
9use rustc_hash::FxHashMap as HashMap;
10use serde::Serialize;
11
12fn domains(surface: &NurbsSurface) -> Result<([f64; 2], [f64; 2]), String> {
13    Ok((
14        KnotVector::new(surface.knots_u.clone(), surface.degree_u)?.domain(),
15        KnotVector::new(surface.knots_v.clone(), surface.degree_v)?.domain(),
16    ))
17}
18
19/// The normal `offset_surface` offsets along: the face's oriented normal with
20/// the singular-row recovery, i.e. the shared evaluator's
21/// [`OffsetNormal::FaceStable`] lane, whose body this function used to be.
22fn stable_face_normal(face: &FaceRecord, u: f64, v: f64) -> Result<Vec3, String> {
23    OffsetEvaluator::new(
24        "offset_surface",
25        &face.surface,
26        OffsetNormal::FaceStable {
27            same_sense: face.same_sense,
28        },
29    )
30    .normal(u, v)
31}
32
33fn greville_parameters(knots: &KnotVector) -> Vec<f64> {
34    let mut parameters = (0..knots.control_point_count())
35        .map(|index| {
36            knots.knots[index + 1..=index + knots.degree]
37                .iter()
38                .sum::<f64>()
39                / knots.degree as f64
40        })
41        .collect::<Vec<_>>();
42    let domain = knots.domain();
43    parameters[0] = domain[0];
44    *parameters.last_mut().unwrap() = domain[1];
45    parameters
46}
47
48/// Rational collocation matrix: rows are the rational basis functions
49/// R_i(t) = N_i(t)·w_i / Σ_k N_k(t)·w_k evaluated at each parameter. With
50/// uniform weights this reduces to the ordinary B-spline collocation matrix.
51fn collocation_matrix(knots: &KnotVector, parameters: &[f64], weights: &[f64]) -> Vec<Vec<f64>> {
52    parameters
53        .iter()
54        .map(|parameter| {
55            let mut row = vec![0.0; knots.control_point_count()];
56            let span = knots.find_span(*parameter);
57            for (offset, value) in knots
58                .basis_functions(span, *parameter)
59                .into_iter()
60                .enumerate()
61            {
62                let index = span - knots.degree + offset;
63                row[index] = value * weights[index];
64            }
65            let denominator: f64 = row.iter().sum();
66            if denominator.abs() > 0.0 {
67                for value in &mut row {
68                    *value /= denominator;
69                }
70            }
71            row
72        })
73        .collect()
74}
75
76/// Split the weight grid into per-direction factors when it is separable
77/// (w_ij = a_i·b_j), which covers every tensor surface built from rational
78/// profile/rail curves (cylinders, cones, spheres, tori, revolves).
79fn separable_weights(weights: &[Vec<f64>]) -> Option<(Vec<f64>, Vec<f64>)> {
80    let first_row = weights.first()?;
81    let anchor = *first_row.first()?;
82    if anchor.abs() <= 1e-12 {
83        return None;
84    }
85    let a: Vec<f64> = weights.iter().map(|row| row[0]).collect();
86    let b: Vec<f64> = first_row.iter().map(|w| w / anchor).collect();
87    for (i, row) in weights.iter().enumerate() {
88        for (j, &w) in row.iter().enumerate() {
89            if (w - a[i] * b[j]).abs() > 1e-10 * (1.0 + w.abs()) {
90                return None;
91            }
92        }
93    }
94    Some((a, b))
95}
96
97/// Interpolate the sample grid in the SOURCE surface's rational basis (same
98/// knots and weights). When the true offset is representable in that basis —
99/// planes, cylinders, cones, spheres, tori — collocation at the Greville grid
100/// recovers it EXACTLY, so offset carriers stay real analytic surfaces
101/// instead of non-rational approximations with span-scale wobble.
102fn interpolate_tensor(
103    knot_u: &KnotVector,
104    knot_v: &KnotVector,
105    parameters_u: &[f64],
106    parameters_v: &[f64],
107    samples: &[Vec<Vec3>],
108    weights: &[Vec<f64>],
109) -> Result<Vec<Vec<Vec4>>, String> {
110    let count_u = parameters_u.len();
111    let count_v = parameters_v.len();
112    if let Some((weights_u, weights_v)) = separable_weights(weights) {
113        let matrix_u = collocation_matrix(knot_u, parameters_u, &weights_u);
114        let matrix_v = collocation_matrix(knot_v, parameters_v, &weights_v);
115        let mut intermediate = vec![vec![Vec3::default(); count_v]; count_u];
116        for column in 0..count_v {
117            let solve_axis = |axis: fn(Vec3) -> f64| {
118                solve_dense(
119                    matrix_u.clone(),
120                    samples.iter().map(|row| axis(row[column])).collect(),
121                )
122            };
123            let x = solve_axis(|point| point.x)?;
124            let y = solve_axis(|point| point.y)?;
125            let z = solve_axis(|point| point.z)?;
126            for row in 0..count_u {
127                intermediate[row][column] = Vec3::new(x[row], y[row], z[row]);
128            }
129        }
130        let mut controls = vec![vec![Vec4::from_point(Vec3::default(), 1.0); count_v]; count_u];
131        for row in 0..count_u {
132            let solve_axis = |axis: fn(Vec3) -> f64| {
133                solve_dense(
134                    matrix_v.clone(),
135                    intermediate[row].iter().copied().map(axis).collect(),
136                )
137            };
138            let x = solve_axis(|point| point.x)?;
139            let y = solve_axis(|point| point.y)?;
140            let z = solve_axis(|point| point.z)?;
141            for column in 0..count_v {
142                controls[row][column] = Vec4::from_point(
143                    Vec3::new(x[column], y[column], z[column]),
144                    weights[row][column],
145                );
146            }
147        }
148        return Ok(controls);
149    }
150
151    // Non-separable weights: solve the full tensor collocation system with
152    // the exact 2D rational basis. Nets are small in practice.
153    let unknowns = count_u * count_v;
154    let mut matrix = vec![vec![0.0; unknowns]; unknowns];
155    for (k, &u) in parameters_u.iter().enumerate() {
156        let span_u = knot_u.find_span(u);
157        let basis_u = knot_u.basis_functions(span_u, u);
158        for (l, &v) in parameters_v.iter().enumerate() {
159            let span_v = knot_v.find_span(v);
160            let basis_v = knot_v.basis_functions(span_v, v);
161            let row = &mut matrix[k * count_v + l];
162            let mut denominator = 0.0;
163            for (du, value_u) in basis_u.iter().enumerate() {
164                let i = span_u - knot_u.degree + du;
165                for (dv, value_v) in basis_v.iter().enumerate() {
166                    let j = span_v - knot_v.degree + dv;
167                    let entry = value_u * value_v * weights[i][j];
168                    row[i * count_v + j] = entry;
169                    denominator += entry;
170                }
171            }
172            if denominator.abs() > 0.0 {
173                for value in row.iter_mut() {
174                    *value /= denominator;
175                }
176            }
177        }
178    }
179    let solve_axis = |axis: fn(Vec3) -> f64| {
180        solve_dense(
181            matrix.clone(),
182            samples
183                .iter()
184                .flat_map(|row| row.iter().copied().map(axis))
185                .collect(),
186        )
187    };
188    let x = solve_axis(|point| point.x)?;
189    let y = solve_axis(|point| point.y)?;
190    let z = solve_axis(|point| point.z)?;
191    let mut controls = vec![vec![Vec4::from_point(Vec3::default(), 1.0); count_v]; count_u];
192    for row in 0..count_u {
193        for column in 0..count_v {
194            let index = row * count_v + column;
195            controls[row][column] = Vec4::from_point(
196                Vec3::new(x[index], y[index], z[index]),
197                weights[row][column],
198            );
199        }
200    }
201    Ok(controls)
202}
203
204/// Which branch of [`offset_surface`] built a carrier — and, with it, whether
205/// comparing that carrier against the pointwise offset at the same `(u, v)` is
206/// even the right question.
207///
208/// Recorded rather than inferred, in the pattern `offset/reintersect.rs`
209/// established for its own two lanes: two of this function's three branches
210/// deliberately move the result off the pointwise offset, and a measurement that
211/// did not know which branch ran would report a designed divergence as a fit
212/// error.
213#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
214pub enum OffsetSurfaceLane {
215    /// A rigid control-net shift of an affine (plane-like) carrier. The normal
216    /// is constant, so the shifted net IS the pointwise offset — exact by
217    /// construction, with no fit to measure.
218    Affine,
219    /// Greville collocation of the pointwise offset. `S_fit(u, v)` is meant to
220    /// BE `offset(u, v)`, and the distance between them is the fit error this
221    /// slice measures.
222    #[default]
223    Fit,
224    /// The result was deliberately moved off the pointwise offset: the planar /
225    /// ruled EXTENSION (which grows the carrier past its source rim, so the same
226    /// `(u, v)` names a different point) or the apex-cone PINCH RETRIM (which
227    /// pulls the crossed sample row back to the offset cone's own apex). Both
228    /// are correct and intended; neither is comparable pointwise.
229    Reparameterised,
230}
231
232/// A fitted offset carrier together with what its construction measured about
233/// itself.
234#[derive(Clone, Debug)]
235pub struct MeasuredOffsetSurface {
236    pub surface: NurbsSurface,
237    pub lane: OffsetSurfaceLane,
238    /// `max ‖S_fit(u, v) − offset(u, v)‖` over
239    /// [`crate::measure_surface_fit_against_pointwise_offset`]'s grid, against
240    /// the band it was judged with.
241    ///
242    /// `None` for [`OffsetSurfaceLane::Reparameterised`] — see that variant.
243    pub fit: Option<MeasuredTolerance>,
244}
245
246/// How far an offset carrier's TRIM grows past the source face at each of
247/// its four parametric sides, in world units.
248///
249/// A carrier is trimmed by the parametric IMAGE of the source loops, so the
250/// growth is realised by reshaping the carrier surface's net while the cloned
251/// pcurves stay put: an affine plane's 2x2 net slides along the plane, a ruled
252/// (linear-v) net stretches each sampled ruling. Both are solved so that the
253/// trim's own uv bounding box moves by exactly these amounts — a trim that
254/// occupies a sub-range of its surface's domain (every booleaned or
255/// blend-trimmed face) grows by the same distance as one that spans it. The
256/// legacy `planar_extension: f64` entry points are the uniform case.
257#[derive(Clone, Copy, Debug, PartialEq)]
258pub struct CarrierExtension {
259    pub u_min: f64,
260    pub u_max: f64,
261    pub v_min: f64,
262    pub v_max: f64,
263}
264
265impl CarrierExtension {
266    pub const NONE: CarrierExtension = CarrierExtension {
267        u_min: 0.0,
268        u_max: 0.0,
269        v_min: 0.0,
270        v_max: 0.0,
271    };
272
273    pub fn uniform(amount: f64) -> CarrierExtension {
274        CarrierExtension {
275            u_min: amount,
276            u_max: amount,
277            v_min: amount,
278            v_max: amount,
279        }
280    }
281
282    pub fn is_active(&self) -> bool {
283        self.u_min > 0.0 || self.u_max > 0.0 || self.v_min > 0.0 || self.v_max > 0.0
284    }
285
286    fn extends_v(&self) -> bool {
287        self.v_min > 0.0 || self.v_max > 0.0
288    }
289}
290
291/// The trim's uv bounding box as FRACTIONS of the surface domain:
292/// `([ta_u, tb_u], [ta_v, tb_v])`, each in `[0, 1]`. Sampled over every
293/// coedge pcurve of every loop.
294fn trim_domain_fractions(
295    face: &FaceRecord,
296    [u0, u1]: [f64; 2],
297    [v0, v1]: [f64; 2],
298) -> Result<([f64; 2], [f64; 2]), String> {
299    let mut low = Vec2 {
300        x: f64::INFINITY,
301        y: f64::INFINITY,
302    };
303    let mut high = Vec2 {
304        x: f64::NEG_INFINITY,
305        y: f64::NEG_INFINITY,
306    };
307    for coedge in face
308        .loops
309        .iter()
310        .flat_map(|loop_record| &loop_record.coedges)
311    {
312        let [p0, p1] = coedge.pcurve.domain()?;
313        for sample in 0..=24 {
314            let uv = coedge
315                .pcurve
316                .evaluate(p0 + (p1 - p0) * sample as f64 / 24.0)?;
317            low.x = low.x.min(uv.x);
318            low.y = low.y.min(uv.y);
319            high.x = high.x.max(uv.x);
320            high.y = high.y.max(uv.y);
321        }
322    }
323    let fraction = |value: f64, start: f64, end: f64| {
324        let span = end - start;
325        if span.abs() <= f64::EPSILON || !value.is_finite() {
326            return None;
327        }
328        Some(((value - start) / span).clamp(0.0, 1.0))
329    };
330    let u = match (fraction(low.x, u0, u1), fraction(high.x, u0, u1)) {
331        (Some(a), Some(b)) if b - a > 1e-6 => [a, b],
332        _ => [0.0, 1.0],
333    };
334    let v = match (fraction(low.y, v0, v1), fraction(high.y, v0, v1)) {
335        (Some(a), Some(b)) if b - a > 1e-6 => [a, b],
336        _ => [0.0, 1.0],
337    };
338    Ok((u, v))
339}
340
341/// How far the surface's domain ENDS must move (`(back, forward)`: the start
342/// end backwards, the far end forwards, world units) so that a trim occupying
343/// the domain fractions `[ta, tb]` grows by exactly `grow_min` at its start and
344/// `grow_max` at its end when its pcurves are left untouched. A point at
345/// fraction `t` of a linearly re-mapped domain moves by
346/// `-(1 - t)·back + t·forward`; solving that at `ta` and `tb` gives the pair
347/// below. It reduces to `(grow_min, grow_max)` for a full-domain trim, and
348/// never shrinks either end (both results are non-negative for non-negative
349/// inputs).
350fn domain_end_moves(grow_min: f64, grow_max: f64, [ta, tb]: [f64; 2]) -> (f64, f64) {
351    let span = tb - ta;
352    if span <= 1e-6 {
353        return (grow_min, grow_max);
354    }
355    let rate = (grow_min + grow_max) / span;
356    let back = grow_min + ta * rate;
357    let forward = (1.0 - ta) * rate - grow_min;
358    (back.max(0.0), forward.max(0.0))
359}
360
361/// Construct the same fitted offset carrier surface as the reference shell
362/// implementation. Positive distance follows its convention and moves
363/// opposite the face's outward normal.
364pub fn offset_surface(
365    face: &FaceRecord,
366    distance: f64,
367    planar_extension: f64,
368) -> Result<NurbsSurface, String> {
369    offset_surface_with_lane(face, distance, &CarrierExtension::uniform(planar_extension))
370        .map(|(surface, _)| surface)
371}
372
373/// [`offset_surface`], plus the deviation MEASURED between the carrier it built
374/// and the pointwise offset that carrier approximates.
375///
376/// The surface is bit-identical to [`offset_surface`]'s — this calls the same
377/// body and adds a read-only pass afterwards. Nothing here can change the
378/// carrier: `occt-offset-algorithms.md` §6.1's ADOPT is a record of what
379/// happened, never a new budget.
380pub fn offset_surface_measured(
381    face: &FaceRecord,
382    distance: f64,
383    planar_extension: f64,
384    band: f64,
385) -> Result<MeasuredOffsetSurface, String> {
386    offset_surface_measured_sided(
387        face,
388        distance,
389        &CarrierExtension::uniform(planar_extension),
390        band,
391    )
392}
393
394/// [`offset_surface_measured`] with a per-side [`CarrierExtension`].
395pub fn offset_surface_measured_sided(
396    face: &FaceRecord,
397    distance: f64,
398    extension: &CarrierExtension,
399    band: f64,
400) -> Result<MeasuredOffsetSurface, String> {
401    let (surface, lane) = offset_surface_with_lane(face, distance, extension)?;
402    let fit = match lane {
403        OffsetSurfaceLane::Affine => Some(MeasuredTolerance::exact(band)),
404        OffsetSurfaceLane::Fit => Some(measure_surface_fit_against_pointwise_offset(
405            &face.surface,
406            face.same_sense,
407            &surface,
408            distance,
409            band,
410        )?),
411        OffsetSurfaceLane::Reparameterised => None,
412    };
413    Ok(MeasuredOffsetSurface { surface, lane, fit })
414}
415
416fn offset_surface_with_lane(
417    face: &FaceRecord,
418    distance: f64,
419    extension: &CarrierExtension,
420) -> Result<(NurbsSurface, OffsetSurfaceLane), String> {
421    let source = &face.surface;
422    let extension_active = extension.is_active();
423    if source.is_affine()? {
424        let ([u0, u1], [v0, v1]) = domains(source)?;
425        let normal = stable_face_normal(face, (u0 + u1) / 2.0, (v0 + v1) / 2.0)?;
426        let shift = normal.scale(-distance);
427        let mut points = source
428            .control_points
429            .iter()
430            .map(|row| {
431                row.iter()
432                    .map(|control| Ok(control.point()?.add(shift)))
433                    .collect::<Result<Vec<_>, String>>()
434            })
435            .collect::<Result<Vec<_>, String>>()?;
436        if extension_active {
437            let p00 = points[0][0];
438            let p01 = points[0][1];
439            let p10 = points[1][0];
440            let direction_u = p10.sub(p00).normalized()?;
441            let direction_v = p01.sub(p00).normalized()?;
442            // The net slides while the cloned pcurves stay put, so a trim
443            // that spans only part of the domain would grow by less than
444            // asked (a 20-wide face trimmed to [0,16] by a blend moved its
445            // x=16 edge 0.6 for a 1.0 pad). Solve the slide for the TRIM's
446            // own bounding box instead.
447            let (trim_u, trim_v) = trim_domain_fractions(face, [u0, u1], [v0, v1])?;
448            let (back_u, forward_u) = domain_end_moves(extension.u_min, extension.u_max, trim_u);
449            let (back_v, forward_v) = domain_end_moves(extension.v_min, extension.v_max, trim_v);
450            points[0][0] = p00
451                .sub(direction_u.scale(back_u))
452                .sub(direction_v.scale(back_v));
453            points[0][1] = p01
454                .sub(direction_u.scale(back_u))
455                .add(direction_v.scale(forward_v));
456            points[1][0] = p10
457                .add(direction_u.scale(forward_u))
458                .sub(direction_v.scale(back_v));
459            points[1][1] = points[1][1]
460                .add(direction_u.scale(forward_u))
461                .add(direction_v.scale(forward_v));
462        }
463        let controls = points
464            .into_iter()
465            .enumerate()
466            .map(|(row, points)| {
467                points
468                    .into_iter()
469                    .enumerate()
470                    .map(|(column, point)| {
471                        Vec4::from_point(point, source.control_points[row][column].w)
472                    })
473                    .collect()
474            })
475            .collect();
476        // The extension slides the control net along the plane, so the same
477        // `(u, v)` no longer names the pointwise offset of the same source
478        // point; without it the shift is rigid and exact.
479        let lane = if extension_active {
480            OffsetSurfaceLane::Reparameterised
481        } else {
482            OffsetSurfaceLane::Affine
483        };
484        return Ok((
485            NurbsSurface::new(
486                source.degree_u,
487                source.degree_v,
488                source.knots_u.clone(),
489                source.knots_v.clone(),
490                controls,
491            )?,
492            lane,
493        ));
494    }
495
496    let knot_u = KnotVector::new(source.knots_u.clone(), source.degree_u)?;
497    let knot_v = KnotVector::new(source.knots_v.clone(), source.degree_v)?;
498    let parameters_u = greville_parameters(&knot_u);
499    let parameters_v = greville_parameters(&knot_v);
500    // The Greville sample grid IS a pointwise offset evaluation — this fit is
501    // the shared evaluator's consumer, not its peer. `offset_surface`'s
502    // positive distance moves OPPOSITE the face normal while the evaluator's
503    // moves ALONG it, so the negation happens once, here, with a name on it
504    // (audit §4.1's four hand negations get no fifth).
505    let evaluator = OffsetEvaluator::new(
506        "offset_surface",
507        source,
508        OffsetNormal::FaceStable {
509            same_sense: face.same_sense,
510        },
511    );
512    let mut samples = Vec::new();
513    for &u in &parameters_u {
514        let mut row = Vec::new();
515        for &v in &parameters_v {
516            row.push(evaluator.at(u, v, -distance)?.point);
517        }
518        samples.push(row);
519    }
520    // APEX-CONE PINCH RETRIM: offsetting an apex cone INWARD moves each
521    // ruling past the axis — the sampled far row becomes a ring on the far
522    // side (radius d·cos half-angle, mirrored through the axis) and the
523    // offset surface self-pinches inside the v-domain. The genuine cavity
524    // ends AT the pinch (the offset cone's own apex). For a linear-v net
525    // (two sample rows — every made/booleaned cone) the pinch lies on each
526    // ruling at the fraction where the radial vector vanishes: detect the
527    // inversion (far-row radials anti-parallel to near-row radials about the
528    // row centroids) and pull the far row back to the pinch point, so the
529    // fitted surface ends in a proper degenerate apex row instead of a
530    // parasitic inverted tip ending in an unweldable ring.
531    // Both blocks below move the sample grid OFF the pointwise offset on
532    // purpose. Recording that is what lets `offset_surface_measured` decline to
533    // report a designed divergence as a fit error.
534    let mut reparameterised = false;
535    if parameters_v.len() == 2 && parameters_u.len() >= 3 {
536        let centroid = |column: usize| {
537            let mut sum = Vec3::default();
538            for row in &samples {
539                sum = sum.add(row[column]);
540            }
541            sum.scale(1.0 / samples.len() as f64)
542        };
543        let near_centroid = centroid(0);
544        let far_centroid = centroid(1);
545        let mut inverted = true;
546        let mut pinch_fraction = 0.0f64;
547        let mut near_mean = 0.0f64;
548        let mut far_mean = 0.0f64;
549        for row in &samples {
550            let near_radial = row[0].sub(near_centroid);
551            let far_radial = row[1].sub(far_centroid);
552            let near_len = near_radial.length();
553            let far_len = far_radial.length();
554            if near_len <= 1e-9 || far_len <= 1e-9 {
555                inverted = false;
556                break;
557            }
558            if near_radial.dot(far_radial) >= 0.0 {
559                inverted = false;
560                break;
561            }
562            pinch_fraction += near_len / (near_len + far_len) / samples.len() as f64;
563            near_mean += near_len / samples.len() as f64;
564            far_mean += far_len / samples.len() as f64;
565        }
566        if inverted {
567            // Pull the crossed (smaller-ring, past-the-pinch) end back to the
568            // pinch point on each ruling.
569            reparameterised = true;
570            let retrim_far = far_mean <= near_mean;
571            for row in &mut samples {
572                let near = row[0];
573                let far = row[1];
574                let pinch = near.add(far.sub(near).scale(pinch_fraction));
575                if retrim_far {
576                    row[1] = pinch;
577                } else {
578                    row[0] = pinch;
579                }
580            }
581        }
582        // RULED EXTENSION: `planar_extension` is a no-op for curved carriers
583        // above, but a cone/cylinder lateral joined at a reflex edge needs
584        // its offset skin to GROW past the source rim exactly like a plane
585        // (a cylinder piercing a cone: the two offsets only meet past both
586        // cloned rims). A linear-v net is ruled — stretching each sampled
587        // ruling beyond both ends stays ON the same surface, so the fitted
588        // carrier keeps its parameterization (knots/pcurves untouched) while
589        // its world image (and with it the cloned trim's image) inflates.
590        if extension.extends_v() && !inverted {
591            let mut min_ruling = f64::MAX;
592            let mut back_allowance = f64::MAX;
593            let mut forward_allowance = f64::MAX;
594            let mut extendable = true;
595            for row in &samples {
596                let ruling = row[1].sub(row[0]);
597                let length = ruling.length();
598                min_ruling = min_ruling.min(length);
599                // Radii about the row centroids expose a converging (conic)
600                // ruling sheaf; the extension must stop short of its apex or
601                // the sheet folds through it.
602                let near_radial = row[0].sub(near_centroid).length();
603                let far_radial = row[1].sub(far_centroid).length();
604                if (far_radial - near_radial).abs() > 1e-9 {
605                    let apex_at = near_radial / (near_radial - far_radial);
606                    if (-1e-9..=1.0 + 1e-9).contains(&apex_at) {
607                        // Apex inside the span: degenerate sheet, do not touch.
608                        extendable = false;
609                        break;
610                    }
611                    if apex_at < 0.0 {
612                        back_allowance = back_allowance.min(0.9 * -apex_at);
613                    } else {
614                        forward_allowance = forward_allowance.min(0.9 * (apex_at - 1.0));
615                    }
616                }
617            }
618            if extendable && min_ruling > 1e-9 {
619                reparameterised = true;
620                // Solved for the TRIM's v-extent, not the domain's: a bore
621                // face keeps its drill's full-height surface and is trimmed
622                // to the part it pierces, so moving the surface's own ends
623                // by |d| moved the trim's rims by only a fraction of that
624                // (a 30-long ruling trimmed to 20 gave 0.667 for 1.0) — and
625                // an outward shell's bore never reached the grown planes.
626                let (_, trim_v) = trim_domain_fractions(face, knot_u.domain(), knot_v.domain())?;
627                let (back_world, forward_world) =
628                    domain_end_moves(extension.v_min, extension.v_max, trim_v);
629                let back = (back_world / min_ruling).min(back_allowance);
630                let forward = (forward_world / min_ruling).min(forward_allowance);
631                for row in &mut samples {
632                    let ruling = row[1].sub(row[0]);
633                    row[0] = row[0].sub(ruling.scale(back));
634                    row[1] = row[1].add(ruling.scale(forward));
635                }
636            }
637        }
638    }
639    let weights = source
640        .control_points
641        .iter()
642        .map(|row| row.iter().map(|point| point.w).collect::<Vec<_>>())
643        .collect::<Vec<_>>();
644    let lane = if reparameterised {
645        OffsetSurfaceLane::Reparameterised
646    } else {
647        OffsetSurfaceLane::Fit
648    };
649    Ok((
650        NurbsSurface::new(
651            source.degree_u,
652            source.degree_v,
653            source.knots_u.clone(),
654            source.knots_v.clone(),
655            interpolate_tensor(
656                &knot_u,
657                &knot_v,
658                &parameters_u,
659                &parameters_v,
660                &samples,
661                &weights,
662            )?,
663        )?,
664        lane,
665    ))
666}
667
668/// The 3D curve of one carrier edge: the image of the source coedge's pcurve
669/// on the carrier surface, with the parameter range `(t0, t1)` that matches the
670/// pcurve's domain fraction for fraction (the contract every coedge of this
671/// carrier relies on, since the source pcurves are reused verbatim).
672///
673/// Built through the [`crate::image_curve`] ladder — exact on an affine sheet
674/// (every planar offset), the exact iso-curve where the pcurve holds one
675/// coordinate constant (a blend rail, a cylinder's cap rim, every
676/// extrude/revolve boundary), and a fitted curve verified to `fit_tolerance`
677/// off its nodes otherwise. Only where the ladder refuses does the edge fall
678/// back to the degree-1 interpolant through `points` (the historical
679/// construction), so nothing that offset before is refused now.
680///
681/// Why the polyline is no longer the first choice: its adaptive subdivision
682/// stops at a 5e-4 chord sag, and a 128-chord rim on an r = 8.5 cylinder sits
683/// up to 1.6e-4 inside the true arc. That is 16x the boolean imprint's
684/// coincidence band, so when a later operation's section curve runs along that
685/// rim (a cutter's side wall coplanar with the offset shell's opening wall,
686/// 2026-09-06 report) the imprint cannot recognise the rim as the section it
687/// already is, and reports every chord vertex as a crossing — one edge split
688/// into forty. An exact arc is recognised as coincident and left alone.
689fn carrier_edge_curve(
690    surface: &NurbsSurface,
691    pcurve: &NurbsCurve,
692    points: &[Vec3],
693    parameters: &[f64],
694    fit_tolerance: f64,
695) -> Result<(NurbsCurve, f64, f64), String> {
696    // `BREP_CARRIER_POLYLINE=1` restores the polyline for an A/B comparison.
697    let image = if std::env::var("BREP_CARRIER_POLYLINE").as_deref() == Ok("1") {
698        Err("BREP_CARRIER_POLYLINE set".to_string())
699    } else {
700        crate::image_curve::image_curve(surface, pcurve, fit_tolerance, "offset_face_carrier")
701    };
702    match image {
703        Ok(image) if image.t0 <= image.t1 => Ok((image.curve, image.t0, image.t1)),
704        Ok(image) => {
705            // The image runs against the pcurve: reverse it so the edge range
706            // is increasing, reflecting the range through the curve's domain.
707            let [start, end] = image.curve.domain()?;
708            let curve = image.curve.reversed()?;
709            Ok((curve, start + end - image.t0, start + end - image.t1))
710        }
711        Err(_) => {
712            let curve = interpolate_curve(points, 1, parameters)?;
713            let [t0, t1] = curve.domain()?;
714            Ok((curve, t0, t1))
715        }
716    }
717}
718
719fn mapped_pcurve_polyline(
720    surface: &NurbsSurface,
721    pcurve: &NurbsCurve,
722    degenerate: bool,
723) -> Result<(Vec<Vec3>, Vec<f64>), String> {
724    let [start, end] = pcurve.domain()?;
725    let evaluate = |fraction: f64| {
726        let uv = pcurve.evaluate(start + (end - start) * fraction)?;
727        surface.evaluate(uv.x, uv.y)
728    };
729    let first = evaluate(0.0)?;
730    let last = evaluate(1.0)?;
731    if degenerate {
732        return Ok((vec![first, last], vec![0.0, 1.0]));
733    }
734    fn append(
735        evaluate: &impl Fn(f64) -> Result<Vec3, String>,
736        a_fraction: f64,
737        a: Vec3,
738        b_fraction: f64,
739        b: Vec3,
740        depth: usize,
741        parameters: &mut Vec<f64>,
742        points: &mut Vec<Vec3>,
743    ) -> Result<(), String> {
744        let fractions =
745            [0.25, 0.5, 0.75].map(|local| a_fraction + (b_fraction - a_fraction) * local);
746        let samples = fractions
747            .map(evaluate)
748            .into_iter()
749            .collect::<Result<Vec<_>, String>>()?;
750        let deviation = samples
751            .iter()
752            .enumerate()
753            .map(|(index, point)| {
754                point
755                    .sub(a.add(b.sub(a).scale((index + 1) as f64 * 0.25)))
756                    .length()
757            })
758            .fold(0.0, f64::max);
759        if deviation <= 5e-4 || depth >= 10 {
760            parameters.push(b_fraction);
761            points.push(b);
762            return Ok(());
763        }
764        append(
765            evaluate,
766            a_fraction,
767            a,
768            fractions[1],
769            samples[1],
770            depth + 1,
771            parameters,
772            points,
773        )?;
774        append(
775            evaluate,
776            fractions[1],
777            samples[1],
778            b_fraction,
779            b,
780            depth + 1,
781            parameters,
782            points,
783        )
784    }
785    let mut parameters = vec![0.0];
786    let mut points = vec![first];
787    append(
788        &evaluate,
789        0.0,
790        first,
791        1.0,
792        last,
793        0,
794        &mut parameters,
795        &mut points,
796    )?;
797    Ok((points, parameters))
798}
799
800/// Everything an offset carrier's construction MEASURED about itself.
801///
802/// The measured half of `occt-offset-algorithms.md` §6.1's ADOPT, in the place
803/// this kernel can put it without a durable-format change: alongside the
804/// transient construction result, never as a field on a
805/// [`crate::BrepSolid`] record. `io/snapshot.rs` is a documented durable format
806/// and `SOLID_CODEC_VERSION` a versioned wire layout; persisting per-entity
807/// tolerances is real, planned, and separately designed
808/// (`docs/developer/kernel-plans/per-entity-tolerances.md` S3). This lands the
809/// measurement with no format churn at all.
810///
811/// Every number here is a RECORD. None of it widens a band — see
812/// [`MeasuredTolerance`]'s direction rule.
813#[derive(Clone, Debug)]
814pub struct CarrierDeviation {
815    /// The derived band every measurement below was judged against:
816    /// [`crate::offset_construction_band`] of the source solid's extent.
817    pub band: f64,
818    /// Which branch built the carrier surface.
819    pub lane: OffsetSurfaceLane,
820    /// The carrier surface's own fit error, when the lane has one.
821    pub surface: Option<MeasuredTolerance>,
822    /// `max_t ‖C_3d(t) − S_off(p(t))‖` per carrier edge id, folded over every
823    /// coedge that references the edge — OCCT's `FillEdgeData` rule
824    /// (`BRepOffset_SimpleOffset.cxx:296-310`), which takes the maximum over
825    /// **every** adjacent face rather than the first one.
826    pub edges: Vec<(u64, MeasuredTolerance)>,
827    /// Per carrier vertex id, propagated from the incident edge ends by
828    /// [`crate::vertex_tolerance_from_edges`] — which is also where the verdict
829    /// on OCCT's 1.001 inflation factor is recorded.
830    pub vertices: Vec<(u64, f64)>,
831}
832
833impl CarrierDeviation {
834    /// The worst thing the construction did, against the tightest band it
835    /// faced. `None` only when there was nothing at all to measure.
836    pub fn worst(&self) -> Option<MeasuredTolerance> {
837        MeasuredTolerance::worst(
838            self.surface
839                .into_iter()
840                .chain(self.edges.iter().map(|(_, measured)| *measured))
841                .chain(
842                    self.vertices
843                        .iter()
844                        .map(|(_, gap)| MeasuredTolerance::new(*gap, self.band)),
845                ),
846        )
847    }
848
849    /// The entities whose measured deviation exceeded the derived band — the
850    /// interesting case, and the only one any gate acts on.
851    pub fn exceedances(&self) -> Vec<String> {
852        let mut out = Vec::new();
853        if let Some(surface) = self.surface {
854            if surface.exceeds_band() {
855                out.push(format!("carrier surface fit {}", surface.describe()));
856            }
857        }
858        for (id, measured) in &self.edges {
859            if measured.exceeds_band() {
860                out.push(format!("edge {id} {}", measured.describe()));
861            }
862        }
863        for (id, gap) in &self.vertices {
864            let measured = MeasuredTolerance::new(*gap, self.band);
865            if measured.exceeds_band() {
866                out.push(format!("vertex {id} {}", measured.describe()));
867            }
868        }
869        out
870    }
871}
872
873#[derive(Clone, Debug, Serialize)]
874pub struct OffsetFaceCarrier {
875    pub vertices: Vec<VertexRecord>,
876    pub edges: Vec<EdgeRecord>,
877    pub face: FaceRecord,
878    /// What the construction measured about itself, or `None` when it was built
879    /// through the unmeasured [`offset_face_carrier`] entry point.
880    ///
881    /// `#[serde(skip)]` on purpose: this struct crosses the wasm ABI as JSON
882    /// (`abi/modeling_b.rs:500`), and a measurement is a diagnostic about a
883    /// build, not part of the carrier the caller asked for. Skipping it keeps
884    /// that payload byte-identical.
885    #[serde(skip)]
886    pub deviation: Option<CarrierDeviation>,
887}
888
889fn claim_vertex_image(
890    source_id: u64,
891    point: Vec3,
892    vertex_images: &mut HashMap<u64, u64>,
893    vertices: &mut Vec<VertexRecord>,
894    next_id: &mut u64,
895) -> u64 {
896    if let Some(id) = vertex_images.get(&source_id) {
897        return *id;
898    }
899    let id = *next_id;
900    *next_id += 1;
901    vertices.push(VertexRecord { id, point });
902    vertex_images.insert(source_id, id);
903    id
904}
905
906pub fn offset_face_carrier(
907    solid: &BrepSolid,
908    face_id: u64,
909    distance: f64,
910    planar_extension: f64,
911) -> Result<OffsetFaceCarrier, String> {
912    offset_face_carrier_impl(
913        solid,
914        face_id,
915        distance,
916        &CarrierExtension::uniform(planar_extension),
917        false,
918    )
919}
920
921/// [`offset_face_carrier`] with a per-side [`CarrierExtension`].
922pub fn offset_face_carrier_sided(
923    solid: &BrepSolid,
924    face_id: u64,
925    distance: f64,
926    extension: &CarrierExtension,
927) -> Result<OffsetFaceCarrier, String> {
928    offset_face_carrier_impl(solid, face_id, distance, extension, false)
929}
930
931/// [`offset_face_carrier`], with every entity it builds measured against the
932/// deviation it was meant to reproduce.
933///
934/// The carrier is bit-identical to [`offset_face_carrier`]'s — same body, same
935/// arithmetic, in the same order — with a read-only measurement pass appended.
936/// Three quantities land in [`CarrierDeviation`]:
937///
938/// * the carrier SURFACE's fit against the pointwise offset it interpolates
939///   ([`offset_surface_measured`]);
940/// * each carrier EDGE's 3D curve against the locus its pcurve traces on that
941///   surface — the trim boundary is built by sampling exactly that composition
942///   and interpolating the images, so this is the construction's own claim,
943///   measured rather than assumed;
944/// * each carrier VERTEX, propagated from the incident edge ends.
945///
946/// The interesting output is [`CarrierDeviation::exceedances`]: an entity whose
947/// measured deviation is worse than the size-derived band assumed. That is a
948/// construction that went wrong in a way the derived band alone cannot see, and
949/// it is the case a caller should refuse on rather than ship.
950pub fn offset_face_carrier_measured(
951    solid: &BrepSolid,
952    face_id: u64,
953    distance: f64,
954    planar_extension: f64,
955) -> Result<OffsetFaceCarrier, String> {
956    offset_face_carrier_impl(
957        solid,
958        face_id,
959        distance,
960        &CarrierExtension::uniform(planar_extension),
961        true,
962    )
963}
964
965/// The measured deviations of one built carrier: surface fit, per edge, per
966/// vertex.
967///
968/// Read-only over what the construction produced. The edge measurement is
969/// [`crate::measure_edge_against_pcurve_image`] — validate's own
970/// `adaptive_coedge_error` floored by a span-midpoint pass, because the sampler
971/// alone is aliased against exactly the curves this construction builds (see
972/// that module's doc) — taken against the far tighter
973/// [`crate::offset_construction_band`] instead of the vendor-forgiving
974/// `pcurve_acceptance` validate will use later.
975fn measure_carrier(
976    surface: &NurbsSurface,
977    vertices: &[VertexRecord],
978    edges: &[EdgeRecord],
979    loops: &[LoopRecord],
980    lane: OffsetSurfaceLane,
981    surface_fit: Option<MeasuredTolerance>,
982    band: f64,
983) -> Result<CarrierDeviation, String> {
984    let edge_by_id: HashMap<u64, &EdgeRecord> = edges.iter().map(|edge| (edge.id, edge)).collect();
985    // Fold over coedges, not edges: a seam edge is referenced twice with two
986    // different pcurves, and OCCT's `FillEdgeData` takes the maximum over every
987    // adjacent face for exactly that reason.
988    let mut per_edge: HashMap<u64, MeasuredTolerance> = HashMap::default();
989    for loop_record in loops {
990        for coedge in &loop_record.coedges {
991            let Some(edge) = edge_by_id.get(&coedge.edge_id) else {
992                continue;
993            };
994            let measured = measure_edge_against_pcurve_image(
995                surface,
996                &coedge.pcurve,
997                edge,
998                coedge.forward,
999                band,
1000            )?;
1001            per_edge
1002                .entry(edge.id)
1003                .and_modify(|existing| *existing = existing.worse_of(measured))
1004                .or_insert(measured);
1005        }
1006    }
1007
1008    let mut measured_edges: Vec<(u64, MeasuredTolerance)> = per_edge.into_iter().collect();
1009    measured_edges.sort_by_key(|(id, _)| *id);
1010    let deviation_of: HashMap<u64, f64> = measured_edges
1011        .iter()
1012        .map(|(id, measured)| (*id, measured.deviation()))
1013        .collect();
1014
1015    // Edge ENDS by the vertex they claim, built once. A degenerate edge claims
1016    // the same vertex at both ends and contributes both, which is right: the
1017    // question is how far every representation meeting there actually lands.
1018    let mut ends_at: HashMap<u64, Vec<(&EdgeRecord, f64)>> = HashMap::default();
1019    for edge in edges {
1020        ends_at
1021            .entry(edge.start_vertex_id)
1022            .or_default()
1023            .push((edge, edge.t0));
1024        ends_at
1025            .entry(edge.end_vertex_id)
1026            .or_default()
1027            .push((edge, edge.t1));
1028    }
1029
1030    let mut measured_vertices = Vec::with_capacity(vertices.len());
1031    for vertex in vertices {
1032        let mut gaps = Vec::new();
1033        let mut incident = Vec::new();
1034        for (edge, parameter) in ends_at.get(&vertex.id).into_iter().flatten() {
1035            gaps.push(vertex_endpoint_gap(
1036                vertex.point,
1037                edge.curve.evaluate(*parameter)?,
1038            ));
1039            incident.push(deviation_of.get(&edge.id).copied().unwrap_or(0.0));
1040        }
1041        measured_vertices.push((vertex.id, vertex_tolerance_from_edges(gaps, incident)));
1042    }
1043
1044    Ok(CarrierDeviation {
1045        band,
1046        lane,
1047        surface: surface_fit,
1048        edges: measured_edges,
1049        vertices: measured_vertices,
1050    })
1051}
1052
1053fn offset_face_carrier_impl(
1054    solid: &BrepSolid,
1055    face_id: u64,
1056    distance: f64,
1057    extension: &CarrierExtension,
1058    measure: bool,
1059) -> Result<OffsetFaceCarrier, String> {
1060    let source = solid
1061        .shells
1062        .iter()
1063        .flat_map(|shell| &shell.faces)
1064        .find(|face| face.id == face_id)
1065        .ok_or_else(|| format!("offset_face_carrier: missing face {face_id}"))?;
1066    // The band is derived from the SOURCE SOLID's extent, matching every other
1067    // direct-edit/offset site (`face_offset.rs:103` and its siblings all take
1068    // `solid_model_scale`). `occt-offset-algorithms.md` §7 item 8 / UNKNOWN 3
1069    // proposes keying such bands on the FACE's own extent instead; that is a
1070    // measurement to make before switching, not a change to smuggle in here.
1071    let band = offset_construction_band(solid_model_scale(solid));
1072    // The accuracy bar a carrier EDGE's 3D curve must meet against the locus its
1073    // pcurve traces on the carrier surface — the kernel's own SSI fit contract,
1074    // the same bar `thicken` holds its wall boundaries to. NOT the construction
1075    // band: that is the surface-fit allowance (5e-4 of the part), 50x looser
1076    // than the boolean's coincidence band, and an edge fitted only that well is
1077    // what a later imprint mistakes for a crossing (see `carrier_edge_curve`).
1078    let fit_tolerance =
1079        crate::KernelTolerances::for_scale(solid_model_scale(solid), 1e-7).intersection_fit;
1080    let (surface, lane, surface_fit) = if measure {
1081        let measured = offset_surface_measured_sided(source, distance, extension, band)?;
1082        (measured.surface, measured.lane, measured.fit)
1083    } else {
1084        let (surface, lane) = offset_surface_with_lane(source, distance, extension)?;
1085        (surface, lane, None)
1086    };
1087    let source_edges = solid
1088        .edges
1089        .iter()
1090        .map(|edge| (edge.id, edge))
1091        .collect::<HashMap<_, _>>();
1092    let source_vertices = solid
1093        .vertices
1094        .iter()
1095        .map(|vertex| (vertex.id, vertex))
1096        .collect::<HashMap<_, _>>();
1097    let mut vertices = Vec::new();
1098    let mut vertex_images = HashMap::default();
1099    let mut edges = Vec::new();
1100    let mut edge_images = HashMap::default();
1101    let mut loops = Vec::new();
1102    let mut next_id = 1u64;
1103
1104    for source_loop in &source.loops {
1105        let mut coedges = Vec::new();
1106        for source_coedge in &source_loop.coedges {
1107            let source_edge = source_edges
1108                .get(&source_coedge.edge_id)
1109                .ok_or_else(|| "offset_face_carrier: missing source edge".to_string())?;
1110            let (source_start, source_end) = if source_coedge.forward {
1111                (source_edge.start_vertex_id, source_edge.end_vertex_id)
1112            } else {
1113                (source_edge.end_vertex_id, source_edge.start_vertex_id)
1114            };
1115            if !source_vertices.contains_key(&source_start)
1116                || !source_vertices.contains_key(&source_end)
1117            {
1118                return Err("offset_face_carrier: missing source vertex".into());
1119            }
1120            // Map even DEGENERATE source edges through the full polyline: a
1121            // cone apex's image on the offset surface is a genuine CIRCLE
1122            // (radius d·cos half-angle), not a point — shortcutting to the
1123            // two endpoints would collapse the ring and leave the carrier's
1124            // topology inconsistent with its surface. Edges whose image truly
1125            // collapses (sphere poles, planar corners) still interpolate to a
1126            // point-sized curve and keep their degenerate flag below.
1127            // Map even DEGENERATE source edges through the full polyline: an
1128            // EXTERIOR cone offset turns the apex point into a genuine RING
1129            // (radius d·cos half-angle) — shortcutting to the endpoints would
1130            // collapse it and leave the carrier topology inconsistent with
1131            // its surface (and the ring imprint would be dropped as
1132            // boundary-coincident with a "degenerate" edge). Images that
1133            // truly collapse (sphere poles; interior apexes after the pinch
1134            // retrim) stay degenerate below. The threshold scales with the
1135            // offset distance: a real ring measures ~d·cos α, while fitted
1136            // pole rows wobble ~1e-4 absolute.
1137            let (points, parameters) =
1138                mapped_pcurve_polyline(&surface, &source_coedge.pcurve, false)?;
1139            let collapse_tolerance = 1e-6f64.max(distance.abs() * 1e-2);
1140            let image_collapsed = points
1141                .iter()
1142                .all(|point| point.sub(points[0]).length() <= collapse_tolerance);
1143            let (edge_id, forward) =
1144                if let Some((edge_id, edge_start_vertex_id, creator_forward)) =
1145                    edge_images.get(&source_edge.id)
1146                {
1147                    if source_start == source_end {
1148                        // A CLOSED source edge (a seam of a periodic face: both
1149                        // ends are the same vertex) is referenced twice by the
1150                        // same loop, once per seam side, and the two references
1151                        // traverse it in OPPOSITE senses — that is what closes
1152                        // the loop. The endpoint test below cannot see that:
1153                        // both ends map to the same vertex image, so it answers
1154                        // `true` for both references and the returning coedge
1155                        // comes back mis-oriented.
1156                        //
1157                        // Measured on a full torus (one vertex, two seam edges):
1158                        // the source records `forward: false` on the two
1159                        // returning coedges and validates clean, while the
1160                        // carrier recorded `forward: true` on both and its rim
1161                        // edge measured 12.5 — a whole part diameter — against
1162                        // its own composed pcurve image. The measured tolerance
1163                        // this slice adds is what surfaced it; no existing gate
1164                        // reaches this shape.
1165                        //
1166                        // The source coedge's own sense is the answer: the image
1167                        // edge runs along the CREATING coedge's pcurve, so a
1168                        // later reference runs with it exactly when the two
1169                        // source coedges traverse the source edge the same way.
1170                        (*edge_id, source_coedge.forward == *creator_forward)
1171                    } else {
1172                        // UNCHANGED for every open edge: the image edge's start
1173                        // vertex identifies which way this coedge runs.
1174                        (
1175                            *edge_id,
1176                            vertex_images.get(&source_start) == Some(edge_start_vertex_id),
1177                        )
1178                    }
1179                } else {
1180                    let (curve, t0, t1) = if image_collapsed {
1181                        let curve = NurbsCurve::new(
1182                            1,
1183                            vec![0.0, 0.0, 1.0, 1.0],
1184                            vec![
1185                                Vec4::from_point(points[0], 1.0),
1186                                Vec4::from_point(points[0], 1.0),
1187                            ],
1188                        )?;
1189                        (curve, 0.0, 1.0)
1190                    } else {
1191                        carrier_edge_curve(
1192                            &surface,
1193                            &source_coedge.pcurve,
1194                            &points,
1195                            &parameters,
1196                            fit_tolerance,
1197                        )?
1198                    };
1199                    let start_vertex_id = claim_vertex_image(
1200                        source_start,
1201                        points[0],
1202                        &mut vertex_images,
1203                        &mut vertices,
1204                        &mut next_id,
1205                    );
1206                    let end_vertex_id = claim_vertex_image(
1207                        source_end,
1208                        points[points.len() - 1],
1209                        &mut vertex_images,
1210                        &mut vertices,
1211                        &mut next_id,
1212                    );
1213                    let id = next_id;
1214                    next_id += 1;
1215                    edges.push(EdgeRecord {
1216                        id,
1217                        curve,
1218                        t0,
1219                        t1,
1220                        start_vertex_id,
1221                        end_vertex_id,
1222                        // Degenerate only if the IMAGE collapsed too — a cone
1223                        // apex maps to a real ring on the offset surface and
1224                        // must carry a real closed edge.
1225                        degenerate: source_edge.degenerate && image_collapsed,
1226                        // Image of a named source edge on the offset carrier;
1227                        // suffixed so it cannot collide with the source edge
1228                        // when both faces survive into one solid.
1229                        name: source_edge
1230                            .name
1231                            .as_ref()
1232                            .map(|name| format!("{name}_Offset")),
1233                    });
1234                    edge_images.insert(
1235                        source_edge.id,
1236                        (id, start_vertex_id, source_coedge.forward),
1237                    );
1238                    (id, true)
1239                };
1240            let id = next_id;
1241            next_id += 1;
1242            coedges.push(CoedgeRecord {
1243                id,
1244                edge_id,
1245                forward,
1246                pcurve: source_coedge.pcurve.clone(),
1247            });
1248        }
1249        let id = next_id;
1250        next_id += 1;
1251        loops.push(LoopRecord { id, coedges });
1252    }
1253    let deviation = if measure {
1254        Some(measure_carrier(
1255            &surface,
1256            &vertices,
1257            &edges,
1258            &loops,
1259            lane,
1260            surface_fit,
1261            band,
1262        )?)
1263    } else {
1264        None
1265    };
1266    Ok(OffsetFaceCarrier {
1267        vertices,
1268        edges,
1269        face: FaceRecord {
1270            id: next_id,
1271            surface,
1272            same_sense: source.same_sense,
1273            loops,
1274            name: source.name.as_ref().map(|name| format!("{name}_Offset")),
1275        },
1276        deviation,
1277    })
1278}
1279
1280#[cfg(test)]
1281mod tests {
1282    use super::*;
1283    use crate::{make_box_brep, make_cylinder_brep};
1284
1285    /// A CLOSED source edge (a periodic face's seam) is referenced twice by the
1286    /// same loop with OPPOSITE senses, and the carrier must reproduce that.
1287    ///
1288    /// Before this was fixed the endpoint test could not see the difference —
1289    /// both ends of a closed edge map to the same vertex image, so it answered
1290    /// `forward: true` for both references. The measured tolerance is what found
1291    /// it: the returning rim edge of a full torus measured 12.5 against its own
1292    /// composed pcurve image, a whole part diameter, on a part 13 across. The
1293    /// source solid records `forward: false` on those coedges and validates
1294    /// clean, so the source is the oracle here, not an opinion.
1295    #[test]
1296    fn a_full_torus_carrier_reproduces_the_source_seam_senses() {
1297        let solid =
1298            crate::make_torus_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 5.0, 1.5).unwrap();
1299        assert!(
1300            solid.validate().is_empty(),
1301            "the source torus is the oracle and must be clean"
1302        );
1303        let face = &solid.shells[0].faces[0];
1304        let source_senses: Vec<bool> = face
1305            .loops
1306            .iter()
1307            .flat_map(|loop_record| &loop_record.coedges)
1308            .map(|coedge| coedge.forward)
1309            .collect();
1310        assert_eq!(
1311            source_senses,
1312            vec![true, true, false, false],
1313            "a full torus's two seam edges are each traversed both ways"
1314        );
1315
1316        let carrier = offset_face_carrier_measured(&solid, face.id, 0.25, 0.0).unwrap();
1317        let carrier_senses: Vec<bool> = carrier
1318            .face
1319            .loops
1320            .iter()
1321            .flat_map(|loop_record| &loop_record.coedges)
1322            .map(|coedge| coedge.forward)
1323            .collect();
1324        assert_eq!(
1325            carrier_senses, source_senses,
1326            "the carrier copies the source topology, senses included"
1327        );
1328
1329        // And the measurement agrees: every rim edge now sits within the
1330        // polyline sag of its own image instead of a part diameter away.
1331        let deviation = carrier.deviation.expect("measured");
1332        for (id, measured) in &deviation.edges {
1333            assert!(
1334                measured.deviation() < 1e-3,
1335                "carrier edge {id} deviates {:.3e} from its pcurve image",
1336                measured.deviation()
1337            );
1338        }
1339    }
1340
1341    /// Measuring is a read-only pass: the carrier it returns must be the one the
1342    /// unmeasured entry point returns, byte for byte.
1343    ///
1344    /// This is the per-call unit form of the slice's bit-identity claim; the
1345    /// corpus form is `examples/retrim_bitidentity_probe.rs`.
1346    #[test]
1347    fn measuring_a_carrier_cannot_change_it() {
1348        let cases: Vec<(&str, BrepSolid)> = vec![
1349            ("box", make_box_brep(Vec3::default(), 20.0, 20.0, 4.0).unwrap()),
1350            (
1351                "cylinder",
1352                make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 5.0, 12.0).unwrap(),
1353            ),
1354            (
1355                "cone",
1356                crate::make_cone_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 5.0, 2.5, 10.0)
1357                    .unwrap(),
1358            ),
1359            (
1360                "sphere",
1361                crate::make_sphere_brep(Vec3::default(), 5.0, Vec3::new(0.0, 0.0, 1.0)).unwrap(),
1362            ),
1363        ];
1364        for (label, solid) in cases {
1365            let face_ids: Vec<u64> = solid
1366                .shells
1367                .iter()
1368                .flat_map(|shell| &shell.faces)
1369                .map(|face| face.id)
1370                .collect();
1371            for face_id in face_ids {
1372                for distance in [0.25, -0.25] {
1373                    for extension in [0.0, 0.5] {
1374                        let plain = offset_face_carrier(&solid, face_id, distance, extension);
1375                        let measured =
1376                            offset_face_carrier_measured(&solid, face_id, distance, extension);
1377                        match (plain, measured) {
1378                            (Ok(plain), Ok(measured)) => assert_eq!(
1379                                serde_json::to_string(&plain).unwrap(),
1380                                serde_json::to_string(&measured).unwrap(),
1381                                "{label} face {face_id} d={distance} ext={extension} moved"
1382                            ),
1383                            (Err(plain), Err(measured)) => assert_eq!(
1384                                plain, measured,
1385                                "{label} face {face_id} refusal text moved"
1386                            ),
1387                            (plain, measured) => panic!(
1388                                "{label} face {face_id}: outcomes disagree ({:?} vs {:?})",
1389                                plain.is_ok(),
1390                                measured.is_ok()
1391                            ),
1392                        }
1393                    }
1394                }
1395            }
1396        }
1397    }
1398
1399    /// The lane is what tells a measurement whether a pointwise comparison is
1400    /// even the right question — the extension and the pinch retrim move the
1401    /// result off the pointwise offset on purpose.
1402    #[test]
1403    fn the_surface_lane_names_the_branch_that_ran() {
1404        let plate = make_box_brep(Vec3::default(), 20.0, 20.0, 4.0).unwrap();
1405        let plane = &plate.shells[0].faces[0];
1406        assert_eq!(
1407            offset_surface_measured(plane, 0.25, 0.0, 1e-2).unwrap().lane,
1408            OffsetSurfaceLane::Affine
1409        );
1410        assert_eq!(
1411            offset_surface_measured(plane, 0.25, 0.5, 1e-2).unwrap().lane,
1412            OffsetSurfaceLane::Reparameterised,
1413            "a grown plane no longer names the same point at the same (u, v)"
1414        );
1415        assert!(
1416            offset_surface_measured(plane, 0.25, 0.5, 1e-2)
1417                .unwrap()
1418                .fit
1419                .is_none(),
1420            "a deliberate divergence is not reported as a fit error"
1421        );
1422
1423        let ball = crate::make_sphere_brep(Vec3::default(), 5.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1424        let sphere = &ball.shells[0].faces[0];
1425        let measured = offset_surface_measured(sphere, 0.25, 0.0, 1e-2).unwrap();
1426        assert_eq!(measured.lane, OffsetSurfaceLane::Fit);
1427        let fit = measured.fit.expect("a fit has an error to report");
1428        assert!(
1429            fit.deviation() > 0.0 && fit.deviation() < 1e-4,
1430            "the sphere's collocation fit is small but not exact (got {:.3e})",
1431            fit.deviation()
1432        );
1433    }
1434
1435    #[test]
1436    fn affine_offset_is_exact_and_preserves_weights() {
1437        let solid = make_box_brep(Vec3::default(), 4.0, 4.0, 4.0).unwrap();
1438        let face = &solid.shells[0].faces[0];
1439        let offset = offset_surface(face, 0.75, 0.0).unwrap();
1440        let domain_u = KnotVector::new(face.surface.knots_u.clone(), 1)
1441            .unwrap()
1442            .domain();
1443        let domain_v = KnotVector::new(face.surface.knots_v.clone(), 1)
1444            .unwrap()
1445            .domain();
1446        let u = (domain_u[0] + domain_u[1]) / 2.0;
1447        let v = (domain_v[0] + domain_v[1]) / 2.0;
1448        let displacement = offset
1449            .evaluate(u, v)
1450            .unwrap()
1451            .sub(face.surface.evaluate(u, v).unwrap());
1452        assert!((displacement.length() - 0.75).abs() < 1e-12);
1453    }
1454
1455    #[test]
1456    fn curved_offset_carrier_maps_every_trim_to_new_surface() {
1457        let solid =
1458            make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 2.0, 4.0).unwrap();
1459        let side = &solid.shells[0].faces[0];
1460        let carrier = offset_face_carrier(&solid, side.id, 0.5, 0.0).unwrap();
1461        for coedge in carrier
1462            .face
1463            .loops
1464            .iter()
1465            .flat_map(|loop_record| &loop_record.coedges)
1466        {
1467            let edge = carrier
1468                .edges
1469                .iter()
1470                .find(|edge| edge.id == coedge.edge_id)
1471                .unwrap();
1472            for fraction in [0.0, 0.3, 0.8, 1.0] {
1473                let uv = coedge.pcurve.evaluate(fraction).unwrap();
1474                let on_surface = carrier.face.surface.evaluate(uv.x, uv.y).unwrap();
1475                let parameter = if coedge.forward {
1476                    edge.t0 + (edge.t1 - edge.t0) * fraction
1477                } else {
1478                    edge.t1 - (edge.t1 - edge.t0) * fraction
1479                };
1480                assert!(
1481                    on_surface
1482                        .sub(edge.curve.evaluate(parameter).unwrap())
1483                        .length()
1484                        < 7e-4
1485                );
1486            }
1487        }
1488    }
1489}