Skip to main content

brep_kernel/offset/
offset.rs

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