Skip to main content

brep_kernel/props/mass_properties/
polygons.rs

1use super::*;
2use crate::topology::CoedgeRecord;
3
4/// The pcurve arc behind one chord of a [`TrimPolygon`]: coedge
5/// `coedge_index` of loop `loop_index`, from parameter `t0` at the chord's
6/// start to `t1` at its end (`t1 < t0` when the loop was reversed), shifted by
7/// `offset` on the covering plane.
8#[derive(Clone, Copy, Debug)]
9pub(super) struct ChordArc {
10    pub loop_index: usize,
11    pub coedge_index: usize,
12    pub t0: f64,
13    pub t1: f64,
14    pub offset: [f64; 2],
15}
16
17/// One trim loop as the integrator consumes it: the chord polygon plus, per
18/// chord, the pcurve arc it stands in for. `arcs[k]` belongs to the chord from
19/// `points[k]` to `points[(k + 1) % n]`; `None` marks a segment with nothing
20/// to correct against — a straight pcurve span, a synthetic side (band
21/// rectangle, cap join, seam closure) or a jump between coedges that do not
22/// meet. The integrator adds, per arc, the signed region between the arc and
23/// its chord, so the chord count sets only the quadrature's work, never the
24/// answer (`integrate_trimmed_polys`).
25#[derive(Clone, Debug)]
26pub(super) struct TrimPolygon {
27    pub points: Vec<[f64; 2]>,
28    pub arcs: Vec<Option<ChordArc>>,
29    /// Where the last pushed coedge's pcurve ends (shifted): the point the next
30    /// vertex has to land on for the chord leaving the last sample to be that
31    /// pcurve's closing arc rather than a jump.
32    pending_end: Option<[f64; 2]>,
33    /// A vertex further than this from `pending_end` is a jump — a seam
34    /// teleport is a whole period, a stitched pcurve gap is far smaller.
35    jump_tolerance: f64,
36}
37
38/// Whether every control point of `curve` lies on the line through its two
39/// ends, to 1e-12 of the control polygon's extent. By the convex-hull
40/// property the whole curve then lies on that line, so the sliver between any
41/// chord of it and the arc behind the chord has no area: its correction is
42/// zero. `is_straight_polyline` is the exact degree-1 case the sampler keeps
43/// at its knots; this is the wider one the sign reading skips corrections
44/// on — a bore's rim on its wall, written by the imprint as a rational
45/// quadratic (the circle's own knots and weights) whose control points sit
46/// on one `v` line.
47pub(super) fn is_straight_in_uv(curve: &NurbsCurve) -> bool {
48    let mut points = Vec::with_capacity(curve.control_points.len());
49    for control in &curve.control_points {
50        let Ok(point) = control.point() else {
51            return false;
52        };
53        points.push([point.x, point.y]);
54    }
55    let (Some(first), Some(last)) = (points.first().copied(), points.last().copied()) else {
56        return false;
57    };
58    let extent = points
59        .iter()
60        .map(|point| (point[0] - first[0]).hypot(point[1] - first[1]))
61        .fold(0.0f64, f64::max);
62    if extent <= 0.0 {
63        return true;
64    }
65    let direction = [last[0] - first[0], last[1] - first[1]];
66    let length = direction[0].hypot(direction[1]);
67    if length <= 1e-12 * extent {
68        return false;
69    }
70    points.iter().all(|point| {
71        ((point[0] - first[0]) * direction[1] - (point[1] - first[1]) * direction[0]).abs()
72            <= 1e-12 * extent * length
73    })
74}
75
76fn is_straight_polyline(curve: &NurbsCurve) -> bool {
77    let rational = curve
78        .control_points
79        .iter()
80        .any(|point| (point.w - curve.control_points[0].w).abs() > 1e-12);
81    curve.degree == 1 && !rational
82}
83
84impl TrimPolygon {
85    pub(super) fn new(domain: [f64; 4]) -> Self {
86        let [u0, u1, v0, v1] = domain;
87        let jump_tolerance = 1e-3 * (u1 - u0).abs().min((v1 - v0).abs()).max(1e-300);
88        Self {
89            points: Vec::new(),
90            arcs: Vec::new(),
91            pending_end: None,
92            jump_tolerance,
93        }
94    }
95
96    pub(super) fn for_face(face: &FaceRecord) -> Result<Self, String> {
97        let [u0, u1] = face.surface.domain_u()?;
98        let [v0, v1] = face.surface.domain_v()?;
99        Ok(Self::new([u0, u1, v0, v1]))
100    }
101
102    fn settle_pending(&mut self, next: [f64; 2]) {
103        if let Some(end) = self.pending_end.take() {
104            if switch!("BREP_MASS_TRIM_JOINS", false) {
105                // The next coedge need not start at this coedge's end.
106                // Keep the complete arc, then a separate straight join;
107                // otherwise the polygon silently replaces its final chord.
108                if end != next && (self.points.last() != Some(&end)
109                    || self.arcs.last().is_some_and(Option::is_some))
110                {
111                    self.points.push(end);
112                    self.arcs.push(None);
113                }
114                return;
115            }
116            let gap = ((next[0] - end[0]).powi(2) + (next[1] - end[1]).powi(2)).sqrt();
117            if gap > self.jump_tolerance {
118                if let Some(last) = self.arcs.last_mut() {
119                    *last = None;
120                }
121            }
122        }
123    }
124
125    /// A vertex with no arc behind the chord that leaves it.
126    pub(super) fn push_point(&mut self, point: [f64; 2]) {
127        self.settle_pending(point);
128        self.points.push(point);
129        self.arcs.push(None);
130    }
131
132    /// Append one coedge's pcurve the way the integrator samples trims: a
133    /// straight polyline at its knots (exact, no arcs), anything curved at
134    /// 24..128 chords spread over its knot spans with the arc recorded behind
135    /// every chord. A chord never straddles a knot, so each arc is one
136    /// polynomial (or rational) piece and the sliver quadrature is exact on it.
137    pub(super) fn push_coedge(
138        &mut self,
139        loop_index: usize,
140        coedge_index: usize,
141        coedge: &CoedgeRecord,
142        offset: [f64; 2],
143    ) -> Result<(), String> {
144        let curve = &coedge.pcurve;
145        let interior = interior_knots(&curve.knots, curve.degree);
146        if is_straight_polyline(curve) {
147            let [start, end] = curve.domain()?;
148            for parameter in std::iter::once(start).chain(interior.iter().copied()) {
149                let point = curve.evaluate(parameter)?;
150                self.push_point([point.x + offset[0], point.y + offset[1]]);
151            }
152            if switch!("BREP_MASS_TRIM_JOINS", false) {
153                let point = curve.evaluate(end)?;
154                self.pending_end = Some([point.x + offset[0], point.y + offset[1]]);
155            }
156            return Ok(());
157        }
158        let sample_count = (24usize).max((interior.len() + 1) * 16).min(128);
159        self.push_coedge_spans(loop_index, coedge_index, coedge, offset, sample_count, false)
160    }
161
162    /// Append about `target` chords of one coedge's pcurve, spread evenly over
163    /// its knot spans (at least one per span, so no chord straddles a knot)
164    /// and tagged with the arc behind each; a straight polyline contributes
165    /// its knots exactly. The end point is pushed too when `include_end`.
166    pub(super) fn push_coedge_spans(
167        &mut self,
168        loop_index: usize,
169        coedge_index: usize,
170        coedge: &CoedgeRecord,
171        offset: [f64; 2],
172        target: usize,
173        include_end: bool,
174    ) -> Result<(), String> {
175        let curve = &coedge.pcurve;
176        let spans = curve_breaks(curve)?;
177        let [_, end] = curve.domain()?;
178        if is_straight_polyline(curve) {
179            let last = if include_end { spans.len() } else { spans.len() - 1 };
180            for &parameter in &spans[..last] {
181                let point = curve.evaluate(parameter)?;
182                self.push_point([point.x + offset[0], point.y + offset[1]]);
183            }
184            if !include_end && switch!("BREP_MASS_TRIM_JOINS", false) {
185                let point = curve.evaluate(end)?;
186                self.pending_end = Some([point.x + offset[0], point.y + offset[1]]);
187            }
188            return Ok(());
189        }
190        let span_count = spans.len().saturating_sub(1).max(1);
191        let per_span = target.div_ceil(span_count).max(1);
192        let mut previous: Option<(f64, [f64; 2])> = None;
193        let mut emit = |this: &mut Self, t: f64| -> Result<(), String> {
194            let point = curve.evaluate(t)?;
195            let point = [point.x + offset[0], point.y + offset[1]];
196            if let Some((t_prev, _)) = previous {
197                if let Some(last) = this.arcs.last_mut() {
198                    *last = Some(ChordArc { loop_index, coedge_index, t0: t_prev, t1: t, offset });
199                }
200            }
201            this.settle_pending(point);
202            this.points.push(point);
203            this.arcs.push(None);
204            previous = Some((t, point));
205            Ok(())
206        };
207        for pair in spans.windows(2) {
208            for index in 0..per_span {
209                emit(self, pair[0] + (pair[1] - pair[0]) * index as f64 / per_span as f64)?;
210            }
211        }
212        if include_end {
213            emit(self, end)?;
214        } else if let Some((t_prev, _)) = previous {
215            // The chord leaving the last sample reaches the pcurve's end, which
216            // the next vertex must supply.
217            if let Some(last) = self.arcs.last_mut() {
218                *last = Some(ChordArc { loop_index, coedge_index, t0: t_prev, t1: end, offset });
219            }
220            let point = curve.evaluate(end)?;
221            self.pending_end = Some([point.x + offset[0], point.y + offset[1]]);
222        }
223        Ok(())
224    }
225
226    /// Close the loop: the chord back to the first vertex is the last pcurve's
227    /// closing arc only if the first vertex is where that pcurve ends.
228    pub(super) fn finish(&mut self) {
229        if let Some(first) = self.points.first().copied() {
230            self.settle_pending(first);
231        }
232        self.pending_end = None;
233    }
234
235    pub(super) fn reverse(&mut self) {
236        self.finish();
237        let n = self.points.len();
238        if n == 0 {
239            return;
240        }
241        let old = std::mem::take(&mut self.arcs);
242        let swap = |arc: Option<ChordArc>| arc.map(|a| ChordArc { t0: a.t1, t1: a.t0, ..a });
243        let mut arcs = Vec::with_capacity(n);
244        for k in 0..n - 1 {
245            arcs.push(swap(old[n - 2 - k]));
246        }
247        arcs.push(swap(old[n - 1]));
248        self.arcs = arcs;
249        self.points.reverse();
250    }
251
252    pub(super) fn shift_u(&mut self, du: f64) {
253        for point in &mut self.points {
254            point[0] += du;
255        }
256        for arc in self.arcs.iter_mut().flatten() {
257            arc.offset[0] += du;
258        }
259        if let Some(end) = &mut self.pending_end {
260            end[0] += du;
261        }
262    }
263
264    /// Concatenate `other` after this loop's vertices; both joins are synthetic.
265    pub(super) fn append(&mut self, mut other: TrimPolygon) {
266        self.finish();
267        other.finish();
268        if let Some(last) = self.arcs.last_mut() {
269            *last = None;
270        }
271        if let Some(last) = other.arcs.last_mut() {
272            *last = None;
273        }
274        self.points.append(&mut other.points);
275        self.arcs.append(&mut other.arcs);
276    }
277}
278
279/// The chord polygons of `face`'s trim loops, as sampled for integration.
280pub fn trim_polygons(face: &FaceRecord) -> Result<Vec<Vec<[f64; 2]>>, String> {
281    Ok(trim_polygons_tagged(face)?
282        .into_iter()
283        .map(|polygon| polygon.points)
284        .collect())
285}
286
287pub(super) fn trim_polygons_tagged(face: &FaceRecord) -> Result<Vec<TrimPolygon>, String> {
288    let mut polygons = Vec::new();
289    for (loop_index, loop_record) in face.loops.iter().enumerate() {
290        let mut polygon = TrimPolygon::for_face(face)?;
291        for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
292            polygon.push_coedge(loop_index, coedge_index, coedge, [0.0, 0.0])?;
293        }
294        polygon.finish();
295        polygons.push(polygon);
296    }
297    Ok(polygons)
298}
299
300/// Rebuild a sphere cap bounded by a varying full-period contact rim and the
301/// collapsed opposite-winding rim at one parameter pole.  In the flat UV
302/// plane both individual loops have zero enclosed area; joined across one cut
303/// of the periodic cover they form the actual cap polygon.
304pub(super) fn singly_periodic_sphere_cap_polygons(
305    face: &FaceRecord,
306    closed_u: bool,
307    closed_v: bool,
308    domain: [f64; 4],
309) -> Result<Option<Vec<TrimPolygon>>, String> {
310    if !matches!(
311        face.surface.analytic(),
312        Some(crate::AnalyticSurface::Sphere { .. })
313    ) || (closed_u, closed_v) != (true, false)
314        || face.loops.len() != 2
315    {
316        return Ok(None);
317    }
318    let [u0, u1, v0, v1] = domain;
319    let period = u1 - u0;
320    let v_span = v1 - v0;
321    struct Rim {
322        polygon: TrimPolygon,
323        winding: f64,
324        vmin: f64,
325        vmax: f64,
326    }
327    let mut rims = Vec::with_capacity(2);
328    for (loop_index, loop_record) in face.loops.iter().enumerate() {
329        let mut polygon = TrimPolygon::new(domain);
330        for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
331            polygon.push_coedge_spans(loop_index, coedge_index, coedge, [0.0, 0.0], 64, true)?;
332        }
333        polygon.finish();
334        let points = &polygon.points;
335        if points.len() < 2 {
336            return Ok(None);
337        }
338        let winding = points.last().unwrap()[0] - points[0][0];
339        let (mut vmin, mut vmax) = (f64::INFINITY, f64::NEG_INFINITY);
340        for point in points {
341            vmin = vmin.min(point[1]);
342            vmax = vmax.max(point[1]);
343        }
344        rims.push(Rim {
345            polygon,
346            winding,
347            vmin,
348            vmax,
349        });
350    }
351    if rims
352        .iter()
353        .any(|rim| (rim.winding.abs() - period).abs() > 0.05 * period)
354        || rims[0].winding * rims[1].winding >= 0.0
355    {
356        return Ok(None);
357    }
358    let flat = |rim: &Rim| rim.vmax - rim.vmin <= 1e-6 * v_span;
359    let pole = match (flat(&rims[0]), flat(&rims[1])) {
360        (true, false) => &rims[0],
361        (false, true) => &rims[1],
362        _ => return Ok(None),
363    };
364    let pole_v = 0.5 * (pole.vmin + pole.vmax);
365    if (pole_v - v0).abs() > 1e-6 * v_span && (pole_v - v1).abs() > 1e-6 * v_span {
366        return Ok(None);
367    }
368
369    let (lower, upper) = if rims[0].vmax <= rims[1].vmin {
370        (&rims[0], &rims[1])
371    } else if rims[1].vmax <= rims[0].vmin {
372        (&rims[1], &rims[0])
373    } else {
374        return Ok(None);
375    };
376    let mut lower_points = lower.polygon.clone();
377    if lower.winding < 0.0 {
378        lower_points.reverse();
379    }
380    let mut upper_points = upper.polygon.clone();
381    if upper.winding > 0.0 {
382        upper_points.reverse();
383    }
384    let shift = ((lower_points.points[0][0] - upper_points.points.last().unwrap()[0]) / period)
385        .round()
386        * period;
387    upper_points.shift_u(shift);
388    lower_points.append(upper_points);
389    Ok(Some(vec![lower_points]))
390}
391
392/// Trim polygons with implicit torus seams unwrapped onto the covering plane.
393/// Returns `(polygons, unwrapped)`; `unwrapped` is true iff any loop crossed an
394/// implicit seam (and therefore carries out-of-domain parameters). Only attempts
395/// the unwrap on a doubly-periodic surface — single-periodic seams the raw
396/// integrator already handles are left in-domain.
397pub(super) fn trim_polygons_unwrapped(
398    face: &FaceRecord,
399    closed_u: bool,
400    closed_v: bool,
401    u_period: f64,
402    v_period: f64,
403) -> Result<(Vec<TrimPolygon>, bool), String> {
404    let mut polygons = Vec::new();
405    let mut unwrapped = false;
406    for (loop_index, loop_record) in face.loops.iter().enumerate() {
407        let offsets = crate::topology::loop_seam_offsets(
408            &loop_record.coedges,
409            closed_u,
410            closed_v,
411            u_period,
412            v_period,
413        )?;
414        let mut polygon = TrimPolygon::for_face(face)?;
415        for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
416            let offset = offsets[coedge_index];
417            if offset[0] != 0.0 || offset[1] != 0.0 {
418                unwrapped = true;
419            }
420            polygon.push_coedge(loop_index, coedge_index, coedge, offset)?;
421        }
422        polygon.finish();
423        polygons.push(polygon);
424    }
425    Ok((polygons, unwrapped))
426}
427
428/// A singly-periodic (cylinder/cone) wall whose seam is IMPLICIT: the closed
429/// wall is bounded by two full-wrap rim loops (iso-parametric lines at constant
430/// cross-level) with NO seam edge connecting them — the two-rim-circles form
431/// OCC/STEP emit for a full lateral face. This is the single-periodic analogue
432/// of [`biperiodic_band_range`]: each rim is a degenerate iso line in parameter
433/// space, so the raw trim integrates the band between them to ~zero (only the
434/// in-band holes survive — a full cylinder wall collapses to a ~1000x-too-small
435/// sliver, ABC 00000056 solid 2). We restrict to the unambiguous case where the
436/// two rims sit at the cross-domain EXTREMES: the material region is then the
437/// WHOLE surface (full periodic span × full cross span) minus any hole/notch
438/// loops, with no between-rims-vs-complement choice to get wrong. Replacing the
439/// two rim lines with a single full-domain band rectangle lets the existing
440/// winding integrator fill the wall while the remaining loops subtract as holes.
441///
442/// Returns the rebuilt polygon set (band rectangle first, then the unwrapped
443/// non-rim loops), or None when `face` is not a clean two-extreme-rim band —
444/// every other periodic face keeps its existing integration path unchanged.
445pub(super) fn singly_periodic_wall_polygons(
446    face: &FaceRecord,
447    closed_u: bool,
448    closed_v: bool,
449    domain: [f64; 4],
450) -> Result<Option<Vec<TrimPolygon>>, String> {
451    // Exactly one periodic direction (the doubly-periodic case is handled by
452    // `biperiodic_band_integral` upstream).
453    if closed_u == closed_v {
454        return Ok(None);
455    }
456    let [u0, u1, v0, v1] = domain;
457    let p_is_u = closed_u;
458    let period = if p_is_u { u1 - u0 } else { v1 - v0 };
459    if !(period > 0.0) {
460        return Ok(None);
461    }
462    let (q_dom_lo, q_dom_hi) = if p_is_u { (v0, v1) } else { (u0, u1) };
463    let q_extent = (q_dom_hi - q_dom_lo).abs();
464    if !(q_extent > 0.0) {
465        return Ok(None);
466    }
467    // (periodic, cross) coordinate split.
468    let coord = |p: [f64; 2]| if p_is_u { (p[0], p[1]) } else { (p[1], p[0]) };
469
470    let mut rim_indices: Vec<usize> = Vec::new();
471    let mut rim_levels: Vec<f64> = Vec::new();
472    for (index, loop_record) in face.loops.iter().enumerate() {
473        // Sample the loop's pcurves in traversal order.
474        let mut points: Vec<[f64; 2]> = Vec::new();
475        for coedge in &loop_record.coedges {
476            let [d0, d1] = coedge.pcurve.domain()?;
477            let samples = 16;
478            for k in 0..=samples {
479                let t = d0 + (d1 - d0) * k as f64 / samples as f64;
480                let p = coedge.pcurve.evaluate(t)?;
481                points.push([p.x, p.y]);
482            }
483        }
484        if points.len() < 2 {
485            return Ok(None);
486        }
487        let (mut qmin, mut qmax) = (f64::INFINITY, f64::NEG_INFINITY);
488        let mut net = 0.0;
489        for pair in points.windows(2) {
490            let (p0, q0) = coord(pair[0]);
491            let (p1, _q1) = coord(pair[1]);
492            qmin = qmin.min(q0);
493            qmax = qmax.max(q0);
494            let mut delta = p1 - p0;
495            if delta > 0.5 * period {
496                delta -= period;
497            } else if delta < -0.5 * period {
498                delta += period;
499            }
500            net += delta;
501        }
502        if let Some(last) = points.last() {
503            let (_p, q) = coord(*last);
504            qmin = qmin.min(q);
505            qmax = qmax.max(q);
506        }
507        // A rim: nets a full period in the periodic direction and stays on a
508        // near-constant cross-level (a true iso line).
509        let is_rim = net.abs() > 0.75 * period && (qmax - qmin) <= 0.02 * q_extent;
510        if is_rim {
511            rim_indices.push(index);
512            rim_levels.push(0.5 * (qmin + qmax));
513        }
514    }
515    if rim_levels.len() != 2 {
516        return Ok(None);
517    }
518    let (q_lo, q_hi) = if rim_levels[0] <= rim_levels[1] {
519        (rim_levels[0], rim_levels[1])
520    } else {
521        (rim_levels[1], rim_levels[0])
522    };
523    // Require the rims at the cross-domain extremes: material is then the whole
524    // wall (no between-rims/complement ambiguity). A sub-band (rims interior to
525    // the cross domain) is declined here — the existing path keeps handling it.
526    let tol = 0.02 * q_extent;
527    if (q_lo - q_dom_lo).abs() > tol || (q_hi - q_dom_hi).abs() > tol {
528        return Ok(None);
529    }
530    if (q_hi - q_lo) <= 0.5 * q_extent {
531        return Ok(None);
532    }
533
534    // Band rectangle over the full periodic span × the rim cross-levels.
535    let (r_ulo, r_uhi, r_vlo, r_vhi) = if p_is_u {
536        (u0, u1, q_lo, q_hi)
537    } else {
538        (q_lo, q_hi, v0, v1)
539    };
540    let mut rectangle = TrimPolygon::new(domain);
541    for corner in [
542        [r_ulo, r_vlo],
543        [r_uhi, r_vlo],
544        [r_uhi, r_vhi],
545        [r_ulo, r_vhi],
546    ] {
547        rectangle.push_point(corner);
548    }
549    let mut polygons: Vec<TrimPolygon> = vec![rectangle];
550    // Keep the non-rim loops (holes / notches), unwrapping any implicit-seam hop
551    // so a seam-straddling hole subtracts as one region.
552    for (index, loop_record) in face.loops.iter().enumerate() {
553        if rim_indices.contains(&index) {
554            continue;
555        }
556        let offsets = crate::topology::loop_seam_offsets(
557            &loop_record.coedges,
558            closed_u,
559            closed_v,
560            u1 - u0,
561            v1 - v0,
562        )?;
563        let mut polygon = TrimPolygon::new(domain);
564        for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
565            polygon.push_coedge(index, coedge_index, coedge, offsets[coedge_index])?;
566        }
567        polygon.finish();
568        polygons.push(polygon);
569    }
570    Ok(Some(polygons))
571}
572
573/// A DOUBLY-periodic (torus) face whose trim is a full-wrap BAND bounded by a
574/// constant-cross-level seam RIM (at a cross-domain extreme) and a single-valued
575/// WAVY CUT. The raw winding integrator collapses it to a sliver (the rim is a
576/// zero-area iso line and the cut's own polygon encloses only the thin strip it
577/// bounds), so — mirroring [`singly_periodic_wall_polygons`] for the singly-
578/// periodic wall — rebuild the material region as ONE simple (u,v) polygon:
579/// the wavy cut as one boundary, the flat rim iso-line (at the OPPOSITE cross
580/// extreme, which is the same physical seam circle) as the other, closed by the
581/// two private wrap-seam edges. The winding integrator then fills the band.
582///
583/// Returns None for anything that is not this exact signature (see
584/// [`crate::topology::analyze_doubly_periodic_seam_band`]) — every other face
585/// keeps its existing integration path bit-for-bit.
586pub(super) fn doubly_periodic_seam_band_polygons(
587    face: &FaceRecord,
588    domain: [f64; 4],
589) -> Result<Option<Vec<TrimPolygon>>, String> {
590    let (closed_u, closed_v) = face.surface.closed_directions()?;
591    if !(closed_u && closed_v) {
592        return Ok(None);
593    }
594    // Sample each loop's pcurves in traversal order; every sample remembers
595    // the pcurve parameter it came from so the band polygon's chords keep
596    // their arcs through the rotation below.
597    type Tagged = ([f64; 2], Option<(usize, usize, f64)>);
598    let mut loops_uv: Vec<Vec<[f64; 2]>> = Vec::with_capacity(face.loops.len());
599    let mut loops_tagged: Vec<Vec<Tagged>> = Vec::with_capacity(face.loops.len());
600    for (loop_index, loop_record) in face.loops.iter().enumerate() {
601        let mut points: Vec<[f64; 2]> = Vec::new();
602        let mut tagged: Vec<Tagged> = Vec::new();
603        for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
604            let [d0, d1] = coedge.pcurve.domain()?;
605            let samples = 24;
606            let curved = !is_straight_polyline(&coedge.pcurve);
607            for k in 0..=samples {
608                let t = d0 + (d1 - d0) * k as f64 / samples as f64;
609                let p = coedge.pcurve.evaluate(t)?;
610                points.push([p.x, p.y]);
611                tagged.push(([p.x, p.y], curved.then_some((loop_index, coedge_index, t))));
612            }
613        }
614        loops_uv.push(points);
615        loops_tagged.push(tagged);
616    }
617    let Some(band) =
618        crate::topology::analyze_doubly_periodic_seam_band(&loops_uv, domain, face.same_sense)
619    else {
620        return Ok(None);
621    };
622    let vertices = crate::topology::seam_band_uv_polygon_with(
623        &loops_tagged,
624        |vertex: &Tagged| vertex.0,
625        |point| (point, None),
626        domain,
627        &band,
628    );
629    let mut polygon = TrimPolygon::new(domain);
630    let n = vertices.len();
631    for (k, (point, tag)) in vertices.iter().enumerate() {
632        polygon.push_point(*point);
633        // The chord to the next vertex is an arc only between two samples of
634        // one pcurve; the rotation keeps consecutive samples adjacent, and a
635        // synthetic vertex (seam extension, rim corner) carries no tag.
636        let next = &vertices[(k + 1) % n];
637        if let (Some((l0, c0, t0)), Some((l1, c1, t1))) = (tag, next.1) {
638            if *l0 == l1 && *c0 == c1 {
639                polygon.arcs[k] = Some(ChordArc {
640                    loop_index: *l0,
641                    coedge_index: *c0,
642                    t0: *t0,
643                    t1,
644                    offset: [0.0, 0.0],
645                });
646            }
647        }
648    }
649    Ok(Some(vec![polygon]))
650}
651
652/// A conservative proof that all trim arcs and their closing chords lie on
653/// the domain boundary. Positive rational weights keep each arc inside its
654/// control hull. Exact coordinate comparisons deliberately decline uncertain
655/// fits; those retain their authored boundary in the trimmed integrator.
656pub(super) fn trim_on_domain_boundary(face: &FaceRecord, domain: [f64; 4]) -> Result<bool, String> {
657    let [u0, u1, v0, v1] = domain;
658    let sides = |p: Vec3| -> u8 {
659        if !p.x.is_finite() || !p.y.is_finite()
660            || p.x < u0 || p.x > u1 || p.y < v0 || p.y > v1 {
661            return 0;
662        }
663        u8::from(p.x == u0) | (u8::from(p.x == u1) << 1)
664            | (u8::from(p.y == v0) << 2) | (u8::from(p.y == v1) << 3)
665    };
666    for rim in &face.loops {
667        if rim.coedges.is_empty() { return Ok(false); }
668        let mut ends = Vec::with_capacity(rim.coedges.len());
669        for coedge in &rim.coedges {
670            let curve = &coedge.pcurve;
671            let mut common = 15;
672            for control in &curve.control_points {
673                if !(control.w > 0.0) { return Ok(false); }
674                common &= sides(control.point()?);
675            }
676            if common == 0 || curve.control_points.is_empty() { return Ok(false); }
677            let [a, b] = curve.domain()?;
678            ends.push((sides(curve.evaluate(a)?), sides(curve.evaluate(b)?)));
679        }
680        for i in 0..ends.len() {
681            if ends[i].1 & ends[(i + 1) % ends.len()].0 == 0 { return Ok(false); }
682        }
683    }
684    Ok(true)
685}
686
687pub(super) fn is_untrimmed(face: &FaceRecord) -> Result<bool, String> {
688    if face.loops.len() != 1 {
689        return Ok(false);
690    }
691    let ku = crate::KnotVector::new(face.surface.knots_u.clone(), face.surface.degree_u)?;
692    let kv = crate::KnotVector::new(face.surface.knots_v.clone(), face.surface.degree_v)?;
693    let [u0, u1] = ku.domain();
694    let [v0, v1] = kv.domain();
695    let domain_area = (u1 - u0) * (v1 - v0);
696    if switch!("BREP_MASS_EXACT_DOMAIN_COVER", false)
697        && !trim_on_domain_boundary(face, [u0, u1, v0, v1])? {
698        return Ok(false);
699    }
700    // With the boundary proof enabled this checks the winding of a loop
701    // confined to the domain boundary. The legacy path instead treats area
702    // closeness alone as evidence that a fitted trim covers the whole domain.
703    Ok((closed_parameter_space_area_on_carrier(face)?.abs() - domain_area).abs() <= 1e-6 * domain_area)
704}
705
706/// The single loop of `face` as an iso-rectangle `[u_lo, u_hi, v_lo, v_hi]`
707/// inside the surface domain: exactly four straight pcurves, each parallel
708/// to a parameter axis, joined end to end, alternating direction, wound
709/// counter-clockwise in uv, every corner within 1e-6 of the domain's span of
710/// the domain. Such a trim is a sub-rectangle of the knot grid and is
711/// integrated by `integration::integrate_iso_rectangle_multi` (lane O,
712/// 2026-10-28). `None` for anything else — a curved or oblique pcurve, a
713/// hole, a clockwise loop (whose meaning the trimmed route decides) — which
714/// keeps the trimmed route. Under `BREP_PROFILE_MASS_FACES` a decline names
715/// its reason on stderr.
716pub(super) fn iso_rectangle_trim(face: &FaceRecord) -> Result<Option<[f64; 4]>, String> {
717    let found = iso_rectangle_trim_impl(face)?;
718    if let Err(reason) = &found {
719        if super::profile::face_lines_enabled() {
720            eprintln!("mass.iso_rect face={} declined: {reason}", face.id);
721        }
722    }
723    Ok(found.ok())
724}
725
726/// `BREP_MASS_ISO_RECT_TRIMMED` (default on since the brep-bench promotion;
727/// `=0` restores the old reading): the pole-side clip in
728/// [`iso_rectangle_trim_impl`], and the trimmed integrator reading an
729/// iso-rectangle face on the untrimmed sweep's rules
730/// (`trimmed::integrate_trimmed_multi_with`).
731pub(super) fn pole_side_clip_on() -> bool {
732    switch!("BREP_MASS_ISO_RECT_TRIMMED", true)
733}
734
735fn iso_rectangle_trim_impl(face: &FaceRecord) -> Result<Result<[f64; 4], &'static str>, String> {
736    if face.loops.len() != 1 {
737        return Ok(Err("not one loop"));
738    }
739    if face.loops[0].coedges.len() != 4 {
740        return Ok(Err("not four coedges"));
741    }
742    let ku = crate::KnotVector::new(face.surface.knots_u.clone(), face.surface.degree_u)?;
743    let kv = crate::KnotVector::new(face.surface.knots_v.clone(), face.surface.degree_v)?;
744    let [u0, u1] = ku.domain();
745    let [v0, v1] = kv.domain();
746    let (span_u, span_v) = (u1 - u0, v1 - v0);
747    if !(span_u > 0.0 && span_v > 0.0) {
748        return Ok(Err("empty domain"));
749    }
750    // Pcurve ends meet and controls sit on their line to round-off on an
751    // exact chart and to the fit's own residual on a fitted one: 1e-7 of the
752    // span admits both and is a decade under the sign reading's own margin.
753    // An approximate axis classification can replace a real curved or
754    // oblique trim by a rectangle. Exact coverage also requires this second
755    // shortcut to preserve the boundary; uncertain fits take the trimmed
756    // route, including its arc corrections and open-domain extension.
757    let exact = switch!("BREP_MASS_EXACT_DOMAIN_COVER", false);
758    let (eps_u, eps_v) = if exact { (0.0, 0.0) } else { (1e-7 * span_u, 1e-7 * span_v) };
759    let mut segments: Vec<([f64; 2], [f64; 2])> = Vec::with_capacity(4);
760    for coedge in &face.loops[0].coedges {
761        // A straight axis-parallel pcurve of ANY degree: every control point
762        // shares the constant coordinate (a NURBS curve lies in its control
763        // hull, so the curve is the segment between its ends) and the control
764        // polygon is monotone in the other, so the curve traverses that
765        // segment once. The imprint emits a fitted straight pcurve on a
766        // fitted carrier; its degree says nothing about the trim.
767        let pcurve = &coedge.pcurve;
768        if pcurve.control_points.len() < 2 {
769            return Ok(Err("a pcurve with one control point"));
770        }
771        let mut controls = Vec::with_capacity(pcurve.control_points.len());
772        for control in &pcurve.control_points {
773            controls.push(control.point()?);
774        }
775        let (first, last) = (controls[0], controls[controls.len() - 1]);
776        let (du, dv) = ((last.x - first.x).abs(), (last.y - first.y).abs());
777        let along_u = dv <= eps_v && du > eps_u;
778        let along_v = du <= eps_u && dv > eps_v;
779        if !(along_u || along_v) {
780            return Ok(Err("a pcurve not parallel to a parameter axis"));
781        }
782        let (constant, varying, eps_constant): (fn(&Vec3) -> f64, fn(&Vec3) -> f64, f64) =
783            if along_u { (|p| p.y, |p| p.x, eps_v) } else { (|p| p.x, |p| p.y, eps_u) };
784        let level = constant(&first);
785        if controls.iter().any(|control| (constant(control) - level).abs() > eps_constant) {
786            return Ok(Err("a pcurve whose controls leave its line"));
787        }
788        let direction = (varying(&last) - varying(&first)).signum();
789        if controls.windows(2).any(|pair| (varying(&pair[1]) - varying(&pair[0])) * direction < 0.0) {
790            return Ok(Err("a pcurve whose controls turn back"));
791        }
792        // A pcurve is stored in the loop's traversal direction; `forward`
793        // relates the coedge to its 3-D edge, not to the pcurve.
794        segments.push(([first.x, first.y], [last.x, last.y]));
795    }
796    let along_u = |segment: &([f64; 2], [f64; 2])| (segment.1[1] - segment.0[1]).abs() <= eps_v;
797    // `BREP_MASS_ISO_RECT_TRIMMED` (default on): a pcurve along u at a level
798    // where the carrier collapses to one point (a sphere's pole) is a
799    // degenerate edge whose u range says nothing about the trim. Boolean
800    // assembly can store it over the whole period (u 1 -> 0) on a face that
801    // spans only part of it, so it does not meet its neighbours and the face
802    // left this route for the trimmed one, which read brep-bench
803    // tangent/half-balls-overlapping's kept quarter sphere (iso-rectangle
804    // [0.25, 0.75] x [0.5, 1]) 1.8e-5 large. Clipped to run from the previous
805    // pcurve's end to the next one's start, it is the rectangle's pole side.
806    if pole_side_clip_on() {
807        for index in 0..4 {
808            let segment = segments[index];
809            if !along_u(&segment) {
810                continue;
811            }
812            let level = segment.0[1];
813            let probe = |u: f64| face.surface.evaluate(u, level);
814            let (a, b, c) = (probe(u0)?, probe(0.5 * (u0 + u1))?, probe(u1)?);
815            let scale = a.length().max(1.0) * 1e-12;
816            if a.sub(b).length() > scale || a.sub(c).length() > scale {
817                continue;
818            }
819            let previous = segments[(index + 3) % 4].1;
820            let next = segments[(index + 1) % 4].0;
821            if (previous[1] - level).abs() <= eps_v && (next[1] - level).abs() <= eps_v {
822                segments[index] = ([previous[0], level], [next[0], level]);
823            }
824        }
825    }
826    for index in 0..4 {
827        let (_, to) = segments[index];
828        let (from, _) = segments[(index + 1) % 4];
829        if (to[0] - from[0]).abs() > eps_u || (to[1] - from[1]).abs() > eps_v {
830            if super::profile::face_lines_enabled() {
831                eprintln!(
832                    "mass.iso_rect face={} gap between pcurves {} and {}: ({:.3e}, {:.3e}) of spans ({:.3e}, {:.3e})",
833                    face.id, index, (index + 1) % 4, to[0] - from[0], to[1] - from[1], span_u, span_v
834                );
835            }
836            return Ok(Err("consecutive pcurves do not meet"));
837        }
838        if along_u(&segments[index]) == along_u(&segments[(index + 1) % 4]) {
839            return Ok(Err("consecutive pcurves run the same way"));
840        }
841    }
842    let corners: Vec<[f64; 2]> = segments.iter().map(|segment| segment.0).collect();
843    if !(polygon_signed_area(&corners) > 0.0) {
844        return Ok(Err("not wound counter-clockwise"));
845    }
846    let (mut u_lo, mut u_hi, mut v_lo, mut v_hi) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
847    for corner in &corners {
848        u_lo = u_lo.min(corner[0]);
849        u_hi = u_hi.max(corner[0]);
850        v_lo = v_lo.min(corner[1]);
851        v_hi = v_hi.max(corner[1]);
852    }
853    // A corner outside the domain by more than round-off is not a sub-rectangle
854    // of the knot grid; one inside it by round-off is clamped onto it.
855    let (slack_u, slack_v) = if exact { (0.0, 0.0) } else { (1e-6 * span_u, 1e-6 * span_v) };
856    if u_lo < u0 - slack_u || u_hi > u1 + slack_u || v_lo < v0 - slack_v || v_hi > v1 + slack_v {
857        return Ok(Err("a corner outside the domain"));
858    }
859    let rectangle = [u_lo.max(u0), u_hi.min(u1), v_lo.max(v0), v_hi.min(v1)];
860    if rectangle[1] - rectangle[0] <= eps_u || rectangle[3] - rectangle[2] <= eps_v {
861        return Ok(Err("a collapsed rectangle"));
862    }
863    Ok(Ok(rectangle))
864}
865
866/// Symmetric degree-5 triangle cubature (barycentric points and weights,
867/// normalized to unit area) — Golovanov §8.10 Table 8.10.2.
868pub(super) const TRIANGLE_CUBATURE: [([f64; 3], f64); 7] = [
869    ([1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0], 0.225),
870    (
871        [0.101286507323456, 0.101286507323456, 0.797426985353087],
872        0.125939180544827,
873    ),
874    (
875        [0.101286507323456, 0.797426985353087, 0.101286507323456],
876        0.125939180544827,
877    ),
878    (
879        [0.797426985353087, 0.101286507323456, 0.101286507323456],
880        0.125939180544827,
881    ),
882    (
883        [0.470142064105115, 0.470142064105115, 0.059715871789770],
884        0.132394152788506,
885    ),
886    (
887        [0.470142064105115, 0.059715871789770, 0.470142064105115],
888        0.132394152788506,
889    ),
890    (
891        [0.059715871789770, 0.470142064105115, 0.470142064105115],
892        0.132394152788506,
893    ),
894];
895
896pub(super) fn polygon_signed_area(polygon: &[[f64; 2]]) -> f64 {
897    crate::polygon::signed_area_2d(polygon.len(), |i| polygon[i])
898}
899
900/// Sutherland–Hodgman clip of one loop against an axis-aligned cell.  A
901/// loop that fully encloses the cell clips to the whole cell; a disjoint
902/// loop clips to nothing — so clipping every loop handles containment,
903/// holes, and partial coverage uniformly, with the loop's winding as the
904/// contribution sign.
905///
906/// The four half-plane passes write into caller-owned buffers: the two working
907/// vectors are ping-ponged for the whole call instead of allocated per pass. The integrator clips every trim loop
908/// against every cell of a knot-conforming grid and then against every
909/// quadtree child of every boundary cell, so the five vectors a call used to
910/// allocate were the shape of its clipping cost.
911pub(super) fn clip_polygon_to_cell_into(
912    polygon: &[[f64; 2]],
913    cell: [f64; 4],
914    scratch: &mut Vec<[f64; 2]>,
915    out: &mut Vec<[f64; 2]>,
916) {
917    let [u_low, u_high, v_low, v_high] = cell;
918    out.clear();
919    out.extend_from_slice(polygon);
920    // (axis, bound, keep_below)
921    for (axis, bound, keep_below) in [
922        (0, u_low, false),
923        (0, u_high, true),
924        (1, v_low, false),
925        (1, v_high, true),
926    ] {
927        if out.len() < 3 {
928            out.clear();
929            return;
930        }
931        let inside = |point: &[f64; 2]| {
932            if keep_below {
933                point[axis] <= bound
934            } else {
935                point[axis] >= bound
936            }
937        };
938        scratch.clear();
939        scratch.reserve(out.len() + 4);
940        for index in 0..out.len() {
941            let a = out[index];
942            let b = out[(index + 1) % out.len()];
943            let a_in = inside(&a);
944            let b_in = inside(&b);
945            if a_in {
946                scratch.push(a);
947            }
948            if a_in != b_in {
949                let t = (bound - a[axis]) / (b[axis] - a[axis]);
950                let mut crossing = [0.0; 2];
951                crossing[axis] = bound;
952                crossing[1 - axis] = a[1 - axis] + t * (b[1 - axis] - a[1 - axis]);
953                scratch.push(crossing);
954            }
955        }
956        std::mem::swap(scratch, out);
957    }
958}
959
960/// The passes of [`clip_polygon_to_cell_into`] split in two, with the same
961/// arithmetic in the same order: [`clip_polygon_to_column_into`] runs the two
962/// u passes (they read only the cell's u bounds, so every cell of one grid
963/// column shares their result) and [`clip_column_to_cell_into`] runs the two v
964/// passes on that fragment. Chained, they leave in `out` exactly what the
965/// four-pass call leaves.
966pub(super) fn clip_polygon_to_column_into(
967    polygon: &[[f64; 2]],
968    u_low: f64,
969    u_high: f64,
970    scratch: &mut Vec<[f64; 2]>,
971    out: &mut Vec<[f64; 2]>,
972) {
973    out.clear();
974    out.extend_from_slice(polygon);
975    clip_passes(&[(0, u_low, false), (0, u_high, true)], scratch, out);
976}
977
978/// The two v passes of [`clip_polygon_to_cell_into`] on a column fragment
979/// from [`clip_polygon_to_column_into`].
980pub(super) fn clip_column_to_cell_into(
981    column: &[[f64; 2]],
982    v_low: f64,
983    v_high: f64,
984    scratch: &mut Vec<[f64; 2]>,
985    out: &mut Vec<[f64; 2]>,
986) {
987    out.clear();
988    out.extend_from_slice(column);
989    clip_passes(&[(1, v_low, false), (1, v_high, true)], scratch, out);
990}
991
992/// The half-plane passes of [`clip_polygon_to_cell_into`], verbatim: before
993/// each pass a fragment under three points clips to nothing.
994fn clip_passes(passes: &[(usize, f64, bool)], scratch: &mut Vec<[f64; 2]>, out: &mut Vec<[f64; 2]>) {
995    for &(axis, bound, keep_below) in passes {
996        if out.len() < 3 {
997            out.clear();
998            return;
999        }
1000        let inside = |point: &[f64; 2]| {
1001            if keep_below {
1002                point[axis] <= bound
1003            } else {
1004                point[axis] >= bound
1005            }
1006        };
1007        scratch.clear();
1008        scratch.reserve(out.len() + 4);
1009        for index in 0..out.len() {
1010            let a = out[index];
1011            let b = out[(index + 1) % out.len()];
1012            let a_in = inside(&a);
1013            let b_in = inside(&b);
1014            if a_in {
1015                scratch.push(a);
1016            }
1017            if a_in != b_in {
1018                let t = (bound - a[axis]) / (b[axis] - a[axis]);
1019                let mut crossing = [0.0; 2];
1020                crossing[axis] = bound;
1021                crossing[1 - axis] = a[1 - axis] + t * (b[1 - axis] - a[1 - axis]);
1022                scratch.push(crossing);
1023            }
1024        }
1025        std::mem::swap(scratch, out);
1026    }
1027}
1028
1029pub(super) fn cell_breaks(knots: &[f64], degree: usize, low: f64, high: f64) -> Vec<f64> {
1030    let mut breaks = vec![low, high];
1031    breaks.extend(interior_knots(knots, degree));
1032    breaks.sort_by(f64::total_cmp);
1033    breaks.dedup_by(|a, b| (*a - *b).abs() <= (high - low) * 1e-12);
1034    // Subdivide spans so the base grid has at least MINIMUM_CELLS cells
1035    // across the domain; boundary cells then refine adaptively.
1036    const MINIMUM_CELLS: usize = 8;
1037    let target = (high - low) / MINIMUM_CELLS as f64;
1038    let mut refined = Vec::with_capacity(breaks.len() * 2);
1039    for pair in breaks.windows(2) {
1040        refined.push(pair[0]);
1041        let span = pair[1] - pair[0];
1042        let pieces = (span / target).ceil().max(1.0) as usize;
1043        for piece in 1..pieces {
1044            refined.push(pair[0] + span * piece as f64 / pieces as f64);
1045        }
1046    }
1047    refined.push(high);
1048    refined
1049}
1050
1051/// Tile one period's `cell_breaks` across `[lo, hi]` for a periodic direction
1052/// whose unwrapped trim ran past the domain end. Shifted copies of the base
1053/// breaks keep the knot-conforming grid on every covered period; a direction
1054/// that stayed in-domain yields exactly the base breaks.
1055pub(super) fn tiled_breaks(base: &[f64], lo: f64, hi: f64, period: f64) -> Vec<f64> {
1056    if period <= 0.0 || base.len() < 2 {
1057        return base.to_vec();
1058    }
1059    let origin = base[0];
1060    let first = ((lo - origin) / period).floor() as i64;
1061    let last = ((hi - origin) / period).ceil() as i64;
1062    let mut breaks = Vec::new();
1063    for tile in first..=last {
1064        let shift = tile as f64 * period;
1065        for &value in base {
1066            let shifted = value + shift;
1067            if breaks.last().map_or(true, |&previous: &f64| {
1068                (shifted - previous).abs() > 1e-12 * period
1069            }) {
1070                breaks.push(shifted);
1071            }
1072        }
1073    }
1074    breaks
1075}