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            let gap = ((next[0] - end[0]).powi(2) + (next[1] - end[1]).powi(2)).sqrt();
105            if gap > self.jump_tolerance {
106                if let Some(last) = self.arcs.last_mut() {
107                    *last = None;
108                }
109            }
110        }
111    }
112
113    /// A vertex with no arc behind the chord that leaves it.
114    pub(super) fn push_point(&mut self, point: [f64; 2]) {
115        self.settle_pending(point);
116        self.points.push(point);
117        self.arcs.push(None);
118    }
119
120    /// Append one coedge's pcurve the way the integrator samples trims: a
121    /// straight polyline at its knots (exact, no arcs), anything curved at
122    /// 24..128 chords spread over its knot spans with the arc recorded behind
123    /// every chord. A chord never straddles a knot, so each arc is one
124    /// polynomial (or rational) piece and the sliver quadrature is exact on it.
125    pub(super) fn push_coedge(
126        &mut self,
127        loop_index: usize,
128        coedge_index: usize,
129        coedge: &CoedgeRecord,
130        offset: [f64; 2],
131    ) -> Result<(), String> {
132        let curve = &coedge.pcurve;
133        let interior = interior_knots(&curve.knots, curve.degree);
134        if is_straight_polyline(curve) {
135            let [start, _] = curve.domain()?;
136            for parameter in std::iter::once(start).chain(interior.iter().copied()) {
137                let point = curve.evaluate(parameter)?;
138                self.push_point([point.x + offset[0], point.y + offset[1]]);
139            }
140            return Ok(());
141        }
142        let sample_count = (24usize).max((interior.len() + 1) * 16).min(128);
143        self.push_coedge_spans(loop_index, coedge_index, coedge, offset, sample_count, false)
144    }
145
146    /// Append about `target` chords of one coedge's pcurve, spread evenly over
147    /// its knot spans (at least one per span, so no chord straddles a knot)
148    /// and tagged with the arc behind each; a straight polyline contributes
149    /// its knots exactly. The end point is pushed too when `include_end`.
150    pub(super) fn push_coedge_spans(
151        &mut self,
152        loop_index: usize,
153        coedge_index: usize,
154        coedge: &CoedgeRecord,
155        offset: [f64; 2],
156        target: usize,
157        include_end: bool,
158    ) -> Result<(), String> {
159        let curve = &coedge.pcurve;
160        let spans = curve_breaks(curve)?;
161        let [_, end] = curve.domain()?;
162        if is_straight_polyline(curve) {
163            let last = if include_end { spans.len() } else { spans.len() - 1 };
164            for &parameter in &spans[..last] {
165                let point = curve.evaluate(parameter)?;
166                self.push_point([point.x + offset[0], point.y + offset[1]]);
167            }
168            return Ok(());
169        }
170        let span_count = spans.len().saturating_sub(1).max(1);
171        let per_span = target.div_ceil(span_count).max(1);
172        let mut previous: Option<(f64, [f64; 2])> = None;
173        let mut emit = |this: &mut Self, t: f64| -> Result<(), String> {
174            let point = curve.evaluate(t)?;
175            let point = [point.x + offset[0], point.y + offset[1]];
176            if let Some((t_prev, _)) = previous {
177                if let Some(last) = this.arcs.last_mut() {
178                    *last = Some(ChordArc { loop_index, coedge_index, t0: t_prev, t1: t, offset });
179                }
180            }
181            this.settle_pending(point);
182            this.points.push(point);
183            this.arcs.push(None);
184            previous = Some((t, point));
185            Ok(())
186        };
187        for pair in spans.windows(2) {
188            for index in 0..per_span {
189                emit(self, pair[0] + (pair[1] - pair[0]) * index as f64 / per_span as f64)?;
190            }
191        }
192        if include_end {
193            emit(self, end)?;
194        } else if let Some((t_prev, _)) = previous {
195            // The chord leaving the last sample reaches the pcurve's end, which
196            // the next vertex must supply.
197            if let Some(last) = self.arcs.last_mut() {
198                *last = Some(ChordArc { loop_index, coedge_index, t0: t_prev, t1: end, offset });
199            }
200            let point = curve.evaluate(end)?;
201            self.pending_end = Some([point.x + offset[0], point.y + offset[1]]);
202        }
203        Ok(())
204    }
205
206    /// Close the loop: the chord back to the first vertex is the last pcurve's
207    /// closing arc only if the first vertex is where that pcurve ends.
208    pub(super) fn finish(&mut self) {
209        if let Some(first) = self.points.first().copied() {
210            self.settle_pending(first);
211        }
212        self.pending_end = None;
213    }
214
215    pub(super) fn reverse(&mut self) {
216        self.finish();
217        let n = self.points.len();
218        if n == 0 {
219            return;
220        }
221        let old = std::mem::take(&mut self.arcs);
222        let swap = |arc: Option<ChordArc>| arc.map(|a| ChordArc { t0: a.t1, t1: a.t0, ..a });
223        let mut arcs = Vec::with_capacity(n);
224        for k in 0..n - 1 {
225            arcs.push(swap(old[n - 2 - k]));
226        }
227        arcs.push(swap(old[n - 1]));
228        self.arcs = arcs;
229        self.points.reverse();
230    }
231
232    pub(super) fn shift_u(&mut self, du: f64) {
233        for point in &mut self.points {
234            point[0] += du;
235        }
236        for arc in self.arcs.iter_mut().flatten() {
237            arc.offset[0] += du;
238        }
239        if let Some(end) = &mut self.pending_end {
240            end[0] += du;
241        }
242    }
243
244    /// Concatenate `other` after this loop's vertices; both joins are synthetic.
245    pub(super) fn append(&mut self, mut other: TrimPolygon) {
246        self.finish();
247        other.finish();
248        if let Some(last) = self.arcs.last_mut() {
249            *last = None;
250        }
251        if let Some(last) = other.arcs.last_mut() {
252            *last = None;
253        }
254        self.points.append(&mut other.points);
255        self.arcs.append(&mut other.arcs);
256    }
257}
258
259/// The chord polygons of `face`'s trim loops, as sampled for integration.
260pub fn trim_polygons(face: &FaceRecord) -> Result<Vec<Vec<[f64; 2]>>, String> {
261    Ok(trim_polygons_tagged(face)?
262        .into_iter()
263        .map(|polygon| polygon.points)
264        .collect())
265}
266
267pub(super) fn trim_polygons_tagged(face: &FaceRecord) -> Result<Vec<TrimPolygon>, String> {
268    let mut polygons = Vec::new();
269    for (loop_index, loop_record) in face.loops.iter().enumerate() {
270        let mut polygon = TrimPolygon::for_face(face)?;
271        for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
272            polygon.push_coedge(loop_index, coedge_index, coedge, [0.0, 0.0])?;
273        }
274        polygon.finish();
275        polygons.push(polygon);
276    }
277    Ok(polygons)
278}
279
280/// Rebuild a sphere cap bounded by a varying full-period contact rim and the
281/// collapsed opposite-winding rim at one parameter pole.  In the flat UV
282/// plane both individual loops have zero enclosed area; joined across one cut
283/// of the periodic cover they form the actual cap polygon.
284pub(super) fn singly_periodic_sphere_cap_polygons(
285    face: &FaceRecord,
286    closed_u: bool,
287    closed_v: bool,
288    domain: [f64; 4],
289) -> Result<Option<Vec<TrimPolygon>>, String> {
290    if !matches!(
291        face.surface.analytic(),
292        Some(crate::AnalyticSurface::Sphere { .. })
293    ) || (closed_u, closed_v) != (true, false)
294        || face.loops.len() != 2
295    {
296        return Ok(None);
297    }
298    let [u0, u1, v0, v1] = domain;
299    let period = u1 - u0;
300    let v_span = v1 - v0;
301    struct Rim {
302        polygon: TrimPolygon,
303        winding: f64,
304        vmin: f64,
305        vmax: f64,
306    }
307    let mut rims = Vec::with_capacity(2);
308    for (loop_index, loop_record) in face.loops.iter().enumerate() {
309        let mut polygon = TrimPolygon::new(domain);
310        for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
311            polygon.push_coedge_spans(loop_index, coedge_index, coedge, [0.0, 0.0], 64, true)?;
312        }
313        polygon.finish();
314        let points = &polygon.points;
315        if points.len() < 2 {
316            return Ok(None);
317        }
318        let winding = points.last().unwrap()[0] - points[0][0];
319        let (mut vmin, mut vmax) = (f64::INFINITY, f64::NEG_INFINITY);
320        for point in points {
321            vmin = vmin.min(point[1]);
322            vmax = vmax.max(point[1]);
323        }
324        rims.push(Rim {
325            polygon,
326            winding,
327            vmin,
328            vmax,
329        });
330    }
331    if rims
332        .iter()
333        .any(|rim| (rim.winding.abs() - period).abs() > 0.05 * period)
334        || rims[0].winding * rims[1].winding >= 0.0
335    {
336        return Ok(None);
337    }
338    let flat = |rim: &Rim| rim.vmax - rim.vmin <= 1e-6 * v_span;
339    let pole = match (flat(&rims[0]), flat(&rims[1])) {
340        (true, false) => &rims[0],
341        (false, true) => &rims[1],
342        _ => return Ok(None),
343    };
344    let pole_v = 0.5 * (pole.vmin + pole.vmax);
345    if (pole_v - v0).abs() > 1e-6 * v_span && (pole_v - v1).abs() > 1e-6 * v_span {
346        return Ok(None);
347    }
348
349    let (lower, upper) = if rims[0].vmax <= rims[1].vmin {
350        (&rims[0], &rims[1])
351    } else if rims[1].vmax <= rims[0].vmin {
352        (&rims[1], &rims[0])
353    } else {
354        return Ok(None);
355    };
356    let mut lower_points = lower.polygon.clone();
357    if lower.winding < 0.0 {
358        lower_points.reverse();
359    }
360    let mut upper_points = upper.polygon.clone();
361    if upper.winding > 0.0 {
362        upper_points.reverse();
363    }
364    let shift = ((lower_points.points[0][0] - upper_points.points.last().unwrap()[0]) / period)
365        .round()
366        * period;
367    upper_points.shift_u(shift);
368    lower_points.append(upper_points);
369    Ok(Some(vec![lower_points]))
370}
371
372/// Trim polygons with implicit torus seams unwrapped onto the covering plane.
373/// Returns `(polygons, unwrapped)`; `unwrapped` is true iff any loop crossed an
374/// implicit seam (and therefore carries out-of-domain parameters). Only attempts
375/// the unwrap on a doubly-periodic surface — single-periodic seams the raw
376/// integrator already handles are left in-domain.
377pub(super) fn trim_polygons_unwrapped(
378    face: &FaceRecord,
379    closed_u: bool,
380    closed_v: bool,
381    u_period: f64,
382    v_period: f64,
383) -> Result<(Vec<TrimPolygon>, bool), String> {
384    let mut polygons = Vec::new();
385    let mut unwrapped = false;
386    for (loop_index, loop_record) in face.loops.iter().enumerate() {
387        let offsets = crate::topology::loop_seam_offsets(
388            &loop_record.coedges,
389            closed_u,
390            closed_v,
391            u_period,
392            v_period,
393        )?;
394        let mut polygon = TrimPolygon::for_face(face)?;
395        for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
396            let offset = offsets[coedge_index];
397            if offset[0] != 0.0 || offset[1] != 0.0 {
398                unwrapped = true;
399            }
400            polygon.push_coedge(loop_index, coedge_index, coedge, offset)?;
401        }
402        polygon.finish();
403        polygons.push(polygon);
404    }
405    Ok((polygons, unwrapped))
406}
407
408/// A singly-periodic (cylinder/cone) wall whose seam is IMPLICIT: the closed
409/// wall is bounded by two full-wrap rim loops (iso-parametric lines at constant
410/// cross-level) with NO seam edge connecting them — the two-rim-circles form
411/// OCC/STEP emit for a full lateral face. This is the single-periodic analogue
412/// of [`biperiodic_band_range`]: each rim is a degenerate iso line in parameter
413/// space, so the raw trim integrates the band between them to ~zero (only the
414/// in-band holes survive — a full cylinder wall collapses to a ~1000x-too-small
415/// sliver, ABC 00000056 solid 2). We restrict to the unambiguous case where the
416/// two rims sit at the cross-domain EXTREMES: the material region is then the
417/// WHOLE surface (full periodic span × full cross span) minus any hole/notch
418/// loops, with no between-rims-vs-complement choice to get wrong. Replacing the
419/// two rim lines with a single full-domain band rectangle lets the existing
420/// winding integrator fill the wall while the remaining loops subtract as holes.
421///
422/// Returns the rebuilt polygon set (band rectangle first, then the unwrapped
423/// non-rim loops), or None when `face` is not a clean two-extreme-rim band —
424/// every other periodic face keeps its existing integration path unchanged.
425pub(super) fn singly_periodic_wall_polygons(
426    face: &FaceRecord,
427    closed_u: bool,
428    closed_v: bool,
429    domain: [f64; 4],
430) -> Result<Option<Vec<TrimPolygon>>, String> {
431    // Exactly one periodic direction (the doubly-periodic case is handled by
432    // `biperiodic_band_integral` upstream).
433    if closed_u == closed_v {
434        return Ok(None);
435    }
436    let [u0, u1, v0, v1] = domain;
437    let p_is_u = closed_u;
438    let period = if p_is_u { u1 - u0 } else { v1 - v0 };
439    if !(period > 0.0) {
440        return Ok(None);
441    }
442    let (q_dom_lo, q_dom_hi) = if p_is_u { (v0, v1) } else { (u0, u1) };
443    let q_extent = (q_dom_hi - q_dom_lo).abs();
444    if !(q_extent > 0.0) {
445        return Ok(None);
446    }
447    // (periodic, cross) coordinate split.
448    let coord = |p: [f64; 2]| if p_is_u { (p[0], p[1]) } else { (p[1], p[0]) };
449
450    let mut rim_indices: Vec<usize> = Vec::new();
451    let mut rim_levels: Vec<f64> = Vec::new();
452    for (index, loop_record) in face.loops.iter().enumerate() {
453        // Sample the loop's pcurves in traversal order.
454        let mut points: Vec<[f64; 2]> = Vec::new();
455        for coedge in &loop_record.coedges {
456            let [d0, d1] = coedge.pcurve.domain()?;
457            let samples = 16;
458            for k in 0..=samples {
459                let t = d0 + (d1 - d0) * k as f64 / samples as f64;
460                let p = coedge.pcurve.evaluate(t)?;
461                points.push([p.x, p.y]);
462            }
463        }
464        if points.len() < 2 {
465            return Ok(None);
466        }
467        let (mut qmin, mut qmax) = (f64::INFINITY, f64::NEG_INFINITY);
468        let mut net = 0.0;
469        for pair in points.windows(2) {
470            let (p0, q0) = coord(pair[0]);
471            let (p1, _q1) = coord(pair[1]);
472            qmin = qmin.min(q0);
473            qmax = qmax.max(q0);
474            let mut delta = p1 - p0;
475            if delta > 0.5 * period {
476                delta -= period;
477            } else if delta < -0.5 * period {
478                delta += period;
479            }
480            net += delta;
481        }
482        if let Some(last) = points.last() {
483            let (_p, q) = coord(*last);
484            qmin = qmin.min(q);
485            qmax = qmax.max(q);
486        }
487        // A rim: nets a full period in the periodic direction and stays on a
488        // near-constant cross-level (a true iso line).
489        let is_rim = net.abs() > 0.75 * period && (qmax - qmin) <= 0.02 * q_extent;
490        if is_rim {
491            rim_indices.push(index);
492            rim_levels.push(0.5 * (qmin + qmax));
493        }
494    }
495    if rim_levels.len() != 2 {
496        return Ok(None);
497    }
498    let (q_lo, q_hi) = if rim_levels[0] <= rim_levels[1] {
499        (rim_levels[0], rim_levels[1])
500    } else {
501        (rim_levels[1], rim_levels[0])
502    };
503    // Require the rims at the cross-domain extremes: material is then the whole
504    // wall (no between-rims/complement ambiguity). A sub-band (rims interior to
505    // the cross domain) is declined here — the existing path keeps handling it.
506    let tol = 0.02 * q_extent;
507    if (q_lo - q_dom_lo).abs() > tol || (q_hi - q_dom_hi).abs() > tol {
508        return Ok(None);
509    }
510    if (q_hi - q_lo) <= 0.5 * q_extent {
511        return Ok(None);
512    }
513
514    // Band rectangle over the full periodic span × the rim cross-levels.
515    let (r_ulo, r_uhi, r_vlo, r_vhi) = if p_is_u {
516        (u0, u1, q_lo, q_hi)
517    } else {
518        (q_lo, q_hi, v0, v1)
519    };
520    let mut rectangle = TrimPolygon::new(domain);
521    for corner in [
522        [r_ulo, r_vlo],
523        [r_uhi, r_vlo],
524        [r_uhi, r_vhi],
525        [r_ulo, r_vhi],
526    ] {
527        rectangle.push_point(corner);
528    }
529    let mut polygons: Vec<TrimPolygon> = vec![rectangle];
530    // Keep the non-rim loops (holes / notches), unwrapping any implicit-seam hop
531    // so a seam-straddling hole subtracts as one region.
532    for (index, loop_record) in face.loops.iter().enumerate() {
533        if rim_indices.contains(&index) {
534            continue;
535        }
536        let offsets = crate::topology::loop_seam_offsets(
537            &loop_record.coedges,
538            closed_u,
539            closed_v,
540            u1 - u0,
541            v1 - v0,
542        )?;
543        let mut polygon = TrimPolygon::new(domain);
544        for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
545            polygon.push_coedge(index, coedge_index, coedge, offsets[coedge_index])?;
546        }
547        polygon.finish();
548        polygons.push(polygon);
549    }
550    Ok(Some(polygons))
551}
552
553/// A DOUBLY-periodic (torus) face whose trim is a full-wrap BAND bounded by a
554/// constant-cross-level seam RIM (at a cross-domain extreme) and a single-valued
555/// WAVY CUT. The raw winding integrator collapses it to a sliver (the rim is a
556/// zero-area iso line and the cut's own polygon encloses only the thin strip it
557/// bounds), so — mirroring [`singly_periodic_wall_polygons`] for the singly-
558/// periodic wall — rebuild the material region as ONE simple (u,v) polygon:
559/// the wavy cut as one boundary, the flat rim iso-line (at the OPPOSITE cross
560/// extreme, which is the same physical seam circle) as the other, closed by the
561/// two private wrap-seam edges. The winding integrator then fills the band.
562///
563/// Returns None for anything that is not this exact signature (see
564/// [`crate::topology::analyze_doubly_periodic_seam_band`]) — every other face
565/// keeps its existing integration path bit-for-bit.
566pub(super) fn doubly_periodic_seam_band_polygons(
567    face: &FaceRecord,
568    domain: [f64; 4],
569) -> Result<Option<Vec<TrimPolygon>>, String> {
570    let (closed_u, closed_v) = face.surface.closed_directions()?;
571    if !(closed_u && closed_v) {
572        return Ok(None);
573    }
574    // Sample each loop's pcurves in traversal order; every sample remembers
575    // the pcurve parameter it came from so the band polygon's chords keep
576    // their arcs through the rotation below.
577    type Tagged = ([f64; 2], Option<(usize, usize, f64)>);
578    let mut loops_uv: Vec<Vec<[f64; 2]>> = Vec::with_capacity(face.loops.len());
579    let mut loops_tagged: Vec<Vec<Tagged>> = Vec::with_capacity(face.loops.len());
580    for (loop_index, loop_record) in face.loops.iter().enumerate() {
581        let mut points: Vec<[f64; 2]> = Vec::new();
582        let mut tagged: Vec<Tagged> = Vec::new();
583        for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
584            let [d0, d1] = coedge.pcurve.domain()?;
585            let samples = 24;
586            let curved = !is_straight_polyline(&coedge.pcurve);
587            for k in 0..=samples {
588                let t = d0 + (d1 - d0) * k as f64 / samples as f64;
589                let p = coedge.pcurve.evaluate(t)?;
590                points.push([p.x, p.y]);
591                tagged.push(([p.x, p.y], curved.then_some((loop_index, coedge_index, t))));
592            }
593        }
594        loops_uv.push(points);
595        loops_tagged.push(tagged);
596    }
597    let Some(band) =
598        crate::topology::analyze_doubly_periodic_seam_band(&loops_uv, domain, face.same_sense)
599    else {
600        return Ok(None);
601    };
602    let vertices = crate::topology::seam_band_uv_polygon_with(
603        &loops_tagged,
604        |vertex: &Tagged| vertex.0,
605        |point| (point, None),
606        domain,
607        &band,
608    );
609    let mut polygon = TrimPolygon::new(domain);
610    let n = vertices.len();
611    for (k, (point, tag)) in vertices.iter().enumerate() {
612        polygon.push_point(*point);
613        // The chord to the next vertex is an arc only between two samples of
614        // one pcurve; the rotation keeps consecutive samples adjacent, and a
615        // synthetic vertex (seam extension, rim corner) carries no tag.
616        let next = &vertices[(k + 1) % n];
617        if let (Some((l0, c0, t0)), Some((l1, c1, t1))) = (tag, next.1) {
618            if *l0 == l1 && *c0 == c1 {
619                polygon.arcs[k] = Some(ChordArc {
620                    loop_index: *l0,
621                    coedge_index: *c0,
622                    t0: *t0,
623                    t1,
624                    offset: [0.0, 0.0],
625                });
626            }
627        }
628    }
629    Ok(Some(vec![polygon]))
630}
631
632pub(super) fn is_untrimmed(face: &FaceRecord) -> Result<bool, String> {
633    if face.loops.len() != 1 {
634        return Ok(false);
635    }
636    let ku = crate::KnotVector::new(face.surface.knots_u.clone(), face.surface.degree_u)?;
637    let kv = crate::KnotVector::new(face.surface.knots_v.clone(), face.surface.degree_v)?;
638    let [u0, u1] = ku.domain();
639    let [v0, v1] = kv.domain();
640    let domain_area = (u1 - u0) * (v1 - v0);
641    // The loop's own joints closed: this asks whether the trim IS the domain, and a
642    // trim that misses its own joints by 8e-9 is still the domain. The bar is 1e-6 of
643    // the DOMAIN area, so on a small parameter box (1.4e-3 on a STEP import) it is
644    // 1.4e-9 — under the miss — and the face took the trimmed path though it is whole.
645    Ok((closed_parameter_space_area_on_carrier(face)?.abs() - domain_area).abs() <= 1e-6 * domain_area)
646}
647
648/// The single loop of `face` as an iso-rectangle `[u_lo, u_hi, v_lo, v_hi]`
649/// inside the surface domain: exactly four straight pcurves, each parallel
650/// to a parameter axis, joined end to end, alternating direction, wound
651/// counter-clockwise in uv, every corner within 1e-6 of the domain's span of
652/// the domain. Such a trim is a sub-rectangle of the knot grid and is
653/// integrated by `integration::integrate_iso_rectangle_multi` (lane O,
654/// 2026-10-28). `None` for anything else — a curved or oblique pcurve, a
655/// hole, a clockwise loop (whose meaning the trimmed route decides) — which
656/// keeps the trimmed route. Under `BREP_PROFILE_MASS_FACES` a decline names
657/// its reason on stderr.
658pub(super) fn iso_rectangle_trim(face: &FaceRecord) -> Result<Option<[f64; 4]>, String> {
659    let found = iso_rectangle_trim_impl(face)?;
660    if let Err(reason) = &found {
661        if super::profile::face_lines_enabled() {
662            eprintln!("mass.iso_rect face={} declined: {reason}", face.id);
663        }
664    }
665    Ok(found.ok())
666}
667
668fn iso_rectangle_trim_impl(face: &FaceRecord) -> Result<Result<[f64; 4], &'static str>, String> {
669    if face.loops.len() != 1 {
670        return Ok(Err("not one loop"));
671    }
672    if face.loops[0].coedges.len() != 4 {
673        return Ok(Err("not four coedges"));
674    }
675    let ku = crate::KnotVector::new(face.surface.knots_u.clone(), face.surface.degree_u)?;
676    let kv = crate::KnotVector::new(face.surface.knots_v.clone(), face.surface.degree_v)?;
677    let [u0, u1] = ku.domain();
678    let [v0, v1] = kv.domain();
679    let (span_u, span_v) = (u1 - u0, v1 - v0);
680    if !(span_u > 0.0 && span_v > 0.0) {
681        return Ok(Err("empty domain"));
682    }
683    // Pcurve ends meet and controls sit on their line to round-off on an
684    // exact chart and to the fit's own residual on a fitted one: 1e-7 of the
685    // span admits both and is a decade under the sign reading's own margin.
686    let (eps_u, eps_v) = (1e-7 * span_u, 1e-7 * span_v);
687    let mut segments: Vec<([f64; 2], [f64; 2])> = Vec::with_capacity(4);
688    for coedge in &face.loops[0].coedges {
689        // A straight axis-parallel pcurve of ANY degree: every control point
690        // shares the constant coordinate (a NURBS curve lies in its control
691        // hull, so the curve is the segment between its ends) and the control
692        // polygon is monotone in the other, so the curve traverses that
693        // segment once. The imprint emits a fitted straight pcurve on a
694        // fitted carrier; its degree says nothing about the trim.
695        let pcurve = &coedge.pcurve;
696        if pcurve.control_points.len() < 2 {
697            return Ok(Err("a pcurve with one control point"));
698        }
699        let mut controls = Vec::with_capacity(pcurve.control_points.len());
700        for control in &pcurve.control_points {
701            controls.push(control.point()?);
702        }
703        let (first, last) = (controls[0], controls[controls.len() - 1]);
704        let (du, dv) = ((last.x - first.x).abs(), (last.y - first.y).abs());
705        let along_u = dv <= eps_v && du > eps_u;
706        let along_v = du <= eps_u && dv > eps_v;
707        if !(along_u || along_v) {
708            return Ok(Err("a pcurve not parallel to a parameter axis"));
709        }
710        let (constant, varying, eps_constant): (fn(&Vec3) -> f64, fn(&Vec3) -> f64, f64) =
711            if along_u { (|p| p.y, |p| p.x, eps_v) } else { (|p| p.x, |p| p.y, eps_u) };
712        let level = constant(&first);
713        if controls.iter().any(|control| (constant(control) - level).abs() > eps_constant) {
714            return Ok(Err("a pcurve whose controls leave its line"));
715        }
716        let direction = (varying(&last) - varying(&first)).signum();
717        if controls.windows(2).any(|pair| (varying(&pair[1]) - varying(&pair[0])) * direction < 0.0) {
718            return Ok(Err("a pcurve whose controls turn back"));
719        }
720        // A pcurve is stored in the loop's traversal direction; `forward`
721        // relates the coedge to its 3-D edge, not to the pcurve.
722        segments.push(([first.x, first.y], [last.x, last.y]));
723    }
724    let along_u = |segment: &([f64; 2], [f64; 2])| (segment.1[1] - segment.0[1]).abs() <= eps_v;
725    for index in 0..4 {
726        let (_, to) = segments[index];
727        let (from, _) = segments[(index + 1) % 4];
728        if (to[0] - from[0]).abs() > eps_u || (to[1] - from[1]).abs() > eps_v {
729            if super::profile::face_lines_enabled() {
730                eprintln!(
731                    "mass.iso_rect face={} gap between pcurves {} and {}: ({:.3e}, {:.3e}) of spans ({:.3e}, {:.3e})",
732                    face.id, index, (index + 1) % 4, to[0] - from[0], to[1] - from[1], span_u, span_v
733                );
734            }
735            return Ok(Err("consecutive pcurves do not meet"));
736        }
737        if along_u(&segments[index]) == along_u(&segments[(index + 1) % 4]) {
738            return Ok(Err("consecutive pcurves run the same way"));
739        }
740    }
741    let corners: Vec<[f64; 2]> = segments.iter().map(|segment| segment.0).collect();
742    if !(polygon_signed_area(&corners) > 0.0) {
743        return Ok(Err("not wound counter-clockwise"));
744    }
745    let (mut u_lo, mut u_hi, mut v_lo, mut v_hi) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
746    for corner in &corners {
747        u_lo = u_lo.min(corner[0]);
748        u_hi = u_hi.max(corner[0]);
749        v_lo = v_lo.min(corner[1]);
750        v_hi = v_hi.max(corner[1]);
751    }
752    // A corner outside the domain by more than round-off is not a sub-rectangle
753    // of the knot grid; one inside it by round-off is clamped onto it.
754    let (slack_u, slack_v) = (1e-6 * span_u, 1e-6 * span_v);
755    if u_lo < u0 - slack_u || u_hi > u1 + slack_u || v_lo < v0 - slack_v || v_hi > v1 + slack_v {
756        return Ok(Err("a corner outside the domain"));
757    }
758    let rectangle = [u_lo.max(u0), u_hi.min(u1), v_lo.max(v0), v_hi.min(v1)];
759    if rectangle[1] - rectangle[0] <= eps_u || rectangle[3] - rectangle[2] <= eps_v {
760        return Ok(Err("a collapsed rectangle"));
761    }
762    Ok(Ok(rectangle))
763}
764
765/// Symmetric degree-5 triangle cubature (barycentric points and weights,
766/// normalized to unit area) — Golovanov §8.10 Table 8.10.2.
767pub(super) const TRIANGLE_CUBATURE: [([f64; 3], f64); 7] = [
768    ([1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0], 0.225),
769    (
770        [0.101286507323456, 0.101286507323456, 0.797426985353087],
771        0.125939180544827,
772    ),
773    (
774        [0.101286507323456, 0.797426985353087, 0.101286507323456],
775        0.125939180544827,
776    ),
777    (
778        [0.797426985353087, 0.101286507323456, 0.101286507323456],
779        0.125939180544827,
780    ),
781    (
782        [0.470142064105115, 0.470142064105115, 0.059715871789770],
783        0.132394152788506,
784    ),
785    (
786        [0.470142064105115, 0.059715871789770, 0.470142064105115],
787        0.132394152788506,
788    ),
789    (
790        [0.059715871789770, 0.470142064105115, 0.470142064105115],
791        0.132394152788506,
792    ),
793];
794
795pub(super) fn polygon_signed_area(polygon: &[[f64; 2]]) -> f64 {
796    crate::polygon::signed_area_2d(polygon.len(), |i| polygon[i])
797}
798
799/// Sutherland–Hodgman clip of one loop against an axis-aligned cell.  A
800/// loop that fully encloses the cell clips to the whole cell; a disjoint
801/// loop clips to nothing — so clipping every loop handles containment,
802/// holes, and partial coverage uniformly, with the loop's winding as the
803/// contribution sign.
804///
805/// The four half-plane passes write into caller-owned buffers: the two working
806/// vectors are ping-ponged for the whole call instead of allocated per pass. The integrator clips every trim loop
807/// against every cell of a knot-conforming grid and then against every
808/// quadtree child of every boundary cell, so the five vectors a call used to
809/// allocate were the shape of its clipping cost.
810pub(super) fn clip_polygon_to_cell_into(
811    polygon: &[[f64; 2]],
812    cell: [f64; 4],
813    scratch: &mut Vec<[f64; 2]>,
814    out: &mut Vec<[f64; 2]>,
815) {
816    let [u_low, u_high, v_low, v_high] = cell;
817    out.clear();
818    out.extend_from_slice(polygon);
819    // (axis, bound, keep_below)
820    for (axis, bound, keep_below) in [
821        (0, u_low, false),
822        (0, u_high, true),
823        (1, v_low, false),
824        (1, v_high, true),
825    ] {
826        if out.len() < 3 {
827            out.clear();
828            return;
829        }
830        let inside = |point: &[f64; 2]| {
831            if keep_below {
832                point[axis] <= bound
833            } else {
834                point[axis] >= bound
835            }
836        };
837        scratch.clear();
838        scratch.reserve(out.len() + 4);
839        for index in 0..out.len() {
840            let a = out[index];
841            let b = out[(index + 1) % out.len()];
842            let a_in = inside(&a);
843            let b_in = inside(&b);
844            if a_in {
845                scratch.push(a);
846            }
847            if a_in != b_in {
848                let t = (bound - a[axis]) / (b[axis] - a[axis]);
849                let mut crossing = [0.0; 2];
850                crossing[axis] = bound;
851                crossing[1 - axis] = a[1 - axis] + t * (b[1 - axis] - a[1 - axis]);
852                scratch.push(crossing);
853            }
854        }
855        std::mem::swap(scratch, out);
856    }
857}
858
859pub(super) fn cell_breaks(knots: &[f64], degree: usize, low: f64, high: f64) -> Vec<f64> {
860    let mut breaks = vec![low, high];
861    breaks.extend(interior_knots(knots, degree));
862    breaks.sort_by(f64::total_cmp);
863    breaks.dedup_by(|a, b| (*a - *b).abs() <= (high - low) * 1e-12);
864    // Subdivide spans so the base grid has at least MINIMUM_CELLS cells
865    // across the domain; boundary cells then refine adaptively.
866    const MINIMUM_CELLS: usize = 8;
867    let target = (high - low) / MINIMUM_CELLS as f64;
868    let mut refined = Vec::with_capacity(breaks.len() * 2);
869    for pair in breaks.windows(2) {
870        refined.push(pair[0]);
871        let span = pair[1] - pair[0];
872        let pieces = (span / target).ceil().max(1.0) as usize;
873        for piece in 1..pieces {
874            refined.push(pair[0] + span * piece as f64 / pieces as f64);
875        }
876    }
877    refined.push(high);
878    refined
879}
880
881/// Tile one period's `cell_breaks` across `[lo, hi]` for a periodic direction
882/// whose unwrapped trim ran past the domain end. Shifted copies of the base
883/// breaks keep the knot-conforming grid on every covered period; a direction
884/// that stayed in-domain yields exactly the base breaks.
885pub(super) fn tiled_breaks(base: &[f64], lo: f64, hi: f64, period: f64) -> Vec<f64> {
886    if period <= 0.0 || base.len() < 2 {
887        return base.to_vec();
888    }
889    let origin = base[0];
890    let first = ((lo - origin) / period).floor() as i64;
891    let last = ((hi - origin) / period).ceil() as i64;
892    let mut breaks = Vec::new();
893    for tile in first..=last {
894        let shift = tile as f64 * period;
895        for &value in base {
896            let shifted = value + shift;
897            if breaks.last().map_or(true, |&previous: &f64| {
898                (shifted - previous).abs() > 1e-12 * period
899            }) {
900                breaks.push(shifted);
901            }
902        }
903    }
904    breaks
905}