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: measuring the deviation AFTER the carrier is built records what
379/// happened; the size-derived band stays the floor and the bar it is judged
380/// against. A measurement never widens a band.
381pub fn offset_surface_measured(
382    face: &FaceRecord,
383    distance: f64,
384    planar_extension: f64,
385    band: f64,
386) -> Result<MeasuredOffsetSurface, String> {
387    offset_surface_measured_sided(
388        face,
389        distance,
390        &CarrierExtension::uniform(planar_extension),
391        band,
392    )
393}
394
395/// [`offset_surface_measured`] with a per-side [`CarrierExtension`].
396pub fn offset_surface_measured_sided(
397    face: &FaceRecord,
398    distance: f64,
399    extension: &CarrierExtension,
400    band: f64,
401) -> Result<MeasuredOffsetSurface, String> {
402    let (surface, lane) = offset_surface_with_lane(face, distance, extension)?;
403    let fit = match lane {
404        OffsetSurfaceLane::Affine => Some(MeasuredTolerance::exact(band)),
405        OffsetSurfaceLane::Fit => Some(measure_surface_fit_against_pointwise_offset(
406            &face.surface,
407            face.same_sense,
408            &surface,
409            distance,
410            band,
411        )?),
412        OffsetSurfaceLane::Reparameterised => None,
413    };
414    Ok(MeasuredOffsetSurface { surface, lane, fit })
415}
416
417fn offset_surface_with_lane(
418    face: &FaceRecord,
419    distance: f64,
420    extension: &CarrierExtension,
421) -> Result<(NurbsSurface, OffsetSurfaceLane), String> {
422    let source = &face.surface;
423    let extension_active = extension.is_active();
424    if source.is_affine()? {
425        let ([u0, u1], [v0, v1]) = domains(source)?;
426        let normal = stable_face_normal(face, (u0 + u1) / 2.0, (v0 + v1) / 2.0)?;
427        let shift = normal.scale(-distance);
428        let mut points = source
429            .control_points
430            .iter()
431            .map(|row| {
432                row.iter()
433                    .map(|control| Ok(control.point()?.add(shift)))
434                    .collect::<Result<Vec<_>, String>>()
435            })
436            .collect::<Result<Vec<_>, String>>()?;
437        if extension_active {
438            let p00 = points[0][0];
439            let p01 = points[0][1];
440            let p10 = points[1][0];
441            let direction_u = p10.sub(p00).normalized()?;
442            let direction_v = p01.sub(p00).normalized()?;
443            // The net slides while the cloned pcurves stay put, so a trim
444            // that spans only part of the domain would grow by less than
445            // asked (a 20-wide face trimmed to [0,16] by a blend moved its
446            // x=16 edge 0.6 for a 1.0 pad). Solve the slide for the TRIM's
447            // own bounding box instead.
448            let (trim_u, trim_v) = trim_domain_fractions(face, [u0, u1], [v0, v1])?;
449            let (back_u, forward_u) = domain_end_moves(extension.u_min, extension.u_max, trim_u);
450            let (back_v, forward_v) = domain_end_moves(extension.v_min, extension.v_max, trim_v);
451            points[0][0] = p00
452                .sub(direction_u.scale(back_u))
453                .sub(direction_v.scale(back_v));
454            points[0][1] = p01
455                .sub(direction_u.scale(back_u))
456                .add(direction_v.scale(forward_v));
457            points[1][0] = p10
458                .add(direction_u.scale(forward_u))
459                .sub(direction_v.scale(back_v));
460            points[1][1] = points[1][1]
461                .add(direction_u.scale(forward_u))
462                .add(direction_v.scale(forward_v));
463        }
464        let controls = points
465            .into_iter()
466            .enumerate()
467            .map(|(row, points)| {
468                points
469                    .into_iter()
470                    .enumerate()
471                    .map(|(column, point)| {
472                        Vec4::from_point(point, source.control_points[row][column].w)
473                    })
474                    .collect()
475            })
476            .collect();
477        // The extension slides the control net along the plane, so the same
478        // `(u, v)` no longer names the pointwise offset of the same source
479        // point; without it the shift is rigid and exact.
480        let lane = if extension_active {
481            OffsetSurfaceLane::Reparameterised
482        } else {
483            OffsetSurfaceLane::Affine
484        };
485        return Ok((
486            NurbsSurface::new(
487                source.degree_u,
488                source.degree_v,
489                source.knots_u.clone(),
490                source.knots_v.clone(),
491                controls,
492            )?,
493            lane,
494        ));
495    }
496
497    let knot_u = KnotVector::new(source.knots_u.clone(), source.degree_u)?;
498    let knot_v = KnotVector::new(source.knots_v.clone(), source.degree_v)?;
499    let parameters_u = greville_parameters(&knot_u);
500    let parameters_v = greville_parameters(&knot_v);
501    // The Greville sample grid IS a pointwise offset evaluation — this fit is
502    // the shared evaluator's consumer, not its peer. `offset_surface`'s
503    // positive distance moves OPPOSITE the face normal while the evaluator's
504    // moves ALONG it, so the negation happens once, here, with a name on it
505    // (audit §4.1's four hand negations get no fifth).
506    let evaluator = OffsetEvaluator::new(
507        "offset_surface",
508        source,
509        OffsetNormal::FaceStable {
510            same_sense: face.same_sense,
511        },
512    );
513    let mut samples = Vec::new();
514    for &u in &parameters_u {
515        let mut row = Vec::new();
516        for &v in &parameters_v {
517            row.push(evaluator.at(u, v, -distance)?.point);
518        }
519        samples.push(row);
520    }
521    // APEX-CONE PINCH RETRIM: offsetting an apex cone INWARD moves each
522    // ruling past the axis — the sampled far row becomes a ring on the far
523    // side (radius d·cos half-angle, mirrored through the axis) and the
524    // offset surface self-pinches inside the v-domain. The genuine cavity
525    // ends AT the pinch (the offset cone's own apex). For a linear-v net
526    // (two sample rows — every made/booleaned cone) the pinch lies on each
527    // ruling at the fraction where the radial vector vanishes: detect the
528    // inversion (far-row radials anti-parallel to near-row radials about the
529    // row centroids) and pull the far row back to the pinch point, so the
530    // fitted surface ends in a proper degenerate apex row instead of a
531    // parasitic inverted tip ending in an unweldable ring.
532    // Both blocks below move the sample grid OFF the pointwise offset on
533    // purpose. Recording that is what lets `offset_surface_measured` decline to
534    // report a designed divergence as a fit error.
535    let mut reparameterised = false;
536    if parameters_v.len() == 2 && parameters_u.len() >= 3 {
537        let centroid = |column: usize| {
538            let mut sum = Vec3::default();
539            for row in &samples {
540                sum = sum.add(row[column]);
541            }
542            sum.scale(1.0 / samples.len() as f64)
543        };
544        let near_centroid = centroid(0);
545        let far_centroid = centroid(1);
546        let mut inverted = true;
547        let mut pinch_fraction = 0.0f64;
548        let mut near_mean = 0.0f64;
549        let mut far_mean = 0.0f64;
550        for row in &samples {
551            let near_radial = row[0].sub(near_centroid);
552            let far_radial = row[1].sub(far_centroid);
553            let near_len = near_radial.length();
554            let far_len = far_radial.length();
555            if near_len <= 1e-9 || far_len <= 1e-9 {
556                inverted = false;
557                break;
558            }
559            if near_radial.dot(far_radial) >= 0.0 {
560                inverted = false;
561                break;
562            }
563            pinch_fraction += near_len / (near_len + far_len) / samples.len() as f64;
564            near_mean += near_len / samples.len() as f64;
565            far_mean += far_len / samples.len() as f64;
566        }
567        if inverted {
568            // Pull the crossed (smaller-ring, past-the-pinch) end back to the
569            // pinch point on each ruling.
570            reparameterised = true;
571            let retrim_far = far_mean <= near_mean;
572            for row in &mut samples {
573                let near = row[0];
574                let far = row[1];
575                let pinch = near.add(far.sub(near).scale(pinch_fraction));
576                if retrim_far {
577                    row[1] = pinch;
578                } else {
579                    row[0] = pinch;
580                }
581            }
582        }
583        // RULED EXTENSION: `planar_extension` is a no-op for curved carriers
584        // above, but a cone/cylinder lateral joined at a reflex edge needs
585        // its offset skin to GROW past the source rim exactly like a plane
586        // (a cylinder piercing a cone: the two offsets only meet past both
587        // cloned rims). A linear-v net is ruled — stretching each sampled
588        // ruling beyond both ends stays ON the same surface, so the fitted
589        // carrier keeps its parameterization (knots/pcurves untouched) while
590        // its world image (and with it the cloned trim's image) inflates.
591        if extension.extends_v() && !inverted {
592            let mut min_ruling = f64::MAX;
593            let mut back_allowance = f64::MAX;
594            let mut forward_allowance = f64::MAX;
595            let mut extendable = true;
596            for row in &samples {
597                let ruling = row[1].sub(row[0]);
598                let length = ruling.length();
599                min_ruling = min_ruling.min(length);
600                // Radii about the row centroids expose a converging (conic)
601                // ruling sheaf; the extension must stop short of its apex or
602                // the sheet folds through it.
603                let near_radial = row[0].sub(near_centroid).length();
604                let far_radial = row[1].sub(far_centroid).length();
605                if (far_radial - near_radial).abs() > 1e-9 {
606                    let apex_at = near_radial / (near_radial - far_radial);
607                    if (-1e-9..=1.0 + 1e-9).contains(&apex_at) {
608                        // Apex inside the span: degenerate sheet, do not touch.
609                        extendable = false;
610                        break;
611                    }
612                    if apex_at < 0.0 {
613                        back_allowance = back_allowance.min(0.9 * -apex_at);
614                    } else {
615                        forward_allowance = forward_allowance.min(0.9 * (apex_at - 1.0));
616                    }
617                }
618            }
619            if extendable && min_ruling > 1e-9 {
620                reparameterised = true;
621                // Solved for the TRIM's v-extent, not the domain's: a bore
622                // face keeps its drill's full-height surface and is trimmed
623                // to the part it pierces, so moving the surface's own ends
624                // by |d| moved the trim's rims by only a fraction of that
625                // (a 30-long ruling trimmed to 20 gave 0.667 for 1.0) — and
626                // an outward shell's bore never reached the grown planes.
627                let (_, trim_v) = trim_domain_fractions(face, knot_u.domain(), knot_v.domain())?;
628                let (back_world, forward_world) =
629                    domain_end_moves(extension.v_min, extension.v_max, trim_v);
630                let back = (back_world / min_ruling).min(back_allowance);
631                let forward = (forward_world / min_ruling).min(forward_allowance);
632                for row in &mut samples {
633                    let ruling = row[1].sub(row[0]);
634                    row[0] = row[0].sub(ruling.scale(back));
635                    row[1] = row[1].add(ruling.scale(forward));
636                }
637            }
638        }
639    }
640    let weights = source
641        .control_points
642        .iter()
643        .map(|row| row.iter().map(|point| point.w).collect::<Vec<_>>())
644        .collect::<Vec<_>>();
645    let lane = if reparameterised {
646        OffsetSurfaceLane::Reparameterised
647    } else {
648        OffsetSurfaceLane::Fit
649    };
650    Ok((
651        NurbsSurface::new(
652            source.degree_u,
653            source.degree_v,
654            source.knots_u.clone(),
655            source.knots_v.clone(),
656            interpolate_tensor(
657                &knot_u,
658                &knot_v,
659                &parameters_u,
660                &parameters_v,
661                &samples,
662                &weights,
663            )?,
664        )?,
665        lane,
666    ))
667}
668
669/// The 3D curve of one carrier edge: the image of the source coedge's pcurve
670/// on the carrier surface, with the parameter range `(t0, t1)` that matches the
671/// pcurve's domain fraction for fraction (the contract every coedge of this
672/// carrier relies on, since the source pcurves are reused verbatim).
673///
674/// Built through the [`crate::image_curve`] ladder — exact on an affine sheet
675/// (every planar offset), the exact iso-curve where the pcurve holds one
676/// coordinate constant (a blend rail, a cylinder's cap rim, every
677/// extrude/revolve boundary), and a fitted curve verified to `fit_tolerance`
678/// off its nodes otherwise. Only where the ladder refuses does the edge fall
679/// back to the degree-1 interpolant through `points` (the historical
680/// construction), so nothing that offset before is refused now.
681///
682/// Why the polyline is no longer the first choice: its adaptive subdivision
683/// stops at a 5e-4 chord sag, and a 128-chord rim on an r = 8.5 cylinder sits
684/// up to 1.6e-4 inside the true arc. That is 16x the boolean imprint's
685/// coincidence band, so when a later operation's section curve runs along that
686/// rim (a cutter's side wall coplanar with the offset shell's opening wall,
687/// 2026-09-06 report) the imprint cannot recognise the rim as the section it
688/// already is, and reports every chord vertex as a crossing — one edge split
689/// into forty. An exact arc is recognised as coincident and left alone.
690fn carrier_edge_curve(
691    surface: &NurbsSurface,
692    pcurve: &NurbsCurve,
693    points: &[Vec3],
694    parameters: &[f64],
695    fit_tolerance: f64,
696) -> Result<(NurbsCurve, f64, f64), String> {
697    // `BREP_CARRIER_POLYLINE=1` restores the polyline for an A/B comparison.
698    let image = if std::env::var("BREP_CARRIER_POLYLINE").as_deref() == Ok("1") {
699        Err("BREP_CARRIER_POLYLINE set".to_string())
700    } else {
701        crate::image_curve::image_curve(surface, pcurve, fit_tolerance, "offset_face_carrier")
702    };
703    match image {
704        Ok(image) if image.t0 <= image.t1 => Ok((image.curve, image.t0, image.t1)),
705        Ok(image) => {
706            // The image runs against the pcurve: reverse it so the edge range
707            // is increasing, reflecting the range through the curve's domain.
708            let [start, end] = image.curve.domain()?;
709            let curve = image.curve.reversed()?;
710            Ok((curve, start + end - image.t0, start + end - image.t1))
711        }
712        Err(_) => {
713            let curve = interpolate_curve(points, 1, parameters)?;
714            let [t0, t1] = curve.domain()?;
715            Ok((curve, t0, t1))
716        }
717    }
718}
719
720fn mapped_pcurve_polyline(
721    surface: &NurbsSurface,
722    pcurve: &NurbsCurve,
723    degenerate: bool,
724) -> Result<(Vec<Vec3>, Vec<f64>), String> {
725    let [start, end] = pcurve.domain()?;
726    let evaluate = |fraction: f64| {
727        let uv = pcurve.evaluate(start + (end - start) * fraction)?;
728        surface.evaluate(uv.x, uv.y)
729    };
730    let first = evaluate(0.0)?;
731    let last = evaluate(1.0)?;
732    if degenerate {
733        return Ok((vec![first, last], vec![0.0, 1.0]));
734    }
735    fn append(
736        evaluate: &impl Fn(f64) -> Result<Vec3, String>,
737        a_fraction: f64,
738        a: Vec3,
739        b_fraction: f64,
740        b: Vec3,
741        depth: usize,
742        parameters: &mut Vec<f64>,
743        points: &mut Vec<Vec3>,
744    ) -> Result<(), String> {
745        let fractions =
746            [0.25, 0.5, 0.75].map(|local| a_fraction + (b_fraction - a_fraction) * local);
747        let samples = fractions
748            .map(evaluate)
749            .into_iter()
750            .collect::<Result<Vec<_>, String>>()?;
751        let deviation = samples
752            .iter()
753            .enumerate()
754            .map(|(index, point)| {
755                point
756                    .sub(a.add(b.sub(a).scale((index + 1) as f64 * 0.25)))
757                    .length()
758            })
759            .fold(0.0, f64::max);
760        if deviation <= 5e-4 || depth >= 10 {
761            parameters.push(b_fraction);
762            points.push(b);
763            return Ok(());
764        }
765        append(
766            evaluate,
767            a_fraction,
768            a,
769            fractions[1],
770            samples[1],
771            depth + 1,
772            parameters,
773            points,
774        )?;
775        append(
776            evaluate,
777            fractions[1],
778            samples[1],
779            b_fraction,
780            b,
781            depth + 1,
782            parameters,
783            points,
784        )
785    }
786    let mut parameters = vec![0.0];
787    let mut points = vec![first];
788    append(
789        &evaluate,
790        0.0,
791        first,
792        1.0,
793        last,
794        0,
795        &mut parameters,
796        &mut points,
797    )?;
798    Ok((points, parameters))
799}
800
801/// Everything an offset carrier's construction MEASURED about itself.
802///
803/// The half this kernel lacked: a tolerance MEASURED from the geometry that was
804/// actually built, rather than a band chosen from `scale` before building. It
805/// lives in the one place that needs no durable-format change: alongside the
806/// transient construction result, never as a field on a
807/// [`crate::BrepSolid`] record. `io/snapshot.rs` is a documented durable format
808/// and `SOLID_CODEC_VERSION` a versioned wire layout; persisting per-entity
809/// tolerances is real, planned, and separately designed
810/// (`docs/developer/kernel-plans/per-entity-tolerances.md` S3). This lands the
811/// measurement with no format churn at all.
812///
813/// Every number here is a RECORD. None of it widens a band — see
814/// [`MeasuredTolerance`]'s direction rule.
815#[derive(Clone, Debug)]
816pub struct CarrierDeviation {
817    /// The derived band every measurement below was judged against:
818    /// [`crate::offset_construction_band`] of the source solid's extent.
819    pub band: f64,
820    /// Which branch built the carrier surface.
821    pub lane: OffsetSurfaceLane,
822    /// The carrier surface's own fit error, when the lane has one.
823    pub surface: Option<MeasuredTolerance>,
824    /// `max_t ‖C_3d(t) − S_off(p(t))‖` per carrier edge id, folded over every
825    /// coedge that references the edge — OCCT's `FillEdgeData` rule
826    /// (`BRepOffset_SimpleOffset.cxx:296-310`), which takes the maximum over
827    /// **every** adjacent face rather than the first one.
828    pub edges: Vec<(u64, MeasuredTolerance)>,
829    /// Per carrier vertex id, propagated from the incident edge ends by
830    /// [`crate::vertex_tolerance_from_edges`] — which is also where the verdict
831    /// on OCCT's 1.001 inflation factor is recorded.
832    pub vertices: Vec<(u64, f64)>,
833}
834
835impl CarrierDeviation {
836    /// The worst thing the construction did, against the tightest band it
837    /// faced. `None` only when there was nothing at all to measure.
838    pub fn worst(&self) -> Option<MeasuredTolerance> {
839        MeasuredTolerance::worst(
840            self.surface
841                .into_iter()
842                .chain(self.edges.iter().map(|(_, measured)| *measured))
843                .chain(
844                    self.vertices
845                        .iter()
846                        .map(|(_, gap)| MeasuredTolerance::new(*gap, self.band)),
847                ),
848        )
849    }
850
851    /// The entities whose measured deviation exceeded the derived band — the
852    /// interesting case, and the only one any gate acts on.
853    pub fn exceedances(&self) -> Vec<String> {
854        let mut out = Vec::new();
855        if let Some(surface) = self.surface {
856            if surface.exceeds_band() {
857                out.push(format!("carrier surface fit {}", surface.describe()));
858            }
859        }
860        for (id, measured) in &self.edges {
861            if measured.exceeds_band() {
862                out.push(format!("edge {id} {}", measured.describe()));
863            }
864        }
865        for (id, gap) in &self.vertices {
866            let measured = MeasuredTolerance::new(*gap, self.band);
867            if measured.exceeds_band() {
868                out.push(format!("vertex {id} {}", measured.describe()));
869            }
870        }
871        out
872    }
873}
874
875#[derive(Clone, Debug, Serialize)]
876pub struct OffsetFaceCarrier {
877    pub vertices: Vec<VertexRecord>,
878    pub edges: Vec<EdgeRecord>,
879    pub face: FaceRecord,
880    /// What the construction measured about itself, or `None` when it was built
881    /// through the unmeasured [`offset_face_carrier`] entry point.
882    ///
883    /// `#[serde(skip)]` on purpose: this struct crosses the wasm ABI as JSON
884    /// (`abi/modeling_b.rs:500`), and a measurement is a diagnostic about a
885    /// build, not part of the carrier the caller asked for. Skipping it keeps
886    /// that payload byte-identical.
887    #[serde(skip)]
888    pub deviation: Option<CarrierDeviation>,
889}
890
891fn claim_vertex_image(
892    source_id: u64,
893    point: Vec3,
894    vertex_images: &mut HashMap<u64, u64>,
895    vertices: &mut Vec<VertexRecord>,
896    next_id: &mut u64,
897) -> u64 {
898    if let Some(id) = vertex_images.get(&source_id) {
899        return *id;
900    }
901    let id = *next_id;
902    *next_id += 1;
903    vertices.push(VertexRecord { id, point });
904    vertex_images.insert(source_id, id);
905    id
906}
907
908pub fn offset_face_carrier(
909    solid: &BrepSolid,
910    face_id: u64,
911    distance: f64,
912    planar_extension: f64,
913) -> Result<OffsetFaceCarrier, String> {
914    offset_face_carrier_impl(
915        solid,
916        face_id,
917        distance,
918        &CarrierExtension::uniform(planar_extension),
919        false,
920    )
921}
922
923/// [`offset_face_carrier`] with a per-side [`CarrierExtension`].
924pub fn offset_face_carrier_sided(
925    solid: &BrepSolid,
926    face_id: u64,
927    distance: f64,
928    extension: &CarrierExtension,
929) -> Result<OffsetFaceCarrier, String> {
930    offset_face_carrier_impl(solid, face_id, distance, extension, false)
931}
932
933/// [`offset_face_carrier`], with every entity it builds measured against the
934/// deviation it was meant to reproduce.
935///
936/// The carrier is bit-identical to [`offset_face_carrier`]'s — same body, same
937/// arithmetic, in the same order — with a read-only measurement pass appended.
938/// Three quantities land in [`CarrierDeviation`]:
939///
940/// * the carrier SURFACE's fit against the pointwise offset it interpolates
941///   ([`offset_surface_measured`]);
942/// * each carrier EDGE's 3D curve against the locus its pcurve traces on that
943///   surface — the trim boundary is built by sampling exactly that composition
944///   and interpolating the images, so this is the construction's own claim,
945///   measured rather than assumed;
946/// * each carrier VERTEX, propagated from the incident edge ends.
947///
948/// The interesting output is [`CarrierDeviation::exceedances`]: an entity whose
949/// measured deviation is worse than the size-derived band assumed. That is a
950/// construction that went wrong in a way the derived band alone cannot see, and
951/// it is the case a caller should refuse on rather than ship.
952pub fn offset_face_carrier_measured(
953    solid: &BrepSolid,
954    face_id: u64,
955    distance: f64,
956    planar_extension: f64,
957) -> Result<OffsetFaceCarrier, String> {
958    offset_face_carrier_impl(
959        solid,
960        face_id,
961        distance,
962        &CarrierExtension::uniform(planar_extension),
963        true,
964    )
965}
966
967/// The measured deviations of one built carrier: surface fit, per edge, per
968/// vertex.
969///
970/// Read-only over what the construction produced. The edge measurement is
971/// [`crate::measure_edge_against_pcurve_image`] — validate's own
972/// `adaptive_coedge_error` floored by a span-midpoint pass, because the sampler
973/// alone is aliased against exactly the curves this construction builds (see
974/// that module's doc) — taken against the far tighter
975/// [`crate::offset_construction_band`] instead of the vendor-forgiving
976/// `pcurve_acceptance` validate will use later.
977fn measure_carrier(
978    surface: &NurbsSurface,
979    vertices: &[VertexRecord],
980    edges: &[EdgeRecord],
981    loops: &[LoopRecord],
982    lane: OffsetSurfaceLane,
983    surface_fit: Option<MeasuredTolerance>,
984    band: f64,
985) -> Result<CarrierDeviation, String> {
986    let edge_by_id: HashMap<u64, &EdgeRecord> = edges.iter().map(|edge| (edge.id, edge)).collect();
987    // Fold over coedges, not edges: a seam edge is referenced twice with two
988    // different pcurves, and OCCT's `FillEdgeData` takes the maximum over every
989    // adjacent face for exactly that reason.
990    let mut per_edge: HashMap<u64, MeasuredTolerance> = HashMap::default();
991    for loop_record in loops {
992        for coedge in &loop_record.coedges {
993            let Some(edge) = edge_by_id.get(&coedge.edge_id) else {
994                continue;
995            };
996            let measured = measure_edge_against_pcurve_image(
997                surface,
998                &coedge.pcurve,
999                edge,
1000                coedge.forward,
1001                band,
1002            )?;
1003            per_edge
1004                .entry(edge.id)
1005                .and_modify(|existing| *existing = existing.worse_of(measured))
1006                .or_insert(measured);
1007        }
1008    }
1009
1010    let mut measured_edges: Vec<(u64, MeasuredTolerance)> = per_edge.into_iter().collect();
1011    measured_edges.sort_by_key(|(id, _)| *id);
1012    let deviation_of: HashMap<u64, f64> = measured_edges
1013        .iter()
1014        .map(|(id, measured)| (*id, measured.deviation()))
1015        .collect();
1016
1017    // Edge ENDS by the vertex they claim, built once. A degenerate edge claims
1018    // the same vertex at both ends and contributes both, which is right: the
1019    // question is how far every representation meeting there actually lands.
1020    let mut ends_at: HashMap<u64, Vec<(&EdgeRecord, f64)>> = HashMap::default();
1021    for edge in edges {
1022        ends_at
1023            .entry(edge.start_vertex_id)
1024            .or_default()
1025            .push((edge, edge.t0));
1026        ends_at
1027            .entry(edge.end_vertex_id)
1028            .or_default()
1029            .push((edge, edge.t1));
1030    }
1031
1032    let mut measured_vertices = Vec::with_capacity(vertices.len());
1033    for vertex in vertices {
1034        let mut gaps = Vec::new();
1035        let mut incident = Vec::new();
1036        for (edge, parameter) in ends_at.get(&vertex.id).into_iter().flatten() {
1037            gaps.push(vertex_endpoint_gap(
1038                vertex.point,
1039                edge.curve.evaluate(*parameter)?,
1040            ));
1041            incident.push(deviation_of.get(&edge.id).copied().unwrap_or(0.0));
1042        }
1043        measured_vertices.push((vertex.id, vertex_tolerance_from_edges(gaps, incident)));
1044    }
1045
1046    Ok(CarrierDeviation {
1047        band,
1048        lane,
1049        surface: surface_fit,
1050        edges: measured_edges,
1051        vertices: measured_vertices,
1052    })
1053}
1054
1055fn offset_face_carrier_impl(
1056    solid: &BrepSolid,
1057    face_id: u64,
1058    distance: f64,
1059    extension: &CarrierExtension,
1060    measure: bool,
1061) -> Result<OffsetFaceCarrier, String> {
1062    let source = solid
1063        .shells
1064        .iter()
1065        .flat_map(|shell| &shell.faces)
1066        .find(|face| face.id == face_id)
1067        .ok_or_else(|| format!("offset_face_carrier: missing face {face_id}"))?;
1068    // The band is derived from the SOURCE SOLID's extent, matching every other
1069    // direct-edit/offset site (`face_offset.rs:103` and its siblings all take
1070    // `solid_model_scale`). The alternative basis is the FACE's own extent — the
1071    // reference shell keys its size-relative offset quantities on the 3D arc
1072    // length of the face's own boundary iso, never on the assembly. Which basis
1073    // is better is unsettled: it is a measurement to make over the refusal
1074    // corpus before switching, not a change to smuggle in here.
1075    let band = offset_construction_band(solid_model_scale(solid));
1076    // The accuracy bar a carrier EDGE's 3D curve must meet against the locus its
1077    // pcurve traces on the carrier surface — the kernel's own SSI fit contract,
1078    // the same bar `thicken` holds its wall boundaries to. NOT the construction
1079    // band: that is the surface-fit allowance (5e-4 of the part), 50x looser
1080    // than the boolean's coincidence band, and an edge fitted only that well is
1081    // what a later imprint mistakes for a crossing (see `carrier_edge_curve`).
1082    let fit_tolerance =
1083        crate::KernelTolerances::for_scale(solid_model_scale(solid), 1e-7).intersection_fit;
1084    let (surface, lane, surface_fit) = if measure {
1085        let measured = offset_surface_measured_sided(source, distance, extension, band)?;
1086        (measured.surface, measured.lane, measured.fit)
1087    } else {
1088        let (surface, lane) = offset_surface_with_lane(source, distance, extension)?;
1089        (surface, lane, None)
1090    };
1091    let source_edges = solid
1092        .edges
1093        .iter()
1094        .map(|edge| (edge.id, edge))
1095        .collect::<HashMap<_, _>>();
1096    let source_vertices = solid
1097        .vertices
1098        .iter()
1099        .map(|vertex| (vertex.id, vertex))
1100        .collect::<HashMap<_, _>>();
1101    let mut vertices = Vec::new();
1102    let mut vertex_images = HashMap::default();
1103    let mut edges = Vec::new();
1104    let mut edge_images = HashMap::default();
1105    let mut loops = Vec::new();
1106    let mut next_id = 1u64;
1107
1108    for source_loop in &source.loops {
1109        let mut coedges = Vec::new();
1110        for source_coedge in &source_loop.coedges {
1111            let source_edge = source_edges
1112                .get(&source_coedge.edge_id)
1113                .ok_or_else(|| "offset_face_carrier: missing source edge".to_string())?;
1114            let (source_start, source_end) = if source_coedge.forward {
1115                (source_edge.start_vertex_id, source_edge.end_vertex_id)
1116            } else {
1117                (source_edge.end_vertex_id, source_edge.start_vertex_id)
1118            };
1119            if !source_vertices.contains_key(&source_start)
1120                || !source_vertices.contains_key(&source_end)
1121            {
1122                return Err("offset_face_carrier: missing source vertex".into());
1123            }
1124            // Map even DEGENERATE source edges through the full polyline: a
1125            // cone apex's image on the offset surface is a genuine CIRCLE
1126            // (radius d·cos half-angle), not a point — shortcutting to the
1127            // two endpoints would collapse the ring and leave the carrier's
1128            // topology inconsistent with its surface. Edges whose image truly
1129            // collapses (sphere poles, planar corners) still interpolate to a
1130            // point-sized curve and keep their degenerate flag below.
1131            // Map even DEGENERATE source edges through the full polyline: an
1132            // EXTERIOR cone offset turns the apex point into a genuine RING
1133            // (radius d·cos half-angle) — shortcutting to the endpoints would
1134            // collapse it and leave the carrier topology inconsistent with
1135            // its surface (and the ring imprint would be dropped as
1136            // boundary-coincident with a "degenerate" edge). Images that
1137            // truly collapse (sphere poles; interior apexes after the pinch
1138            // retrim) stay degenerate below. The threshold scales with the
1139            // offset distance: a real ring measures ~d·cos α, while fitted
1140            // pole rows wobble ~1e-4 absolute.
1141            let (points, parameters) =
1142                mapped_pcurve_polyline(&surface, &source_coedge.pcurve, false)?;
1143            let collapse_tolerance = 1e-6f64.max(distance.abs() * 1e-2);
1144            let image_collapsed = points
1145                .iter()
1146                .all(|point| point.sub(points[0]).length() <= collapse_tolerance);
1147            let (edge_id, forward) =
1148                if let Some((edge_id, edge_start_vertex_id, creator_forward)) =
1149                    edge_images.get(&source_edge.id)
1150                {
1151                    if source_start == source_end {
1152                        // A CLOSED source edge (a seam of a periodic face: both
1153                        // ends are the same vertex) is referenced twice by the
1154                        // same loop, once per seam side, and the two references
1155                        // traverse it in OPPOSITE senses — that is what closes
1156                        // the loop. The endpoint test below cannot see that:
1157                        // both ends map to the same vertex image, so it answers
1158                        // `true` for both references and the returning coedge
1159                        // comes back mis-oriented.
1160                        //
1161                        // Measured on a full torus (one vertex, two seam edges):
1162                        // the source records `forward: false` on the two
1163                        // returning coedges and validates clean, while the
1164                        // carrier recorded `forward: true` on both and its rim
1165                        // edge measured 12.5 — a whole part diameter — against
1166                        // its own composed pcurve image. The measured tolerance
1167                        // this slice adds is what surfaced it; no existing gate
1168                        // reaches this shape.
1169                        //
1170                        // The source coedge's own sense is the answer: the image
1171                        // edge runs along the CREATING coedge's pcurve, so a
1172                        // later reference runs with it exactly when the two
1173                        // source coedges traverse the source edge the same way.
1174                        (*edge_id, source_coedge.forward == *creator_forward)
1175                    } else {
1176                        // UNCHANGED for every open edge: the image edge's start
1177                        // vertex identifies which way this coedge runs.
1178                        (
1179                            *edge_id,
1180                            vertex_images.get(&source_start) == Some(edge_start_vertex_id),
1181                        )
1182                    }
1183                } else {
1184                    let (curve, t0, t1) = if image_collapsed {
1185                        let curve = NurbsCurve::new(
1186                            1,
1187                            vec![0.0, 0.0, 1.0, 1.0],
1188                            vec![
1189                                Vec4::from_point(points[0], 1.0),
1190                                Vec4::from_point(points[0], 1.0),
1191                            ],
1192                        )?;
1193                        (curve, 0.0, 1.0)
1194                    } else {
1195                        carrier_edge_curve(
1196                            &surface,
1197                            &source_coedge.pcurve,
1198                            &points,
1199                            &parameters,
1200                            fit_tolerance,
1201                        )?
1202                    };
1203                    let start_vertex_id = claim_vertex_image(
1204                        source_start,
1205                        points[0],
1206                        &mut vertex_images,
1207                        &mut vertices,
1208                        &mut next_id,
1209                    );
1210                    let end_vertex_id = claim_vertex_image(
1211                        source_end,
1212                        points[points.len() - 1],
1213                        &mut vertex_images,
1214                        &mut vertices,
1215                        &mut next_id,
1216                    );
1217                    let id = next_id;
1218                    next_id += 1;
1219                    edges.push(EdgeRecord {
1220                        id,
1221                        curve,
1222                        t0,
1223                        t1,
1224                        start_vertex_id,
1225                        end_vertex_id,
1226                        // Degenerate only if the IMAGE collapsed too — a cone
1227                        // apex maps to a real ring on the offset surface and
1228                        // must carry a real closed edge.
1229                        degenerate: source_edge.degenerate && image_collapsed,
1230                        // Image of a named source edge on the offset carrier;
1231                        // suffixed so it cannot collide with the source edge
1232                        // when both faces survive into one solid.
1233                        name: source_edge
1234                            .name
1235                            .as_ref()
1236                            .map(|name| format!("{name}_Offset")),
1237                    });
1238                    edge_images.insert(
1239                        source_edge.id,
1240                        (id, start_vertex_id, source_coedge.forward),
1241                    );
1242                    (id, true)
1243                };
1244            let id = next_id;
1245            next_id += 1;
1246            coedges.push(CoedgeRecord {
1247                id,
1248                edge_id,
1249                forward,
1250                pcurve: source_coedge.pcurve.clone(),
1251            });
1252        }
1253        let id = next_id;
1254        next_id += 1;
1255        loops.push(LoopRecord { id, coedges });
1256    }
1257    let deviation = if measure {
1258        Some(measure_carrier(
1259            &surface,
1260            &vertices,
1261            &edges,
1262            &loops,
1263            lane,
1264            surface_fit,
1265            band,
1266        )?)
1267    } else {
1268        None
1269    };
1270    Ok(OffsetFaceCarrier {
1271        vertices,
1272        edges,
1273        face: FaceRecord {
1274            id: next_id,
1275            surface,
1276            same_sense: source.same_sense,
1277            loops,
1278            name: source.name.as_ref().map(|name| format!("{name}_Offset")),
1279        },
1280        deviation,
1281    })
1282}
1283
1284// BREP private tests: f18b466be3e719c5