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    /// A CLOSED-FORM offset built from the source's recognized analytic shape
220    /// rather than sampled: a sphere's concentric mate, whose net is the
221    /// source's scaled about the centre. Exact like `Affine`, but derived from
222    /// geometry the recognizer proved rather than from a constant normal, so
223    /// `offset_surface_measured_sided` measures it instead of declaring it.
224    Analytic,
225    /// Greville collocation of the pointwise offset. `S_fit(u, v)` is meant to
226    /// BE `offset(u, v)`, and the distance between them is the fit error this
227    /// slice measures.
228    #[default]
229    Fit,
230    /// The result was deliberately moved off the pointwise offset: the planar /
231    /// ruled EXTENSION (which grows the carrier past its source rim, so the same
232    /// `(u, v)` names a different point) or the apex-cone PINCH RETRIM (which
233    /// pulls the crossed sample row back to the offset cone's own apex). Both
234    /// are correct and intended; neither is comparable pointwise.
235    Reparameterised,
236}
237
238/// A fitted offset carrier together with what its construction measured about
239/// itself.
240#[derive(Clone, Debug)]
241pub struct MeasuredOffsetSurface {
242    pub surface: NurbsSurface,
243    pub lane: OffsetSurfaceLane,
244    /// `max ‖S_fit(u, v) − offset(u, v)‖` over
245    /// [`crate::measure_surface_fit_against_pointwise_offset`]'s grid, against
246    /// the band it was judged with. When the RULED EXTENSION fired, the offset
247    /// compared against is the offset of the source EXTENDED ALONG ITS RULINGS —
248    /// the surface the carrier is the pointwise offset of — not of `face.surface`.
249    ///
250    /// `None` for [`OffsetSurfaceLane::Reparameterised`] — see that variant.
251    pub fit: Option<MeasuredTolerance>,
252}
253
254/// How far an offset carrier's TRIM grows past the source face at each of
255/// its four parametric sides, in world units.
256///
257/// A carrier is trimmed by the parametric IMAGE of the source loops, so the
258/// growth is realised by reshaping the carrier surface's net while the cloned
259/// pcurves stay put: an affine plane's 2x2 net slides along the plane, a ruled
260/// (linear-v) net stretches each sampled ruling. Both are solved so that the
261/// trim's own uv bounding box moves by exactly these amounts — a trim that
262/// occupies a sub-range of its surface's domain (every booleaned or
263/// blend-trimmed face) grows by the same distance as one that spans it. The
264/// legacy `planar_extension: f64` entry points are the uniform case.
265#[derive(Clone, Copy, Debug, PartialEq)]
266pub struct CarrierExtension {
267    pub u_min: f64,
268    pub u_max: f64,
269    pub v_min: f64,
270    pub v_max: f64,
271}
272
273impl CarrierExtension {
274    pub const NONE: CarrierExtension = CarrierExtension {
275        u_min: 0.0,
276        u_max: 0.0,
277        v_min: 0.0,
278        v_max: 0.0,
279    };
280
281    pub fn uniform(amount: f64) -> CarrierExtension {
282        CarrierExtension {
283            u_min: amount,
284            u_max: amount,
285            v_min: amount,
286            v_max: amount,
287        }
288    }
289
290    pub fn is_active(&self) -> bool {
291        self.u_min > 0.0 || self.u_max > 0.0 || self.v_min > 0.0 || self.v_max > 0.0
292    }
293
294    fn extends_v(&self) -> bool {
295        self.v_min > 0.0 || self.v_max > 0.0
296    }
297}
298
299/// The trim's uv bounding box as FRACTIONS of the surface domain:
300/// `([ta_u, tb_u], [ta_v, tb_v])`, each in `[0, 1]`. Sampled over every
301/// coedge pcurve of every loop.
302fn trim_domain_fractions(
303    face: &FaceRecord,
304    [u0, u1]: [f64; 2],
305    [v0, v1]: [f64; 2],
306) -> Result<([f64; 2], [f64; 2]), String> {
307    let mut low = Vec2 {
308        x: f64::INFINITY,
309        y: f64::INFINITY,
310    };
311    let mut high = Vec2 {
312        x: f64::NEG_INFINITY,
313        y: f64::NEG_INFINITY,
314    };
315    for coedge in face
316        .loops
317        .iter()
318        .flat_map(|loop_record| &loop_record.coedges)
319    {
320        let [p0, p1] = coedge.pcurve.domain()?;
321        for sample in 0..=24 {
322            let uv = coedge
323                .pcurve
324                .evaluate(p0 + (p1 - p0) * sample as f64 / 24.0)?;
325            low.x = low.x.min(uv.x);
326            low.y = low.y.min(uv.y);
327            high.x = high.x.max(uv.x);
328            high.y = high.y.max(uv.y);
329        }
330    }
331    let fraction = |value: f64, start: f64, end: f64| {
332        let span = end - start;
333        if span.abs() <= f64::EPSILON || !value.is_finite() {
334            return None;
335        }
336        Some(((value - start) / span).clamp(0.0, 1.0))
337    };
338    let u = match (fraction(low.x, u0, u1), fraction(high.x, u0, u1)) {
339        (Some(a), Some(b)) if b - a > 1e-6 => [a, b],
340        _ => [0.0, 1.0],
341    };
342    let v = match (fraction(low.y, v0, v1), fraction(high.y, v0, v1)) {
343        (Some(a), Some(b)) if b - a > 1e-6 => [a, b],
344        _ => [0.0, 1.0],
345    };
346    Ok((u, v))
347}
348
349/// How far the surface's domain ENDS must move (`(back, forward)`: the start
350/// end backwards, the far end forwards, world units) so that a trim occupying
351/// the domain fractions `[ta, tb]` grows by exactly `grow_min` at its start and
352/// `grow_max` at its end when its pcurves are left untouched. A point at
353/// fraction `t` of a linearly re-mapped domain moves by
354/// `-(1 - t)·back + t·forward`; solving that at `ta` and `tb` gives the pair
355/// below. It reduces to `(grow_min, grow_max)` for a full-domain trim, and
356/// never shrinks either end (both results are non-negative for non-negative
357/// inputs).
358fn domain_end_moves(grow_min: f64, grow_max: f64, [ta, tb]: [f64; 2]) -> (f64, f64) {
359    let span = tb - ta;
360    if span <= 1e-6 {
361        return (grow_min, grow_max);
362    }
363    let rate = (grow_min + grow_max) / span;
364    let back = grow_min + ta * rate;
365    let forward = (1.0 - ta) * rate - grow_min;
366    (back.max(0.0), forward.max(0.0))
367}
368
369/// Construct the same fitted offset carrier surface as the reference shell
370/// implementation. Positive distance follows its convention and moves
371/// opposite the face's outward normal.
372pub fn offset_surface(
373    face: &FaceRecord,
374    distance: f64,
375    planar_extension: f64,
376) -> Result<NurbsSurface, String> {
377    offset_surface_with_lane(face, distance, &CarrierExtension::uniform(planar_extension))
378        .map(|(surface, _)| surface)
379}
380
381/// [`offset_surface`], plus the deviation MEASURED between the carrier it built
382/// and the pointwise offset that carrier approximates.
383///
384/// The surface is bit-identical to [`offset_surface`]'s — this calls the same
385/// body and adds a read-only pass afterwards. Nothing here can change the
386/// carrier: measuring the deviation AFTER the carrier is built records what
387/// happened; the size-derived band stays the floor and the bar it is judged
388/// against. A measurement never widens a band.
389pub fn offset_surface_measured(
390    face: &FaceRecord,
391    distance: f64,
392    planar_extension: f64,
393    band: f64,
394) -> Result<MeasuredOffsetSurface, String> {
395    offset_surface_measured_sided(
396        face,
397        distance,
398        &CarrierExtension::uniform(planar_extension),
399        band,
400    )
401}
402
403/// [`offset_surface_measured`] with a per-side [`CarrierExtension`].
404pub fn offset_surface_measured_sided(
405    face: &FaceRecord,
406    distance: f64,
407    extension: &CarrierExtension,
408    band: f64,
409) -> Result<MeasuredOffsetSurface, String> {
410    let (surface, lane, extended) = offset_surface_constructed(face, distance, extension)?;
411    let fit = match lane {
412        OffsetSurfaceLane::Affine => Some(MeasuredTolerance::exact(band)),
413        // `Analytic` is exact by construction and already verified against the
414        // pointwise offset before it was returned — but it is measured here
415        // like a fit anyway, so the record carries the number instead of the
416        // claim. It reads ~1e-13 where `Fit` reads ~1e-6.
417        OffsetSurfaceLane::Analytic | OffsetSurfaceLane::Fit => {
418            Some(measure_surface_fit_against_pointwise_offset(
419                extended.as_ref().unwrap_or(&face.surface),
420                face.same_sense,
421                &surface,
422                distance,
423                band,
424            )?)
425        }
426        OffsetSurfaceLane::Reparameterised => None,
427    };
428    Ok(MeasuredOffsetSurface { surface, lane, fit })
429}
430
431/// The EXACT concentric offset of a sphere, or `None` for every other carrier.
432///
433/// A sphere's normal is radial, so its pointwise offset at distance `d` is the
434/// concentric sphere of radius `R' = R ± d`. A NURBS map is affine in its
435/// control points — `S = Σ N_i w_i P_i / Σ N_i w_i` — so scaling every control
436/// point about the centre by `R'/R` scales the whole image by that factor about
437/// that point, and the result IS that sphere to rounding. Knots, degrees and
438/// weights are untouched, so the same `(u, v)` still names the corresponding
439/// point: the trims `offset_face_carrier_impl` clones onto this carrier
440/// verbatim land exactly where they did on the source.
441///
442/// The Greville fit below reaches the same surface only to its collocation
443/// error. On `offset-shell-box-bore-dished-face` that error put the dished
444/// face's volume contribution 4.69e-3 away from the closed form — the larger
445/// share of the case's 3.9435e-3 deficit.
446///
447/// Everything here is COMPUTED, never claimed. The offset radius comes from the
448/// same [`OffsetEvaluator`] the fit lane samples, so this lane cannot invent a
449/// sign convention; the guards below reject a centre the recognizer got wrong
450/// or a sphere turned inside out by an inward offset deeper than its radius;
451/// and the finished net is checked against that evaluator on a grid before it
452/// is returned. Every refusal falls through to the fit, which is what built
453/// these carriers before.
454///
455/// A sphere the recognizer does not recognize — a FITTED patch that merely
456/// looks spherical — stays on the fit lane, unchanged. This lane widens nothing
457/// on its own: cylinders, cones and tori want a scaling about an axis or a
458/// spine rather than a point, which is a different construction.
459fn sphere_offset_surface(face: &FaceRecord, distance: f64) -> Result<Option<NurbsSurface>, String> {
460    // Escape hatch, the same shape as `BREP_NO_SPHERE_CHARTS`: it makes the
461    // closed form and the collocation fit two cells of one binary, so a
462    // measurement can separate this lane's effect from everything else.
463    if std::env::var_os("BREP_NO_ANALYTIC_OFFSET").is_some() {
464        return Ok(None);
465    }
466    let source = &face.surface;
467    let Some((centre, radius)) = source.analytic().and_then(|shape| shape.sphere_geometry()) else {
468        return Ok(None);
469    };
470    let ([u0, u1], [v0, v1]) = domains(source)?;
471    // `offset_surface`'s positive distance moves OPPOSITE the face normal while
472    // the evaluator's moves ALONG it; the negation is the fit's own, reused
473    // here rather than re-derived (audit §4.1's hand negations get no new one).
474    // A normal this lane cannot read is a DECLINE, not an error: the fit below
475    // samples the same evaluator across the whole Greville grid and has its own
476    // singular-row recovery, so anything that offset before must still offset.
477    let Ok(sample) = OffsetEvaluator::new(
478        "offset_surface",
479        source,
480        OffsetNormal::FaceStable {
481            same_sense: face.same_sense,
482        },
483    )
484    .at((u0 + u1) / 2.0, (v0 + v1) / 2.0, -distance)
485    else {
486        return Ok(None);
487    };
488    let source_radial = sample.source.sub(centre);
489    let offset_radial = sample.point.sub(centre);
490    let offset_radius = offset_radial.length();
491    let scale = radius.max(1.0);
492    if radius <= 1e-12
493        || offset_radius <= 1e-9 * scale
494        // Offset through the centre and out the far side: the concentric mate
495        // is a sphere turned inside out, not this construction's answer.
496        || offset_radial.dot(source_radial) <= 0.0
497        // The move must be radial and exactly |d| long. A recognized centre
498        // that is not the real one fails here before it can scale anything.
499        || ((offset_radius - radius).abs() - distance.abs()).abs() > 1e-9 * scale
500    {
501        return Ok(None);
502    }
503    let factor = offset_radius / radius;
504    // A zero-weight control has no Euclidean point to scale. The fit lane never
505    // asks one for a position, so declining keeps this lane strictly additive.
506    let Ok(controls) = source
507        .control_points
508        .iter()
509        .map(|row| {
510            row.iter()
511                .map(|control| {
512                    Ok(Vec4::from_point(
513                        centre.add(control.point()?.sub(centre).scale(factor)),
514                        control.w,
515                    ))
516                })
517                .collect::<Result<Vec<_>, String>>()
518        })
519        .collect::<Result<Vec<Vec<_>>, String>>()
520    else {
521        return Ok(None);
522    };
523    let Ok(surface) = NurbsSurface::new(
524        source.degree_u,
525        source.degree_v,
526        source.knots_u.clone(),
527        source.knots_v.clone(),
528        controls,
529    ) else {
530        return Ok(None);
531    };
532    // The claim, tested. A net that does not land on the pointwise offset
533    // everywhere is not this lane's surface, whatever it was recognized as —
534    // and a measurement that could not be taken is not a pass.
535    match measure_surface_fit_against_pointwise_offset(
536        source,
537        face.same_sense,
538        &surface,
539        distance,
540        1e-9 * scale,
541    ) {
542        Ok(measured) if measured.within_band() => Ok(Some(surface)),
543        _ => Ok(None),
544    }
545}
546
547/// The EXACT offset of a surface of REVOLUTION whose generatrix is ONE CIRCLE
548/// ARC — a torus of any kind (ring, horn, spindle), over a full or a partial
549/// sweep — or `None` for every other carrier.
550///
551/// Why it is exact. A revolution's normal lies in the meridian plane and equals
552/// the generatrix's own in-plane normal there, so the offset surface IS the
553/// revolution of the offset generatrix, and the offset of a circle arc of
554/// radius `r` about `c` is the concentric arc of radius `r ± d`. On the net:
555/// row `i` of a revolution's control net is an AFFINE image `A_i` of the
556/// generatrix's controls — a rotation for the on-circle rows, but a rotation
557/// STRETCHED radially by `1/cos` of the half-span for the mid rows, which is
558/// not a similarity. Affine maps commute with scaling about a point, so scaling
559/// row `i` about `C_i = A_i(c)` by `(r ± d)/r` is exactly `A_i` applied to the
560/// offset circle's net; that commutation is the only reason the stretched rows
561/// come out right. `C_i` is found without reading `A_i` at all: `c` is an affine
562/// combination of three generatrix controls, and the same combination of row
563/// `i`'s controls is `A_i(c)`. Knots, degrees and weights are untouched, so the
564/// same `(u, v)` still names the corresponding point and cloned trims land
565/// where they did, as on [`sphere_offset_surface`].
566///
567/// Everything is computed and then tested, never claimed. The generatrix is
568/// row 0 of the net, and it must lie on one circle to rounding. The sign and
569/// size of the move come from the same [`OffsetEvaluator`] the fit samples,
570/// read at a generatrix point against the circle's centre: the move must be
571/// radial and exactly `|d|` long, and an offset through the centre (`r − d ≤
572/// 0`, where the offset circle turns inside out) declines. The finished net
573/// must land on the pointwise offset everywhere on the measurement grid, to
574/// `1e-9` of the model's scale. Every decline falls through to the fit.
575///
576/// Measured on the partial lemon (a 106° arc of radius 5 centred 3 off the
577/// axis) shelled inward by 1: the fitted carrier sat 1.4e-5 off the pointwise
578/// offset and its shell 3.6e-7 short of the Pappus form in volume.
579fn revolved_arc_offset_surface(
580    face: &FaceRecord,
581    distance: f64,
582) -> Result<Option<NurbsSurface>, String> {
583    if std::env::var_os("BREP_NO_ANALYTIC_OFFSET").is_some() {
584        return Ok(None);
585    }
586    let source = &face.surface;
587    match source.analytic() {
588        Some(crate::AnalyticSurface::Revolution { .. } | crate::AnalyticSurface::Torus { .. }) => {}
589        _ => return Ok(None),
590    }
591    let rows = &source.control_points;
592    let Some(first_row) = rows.first() else {
593        return Ok(None);
594    };
595    if rows.iter().flatten().any(|control| !(control.w > 0.0)) {
596        return Ok(None);
597    }
598    let Ok(generatrix) = NurbsCurve::new(source.degree_v, source.knots_v.clone(), first_row.clone())
599    else {
600        return Ok(None);
601    };
602    let [t0, t1] = generatrix.domain()?;
603    let at = |fraction: f64| generatrix.evaluate(t0 + (t1 - t0) * fraction);
604    // The circle through three generatrix points, then every sample held to it.
605    let (a, b, e) = (at(0.0)?, at(1.0 / 3.0)?, at(2.0 / 3.0)?);
606    let (ab, ae) = (b.sub(a), e.sub(a));
607    let normal = ab.cross(ae);
608    let normal_length_squared = normal.dot(normal);
609    if normal_length_squared <= 1e-24 {
610        return Ok(None);
611    }
612    let centre = a.add(
613        ae.cross(normal)
614            .scale(ab.dot(ab))
615            .add(normal.cross(ab).scale(ae.dot(ae)))
616            .scale(0.5 / normal_length_squared),
617    );
618    let radius = a.sub(centre).length();
619    let scale = (centre.length() + radius).max(1.0);
620    let band = 1e-9 * scale;
621    let unit_normal = normal.scale(1.0 / normal_length_squared.sqrt());
622    let samples = 16 * generatrix.control_points.len();
623    for index in 0..=samples {
624        let point = at(index as f64 / samples as f64)?;
625        let radial = point.sub(centre);
626        if (radial.length() - radius).abs() > band || radial.dot(unit_normal).abs() > band {
627            return Ok(None);
628        }
629    }
630    // `centre` as an affine combination of three generatrix controls, the best
631    // conditioned pair against the first.
632    let controls = first_row
633        .iter()
634        .map(|control| control.point())
635        .collect::<Result<Vec<_>, String>>()?;
636    let origin = controls[0];
637    let mut best = (0usize, 0usize, 0.0f64);
638    for first in 1..controls.len() {
639        for second in first + 1..controls.len() {
640            let area = controls[first]
641                .sub(origin)
642                .cross(controls[second].sub(origin))
643                .length();
644            if area > best.2 {
645                best = (first, second, area);
646            }
647        }
648    }
649    let (first, second, area) = best;
650    if area <= 1e-9 * scale * scale {
651        return Ok(None);
652    }
653    let (along_first, along_second) = (controls[first].sub(origin), controls[second].sub(origin));
654    let offset_from_origin = centre.sub(origin);
655    let (g11, g12, g22) = (
656        along_first.dot(along_first),
657        along_first.dot(along_second),
658        along_second.dot(along_second),
659    );
660    let determinant = g11 * g22 - g12 * g12;
661    let (r1, r2) = (along_first.dot(offset_from_origin), along_second.dot(offset_from_origin));
662    let s = (r1 * g22 - r2 * g12) / determinant;
663    let t = (r2 * g11 - r1 * g12) / determinant;
664    if origin
665        .add(along_first.scale(s))
666        .add(along_second.scale(t))
667        .sub(centre)
668        .length()
669        > band
670    {
671        return Ok(None);
672    }
673    // The move, read off the shared evaluator on the generatrix row. The
674    // negation is the fit's own, as in `sphere_offset_surface`.
675    let ([u0, _], [v0, v1]) = domains(source)?;
676    let Ok(sample) = OffsetEvaluator::new(
677        "offset_surface",
678        source,
679        OffsetNormal::FaceStable {
680            same_sense: face.same_sense,
681        },
682    )
683    .at(u0, (v0 + v1) / 2.0, -distance)
684    else {
685        return Ok(None);
686    };
687    let source_radial = sample.source.sub(centre);
688    let offset_radial = sample.point.sub(centre);
689    let offset_radius = offset_radial.length();
690    if (source_radial.length() - radius).abs() > band
691        || offset_radius <= band
692        || offset_radial.dot(source_radial) <= 0.0
693        || ((offset_radius - radius).abs() - distance.abs()).abs() > band
694    {
695        return Ok(None);
696    }
697    let factor = offset_radius / radius;
698    let Ok(offset_rows) = rows
699        .iter()
700        .map(|row| {
701            let points = row.iter().map(|control| control.point()).collect::<Result<Vec<_>, String>>()?;
702            let row_origin = points[0];
703            let row_centre = row_origin
704                .add(points[first].sub(row_origin).scale(s))
705                .add(points[second].sub(row_origin).scale(t));
706            Ok(points
707                .iter()
708                .zip(row)
709                .map(|(point, control)| {
710                    Vec4::from_point(row_centre.add(point.sub(row_centre).scale(factor)), control.w)
711                })
712                .collect::<Vec<_>>())
713        })
714        .collect::<Result<Vec<Vec<_>>, String>>()
715    else {
716        return Ok(None);
717    };
718    let Ok(surface) = NurbsSurface::new(
719        source.degree_u,
720        source.degree_v,
721        source.knots_u.clone(),
722        source.knots_v.clone(),
723        offset_rows,
724    ) else {
725        return Ok(None);
726    };
727    match measure_surface_fit_against_pointwise_offset(
728        source,
729        face.same_sense,
730        &surface,
731        distance,
732        band,
733    ) {
734        Ok(measured) if measured.within_band() => Ok(Some(surface)),
735        _ => Ok(None),
736    }
737}
738
739pub(crate) fn offset_surface_with_lane(
740    face: &FaceRecord,
741    distance: f64,
742    extension: &CarrierExtension,
743) -> Result<(NurbsSurface, OffsetSurfaceLane), String> {
744    offset_surface_constructed(face, distance, extension).map(|(surface, lane, _)| (surface, lane))
745}
746
747/// [`offset_surface_with_lane`], plus the surface the carrier is the pointwise
748/// offset OF when that is not the face's own: the source extended along its
749/// rulings, for a ruled extension (see the RULED EXTENSION block).
750fn offset_surface_constructed(
751    face: &FaceRecord,
752    distance: f64,
753    extension: &CarrierExtension,
754) -> Result<(NurbsSurface, OffsetSurfaceLane, Option<NurbsSurface>), String> {
755    let source = &face.surface;
756    let extension_active = extension.is_active();
757    if source.is_affine()? {
758        let ([u0, u1], [v0, v1]) = domains(source)?;
759        let normal = stable_face_normal(face, (u0 + u1) / 2.0, (v0 + v1) / 2.0)?;
760        let shift = normal.scale(-distance);
761        let mut points = source
762            .control_points
763            .iter()
764            .map(|row| {
765                row.iter()
766                    .map(|control| Ok(control.point()?.add(shift)))
767                    .collect::<Result<Vec<_>, String>>()
768            })
769            .collect::<Result<Vec<_>, String>>()?;
770        if extension_active {
771            let p00 = points[0][0];
772            let p01 = points[0][1];
773            let p10 = points[1][0];
774            let direction_u = p10.sub(p00).normalized()?;
775            let direction_v = p01.sub(p00).normalized()?;
776            // The net slides while the cloned pcurves stay put, so a trim
777            // that spans only part of the domain would grow by less than
778            // asked (a 20-wide face trimmed to [0,16] by a blend moved its
779            // x=16 edge 0.6 for a 1.0 pad). Solve the slide for the TRIM's
780            // own bounding box instead.
781            let (trim_u, trim_v) = trim_domain_fractions(face, [u0, u1], [v0, v1])?;
782            let (back_u, forward_u) = domain_end_moves(extension.u_min, extension.u_max, trim_u);
783            let (back_v, forward_v) = domain_end_moves(extension.v_min, extension.v_max, trim_v);
784            points[0][0] = p00
785                .sub(direction_u.scale(back_u))
786                .sub(direction_v.scale(back_v));
787            points[0][1] = p01
788                .sub(direction_u.scale(back_u))
789                .add(direction_v.scale(forward_v));
790            points[1][0] = p10
791                .add(direction_u.scale(forward_u))
792                .sub(direction_v.scale(back_v));
793            points[1][1] = points[1][1]
794                .add(direction_u.scale(forward_u))
795                .add(direction_v.scale(forward_v));
796        }
797        let controls = points
798            .into_iter()
799            .enumerate()
800            .map(|(row, points)| {
801                points
802                    .into_iter()
803                    .enumerate()
804                    .map(|(column, point)| {
805                        Vec4::from_point(point, source.control_points[row][column].w)
806                    })
807                    .collect()
808            })
809            .collect();
810        // The extension slides the control net along the plane, so the same
811        // `(u, v)` no longer names the pointwise offset of the same source
812        // point; without it the shift is rigid and exact.
813        let lane = if extension_active {
814            OffsetSurfaceLane::Reparameterised
815        } else {
816            OffsetSurfaceLane::Affine
817        };
818        return Ok((
819            NurbsSurface::new(
820                source.degree_u,
821                source.degree_v,
822                source.knots_u.clone(),
823                source.knots_v.clone(),
824                controls,
825            )?,
826            lane,
827            None,
828        ));
829    }
830
831    // A recognized SPHERE has a closed-form concentric offset that needs no
832    // collocation at all. Taken before the fit so the exact answer wins
833    // wherever it exists; `sphere_offset_surface` declines on everything else,
834    // and on a sphere whose construction it cannot verify.
835    //
836    // The extension cannot reach a sphere either way: both blocks below sit
837    // under `parameters_v.len() == 2`, and a sphere's meridian is a degree-2
838    // net of five controls. Taking this lane under an active extension keeps
839    // exactly the behaviour the fit had — no growth — and only sharpens it.
840    if let Some(surface) = sphere_offset_surface(face, distance)? {
841        return Ok((surface, OffsetSurfaceLane::Analytic, None));
842    }
843    // The same holds for a revolved ARC: its generatrix has three or more
844    // controls, so no extension below could reach it.
845    if let Some(surface) = revolved_arc_offset_surface(face, distance)? {
846        return Ok((surface, OffsetSurfaceLane::Analytic, None));
847    }
848
849    let knot_u = KnotVector::new(source.knots_u.clone(), source.degree_u)?;
850    let knot_v = KnotVector::new(source.knots_v.clone(), source.degree_v)?;
851    let parameters_u = greville_parameters(&knot_u);
852    let parameters_v = greville_parameters(&knot_v);
853    // The Greville sample grid IS a pointwise offset evaluation — this fit is
854    // the shared evaluator's consumer, not its peer. `offset_surface`'s
855    // positive distance moves OPPOSITE the face normal while the evaluator's
856    // moves ALONG it, so the negation happens once, here, with a name on it
857    // (audit §4.1's four hand negations get no fifth).
858    let evaluator = OffsetEvaluator::new(
859        "offset_surface",
860        source,
861        OffsetNormal::FaceStable {
862            same_sense: face.same_sense,
863        },
864    );
865    let mut samples = Vec::new();
866    for &u in &parameters_u {
867        let mut row = Vec::new();
868        for &v in &parameters_v {
869            row.push(evaluator.at(u, v, -distance)?.point);
870        }
871        samples.push(row);
872    }
873    // APEX-CONE PINCH RETRIM: offsetting an apex cone INWARD moves each
874    // ruling past the axis — the sampled far row becomes a ring on the far
875    // side (radius d·cos half-angle, mirrored through the axis) and the
876    // offset surface self-pinches inside the v-domain. The genuine cavity
877    // ends AT the pinch (the offset cone's own apex). For a linear-v net
878    // (two sample rows — every made/booleaned cone) the pinch lies on each
879    // ruling at the fraction where the radial vector vanishes: detect the
880    // inversion (far-row radials anti-parallel to near-row radials about the
881    // row centroids) and pull the far row back to the pinch point, so the
882    // fitted surface ends in a proper degenerate apex row instead of a
883    // parasitic inverted tip ending in an unweldable ring.
884    // Both blocks below move the sample grid OFF the pointwise offset on
885    // purpose. Recording that is what lets `offset_surface_measured` decline to
886    // report a designed divergence as a fit error.
887    let mut reparameterised = false;
888    let mut extended_source: Option<NurbsSurface> = None;
889    if parameters_v.len() == 2 && parameters_u.len() >= 3 {
890        let centroid = |column: usize| {
891            let mut sum = Vec3::default();
892            for row in &samples {
893                sum = sum.add(row[column]);
894            }
895            sum.scale(1.0 / samples.len() as f64)
896        };
897        let near_centroid = centroid(0);
898        let far_centroid = centroid(1);
899        let mut inverted = true;
900        let mut pinch_fraction = 0.0f64;
901        let mut near_mean = 0.0f64;
902        let mut far_mean = 0.0f64;
903        for row in &samples {
904            let near_radial = row[0].sub(near_centroid);
905            let far_radial = row[1].sub(far_centroid);
906            let near_len = near_radial.length();
907            let far_len = far_radial.length();
908            if near_len <= 1e-9 || far_len <= 1e-9 {
909                inverted = false;
910                break;
911            }
912            if near_radial.dot(far_radial) >= 0.0 {
913                inverted = false;
914                break;
915            }
916            pinch_fraction += near_len / (near_len + far_len) / samples.len() as f64;
917            near_mean += near_len / samples.len() as f64;
918            far_mean += far_len / samples.len() as f64;
919        }
920        if inverted {
921            let retrim_far = far_mean <= near_mean;
922            // WHOSE fold is it?  The retrim exists so a cavity that ends at the
923            // offset cone's own apex ends there in the TOPOLOGY too, instead of
924            // running past the pinch into a mirrored ring no weld can use.  It
925            // pays for that by moving the sample grid off the pointwise offset,
926            // and the price is the shared-basis identity every consumer rests
927            // on: after it, the SAME `(u, v)` no longer names the offset of the
928            // same source point.
929            //
930            // That price is only worth paying when the face's TRIM actually
931            // reaches past the pinch.  A trim on a cone's wide half offsets
932            // perfectly well; its carrier folds somewhere the face never goes,
933            // and shearing its parameterization to tidy that away sheared the
934            // trim's own pcurves with it — a 45° cone sheet trimmed to its wide
935            // half shipped a slab 0.86 % off its Pappus volume, validating and
936            // wrong (`thicken.rs`,
937            // `a_re_parameterized_offset_carrier_keeps_the_pointwise_offset`).
938            //
939            // So read the trim, the way the extension below already does.  With
940            // a linear v basis the pinch sits at the v FRACTION
941            // `pinch_fraction`, and the folded side is whichever end the retrim
942            // would have pulled.  A face with no loops still reports the whole
943            // domain, which keeps every full-domain carrier on its exact
944            // previous path.
945            let (_, trim_v) = trim_domain_fractions(face, knot_u.domain(), knot_v.domain())?;
946            let trim_reaches_the_pinch = if retrim_far {
947                trim_v[1] > pinch_fraction + 1e-9
948            } else {
949                trim_v[0] < pinch_fraction - 1e-9
950            };
951            if trim_reaches_the_pinch {
952                // Pull the crossed (smaller-ring, past-the-pinch) end back to
953                // the pinch point on each ruling.
954                reparameterised = true;
955                for row in &mut samples {
956                    let near = row[0];
957                    let far = row[1];
958                    let pinch = near.add(far.sub(near).scale(pinch_fraction));
959                    if retrim_far {
960                        row[1] = pinch;
961                    } else {
962                        row[0] = pinch;
963                    }
964                }
965            }
966        }
967        // RULED EXTENSION: `planar_extension` is a no-op for curved carriers
968        // above, but a cone/cylinder lateral joined at a reflex edge needs
969        // its offset skin to GROW past the source rim exactly like a plane
970        // (a cylinder piercing a cone: the two offsets only meet past both
971        // cloned rims). The carrier's parameterization stays (knots/pcurves
972        // untouched) while its world image — and with it the cloned trim's
973        // image — inflates: the carrier is the pointwise offset of the SOURCE
974        // EXTENDED ALONG ITS RULINGS, which a linear-v net does exactly.
975        //
976        // Stretching the sampled OFFSET rulings instead reaches the same
977        // surface only where the source is DEVELOPABLE — its normal constant
978        // along each ruling, as on a cylinder, a cone or an extruded wall —
979        // because only there is the offset ruled too. A twisted loft is ruled
980        // and not developable: its stretched offset rulings left the offset,
981        // and an outward shell of one validated 0.235 short (1.0 %). So the
982        // stretch is MEASURED against the extended source's pointwise offset,
983        // at both ends and the middle of every sampled ruling, and kept only
984        // within the fit contract; otherwise the extended source is sampled
985        // pointwise, and either way the refinement reads the carrier against it.
986        if extension.extends_v() && !inverted {
987            let mut min_ruling = f64::MAX;
988            let mut back_allowance = f64::MAX;
989            let mut forward_allowance = f64::MAX;
990            let mut extendable = true;
991            for row in &samples {
992                let ruling = row[1].sub(row[0]);
993                let length = ruling.length();
994                min_ruling = min_ruling.min(length);
995                // Radii about the row centroids expose a converging (conic)
996                // ruling sheaf; the extension must stop short of its apex or
997                // the sheet folds through it.
998                let near_radial = row[0].sub(near_centroid).length();
999                let far_radial = row[1].sub(far_centroid).length();
1000                if (far_radial - near_radial).abs() > 1e-9 {
1001                    let apex_at = near_radial / (near_radial - far_radial);
1002                    if (-1e-9..=1.0 + 1e-9).contains(&apex_at) {
1003                        // Apex inside the span: degenerate sheet, do not touch.
1004                        extendable = false;
1005                        break;
1006                    }
1007                    if apex_at < 0.0 {
1008                        back_allowance = back_allowance.min(0.9 * -apex_at);
1009                    } else {
1010                        forward_allowance = forward_allowance.min(0.9 * (apex_at - 1.0));
1011                    }
1012                }
1013            }
1014            // The allowances above are read off the OFFSET rows. The source's
1015            // own ruling sheaf converges at a different rate — an outward
1016            // offset moves a cone's apex FARTHER along the ruling — so a
1017            // growth the offset rows allow can still carry the EXTENDED SOURCE
1018            // through its own apex, and its pointwise offset would then be
1019            // taken on a surface folded back on itself. Read the same radial
1020            // ratio on the source rows and clamp by the tighter of the two.
1021            let mut source_back = f64::MAX;
1022            let mut source_forward = f64::MAX;
1023            if extendable {
1024                let source_centroid = |column: usize| {
1025                    let mut sum = Vec3::default();
1026                    for row in &source.control_points {
1027                        if let Ok(point) = row[column].point() {
1028                            sum = sum.add(point);
1029                        }
1030                    }
1031                    sum.scale(1.0 / source.control_points.len() as f64)
1032                };
1033                let (near_source, far_source) = (source_centroid(0), source_centroid(1));
1034                for row in &source.control_points {
1035                    let (near, far) = (row[0].point()?, row[1].point()?);
1036                    let near_radial = near.sub(near_source).length();
1037                    let far_radial = far.sub(far_source).length();
1038                    if (far_radial - near_radial).abs() > 1e-9 {
1039                        let apex_at = near_radial / (near_radial - far_radial);
1040                        if (-1e-9..=1.0 + 1e-9).contains(&apex_at) {
1041                            extendable = false;
1042                            break;
1043                        }
1044                        if apex_at < 0.0 {
1045                            source_back = source_back.min(0.9 * -apex_at);
1046                        } else {
1047                            source_forward = source_forward.min(0.9 * (apex_at - 1.0));
1048                        }
1049                    }
1050                }
1051            }
1052            let (back_allowance, forward_allowance) =
1053                (back_allowance.min(source_back), forward_allowance.min(source_forward));
1054            if extendable && min_ruling > 1e-9 {
1055                // Solved for the TRIM's v-extent, not the domain's: a bore
1056                // face keeps its drill's full-height surface and is trimmed
1057                // to the part it pierces, so moving the surface's own ends
1058                // by |d| moved the trim's rims by only a fraction of that
1059                // (a 30-long ruling trimmed to 20 gave 0.667 for 1.0) — and
1060                // an outward shell's bore never reached the grown planes.
1061                let (_, trim_v) = trim_domain_fractions(face, knot_u.domain(), knot_v.domain())?;
1062                let (back_world, forward_world) =
1063                    domain_end_moves(extension.v_min, extension.v_max, trim_v);
1064                let back = (back_world / min_ruling).min(back_allowance);
1065                let forward = (forward_world / min_ruling).min(forward_allowance);
1066                let stretched = samples
1067                    .iter()
1068                    .map(|row| {
1069                        let ruling = row[1].sub(row[0]);
1070                        vec![row[0].sub(ruling.scale(back)), row[1].add(ruling.scale(forward))]
1071                    })
1072                    .collect::<Vec<_>>();
1073                let extended = extend_along_rulings(source, back, forward)?;
1074                let extended_offsets = OffsetEvaluator::new(
1075                    "offset_surface",
1076                    &extended,
1077                    OffsetNormal::FaceStable {
1078                        same_sense: face.same_sense,
1079                    },
1080                );
1081                let [v0, v1] = knot_v.domain();
1082                let mut developability = 0.0f64;
1083                for (row, &u) in stretched.iter().zip(&parameters_u) {
1084                    for fraction in [0.0, 0.5, 1.0] {
1085                        let exact = extended_offsets.at(u, v0 + (v1 - v0) * fraction, -distance)?.point;
1086                        let along = row[0].add(row[1].sub(row[0]).scale(fraction));
1087                        developability = developability.max(exact.sub(along).length());
1088                    }
1089                }
1090                let contract = offset_fit_contract(source);
1091                let stretch_kept = developability <= contract;
1092                samples = if stretch_kept {
1093                    stretched
1094                } else {
1095                    parameters_u
1096                        .iter()
1097                        .map(|&u| {
1098                            parameters_v
1099                                .iter()
1100                                .map(|&v| Ok(extended_offsets.at(u, v, -distance)?.point))
1101                                .collect::<Result<Vec<_>, String>>()
1102                        })
1103                        .collect::<Result<Vec<_>, String>>()?
1104                };
1105                if let Ok(path) = std::env::var("BREP_OFFSET_FIT_TRACE") {
1106                    use std::io::Write;
1107                    let line = format!(
1108                        "extension deg=({},{}) net=({},{}) d={distance:.6} back={back:.6} forward={forward:.6} \
1109                         developability={developability:.3e} contract={contract:.3e} lane={} thread={} args={}\n",
1110                        source.degree_u,
1111                        source.degree_v,
1112                        source.control_points.len(),
1113                        source.control_points[0].len(),
1114                        if stretch_kept { "stretch" } else { "pointwise" },
1115                        std::thread::current().name().unwrap_or("-"),
1116                        std::env::args().skip(1).collect::<Vec<_>>().join(" "),
1117                    );
1118                    if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
1119                        let _ = file.write_all(line.as_bytes());
1120                    }
1121                }
1122                extended_source = Some(extended);
1123            }
1124        }
1125    }
1126    let reference = extended_source.as_ref().unwrap_or(source);
1127    let weights = reference
1128        .control_points
1129        .iter()
1130        .map(|row| row.iter().map(|point| point.w).collect::<Vec<_>>())
1131        .collect::<Vec<_>>();
1132    let fitted = NurbsSurface::new(
1133        source.degree_u,
1134        source.degree_v,
1135        source.knots_u.clone(),
1136        source.knots_v.clone(),
1137        interpolate_tensor(
1138            &knot_u,
1139            &knot_v,
1140            &parameters_u,
1141            &parameters_v,
1142            &samples,
1143            &weights,
1144        )?,
1145    )?;
1146    if reparameterised {
1147        return Ok((fitted, OffsetSurfaceLane::Reparameterised, None));
1148    }
1149    let reference_offsets = OffsetEvaluator::new(
1150        "offset_surface",
1151        reference,
1152        OffsetNormal::FaceStable {
1153            same_sense: face.same_sense,
1154        },
1155    );
1156    Ok((
1157        refine_offset_fit(reference, &reference_offsets, distance, fitted)?,
1158        OffsetSurfaceLane::Fit,
1159        extended_source,
1160    ))
1161}
1162
1163/// A linear-v surface continued along its own rulings by `back` and `forward`
1164/// ruling lengths — the same surface, re-parameterized onto the longer span.
1165/// Exact: each ruling is linear in homogeneous coordinates, so extrapolating the
1166/// homogeneous control rows traces the ruling past both ends. Refused where a
1167/// continued weight stops being positive, which is where the ruling leaves the
1168/// rational patch.
1169fn extend_along_rulings(surface: &NurbsSurface, back: f64, forward: f64) -> Result<NurbsSurface, String> {
1170    let rows = surface
1171        .control_points
1172        .iter()
1173        .map(|row| {
1174            let step = row[1].add(row[0].scale(-1.0));
1175            vec![row[0].add(step.scale(-back)), row[1].add(step.scale(forward))]
1176        })
1177        .collect::<Vec<_>>();
1178    if rows.iter().flatten().any(|point| point.w <= 1e-12) {
1179        return Err(format!(
1180            "offset_surface: extending a ruled face by ({back:.6}, {forward:.6}) of its rulings \
1181             carries a weight through zero; the extended face is not this surface"
1182        ));
1183    }
1184    NurbsSurface::new(surface.degree_u, 1, surface.knots_u.clone(), surface.knots_v.clone(), rows)
1185}
1186
1187/// The fit contract a carrier of `source` is refined toward: the kernel's
1188/// intersection-fit tolerance for the source net's extent.
1189fn offset_fit_contract(source: &NurbsSurface) -> f64 {
1190    let scale = crate::model_scale(
1191        source
1192            .control_points
1193            .iter()
1194            .flatten()
1195            .filter_map(|point| point.point().ok()),
1196    );
1197    crate::KernelTolerances::for_scale(scale, 1e-7).intersection_fit
1198}
1199
1200/// Halvings of every knot span [`refine_offset_fit`] may take, per direction.
1201const OFFSET_FIT_REFINEMENTS: usize = 6;
1202
1203/// The largest net one refined collocation may solve: separable weights solve
1204/// one direction at a time, non-separable ones solve the whole tensor densely.
1205const OFFSET_FIT_MAX_CONTROLS_SEPARABLE: usize = 16_384;
1206const OFFSET_FIT_MAX_CONTROLS_TENSOR: usize = 900;
1207
1208/// The out-of-sample deviation of a collocation fit from the pointwise offset,
1209/// read at the MIDPOINTS between consecutive Greville parameters, where the fit
1210/// interpolates nothing: along u (u midpoints on the v Greville rows), along v,
1211/// and between both.
1212#[derive(Clone, Copy, Debug)]
1213struct OffsetFitDeviation {
1214    along_u: f64,
1215    along_v: f64,
1216    between: f64,
1217}
1218
1219impl OffsetFitDeviation {
1220    fn worst(&self) -> f64 {
1221        self.along_u.max(self.along_v).max(self.between)
1222    }
1223}
1224
1225fn greville_midpoints(parameters: &[f64]) -> Vec<f64> {
1226    parameters
1227        .windows(2)
1228        .filter(|pair| pair[1] - pair[0] > 1e-12)
1229        .map(|pair| 0.5 * (pair[0] + pair[1]))
1230        .collect()
1231}
1232
1233fn offset_fit_deviation(
1234    fitted: &NurbsSurface,
1235    evaluator: &OffsetEvaluator,
1236    distance: f64,
1237) -> Result<OffsetFitDeviation, String> {
1238    let parameters_u = greville_parameters(&KnotVector::new(fitted.knots_u.clone(), fitted.degree_u)?);
1239    let parameters_v = greville_parameters(&KnotVector::new(fitted.knots_v.clone(), fitted.degree_v)?);
1240    let (middle_u, middle_v) = (greville_midpoints(&parameters_u), greville_midpoints(&parameters_v));
1241    let gap = |u: f64, v: f64| -> Result<f64, String> {
1242        Ok(fitted.evaluate(u, v)?.sub(evaluator.at(u, v, -distance)?.point).length())
1243    };
1244    let mut deviation = OffsetFitDeviation {
1245        along_u: 0.0,
1246        along_v: 0.0,
1247        between: 0.0,
1248    };
1249    for &u in &middle_u {
1250        for &v in &parameters_v {
1251            deviation.along_u = deviation.along_u.max(gap(u, v)?);
1252        }
1253        for &v in &middle_v {
1254            deviation.between = deviation.between.max(gap(u, v)?);
1255        }
1256    }
1257    for &u in &parameters_u {
1258        for &v in &middle_v {
1259            deviation.along_v = deviation.along_v.max(gap(u, v)?);
1260        }
1261    }
1262    Ok(deviation)
1263}
1264
1265/// `surface` with each requested LINEAR direction raised to cubic, the same
1266/// geometry: every span re-expressed as a cubic Bezier on the homogeneous net
1267/// (the points at thirds), joined C0 by triple knots. `None` when a requested
1268/// direction is not linear or carries a repeated interior knot.
1269///
1270/// A linear direction is raised rather than halved because the offset of a
1271/// ruled surface is generally not ruled, and a piecewise-linear fit converges
1272/// only as h^2: the twisted loft's v direction still read 3.3e-6 after six
1273/// halvings, where the cubic basis converges as h^4.
1274fn raise_linear_directions(
1275    surface: &NurbsSurface,
1276    in_u: bool,
1277    in_v: bool,
1278) -> Result<Option<NurbsSurface>, String> {
1279    fn raise(knots: &[f64], row: &[Vec4]) -> Option<(Vec<f64>, Vec<Vec4>)> {
1280        // Degree 1: knots are [k0, k0, k1, ..., kn, kn] for n + 1 controls.
1281        let count = row.len();
1282        if knots.len() != count + 2 {
1283            return None;
1284        }
1285        let breaks = &knots[1..=count];
1286        if breaks.windows(2).any(|pair| pair[1] - pair[0] <= 1e-12) {
1287            return None;
1288        }
1289        let mut raised_knots = vec![breaks[0]; 4];
1290        for &interior in &breaks[1..count - 1] {
1291            raised_knots.extend([interior; 3]);
1292        }
1293        raised_knots.extend([breaks[count - 1]; 4]);
1294        let mut points = Vec::with_capacity(3 * (count - 1) + 1);
1295        for pair in row.windows(2) {
1296            let step = pair[1].add(pair[0].scale(-1.0));
1297            points.push(pair[0]);
1298            points.push(pair[0].add(step.scale(1.0 / 3.0)));
1299            points.push(pair[0].add(step.scale(2.0 / 3.0)));
1300        }
1301        points.push(row[count - 1]);
1302        Some((raised_knots, points))
1303    }
1304    let mut knots_u = surface.knots_u.clone();
1305    let mut knots_v = surface.knots_v.clone();
1306    let mut degree_u = surface.degree_u;
1307    let mut degree_v = surface.degree_v;
1308    let mut net = surface.control_points.clone();
1309    if in_v {
1310        if degree_v != 1 {
1311            return Ok(None);
1312        }
1313        let mut rows = Vec::with_capacity(net.len());
1314        for row in &net {
1315            let Some((knots, points)) = raise(&surface.knots_v, row) else {
1316                return Ok(None);
1317            };
1318            knots_v = knots;
1319            rows.push(points);
1320        }
1321        net = rows;
1322        degree_v = 3;
1323    }
1324    if in_u {
1325        if degree_u != 1 {
1326            return Ok(None);
1327        }
1328        let count_v = net[0].len();
1329        let mut columns = Vec::with_capacity(count_v);
1330        for column in 0..count_v {
1331            let row = net.iter().map(|row| row[column]).collect::<Vec<_>>();
1332            let Some((knots, points)) = raise(&surface.knots_u, &row) else {
1333                return Ok(None);
1334            };
1335            knots_u = knots;
1336            columns.push(points);
1337        }
1338        let count_u = columns[0].len();
1339        net = (0..count_u)
1340            .map(|row| columns.iter().map(|column| column[row]).collect())
1341            .collect();
1342        degree_u = 3;
1343    }
1344    Ok(Some(NurbsSurface::new(degree_u, degree_v, knots_u, knots_v, net)?))
1345}
1346
1347/// `surface`, the same geometry with a knot inserted at the midpoint of every
1348/// non-empty span of each requested direction (exact Boehm insertion on the
1349/// homogeneous net, so the weights refine with it).
1350fn halve_knot_spans(surface: &NurbsSurface, in_u: bool, in_v: bool) -> Result<NurbsSurface, String> {
1351    fn midpoints(knots: &[f64], degree: usize) -> Vec<f64> {
1352        let last = knots.len() - 1 - degree;
1353        knots[degree..=last]
1354            .windows(2)
1355            .filter(|pair| pair[1] - pair[0] > 1e-12)
1356            .map(|pair| 0.5 * (pair[0] + pair[1]))
1357            .collect()
1358    }
1359    let refine = |degree: usize, knots: &[f64], net: Vec<Vec<Vec4>>| -> Result<(Vec<f64>, Vec<Vec<Vec4>>), String> {
1360        let inserted = midpoints(knots, degree);
1361        let mut refined_knots = knots.to_vec();
1362        let mut rows = Vec::with_capacity(net.len());
1363        for row in net {
1364            let mut curve = NurbsCurve::new(degree, knots.to_vec(), row)?;
1365            for &parameter in &inserted {
1366                curve = curve.insert_knot(parameter, 1)?;
1367            }
1368            refined_knots = curve.knots.clone();
1369            rows.push(curve.control_points);
1370        }
1371        Ok((refined_knots, rows))
1372    };
1373    let mut knots_u = surface.knots_u.clone();
1374    let mut knots_v = surface.knots_v.clone();
1375    let mut net = surface.control_points.clone();
1376    if in_v {
1377        let (knots, rows) = refine(surface.degree_v, &knots_v, net)?;
1378        knots_v = knots;
1379        net = rows;
1380    }
1381    if in_u {
1382        // Transpose so each u column is a curve, refine, transpose back.
1383        let count_v = net[0].len();
1384        let columns = (0..count_v)
1385            .map(|column| net.iter().map(|row| row[column]).collect::<Vec<_>>())
1386            .collect::<Vec<_>>();
1387        let (knots, refined) = refine(surface.degree_u, &knots_u, columns)?;
1388        knots_u = knots;
1389        let count_u = refined[0].len();
1390        net = (0..count_u)
1391            .map(|row| refined.iter().map(|column| column[row]).collect())
1392            .collect();
1393    }
1394    NurbsSurface::new(surface.degree_u, surface.degree_v, knots_u, knots_v, net)
1395}
1396
1397/// A collocation fit of the pointwise offset, refined until its OUT-OF-SAMPLE
1398/// deviation meets the kernel's intersection-fit contract.
1399///
1400/// The Greville fit interpolates the pointwise offset in the SOURCE's basis.
1401/// That is exact wherever the offset lives in that basis — planes, cylinders,
1402/// cones, spheres, tori, which is why it was built that way — and, on a
1403/// free-form face, only as good as the source's span count: a single-span cubic
1404/// Bezier wall offset by 0.3 was measured 1.2e-2 to 1.7e-2 off its pointwise
1405/// offset, with the fit exact at its four Greville rows and nowhere else.
1406///
1407/// Refinement halves the knot spans of the direction the deviation is read in
1408/// (exact knot insertion, so the parameterization does not move: the same
1409/// `(u, v)` still names the offset of the same source point, and every trim
1410/// pcurve cloned onto the carrier stays valid) and re-fits. A fit already under
1411/// the bar is returned untouched, bit for bit. The bar is the one a carrier's
1412/// EDGES are already held to (`offset_face_carrier_impl`'s `fit_tolerance`),
1413/// derived from the source net's extent.
1414///
1415/// The contract is a TARGET; the offset construction band is the GATE. A fit
1416/// that is still over the contract when the rounds run out
1417/// ([`OFFSET_FIT_REFINEMENTS`]), when a round stops improving, or when the next
1418/// net would pass the solver ceilings, is ACCEPTED as its best measured round if
1419/// that round is inside `offset_construction_band` — the allowance every
1420/// offset construction in this family is already judged against — and refused
1421/// by name if it is not. Accepted over the contract is a decision, not a
1422/// silence: `offset_surface_measured` reports the deviation, and
1423/// `a_fit_the_rounds_leave_over_the_contract_is_accepted_inside_the_band` pins
1424/// it. (Measured on the corpus: two imported `OFFSET_SURFACE`s end their rounds
1425/// at 1.3e-5 and 3.4e-5 against a contract of 2.15e-6 and a band of 0.107.)
1426///
1427/// Cost is bounded by the rounds: a direction's spans grow at most 64-fold, a
1428/// round costs one pointwise offset per Greville node plus about four per node
1429/// to measure, and one dense collocation solve per row and per column
1430/// (`n_v·n_u³ + n_u·n_v³` for separable weights), and the rounds' costs are
1431/// geometric, so the last round is about half of the whole.
1432fn refine_offset_fit(
1433    source: &NurbsSurface,
1434    evaluator: &OffsetEvaluator,
1435    distance: f64,
1436    fitted: NurbsSurface,
1437) -> Result<NurbsSurface, String> {
1438    let scale = crate::model_scale(
1439        source
1440            .control_points
1441            .iter()
1442            .flatten()
1443            .filter_map(|point| point.point().ok()),
1444    );
1445    let bar = crate::KernelTolerances::for_scale(scale, 1e-7).intersection_fit;
1446    let trace = std::env::var("BREP_OFFSET_FIT_TRACE").ok();
1447    let started = web_time::Instant::now();
1448    let enabled = std::env::var("BREP_OFFSET_FIT_REFINE").as_deref() != Ok("0");
1449    let Ok(initial) = offset_fit_deviation(&fitted, evaluator, distance) else {
1450        return Ok(fitted);
1451    };
1452    let mut best = fitted;
1453    let mut best_deviation = initial;
1454    let mut refined_source = source.clone();
1455    let mut rounds = 0usize;
1456    let mut stop = "under bar";
1457    if enabled {
1458        while best_deviation.worst() > bar {
1459            if rounds == OFFSET_FIT_REFINEMENTS {
1460                stop = "refinement limit";
1461                break;
1462            }
1463            let (along_u, along_v) = (best_deviation.along_u > bar, best_deviation.along_v > bar);
1464            let (in_u, in_v) = if along_u || along_v {
1465                (along_u, along_v)
1466            } else {
1467                (true, true)
1468            };
1469            let (raise_u, raise_v) = (in_u && refined_source.degree_u == 1, in_v && refined_source.degree_v == 1);
1470            let raised = if raise_u || raise_v {
1471                raise_linear_directions(&refined_source, raise_u, raise_v)?
1472            } else {
1473                None
1474            };
1475            let (halve_u, halve_v) = match raised {
1476                Some(_) => (in_u && !raise_u, in_v && !raise_v),
1477                None => (in_u, in_v),
1478            };
1479            let raised = raised.unwrap_or_else(|| refined_source.clone());
1480            let candidate_source = if halve_u || halve_v {
1481                halve_knot_spans(&raised, halve_u, halve_v)?
1482            } else {
1483                raised
1484            };
1485            let weights = candidate_source
1486                .control_points
1487                .iter()
1488                .map(|row| row.iter().map(|point| point.w).collect::<Vec<_>>())
1489                .collect::<Vec<_>>();
1490            let controls = weights.len() * weights[0].len();
1491            let ceiling = if separable_weights(&weights).is_some() {
1492                OFFSET_FIT_MAX_CONTROLS_SEPARABLE
1493            } else {
1494                OFFSET_FIT_MAX_CONTROLS_TENSOR
1495            };
1496            if controls > ceiling {
1497                stop = "net ceiling";
1498                break;
1499            }
1500            let knot_u = KnotVector::new(candidate_source.knots_u.clone(), candidate_source.degree_u)?;
1501            let knot_v = KnotVector::new(candidate_source.knots_v.clone(), candidate_source.degree_v)?;
1502            let parameters_u = greville_parameters(&knot_u);
1503            let parameters_v = greville_parameters(&knot_v);
1504            let samples = parameters_u
1505                .iter()
1506                .map(|&u| {
1507                    parameters_v
1508                        .iter()
1509                        .map(|&v| Ok(evaluator.at(u, v, -distance)?.point))
1510                        .collect::<Result<Vec<_>, String>>()
1511                })
1512                .collect::<Result<Vec<_>, String>>();
1513            let Ok(samples) = samples else {
1514                stop = "unevaluable sample";
1515                break;
1516            };
1517            let candidate = NurbsSurface::new(
1518                candidate_source.degree_u,
1519                candidate_source.degree_v,
1520                candidate_source.knots_u.clone(),
1521                candidate_source.knots_v.clone(),
1522                interpolate_tensor(&knot_u, &knot_v, &parameters_u, &parameters_v, &samples, &weights)?,
1523            )?;
1524            let Ok(deviation) = offset_fit_deviation(&candidate, evaluator, distance) else {
1525                stop = "unmeasurable round";
1526                break;
1527            };
1528            rounds += 1;
1529            if deviation.worst() >= best_deviation.worst() {
1530                stop = "stopped improving";
1531                break;
1532            }
1533            best = candidate;
1534            best_deviation = deviation;
1535            refined_source = candidate_source;
1536        }
1537    } else if best_deviation.worst() > bar {
1538        stop = "refinement disabled";
1539    }
1540    // The gate. Refinement aims at the fit contract; a carrier it could not
1541    // bring inside the offset family's own acceptance band is refused by name
1542    // rather than handed on — a fit measured six orders past its band used to be
1543    // recorded and used anyway.
1544    let band = offset_construction_band(scale);
1545    let refused = enabled && best_deviation.worst() > band;
1546    if let Some(path) = trace {
1547        use std::io::Write;
1548        let caller = std::env::args().skip(1).collect::<Vec<_>>().join(" ");
1549        let thread = std::thread::current().name().unwrap_or("-").to_string();
1550        let line = format!(
1551            "fit deg=({},{}) net=({},{})->({},{}) d={distance:.6} scale={scale:.4} bar={bar:.3e} \
1552             initial=(u {:.3e}, v {:.3e}, uv {:.3e}) final={:.3e} rounds={rounds} stop={stop} \
1553             refused={refused} seconds={:.4} thread={thread} args={caller}\n",
1554            source.degree_u,
1555            source.degree_v,
1556            source.control_points.len(),
1557            source.control_points[0].len(),
1558            best.control_points.len(),
1559            best.control_points[0].len(),
1560            initial.along_u,
1561            initial.along_v,
1562            initial.between,
1563            best_deviation.worst(),
1564            started.elapsed().as_secs_f64(),
1565        );
1566        if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
1567            let _ = file.write_all(line.as_bytes());
1568        }
1569    }
1570    if refused {
1571        return Err(format!(
1572            "offset_surface: the fitted offset carrier stays {:.3e} off its pointwise offset at \
1573             distance {distance} after {rounds} refinement round(s) ({stop}), past the offset \
1574             construction band {band:.3e}",
1575            best_deviation.worst()
1576        ));
1577    }
1578    Ok(best)
1579}
1580
1581/// Two offset sheets of one source, re-expressed on ONE basis without moving
1582/// either: a linear direction facing a cubic one is raised, then each sheet
1583/// takes the knots the other has and it lacks (exact insertion).
1584///
1585/// [`refine_offset_fit`] refines each carrier by its own measured deviation, so
1586/// two sheets of the same source at different distances can leave on different
1587/// nets; a consumer that pairs their iso curves control point for control point
1588/// (`thicken`'s ruled walls) needs them on the same one. Weights follow the
1589/// insertions, and both sheets carry the source's weights refined the same way.
1590pub(crate) fn unify_offset_sheet_bases(
1591    first: &NurbsSurface,
1592    second: &NurbsSurface,
1593) -> Result<(NurbsSurface, NurbsSurface), String> {
1594    fn raise_to(surface: &NurbsSurface, degree_u: usize, degree_v: usize) -> Result<NurbsSurface, String> {
1595        let raise_u = surface.degree_u == 1 && degree_u == 3;
1596        let raise_v = surface.degree_v == 1 && degree_v == 3;
1597        if !raise_u && !raise_v {
1598            return Ok(surface.clone());
1599        }
1600        raise_linear_directions(surface, raise_u, raise_v)?
1601            .ok_or_else(|| "offset sheets: a linear direction could not be raised".to_string())
1602    }
1603    let degree_u = first.degree_u.max(second.degree_u);
1604    let degree_v = first.degree_v.max(second.degree_v);
1605    let mut first = raise_to(first, degree_u, degree_v)?;
1606    let mut second = raise_to(second, degree_u, degree_v)?;
1607    if first.degree_u != second.degree_u || first.degree_v != second.degree_v {
1608        return Err(format!(
1609            "offset sheets: degrees ({},{}) and ({},{}) have no common basis",
1610            first.degree_u, first.degree_v, second.degree_u, second.degree_v
1611        ));
1612    }
1613    // Knots one vector has beyond the other, counted with multiplicity.
1614    fn missing(from: &[f64], into: &[f64]) -> Vec<f64> {
1615        let mut extra = Vec::new();
1616        let mut index = 0usize;
1617        for &knot in from {
1618            while index < into.len() && into[index] < knot - 1e-12 {
1619                index += 1;
1620            }
1621            if index < into.len() && (into[index] - knot).abs() <= 1e-12 {
1622                index += 1;
1623            } else {
1624                extra.push(knot);
1625            }
1626        }
1627        extra
1628    }
1629    fn insert(surface: &NurbsSurface, knots: &[f64], along_u: bool) -> Result<NurbsSurface, String> {
1630        if knots.is_empty() {
1631            return Ok(surface.clone());
1632        }
1633        let (degree, vector) = if along_u {
1634            (surface.degree_u, &surface.knots_u)
1635        } else {
1636            (surface.degree_v, &surface.knots_v)
1637        };
1638        let lines = if along_u {
1639            (0..surface.control_points[0].len())
1640                .map(|column| surface.control_points.iter().map(|row| row[column]).collect::<Vec<_>>())
1641                .collect::<Vec<_>>()
1642        } else {
1643            surface.control_points.clone()
1644        };
1645        let mut refined_knots = vector.clone();
1646        let mut refined = Vec::with_capacity(lines.len());
1647        for line in lines {
1648            let mut curve = NurbsCurve::new(degree, vector.clone(), line)?;
1649            for &knot in knots {
1650                curve = curve.insert_knot(knot, 1)?;
1651            }
1652            refined_knots = curve.knots.clone();
1653            refined.push(curve.control_points);
1654        }
1655        let net = if along_u {
1656            (0..refined[0].len())
1657                .map(|row| refined.iter().map(|column| column[row]).collect())
1658                .collect()
1659        } else {
1660            refined
1661        };
1662        let (knots_u, knots_v) = if along_u {
1663            (refined_knots, surface.knots_v.clone())
1664        } else {
1665            (surface.knots_u.clone(), refined_knots)
1666        };
1667        NurbsSurface::new(surface.degree_u, surface.degree_v, knots_u, knots_v, net)
1668    }
1669    let (to_first_u, to_second_u) = (
1670        missing(&second.knots_u, &first.knots_u),
1671        missing(&first.knots_u, &second.knots_u),
1672    );
1673    first = insert(&first, &to_first_u, true)?;
1674    second = insert(&second, &to_second_u, true)?;
1675    let (to_first_v, to_second_v) = (
1676        missing(&second.knots_v, &first.knots_v),
1677        missing(&first.knots_v, &second.knots_v),
1678    );
1679    first = insert(&first, &to_first_v, false)?;
1680    second = insert(&second, &to_second_v, false)?;
1681    Ok((first, second))
1682}
1683
1684/// The 3D curve of one carrier edge: the image of the source coedge's pcurve
1685/// on the carrier surface, with the parameter range `(t0, t1)` that matches the
1686/// pcurve's domain fraction for fraction (the contract every coedge of this
1687/// carrier relies on, since the source pcurves are reused verbatim).
1688///
1689/// Built through the [`crate::image_curve`] ladder — exact on an affine sheet
1690/// (every planar offset), the exact iso-curve where the pcurve holds one
1691/// coordinate constant (a blend rail, a cylinder's cap rim, every
1692/// extrude/revolve boundary), and a fitted curve verified to `fit_tolerance`
1693/// off its nodes otherwise. Only where the ladder refuses does the edge fall
1694/// back to the degree-1 interpolant through `points` (the historical
1695/// construction), so nothing that offset before is refused now.
1696///
1697/// Why the polyline is no longer the first choice: its adaptive subdivision
1698/// stops at a 5e-4 chord sag, and a 128-chord rim on an r = 8.5 cylinder sits
1699/// up to 1.6e-4 inside the true arc. That is 16x the boolean imprint's
1700/// coincidence band, so when a later operation's section curve runs along that
1701/// rim (a cutter's side wall coplanar with the offset shell's opening wall,
1702/// 2026-09-06 report) the imprint cannot recognise the rim as the section it
1703/// already is, and reports every chord vertex as a crossing — one edge split
1704/// into forty. An exact arc is recognised as coincident and left alone.
1705fn carrier_edge_curve(
1706    surface: &NurbsSurface,
1707    pcurve: &NurbsCurve,
1708    points: &[Vec3],
1709    parameters: &[f64],
1710    fit_tolerance: f64,
1711) -> Result<(NurbsCurve, f64, f64), String> {
1712    // `BREP_CARRIER_POLYLINE=1` restores the polyline for an A/B comparison.
1713    let image = if std::env::var("BREP_CARRIER_POLYLINE").as_deref() == Ok("1") {
1714        Err("BREP_CARRIER_POLYLINE set".to_string())
1715    } else {
1716        crate::image_curve::image_curve(surface, pcurve, fit_tolerance, "offset_face_carrier")
1717    };
1718    match image {
1719        Ok(image) if image.t0 <= image.t1 => Ok((image.curve, image.t0, image.t1)),
1720        Ok(image) => {
1721            // The image runs against the pcurve: reverse it so the edge range
1722            // is increasing, reflecting the range through the curve's domain.
1723            let [start, end] = image.curve.domain()?;
1724            let curve = image.curve.reversed()?;
1725            Ok((curve, start + end - image.t0, start + end - image.t1))
1726        }
1727        Err(_) => {
1728            let curve = interpolate_curve(points, 1, parameters)?;
1729            let [t0, t1] = curve.domain()?;
1730            Ok((curve, t0, t1))
1731        }
1732    }
1733}
1734
1735fn mapped_pcurve_polyline(
1736    surface: &NurbsSurface,
1737    pcurve: &NurbsCurve,
1738    degenerate: bool,
1739) -> Result<(Vec<Vec3>, Vec<f64>), String> {
1740    let [start, end] = pcurve.domain()?;
1741    let evaluate = |fraction: f64| {
1742        let uv = pcurve.evaluate(start + (end - start) * fraction)?;
1743        surface.evaluate(uv.x, uv.y)
1744    };
1745    let first = evaluate(0.0)?;
1746    let last = evaluate(1.0)?;
1747    if degenerate {
1748        return Ok((vec![first, last], vec![0.0, 1.0]));
1749    }
1750    fn append(
1751        evaluate: &impl Fn(f64) -> Result<Vec3, String>,
1752        a_fraction: f64,
1753        a: Vec3,
1754        b_fraction: f64,
1755        b: Vec3,
1756        depth: usize,
1757        parameters: &mut Vec<f64>,
1758        points: &mut Vec<Vec3>,
1759    ) -> Result<(), String> {
1760        let fractions =
1761            [0.25, 0.5, 0.75].map(|local| a_fraction + (b_fraction - a_fraction) * local);
1762        let samples = fractions
1763            .map(evaluate)
1764            .into_iter()
1765            .collect::<Result<Vec<_>, String>>()?;
1766        let deviation = samples
1767            .iter()
1768            .enumerate()
1769            .map(|(index, point)| {
1770                point
1771                    .sub(a.add(b.sub(a).scale((index + 1) as f64 * 0.25)))
1772                    .length()
1773            })
1774            .fold(0.0, f64::max);
1775        if deviation <= 5e-4 || depth >= 10 {
1776            parameters.push(b_fraction);
1777            points.push(b);
1778            return Ok(());
1779        }
1780        append(
1781            evaluate,
1782            a_fraction,
1783            a,
1784            fractions[1],
1785            samples[1],
1786            depth + 1,
1787            parameters,
1788            points,
1789        )?;
1790        append(
1791            evaluate,
1792            fractions[1],
1793            samples[1],
1794            b_fraction,
1795            b,
1796            depth + 1,
1797            parameters,
1798            points,
1799        )
1800    }
1801    let mut parameters = vec![0.0];
1802    let mut points = vec![first];
1803    append(
1804        &evaluate,
1805        0.0,
1806        first,
1807        1.0,
1808        last,
1809        0,
1810        &mut parameters,
1811        &mut points,
1812    )?;
1813    Ok((points, parameters))
1814}
1815
1816/// Everything an offset carrier's construction MEASURED about itself.
1817///
1818/// The half this kernel lacked: a tolerance MEASURED from the geometry that was
1819/// actually built, rather than a band chosen from `scale` before building. It
1820/// lives in the one place that needs no durable-format change: alongside the
1821/// transient construction result, never as a field on a [`crate::BrepSolid`]
1822/// record. `io/snapshot.rs` is a documented durable format and
1823/// `SOLID_CODEC_VERSION` a versioned wire layout; persisting per-entity
1824/// tolerances is real, planned, and separately designed. This lands the
1825/// measurement with no format churn at all.
1826///
1827/// Every number here is a RECORD. None of it widens a band — see
1828/// [`MeasuredTolerance`]'s direction rule.
1829#[derive(Clone, Debug)]
1830pub struct CarrierDeviation {
1831    /// The derived band every measurement below was judged against:
1832    /// [`crate::offset_construction_band`] of the source solid's extent.
1833    pub band: f64,
1834    /// Which branch built the carrier surface.
1835    pub lane: OffsetSurfaceLane,
1836    /// The carrier surface's own fit error, when the lane has one.
1837    pub surface: Option<MeasuredTolerance>,
1838    /// `max_t ‖C_3d(t) − S_off(p(t))‖` per carrier edge id, folded over every
1839    /// coedge that references the edge — OCCT's `FillEdgeData` rule
1840    /// (`BRepOffset_SimpleOffset.cxx:296-310`), which takes the maximum over
1841    /// **every** adjacent face rather than the first one.
1842    pub edges: Vec<(u64, MeasuredTolerance)>,
1843    /// Per carrier vertex id, propagated from the incident edge ends by
1844    /// [`crate::vertex_tolerance_from_edges`] — which is also where the verdict
1845    /// on OCCT's 1.001 inflation factor is recorded.
1846    pub vertices: Vec<(u64, f64)>,
1847}
1848
1849impl CarrierDeviation {
1850    /// The worst thing the construction did, against the tightest band it
1851    /// faced. `None` only when there was nothing at all to measure.
1852    pub fn worst(&self) -> Option<MeasuredTolerance> {
1853        MeasuredTolerance::worst(
1854            self.surface
1855                .into_iter()
1856                .chain(self.edges.iter().map(|(_, measured)| *measured))
1857                .chain(
1858                    self.vertices
1859                        .iter()
1860                        .map(|(_, gap)| MeasuredTolerance::new(*gap, self.band)),
1861                ),
1862        )
1863    }
1864
1865    /// The entities whose measured deviation exceeded the derived band — the
1866    /// interesting case, and the only one any gate acts on.
1867    pub fn exceedances(&self) -> Vec<String> {
1868        let mut out = Vec::new();
1869        if let Some(surface) = self.surface {
1870            if surface.exceeds_band() {
1871                out.push(format!("carrier surface fit {}", surface.describe()));
1872            }
1873        }
1874        for (id, measured) in &self.edges {
1875            if measured.exceeds_band() {
1876                out.push(format!("edge {id} {}", measured.describe()));
1877            }
1878        }
1879        for (id, gap) in &self.vertices {
1880            let measured = MeasuredTolerance::new(*gap, self.band);
1881            if measured.exceeds_band() {
1882                out.push(format!("vertex {id} {}", measured.describe()));
1883            }
1884        }
1885        out
1886    }
1887}
1888
1889#[derive(Clone, Debug, Serialize)]
1890pub struct OffsetFaceCarrier {
1891    pub vertices: Vec<VertexRecord>,
1892    pub edges: Vec<EdgeRecord>,
1893    pub face: FaceRecord,
1894    /// What the construction measured about itself, or `None` when it was built
1895    /// through the unmeasured [`offset_face_carrier`] entry point.
1896    ///
1897    /// `#[serde(skip)]` on purpose: this struct crosses the wasm ABI as JSON
1898    /// (`abi/modeling_b.rs:500`), and a measurement is a diagnostic about a
1899    /// build, not part of the carrier the caller asked for. Skipping it keeps
1900    /// that payload byte-identical.
1901    #[serde(skip)]
1902    pub deviation: Option<CarrierDeviation>,
1903}
1904
1905fn claim_vertex_image(
1906    source_id: u64,
1907    point: Vec3,
1908    vertex_images: &mut HashMap<u64, u64>,
1909    vertices: &mut Vec<VertexRecord>,
1910    next_id: &mut u64,
1911) -> u64 {
1912    if let Some(id) = vertex_images.get(&source_id) {
1913        return *id;
1914    }
1915    let id = *next_id;
1916    *next_id += 1;
1917    vertices.push(VertexRecord { id, point });
1918    vertex_images.insert(source_id, id);
1919    id
1920}
1921
1922pub fn offset_face_carrier(
1923    solid: &BrepSolid,
1924    face_id: u64,
1925    distance: f64,
1926    planar_extension: f64,
1927) -> Result<OffsetFaceCarrier, String> {
1928    offset_face_carrier_impl(
1929        solid,
1930        face_id,
1931        distance,
1932        &CarrierExtension::uniform(planar_extension),
1933        false,
1934    )
1935}
1936
1937/// [`offset_face_carrier`] with a per-side [`CarrierExtension`].
1938pub fn offset_face_carrier_sided(
1939    solid: &BrepSolid,
1940    face_id: u64,
1941    distance: f64,
1942    extension: &CarrierExtension,
1943) -> Result<OffsetFaceCarrier, String> {
1944    offset_face_carrier_impl(solid, face_id, distance, extension, false)
1945}
1946
1947/// [`offset_face_carrier`], with every entity it builds measured against the
1948/// deviation it was meant to reproduce.
1949///
1950/// The carrier is bit-identical to [`offset_face_carrier`]'s — same body, same
1951/// arithmetic, in the same order — with a read-only measurement pass appended.
1952/// Three quantities land in [`CarrierDeviation`]:
1953///
1954/// * the carrier SURFACE's fit against the pointwise offset it interpolates
1955///   ([`offset_surface_measured`]);
1956/// * each carrier EDGE's 3D curve against the locus its pcurve traces on that
1957///   surface — the trim boundary is built by sampling exactly that composition
1958///   and interpolating the images, so this is the construction's own claim,
1959///   measured rather than assumed;
1960/// * each carrier VERTEX, propagated from the incident edge ends.
1961///
1962/// The interesting output is [`CarrierDeviation::exceedances`]: an entity whose
1963/// measured deviation is worse than the size-derived band assumed. That is a
1964/// construction that went wrong in a way the derived band alone cannot see, and
1965/// it is the case a caller should refuse on rather than ship.
1966pub fn offset_face_carrier_measured(
1967    solid: &BrepSolid,
1968    face_id: u64,
1969    distance: f64,
1970    planar_extension: f64,
1971) -> Result<OffsetFaceCarrier, String> {
1972    offset_face_carrier_impl(
1973        solid,
1974        face_id,
1975        distance,
1976        &CarrierExtension::uniform(planar_extension),
1977        true,
1978    )
1979}
1980
1981/// The measured deviations of one built carrier: surface fit, per edge, per
1982/// vertex.
1983///
1984/// Read-only over what the construction produced. The edge measurement is
1985/// [`crate::measure_edge_against_pcurve_image`] — validate's own
1986/// `adaptive_coedge_error` floored by a span-midpoint pass, because the sampler
1987/// alone is aliased against exactly the curves this construction builds (see
1988/// that module's doc) — taken against the far tighter
1989/// [`crate::offset_construction_band`] instead of the vendor-forgiving
1990/// `pcurve_acceptance` validate will use later.
1991fn measure_carrier(
1992    surface: &NurbsSurface,
1993    vertices: &[VertexRecord],
1994    edges: &[EdgeRecord],
1995    loops: &[LoopRecord],
1996    lane: OffsetSurfaceLane,
1997    surface_fit: Option<MeasuredTolerance>,
1998    band: f64,
1999) -> Result<CarrierDeviation, String> {
2000    let edge_by_id: HashMap<u64, &EdgeRecord> = edges.iter().map(|edge| (edge.id, edge)).collect();
2001    // Fold over coedges, not edges: a seam edge is referenced twice with two
2002    // different pcurves, and OCCT's `FillEdgeData` takes the maximum over every
2003    // adjacent face for exactly that reason.
2004    let mut per_edge: HashMap<u64, MeasuredTolerance> = HashMap::default();
2005    for loop_record in loops {
2006        for coedge in &loop_record.coedges {
2007            let Some(edge) = edge_by_id.get(&coedge.edge_id) else {
2008                continue;
2009            };
2010            let measured = measure_edge_against_pcurve_image(
2011                surface,
2012                &coedge.pcurve,
2013                edge,
2014                coedge.forward,
2015                band,
2016            )?;
2017            per_edge
2018                .entry(edge.id)
2019                .and_modify(|existing| *existing = existing.worse_of(measured))
2020                .or_insert(measured);
2021        }
2022    }
2023
2024    let mut measured_edges: Vec<(u64, MeasuredTolerance)> = per_edge.into_iter().collect();
2025    measured_edges.sort_by_key(|(id, _)| *id);
2026    let deviation_of: HashMap<u64, f64> = measured_edges
2027        .iter()
2028        .map(|(id, measured)| (*id, measured.deviation()))
2029        .collect();
2030
2031    // Edge ENDS by the vertex they claim, built once. A degenerate edge claims
2032    // the same vertex at both ends and contributes both, which is right: the
2033    // question is how far every representation meeting there actually lands.
2034    let mut ends_at: HashMap<u64, Vec<(&EdgeRecord, f64)>> = HashMap::default();
2035    for edge in edges {
2036        ends_at
2037            .entry(edge.start_vertex_id)
2038            .or_default()
2039            .push((edge, edge.t0));
2040        ends_at
2041            .entry(edge.end_vertex_id)
2042            .or_default()
2043            .push((edge, edge.t1));
2044    }
2045
2046    let mut measured_vertices = Vec::with_capacity(vertices.len());
2047    for vertex in vertices {
2048        let mut gaps = Vec::new();
2049        let mut incident = Vec::new();
2050        for (edge, parameter) in ends_at.get(&vertex.id).into_iter().flatten() {
2051            gaps.push(vertex_endpoint_gap(
2052                vertex.point,
2053                edge.curve.evaluate(*parameter)?,
2054            ));
2055            incident.push(deviation_of.get(&edge.id).copied().unwrap_or(0.0));
2056        }
2057        measured_vertices.push((vertex.id, vertex_tolerance_from_edges(gaps, incident)));
2058    }
2059
2060    Ok(CarrierDeviation {
2061        band,
2062        lane,
2063        surface: surface_fit,
2064        edges: measured_edges,
2065        vertices: measured_vertices,
2066    })
2067}
2068
2069fn offset_face_carrier_impl(
2070    solid: &BrepSolid,
2071    face_id: u64,
2072    distance: f64,
2073    extension: &CarrierExtension,
2074    measure: bool,
2075) -> Result<OffsetFaceCarrier, String> {
2076    let source = solid
2077        .shells
2078        .iter()
2079        .flat_map(|shell| &shell.faces)
2080        .find(|face| face.id == face_id)
2081        .ok_or_else(|| format!("offset_face_carrier: missing face {face_id}"))?;
2082    // The band is derived from the SOURCE SOLID's extent, matching every other
2083    // direct-edit/offset site (`face_offset.rs:103` and its siblings all take
2084    // `solid_model_scale`). The alternative basis is the FACE's own extent — the
2085    // reference shell keys its size-relative offset quantities on the 3D arc
2086    // length of the face's own boundary iso, never on the assembly. Which basis
2087    // is better is unsettled: it is a measurement to make over the refusal
2088    // corpus before switching, not a change to smuggle in here.
2089    let band = offset_construction_band(solid_model_scale(solid));
2090    // The accuracy bar a carrier EDGE's 3D curve must meet against the locus its
2091    // pcurve traces on the carrier surface — the kernel's own SSI fit contract,
2092    // the same bar `thicken` holds its wall boundaries to. NOT the construction
2093    // band: that is the surface-fit allowance (5e-4 of the part), 50x looser
2094    // than the boolean's coincidence band, and an edge fitted only that well is
2095    // what a later imprint mistakes for a crossing (see `carrier_edge_curve`).
2096    let fit_tolerance =
2097        crate::KernelTolerances::for_scale(solid_model_scale(solid), 1e-7).intersection_fit;
2098    let (surface, lane, surface_fit) = if measure {
2099        let measured = offset_surface_measured_sided(source, distance, extension, band)?;
2100        (measured.surface, measured.lane, measured.fit)
2101    } else {
2102        let (surface, lane) = offset_surface_with_lane(source, distance, extension)?;
2103        (surface, lane, None)
2104    };
2105    let source_edges = solid
2106        .edges
2107        .iter()
2108        .map(|edge| (edge.id, edge))
2109        .collect::<HashMap<_, _>>();
2110    let source_vertices = solid
2111        .vertices
2112        .iter()
2113        .map(|vertex| (vertex.id, vertex))
2114        .collect::<HashMap<_, _>>();
2115    let mut vertices = Vec::new();
2116    let mut vertex_images = HashMap::default();
2117    let mut edges = Vec::new();
2118    let mut edge_images = HashMap::default();
2119    let mut loops = Vec::new();
2120    let mut next_id = 1u64;
2121
2122    for source_loop in &source.loops {
2123        let mut coedges = Vec::new();
2124        for source_coedge in &source_loop.coedges {
2125            let source_edge = source_edges
2126                .get(&source_coedge.edge_id)
2127                .ok_or_else(|| "offset_face_carrier: missing source edge".to_string())?;
2128            let (source_start, source_end) = if source_coedge.forward {
2129                (source_edge.start_vertex_id, source_edge.end_vertex_id)
2130            } else {
2131                (source_edge.end_vertex_id, source_edge.start_vertex_id)
2132            };
2133            if !source_vertices.contains_key(&source_start)
2134                || !source_vertices.contains_key(&source_end)
2135            {
2136                return Err("offset_face_carrier: missing source vertex".into());
2137            }
2138            // Map even DEGENERATE source edges through the full polyline: a
2139            // cone apex's image on the offset surface is a genuine CIRCLE
2140            // (radius d·cos half-angle), not a point — shortcutting to the
2141            // two endpoints would collapse the ring and leave the carrier's
2142            // topology inconsistent with its surface. Edges whose image truly
2143            // collapses (sphere poles, planar corners) still interpolate to a
2144            // point-sized curve and keep their degenerate flag below.
2145            // Map even DEGENERATE source edges through the full polyline: an
2146            // EXTERIOR cone offset turns the apex point into a genuine RING
2147            // (radius d·cos half-angle) — shortcutting to the endpoints would
2148            // collapse it and leave the carrier topology inconsistent with
2149            // its surface (and the ring imprint would be dropped as
2150            // boundary-coincident with a "degenerate" edge). Images that
2151            // truly collapse (sphere poles; interior apexes after the pinch
2152            // retrim) stay degenerate below. The threshold scales with the
2153            // offset distance: a real ring measures ~d·cos α, while fitted
2154            // pole rows wobble ~1e-4 absolute.
2155            let (points, parameters) =
2156                mapped_pcurve_polyline(&surface, &source_coedge.pcurve, false)?;
2157            let collapse_tolerance = 1e-6f64.max(distance.abs() * 1e-2);
2158            let image_collapsed = points
2159                .iter()
2160                .all(|point| point.sub(points[0]).length() <= collapse_tolerance);
2161            let (edge_id, forward) =
2162                if let Some((edge_id, edge_start_vertex_id, creator_forward)) =
2163                    edge_images.get(&source_edge.id)
2164                {
2165                    if source_start == source_end {
2166                        // A CLOSED source edge (a seam of a periodic face: both
2167                        // ends are the same vertex) is referenced twice by the
2168                        // same loop, once per seam side, and the two references
2169                        // traverse it in OPPOSITE senses — that is what closes
2170                        // the loop. The endpoint test below cannot see that:
2171                        // both ends map to the same vertex image, so it answers
2172                        // `true` for both references and the returning coedge
2173                        // comes back mis-oriented.
2174                        //
2175                        // Measured on a full torus (one vertex, two seam edges):
2176                        // the source records `forward: false` on the two
2177                        // returning coedges and validates clean, while the
2178                        // carrier recorded `forward: true` on both and its rim
2179                        // edge measured 12.5 — a whole part diameter — against
2180                        // its own composed pcurve image. The measured tolerance
2181                        // this slice adds is what surfaced it; no existing gate
2182                        // reaches this shape.
2183                        //
2184                        // The source coedge's own sense is the answer: the image
2185                        // edge runs along the CREATING coedge's pcurve, so a
2186                        // later reference runs with it exactly when the two
2187                        // source coedges traverse the source edge the same way.
2188                        (*edge_id, source_coedge.forward == *creator_forward)
2189                    } else {
2190                        // UNCHANGED for every open edge: the image edge's start
2191                        // vertex identifies which way this coedge runs.
2192                        (
2193                            *edge_id,
2194                            vertex_images.get(&source_start) == Some(edge_start_vertex_id),
2195                        )
2196                    }
2197                } else {
2198                    let (curve, t0, t1) = if image_collapsed {
2199                        let curve = NurbsCurve::new(
2200                            1,
2201                            vec![0.0, 0.0, 1.0, 1.0],
2202                            vec![
2203                                Vec4::from_point(points[0], 1.0),
2204                                Vec4::from_point(points[0], 1.0),
2205                            ],
2206                        )?;
2207                        (curve, 0.0, 1.0)
2208                    } else {
2209                        carrier_edge_curve(
2210                            &surface,
2211                            &source_coedge.pcurve,
2212                            &points,
2213                            &parameters,
2214                            fit_tolerance,
2215                        )?
2216                    };
2217                    let start_vertex_id = claim_vertex_image(
2218                        source_start,
2219                        points[0],
2220                        &mut vertex_images,
2221                        &mut vertices,
2222                        &mut next_id,
2223                    );
2224                    let end_vertex_id = claim_vertex_image(
2225                        source_end,
2226                        points[points.len() - 1],
2227                        &mut vertex_images,
2228                        &mut vertices,
2229                        &mut next_id,
2230                    );
2231                    let id = next_id;
2232                    next_id += 1;
2233                    edges.push(EdgeRecord {
2234                        id,
2235                        curve,
2236                        t0,
2237                        t1,
2238                        start_vertex_id,
2239                        end_vertex_id,
2240                        // Degenerate exactly when the IMAGE collapsed. A cone
2241                        // apex maps to a real RING on an exterior offset and
2242                        // must carry a real closed edge; a real source edge
2243                        // whose image collapses to a point is a degenerate edge
2244                        // ON THE CARRIER whatever it was on the source.
2245                        //
2246                        // That second half is what the CARVE needs. The new
2247                        // boundary a carve introduces is a piece of the fold
2248                        // locus, which on the source is an ordinary curve — a
2249                        // parallel circle on a cone — and whose whole defining
2250                        // property is that the offset collapses there. Reading
2251                        // the source's flag as well left it a zero-length
2252                        // NON-degenerate closed edge, which no weld can use.
2253                        degenerate: image_collapsed,
2254                        // Image of a named source edge on the offset carrier;
2255                        // suffixed so it cannot collide with the source edge
2256                        // when both faces survive into one solid.
2257                        name: source_edge
2258                            .name
2259                            .as_ref()
2260                            .map(|name| format!("{name}_Offset")),
2261                    });
2262                    edge_images.insert(
2263                        source_edge.id,
2264                        (id, start_vertex_id, source_coedge.forward),
2265                    );
2266                    (id, true)
2267                };
2268            let id = next_id;
2269            next_id += 1;
2270            coedges.push(CoedgeRecord {
2271                id,
2272                edge_id,
2273                forward,
2274                pcurve: source_coedge.pcurve.clone(),
2275            });
2276        }
2277        let id = next_id;
2278        next_id += 1;
2279        loops.push(LoopRecord { id, coedges });
2280    }
2281    let deviation = if measure {
2282        Some(measure_carrier(
2283            &surface,
2284            &vertices,
2285            &edges,
2286            &loops,
2287            lane,
2288            surface_fit,
2289            band,
2290        )?)
2291    } else {
2292        None
2293    };
2294    Ok(OffsetFaceCarrier {
2295        vertices,
2296        edges,
2297        face: FaceRecord {
2298            id: next_id,
2299            surface,
2300            same_sense: source.same_sense,
2301            loops,
2302            name: source.name.as_ref().map(|name| format!("{name}_Offset")),
2303        },
2304        deviation,
2305    })
2306}
2307