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
38fn is_straight_polyline(curve: &NurbsCurve) -> bool {
39    let rational = curve
40        .control_points
41        .iter()
42        .any(|point| (point.w - curve.control_points[0].w).abs() > 1e-12);
43    curve.degree == 1 && !rational
44}
45
46impl TrimPolygon {
47    pub(super) fn new(domain: [f64; 4]) -> Self {
48        let [u0, u1, v0, v1] = domain;
49        let jump_tolerance = 1e-3 * (u1 - u0).abs().min((v1 - v0).abs()).max(1e-300);
50        Self {
51            points: Vec::new(),
52            arcs: Vec::new(),
53            pending_end: None,
54            jump_tolerance,
55        }
56    }
57
58    pub(super) fn for_face(face: &FaceRecord) -> Result<Self, String> {
59        let [u0, u1] = face.surface.domain_u()?;
60        let [v0, v1] = face.surface.domain_v()?;
61        Ok(Self::new([u0, u1, v0, v1]))
62    }
63
64    fn settle_pending(&mut self, next: [f64; 2]) {
65        if let Some(end) = self.pending_end.take() {
66            let gap = ((next[0] - end[0]).powi(2) + (next[1] - end[1]).powi(2)).sqrt();
67            if gap > self.jump_tolerance {
68                if let Some(last) = self.arcs.last_mut() {
69                    *last = None;
70                }
71            }
72        }
73    }
74
75    /// A vertex with no arc behind the chord that leaves it.
76    pub(super) fn push_point(&mut self, point: [f64; 2]) {
77        self.settle_pending(point);
78        self.points.push(point);
79        self.arcs.push(None);
80    }
81
82    /// Append one coedge's pcurve the way the integrator samples trims: a
83    /// straight polyline at its knots (exact, no arcs), anything curved at
84    /// 24..128 chords spread over its knot spans with the arc recorded behind
85    /// every chord. A chord never straddles a knot, so each arc is one
86    /// polynomial (or rational) piece and the sliver quadrature is exact on it.
87    pub(super) fn push_coedge(
88        &mut self,
89        loop_index: usize,
90        coedge_index: usize,
91        coedge: &CoedgeRecord,
92        offset: [f64; 2],
93    ) -> Result<(), String> {
94        let curve = &coedge.pcurve;
95        let interior = interior_knots(&curve.knots, curve.degree);
96        if is_straight_polyline(curve) {
97            let [start, _] = curve.domain()?;
98            for parameter in std::iter::once(start).chain(interior.iter().copied()) {
99                let point = curve.evaluate(parameter)?;
100                self.push_point([point.x + offset[0], point.y + offset[1]]);
101            }
102            return Ok(());
103        }
104        let sample_count = (24usize).max((interior.len() + 1) * 16).min(128);
105        self.push_coedge_spans(loop_index, coedge_index, coedge, offset, sample_count, false)
106    }
107
108    /// Append about `target` chords of one coedge's pcurve, spread evenly over
109    /// its knot spans (at least one per span, so no chord straddles a knot)
110    /// and tagged with the arc behind each; a straight polyline contributes
111    /// its knots exactly. The end point is pushed too when `include_end`.
112    pub(super) fn push_coedge_spans(
113        &mut self,
114        loop_index: usize,
115        coedge_index: usize,
116        coedge: &CoedgeRecord,
117        offset: [f64; 2],
118        target: usize,
119        include_end: bool,
120    ) -> Result<(), String> {
121        let curve = &coedge.pcurve;
122        let spans = curve_breaks(curve)?;
123        let [_, end] = curve.domain()?;
124        if is_straight_polyline(curve) {
125            let last = if include_end { spans.len() } else { spans.len() - 1 };
126            for &parameter in &spans[..last] {
127                let point = curve.evaluate(parameter)?;
128                self.push_point([point.x + offset[0], point.y + offset[1]]);
129            }
130            return Ok(());
131        }
132        let span_count = spans.len().saturating_sub(1).max(1);
133        let per_span = target.div_ceil(span_count).max(1);
134        let mut previous: Option<(f64, [f64; 2])> = None;
135        let mut emit = |this: &mut Self, t: f64| -> Result<(), String> {
136            let point = curve.evaluate(t)?;
137            let point = [point.x + offset[0], point.y + offset[1]];
138            if let Some((t_prev, _)) = previous {
139                if let Some(last) = this.arcs.last_mut() {
140                    *last = Some(ChordArc { loop_index, coedge_index, t0: t_prev, t1: t, offset });
141                }
142            }
143            this.settle_pending(point);
144            this.points.push(point);
145            this.arcs.push(None);
146            previous = Some((t, point));
147            Ok(())
148        };
149        for pair in spans.windows(2) {
150            for index in 0..per_span {
151                emit(self, pair[0] + (pair[1] - pair[0]) * index as f64 / per_span as f64)?;
152            }
153        }
154        if include_end {
155            emit(self, end)?;
156        } else if let Some((t_prev, _)) = previous {
157            // The chord leaving the last sample reaches the pcurve's end, which
158            // the next vertex must supply.
159            if let Some(last) = self.arcs.last_mut() {
160                *last = Some(ChordArc { loop_index, coedge_index, t0: t_prev, t1: end, offset });
161            }
162            let point = curve.evaluate(end)?;
163            self.pending_end = Some([point.x + offset[0], point.y + offset[1]]);
164        }
165        Ok(())
166    }
167
168    /// Close the loop: the chord back to the first vertex is the last pcurve's
169    /// closing arc only if the first vertex is where that pcurve ends.
170    pub(super) fn finish(&mut self) {
171        if let Some(first) = self.points.first().copied() {
172            self.settle_pending(first);
173        }
174        self.pending_end = None;
175    }
176
177    pub(super) fn reverse(&mut self) {
178        self.finish();
179        let n = self.points.len();
180        if n == 0 {
181            return;
182        }
183        let old = std::mem::take(&mut self.arcs);
184        let swap = |arc: Option<ChordArc>| arc.map(|a| ChordArc { t0: a.t1, t1: a.t0, ..a });
185        let mut arcs = Vec::with_capacity(n);
186        for k in 0..n - 1 {
187            arcs.push(swap(old[n - 2 - k]));
188        }
189        arcs.push(swap(old[n - 1]));
190        self.arcs = arcs;
191        self.points.reverse();
192    }
193
194    pub(super) fn shift_u(&mut self, du: f64) {
195        for point in &mut self.points {
196            point[0] += du;
197        }
198        for arc in self.arcs.iter_mut().flatten() {
199            arc.offset[0] += du;
200        }
201        if let Some(end) = &mut self.pending_end {
202            end[0] += du;
203        }
204    }
205
206    /// Concatenate `other` after this loop's vertices; both joins are synthetic.
207    pub(super) fn append(&mut self, mut other: TrimPolygon) {
208        self.finish();
209        other.finish();
210        if let Some(last) = self.arcs.last_mut() {
211            *last = None;
212        }
213        if let Some(last) = other.arcs.last_mut() {
214            *last = None;
215        }
216        self.points.append(&mut other.points);
217        self.arcs.append(&mut other.arcs);
218    }
219}
220
221/// The chord polygons of `face`'s trim loops, as sampled for integration.
222pub fn trim_polygons(face: &FaceRecord) -> Result<Vec<Vec<[f64; 2]>>, String> {
223    Ok(trim_polygons_tagged(face)?
224        .into_iter()
225        .map(|polygon| polygon.points)
226        .collect())
227}
228
229pub(super) fn trim_polygons_tagged(face: &FaceRecord) -> Result<Vec<TrimPolygon>, String> {
230    let mut polygons = Vec::new();
231    for (loop_index, loop_record) in face.loops.iter().enumerate() {
232        let mut polygon = TrimPolygon::for_face(face)?;
233        for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
234            polygon.push_coedge(loop_index, coedge_index, coedge, [0.0, 0.0])?;
235        }
236        polygon.finish();
237        polygons.push(polygon);
238    }
239    Ok(polygons)
240}
241
242/// Rebuild a sphere cap bounded by a varying full-period contact rim and the
243/// collapsed opposite-winding rim at one parameter pole.  In the flat UV
244/// plane both individual loops have zero enclosed area; joined across one cut
245/// of the periodic cover they form the actual cap polygon.
246pub(super) fn singly_periodic_sphere_cap_polygons(
247    face: &FaceRecord,
248    closed_u: bool,
249    closed_v: bool,
250    domain: [f64; 4],
251) -> Result<Option<Vec<TrimPolygon>>, String> {
252    if !matches!(
253        face.surface.analytic(),
254        Some(crate::AnalyticSurface::Sphere { .. })
255    ) || (closed_u, closed_v) != (true, false)
256        || face.loops.len() != 2
257    {
258        return Ok(None);
259    }
260    let [u0, u1, v0, v1] = domain;
261    let period = u1 - u0;
262    let v_span = v1 - v0;
263    struct Rim {
264        polygon: TrimPolygon,
265        winding: f64,
266        vmin: f64,
267        vmax: f64,
268    }
269    let mut rims = Vec::with_capacity(2);
270    for (loop_index, loop_record) in face.loops.iter().enumerate() {
271        let mut polygon = TrimPolygon::new(domain);
272        for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
273            polygon.push_coedge_spans(loop_index, coedge_index, coedge, [0.0, 0.0], 64, true)?;
274        }
275        polygon.finish();
276        let points = &polygon.points;
277        if points.len() < 2 {
278            return Ok(None);
279        }
280        let winding = points.last().unwrap()[0] - points[0][0];
281        let (mut vmin, mut vmax) = (f64::INFINITY, f64::NEG_INFINITY);
282        for point in points {
283            vmin = vmin.min(point[1]);
284            vmax = vmax.max(point[1]);
285        }
286        rims.push(Rim {
287            polygon,
288            winding,
289            vmin,
290            vmax,
291        });
292    }
293    if rims
294        .iter()
295        .any(|rim| (rim.winding.abs() - period).abs() > 0.05 * period)
296        || rims[0].winding * rims[1].winding >= 0.0
297    {
298        return Ok(None);
299    }
300    let flat = |rim: &Rim| rim.vmax - rim.vmin <= 1e-6 * v_span;
301    let pole = match (flat(&rims[0]), flat(&rims[1])) {
302        (true, false) => &rims[0],
303        (false, true) => &rims[1],
304        _ => return Ok(None),
305    };
306    let pole_v = 0.5 * (pole.vmin + pole.vmax);
307    if (pole_v - v0).abs() > 1e-6 * v_span && (pole_v - v1).abs() > 1e-6 * v_span {
308        return Ok(None);
309    }
310
311    let (lower, upper) = if rims[0].vmax <= rims[1].vmin {
312        (&rims[0], &rims[1])
313    } else if rims[1].vmax <= rims[0].vmin {
314        (&rims[1], &rims[0])
315    } else {
316        return Ok(None);
317    };
318    let mut lower_points = lower.polygon.clone();
319    if lower.winding < 0.0 {
320        lower_points.reverse();
321    }
322    let mut upper_points = upper.polygon.clone();
323    if upper.winding > 0.0 {
324        upper_points.reverse();
325    }
326    let shift = ((lower_points.points[0][0] - upper_points.points.last().unwrap()[0]) / period)
327        .round()
328        * period;
329    upper_points.shift_u(shift);
330    lower_points.append(upper_points);
331    Ok(Some(vec![lower_points]))
332}
333
334/// Trim polygons with implicit torus seams unwrapped onto the covering plane.
335/// Returns `(polygons, unwrapped)`; `unwrapped` is true iff any loop crossed an
336/// implicit seam (and therefore carries out-of-domain parameters). Only attempts
337/// the unwrap on a doubly-periodic surface — single-periodic seams the raw
338/// integrator already handles are left in-domain.
339pub(super) fn trim_polygons_unwrapped(
340    face: &FaceRecord,
341    closed_u: bool,
342    closed_v: bool,
343    u_period: f64,
344    v_period: f64,
345) -> Result<(Vec<TrimPolygon>, bool), String> {
346    let mut polygons = Vec::new();
347    let mut unwrapped = false;
348    for (loop_index, loop_record) in face.loops.iter().enumerate() {
349        let offsets = crate::topology::loop_seam_offsets(
350            &loop_record.coedges,
351            closed_u,
352            closed_v,
353            u_period,
354            v_period,
355        )?;
356        let mut polygon = TrimPolygon::for_face(face)?;
357        for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
358            let offset = offsets[coedge_index];
359            if offset[0] != 0.0 || offset[1] != 0.0 {
360                unwrapped = true;
361            }
362            polygon.push_coedge(loop_index, coedge_index, coedge, offset)?;
363        }
364        polygon.finish();
365        polygons.push(polygon);
366    }
367    Ok((polygons, unwrapped))
368}
369
370/// A singly-periodic (cylinder/cone) wall whose seam is IMPLICIT: the closed
371/// wall is bounded by two full-wrap rim loops (iso-parametric lines at constant
372/// cross-level) with NO seam edge connecting them — the two-rim-circles form
373/// OCC/STEP emit for a full lateral face. This is the single-periodic analogue
374/// of [`biperiodic_band_range`]: each rim is a degenerate iso line in parameter
375/// space, so the raw trim integrates the band between them to ~zero (only the
376/// in-band holes survive — a full cylinder wall collapses to a ~1000x-too-small
377/// sliver, ABC 00000056 solid 2). We restrict to the unambiguous case where the
378/// two rims sit at the cross-domain EXTREMES: the material region is then the
379/// WHOLE surface (full periodic span × full cross span) minus any hole/notch
380/// loops, with no between-rims-vs-complement choice to get wrong. Replacing the
381/// two rim lines with a single full-domain band rectangle lets the existing
382/// winding integrator fill the wall while the remaining loops subtract as holes.
383///
384/// Returns the rebuilt polygon set (band rectangle first, then the unwrapped
385/// non-rim loops), or None when `face` is not a clean two-extreme-rim band —
386/// every other periodic face keeps its existing integration path unchanged.
387pub(super) fn singly_periodic_wall_polygons(
388    face: &FaceRecord,
389    closed_u: bool,
390    closed_v: bool,
391    domain: [f64; 4],
392) -> Result<Option<Vec<TrimPolygon>>, String> {
393    // Exactly one periodic direction (the doubly-periodic case is handled by
394    // `biperiodic_band_integral` upstream).
395    if closed_u == closed_v {
396        return Ok(None);
397    }
398    let [u0, u1, v0, v1] = domain;
399    let p_is_u = closed_u;
400    let period = if p_is_u { u1 - u0 } else { v1 - v0 };
401    if !(period > 0.0) {
402        return Ok(None);
403    }
404    let (q_dom_lo, q_dom_hi) = if p_is_u { (v0, v1) } else { (u0, u1) };
405    let q_extent = (q_dom_hi - q_dom_lo).abs();
406    if !(q_extent > 0.0) {
407        return Ok(None);
408    }
409    // (periodic, cross) coordinate split.
410    let coord = |p: [f64; 2]| if p_is_u { (p[0], p[1]) } else { (p[1], p[0]) };
411
412    let mut rim_indices: Vec<usize> = Vec::new();
413    let mut rim_levels: Vec<f64> = Vec::new();
414    for (index, loop_record) in face.loops.iter().enumerate() {
415        // Sample the loop's pcurves in traversal order.
416        let mut points: Vec<[f64; 2]> = Vec::new();
417        for coedge in &loop_record.coedges {
418            let [d0, d1] = coedge.pcurve.domain()?;
419            let samples = 16;
420            for k in 0..=samples {
421                let t = d0 + (d1 - d0) * k as f64 / samples as f64;
422                let p = coedge.pcurve.evaluate(t)?;
423                points.push([p.x, p.y]);
424            }
425        }
426        if points.len() < 2 {
427            return Ok(None);
428        }
429        let (mut qmin, mut qmax) = (f64::INFINITY, f64::NEG_INFINITY);
430        let mut net = 0.0;
431        for pair in points.windows(2) {
432            let (p0, q0) = coord(pair[0]);
433            let (p1, _q1) = coord(pair[1]);
434            qmin = qmin.min(q0);
435            qmax = qmax.max(q0);
436            let mut delta = p1 - p0;
437            if delta > 0.5 * period {
438                delta -= period;
439            } else if delta < -0.5 * period {
440                delta += period;
441            }
442            net += delta;
443        }
444        if let Some(last) = points.last() {
445            let (_p, q) = coord(*last);
446            qmin = qmin.min(q);
447            qmax = qmax.max(q);
448        }
449        // A rim: nets a full period in the periodic direction and stays on a
450        // near-constant cross-level (a true iso line).
451        let is_rim = net.abs() > 0.75 * period && (qmax - qmin) <= 0.02 * q_extent;
452        if is_rim {
453            rim_indices.push(index);
454            rim_levels.push(0.5 * (qmin + qmax));
455        }
456    }
457    if rim_levels.len() != 2 {
458        return Ok(None);
459    }
460    let (q_lo, q_hi) = if rim_levels[0] <= rim_levels[1] {
461        (rim_levels[0], rim_levels[1])
462    } else {
463        (rim_levels[1], rim_levels[0])
464    };
465    // Require the rims at the cross-domain extremes: material is then the whole
466    // wall (no between-rims/complement ambiguity). A sub-band (rims interior to
467    // the cross domain) is declined here — the existing path keeps handling it.
468    let tol = 0.02 * q_extent;
469    if (q_lo - q_dom_lo).abs() > tol || (q_hi - q_dom_hi).abs() > tol {
470        return Ok(None);
471    }
472    if (q_hi - q_lo) <= 0.5 * q_extent {
473        return Ok(None);
474    }
475
476    // Band rectangle over the full periodic span × the rim cross-levels.
477    let (r_ulo, r_uhi, r_vlo, r_vhi) = if p_is_u {
478        (u0, u1, q_lo, q_hi)
479    } else {
480        (q_lo, q_hi, v0, v1)
481    };
482    let mut rectangle = TrimPolygon::new(domain);
483    for corner in [
484        [r_ulo, r_vlo],
485        [r_uhi, r_vlo],
486        [r_uhi, r_vhi],
487        [r_ulo, r_vhi],
488    ] {
489        rectangle.push_point(corner);
490    }
491    let mut polygons: Vec<TrimPolygon> = vec![rectangle];
492    // Keep the non-rim loops (holes / notches), unwrapping any implicit-seam hop
493    // so a seam-straddling hole subtracts as one region.
494    for (index, loop_record) in face.loops.iter().enumerate() {
495        if rim_indices.contains(&index) {
496            continue;
497        }
498        let offsets = crate::topology::loop_seam_offsets(
499            &loop_record.coedges,
500            closed_u,
501            closed_v,
502            u1 - u0,
503            v1 - v0,
504        )?;
505        let mut polygon = TrimPolygon::new(domain);
506        for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
507            polygon.push_coedge(index, coedge_index, coedge, offsets[coedge_index])?;
508        }
509        polygon.finish();
510        polygons.push(polygon);
511    }
512    Ok(Some(polygons))
513}
514
515/// A DOUBLY-periodic (torus) face whose trim is a full-wrap BAND bounded by a
516/// constant-cross-level seam RIM (at a cross-domain extreme) and a single-valued
517/// WAVY CUT. The raw winding integrator collapses it to a sliver (the rim is a
518/// zero-area iso line and the cut's own polygon encloses only the thin strip it
519/// bounds), so — mirroring [`singly_periodic_wall_polygons`] for the singly-
520/// periodic wall — rebuild the material region as ONE simple (u,v) polygon:
521/// the wavy cut as one boundary, the flat rim iso-line (at the OPPOSITE cross
522/// extreme, which is the same physical seam circle) as the other, closed by the
523/// two private wrap-seam edges. The winding integrator then fills the band.
524///
525/// Returns None for anything that is not this exact signature (see
526/// [`crate::topology::analyze_doubly_periodic_seam_band`]) — every other face
527/// keeps its existing integration path bit-for-bit.
528pub(super) fn doubly_periodic_seam_band_polygons(
529    face: &FaceRecord,
530    domain: [f64; 4],
531) -> Result<Option<Vec<TrimPolygon>>, String> {
532    let (closed_u, closed_v) = face.surface.closed_directions()?;
533    if !(closed_u && closed_v) {
534        return Ok(None);
535    }
536    // Sample each loop's pcurves in traversal order; every sample remembers
537    // the pcurve parameter it came from so the band polygon's chords keep
538    // their arcs through the rotation below.
539    type Tagged = ([f64; 2], Option<(usize, usize, f64)>);
540    let mut loops_uv: Vec<Vec<[f64; 2]>> = Vec::with_capacity(face.loops.len());
541    let mut loops_tagged: Vec<Vec<Tagged>> = Vec::with_capacity(face.loops.len());
542    for (loop_index, loop_record) in face.loops.iter().enumerate() {
543        let mut points: Vec<[f64; 2]> = Vec::new();
544        let mut tagged: Vec<Tagged> = Vec::new();
545        for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
546            let [d0, d1] = coedge.pcurve.domain()?;
547            let samples = 24;
548            let curved = !is_straight_polyline(&coedge.pcurve);
549            for k in 0..=samples {
550                let t = d0 + (d1 - d0) * k as f64 / samples as f64;
551                let p = coedge.pcurve.evaluate(t)?;
552                points.push([p.x, p.y]);
553                tagged.push(([p.x, p.y], curved.then_some((loop_index, coedge_index, t))));
554            }
555        }
556        loops_uv.push(points);
557        loops_tagged.push(tagged);
558    }
559    let Some(band) =
560        crate::topology::analyze_doubly_periodic_seam_band(&loops_uv, domain, face.same_sense)
561    else {
562        return Ok(None);
563    };
564    let vertices = crate::topology::seam_band_uv_polygon_with(
565        &loops_tagged,
566        |vertex: &Tagged| vertex.0,
567        |point| (point, None),
568        domain,
569        &band,
570    );
571    let mut polygon = TrimPolygon::new(domain);
572    let n = vertices.len();
573    for (k, (point, tag)) in vertices.iter().enumerate() {
574        polygon.push_point(*point);
575        // The chord to the next vertex is an arc only between two samples of
576        // one pcurve; the rotation keeps consecutive samples adjacent, and a
577        // synthetic vertex (seam extension, rim corner) carries no tag.
578        let next = &vertices[(k + 1) % n];
579        if let (Some((l0, c0, t0)), Some((l1, c1, t1))) = (tag, next.1) {
580            if *l0 == l1 && *c0 == c1 {
581                polygon.arcs[k] = Some(ChordArc {
582                    loop_index: *l0,
583                    coedge_index: *c0,
584                    t0: *t0,
585                    t1,
586                    offset: [0.0, 0.0],
587                });
588            }
589        }
590    }
591    Ok(Some(vec![polygon]))
592}
593
594pub(super) fn is_untrimmed(face: &FaceRecord) -> Result<bool, String> {
595    if face.loops.len() != 1 {
596        return Ok(false);
597    }
598    let ku = crate::KnotVector::new(face.surface.knots_u.clone(), face.surface.degree_u)?;
599    let kv = crate::KnotVector::new(face.surface.knots_v.clone(), face.surface.degree_v)?;
600    let [u0, u1] = ku.domain();
601    let [v0, v1] = kv.domain();
602    let domain_area = (u1 - u0) * (v1 - v0);
603    Ok((parameter_space_area(face)?.abs() - domain_area).abs() <= 1e-6 * domain_area)
604}
605
606/// Symmetric degree-5 triangle cubature (barycentric points and weights,
607/// normalized to unit area) — Golovanov §8.10 Table 8.10.2.
608pub(super) const TRIANGLE_CUBATURE: [([f64; 3], f64); 7] = [
609    ([1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0], 0.225),
610    (
611        [0.101286507323456, 0.101286507323456, 0.797426985353087],
612        0.125939180544827,
613    ),
614    (
615        [0.101286507323456, 0.797426985353087, 0.101286507323456],
616        0.125939180544827,
617    ),
618    (
619        [0.797426985353087, 0.101286507323456, 0.101286507323456],
620        0.125939180544827,
621    ),
622    (
623        [0.470142064105115, 0.470142064105115, 0.059715871789770],
624        0.132394152788506,
625    ),
626    (
627        [0.470142064105115, 0.059715871789770, 0.470142064105115],
628        0.132394152788506,
629    ),
630    (
631        [0.059715871789770, 0.470142064105115, 0.470142064105115],
632        0.132394152788506,
633    ),
634];
635
636pub(super) fn polygon_signed_area(polygon: &[[f64; 2]]) -> f64 {
637    let mut area = 0.0;
638    for index in 0..polygon.len() {
639        let a = polygon[index];
640        let b = polygon[(index + 1) % polygon.len()];
641        area += a[0] * b[1] - b[0] * a[1];
642    }
643    area * 0.5
644}
645
646/// Sutherland–Hodgman clip of one loop against an axis-aligned cell.  A
647/// loop that fully encloses the cell clips to the whole cell; a disjoint
648/// loop clips to nothing — so clipping every loop handles containment,
649/// holes, and partial coverage uniformly, with the loop's winding as the
650/// contribution sign.
651pub(super) fn clip_polygon_to_cell(polygon: &[[f64; 2]], cell: [f64; 4]) -> Vec<[f64; 2]> {
652    let [u_low, u_high, v_low, v_high] = cell;
653    let mut current = polygon.to_vec();
654    // (axis, bound, keep_below)
655    for (axis, bound, keep_below) in [
656        (0, u_low, false),
657        (0, u_high, true),
658        (1, v_low, false),
659        (1, v_high, true),
660    ] {
661        if current.len() < 3 {
662            return Vec::new();
663        }
664        let inside = |point: &[f64; 2]| {
665            if keep_below {
666                point[axis] <= bound
667            } else {
668                point[axis] >= bound
669            }
670        };
671        let mut next = Vec::with_capacity(current.len() + 4);
672        for index in 0..current.len() {
673            let a = current[index];
674            let b = current[(index + 1) % current.len()];
675            let a_in = inside(&a);
676            let b_in = inside(&b);
677            if a_in {
678                next.push(a);
679            }
680            if a_in != b_in {
681                let t = (bound - a[axis]) / (b[axis] - a[axis]);
682                let mut crossing = [0.0; 2];
683                crossing[axis] = bound;
684                crossing[1 - axis] = a[1 - axis] + t * (b[1 - axis] - a[1 - axis]);
685                next.push(crossing);
686            }
687        }
688        current = next;
689    }
690    current
691}
692
693pub(super) fn cell_breaks(knots: &[f64], degree: usize, low: f64, high: f64) -> Vec<f64> {
694    let mut breaks = vec![low, high];
695    breaks.extend(interior_knots(knots, degree));
696    breaks.sort_by(f64::total_cmp);
697    breaks.dedup_by(|a, b| (*a - *b).abs() <= (high - low) * 1e-12);
698    // Subdivide spans so the base grid has at least MINIMUM_CELLS cells
699    // across the domain; boundary cells then refine adaptively.
700    const MINIMUM_CELLS: usize = 8;
701    let target = (high - low) / MINIMUM_CELLS as f64;
702    let mut refined = Vec::with_capacity(breaks.len() * 2);
703    for pair in breaks.windows(2) {
704        refined.push(pair[0]);
705        let span = pair[1] - pair[0];
706        let pieces = (span / target).ceil().max(1.0) as usize;
707        for piece in 1..pieces {
708            refined.push(pair[0] + span * piece as f64 / pieces as f64);
709        }
710    }
711    refined.push(high);
712    refined
713}
714
715/// Tile one period's `cell_breaks` across `[lo, hi]` for a periodic direction
716/// whose unwrapped trim ran past the domain end. Shifted copies of the base
717/// breaks keep the knot-conforming grid on every covered period; a direction
718/// that stayed in-domain yields exactly the base breaks.
719pub(super) fn tiled_breaks(base: &[f64], lo: f64, hi: f64, period: f64) -> Vec<f64> {
720    if period <= 0.0 || base.len() < 2 {
721        return base.to_vec();
722    }
723    let origin = base[0];
724    let first = ((lo - origin) / period).floor() as i64;
725    let last = ((hi - origin) / period).ceil() as i64;
726    let mut breaks = Vec::new();
727    for tile in first..=last {
728        let shift = tile as f64 * period;
729        for &value in base {
730            let shifted = value + shift;
731            if breaks.last().map_or(true, |&previous: &f64| {
732                (shifted - previous).abs() > 1e-12 * period
733            }) {
734                breaks.push(shifted);
735            }
736        }
737    }
738    breaks
739}