Skip to main content

brep_kernel/offset/
offset.rs

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