Skip to main content

brepkit_check/properties/
face_integrator.rs

1//! Per-face Gauss quadrature integration for area, volume, CoM, and inertia.
2//!
3//! Provides numerical integration of geometric properties over individual
4//! faces. Planar faces use polygon fan triangulation; parametric faces
5//! (cylinder, cone, sphere, torus, NURBS) use tensor-product Gauss-Legendre
6//! quadrature over the UV domain.
7
8use brepkit_math::quadrature::gauss_legendre_points;
9use brepkit_math::traits::ParametricSurface;
10use brepkit_math::vec::{Point3, Vec3};
11use brepkit_topology::Topology;
12use brepkit_topology::edge::EdgeCurve;
13use brepkit_topology::face::{FaceId, FaceSurface};
14
15use crate::CheckError;
16
17/// Contribution of a single face to global geometric properties.
18#[derive(Debug, Clone)]
19pub struct FaceContribution {
20    /// Face area.
21    pub area: f64,
22    /// Volume contribution: (1/3) integral of P dot N dA.
23    pub volume: f64,
24    /// Volume-weighted x-moment: (1/2) integral of x^2 * n_x dA (divergence theorem).
25    pub volume_moment_x: f64,
26    /// Volume-weighted y-moment: (1/2) integral of y^2 * n_y dA (divergence theorem).
27    pub volume_moment_y: f64,
28    /// Volume-weighted z-moment: (1/2) integral of z^2 * n_z dA (divergence theorem).
29    pub volume_moment_z: f64,
30    /// Area-weighted centroid x-component (for surface centroid, not solid CoM).
31    pub centroid_x: f64,
32    /// Area-weighted centroid y-component (for surface centroid, not solid CoM).
33    pub centroid_y: f64,
34    /// Area-weighted centroid z-component (for surface centroid, not solid CoM).
35    pub centroid_z: f64,
36}
37
38/// Integrate a face's geometric contribution using Gauss quadrature.
39///
40/// For planar faces, evaluates via polygon fan triangulation. For
41/// parametric surfaces (analytic and NURBS), evaluates the surface and its
42/// partial derivatives on a Gauss-point grid over the UV domain derived
43/// from the face's boundary vertices.
44///
45/// # Errors
46///
47/// Returns an error if topology entities are missing or the face has
48/// insufficient geometry for integration.
49#[allow(clippy::too_many_lines)]
50pub fn integrate_face(
51    topo: &Topology,
52    face_id: FaceId,
53    gauss_order: usize,
54) -> Result<FaceContribution, CheckError> {
55    let face = topo.face(face_id)?;
56    let reversed = face.is_reversed();
57    let sign = if reversed { -1.0 } else { 1.0 };
58
59    match face.surface() {
60        FaceSurface::Plane { normal, .. } => {
61            let effective_normal = if reversed { -*normal } else { *normal };
62            integrate_planar_face(topo, face_id, effective_normal)
63        }
64        FaceSurface::Cylinder(s) => {
65            let full = (
66                (0.0, std::f64::consts::TAU),
67                (f64::NEG_INFINITY, f64::INFINITY),
68            );
69            let (u_range, v_range) = face_uv_bounds(topo, face_id, s, true, false, full)?;
70            let uv_boundary = build_face_uv_boundary(topo, face_id, |p| s.project_point(p), true)?;
71            Ok(integrate_with_trimming(
72                s,
73                u_range,
74                v_range,
75                gauss_order,
76                sign,
77                &uv_boundary,
78                true,
79                &[],
80            ))
81        }
82        FaceSurface::Cone(s) => {
83            let full = (
84                (0.0, std::f64::consts::TAU),
85                (f64::NEG_INFINITY, f64::INFINITY),
86            );
87            let (u_range, v_range) = face_uv_bounds(topo, face_id, s, true, false, full)?;
88            let uv_boundary = build_face_uv_boundary(topo, face_id, |p| s.project_point(p), true)?;
89            Ok(integrate_with_trimming(
90                s,
91                u_range,
92                v_range,
93                gauss_order,
94                sign,
95                &uv_boundary,
96                true,
97                &[],
98            ))
99        }
100        FaceSurface::Sphere(s) => {
101            let full = (
102                (0.0, std::f64::consts::TAU),
103                (-std::f64::consts::FRAC_PI_2, std::f64::consts::FRAC_PI_2),
104            );
105            let (u_range, v_range) = face_uv_bounds(topo, face_id, s, true, false, full)?;
106            let uv_boundary = build_face_uv_boundary(topo, face_id, |p| s.project_point(p), true)?;
107            let hole_vs = full_revolution_hole_vs(topo, face_id, s);
108            Ok(integrate_with_trimming(
109                s,
110                u_range,
111                v_range,
112                gauss_order,
113                sign,
114                &uv_boundary,
115                true,
116                &hole_vs,
117            ))
118        }
119        FaceSurface::Torus(s) => {
120            let full = ((0.0, std::f64::consts::TAU), (0.0, std::f64::consts::TAU));
121            let (u_range, v_range) = face_uv_bounds(topo, face_id, s, true, true, full)?;
122            let uv_boundary = build_face_uv_boundary(topo, face_id, |p| s.project_point(p), true)?;
123            Ok(integrate_with_trimming(
124                s,
125                u_range,
126                v_range,
127                gauss_order,
128                sign,
129                &uv_boundary,
130                true,
131                &[],
132            ))
133        }
134        FaceSurface::Nurbs(s) => {
135            let full = (s.domain_u(), s.domain_v());
136            let periodic_u = s.is_periodic_u();
137            let periodic_v = s.is_periodic_v();
138            let (u_range, v_range) =
139                face_uv_bounds(topo, face_id, s, periodic_u, periodic_v, full)?;
140            let uv_boundary =
141                build_face_uv_boundary(topo, face_id, |p| s.project_point(p), periodic_u)?;
142            Ok(integrate_with_trimming(
143                s,
144                u_range,
145                v_range,
146                gauss_order,
147                sign,
148                &uv_boundary,
149                periodic_u,
150                &[],
151            ))
152        }
153    }
154}
155
156/// UV domain bounds as `((u_min, u_max), (v_min, v_max))`.
157type UvBounds = ((f64, f64), (f64, f64));
158
159/// The v-positions of a face's full-revolution inner wires (holes) on a
160/// surface periodic in u.
161///
162/// A boolean that drills a cylinder through a sphere leaves each spherical
163/// band bounded by a latitude circle hole (the tunnel rim). Such a hole wraps
164/// the full u-period and sits at a single v, so the band runs from its outer
165/// latitude to the hole — not on to the pole. Collecting these lets the
166/// integrator clip the band instead of over-integrating the polar cap the hole
167/// removed. Each entry is the mean projected v of one full-revolution hole.
168fn full_revolution_hole_vs<S: ParametricSurface>(
169    topo: &Topology,
170    face_id: FaceId,
171    surface: &S,
172) -> Vec<f64> {
173    use std::f64::consts::TAU;
174    let Ok(face) = topo.face(face_id) else {
175        return Vec::new();
176    };
177    let mut out = Vec::new();
178    for &wid in face.inner_wires() {
179        let Ok(wire) = topo.wire(wid) else { continue };
180        let mut us = Vec::new();
181        let mut vs = Vec::new();
182        for oe in wire.edges() {
183            let Ok(edge) = topo.edge(oe.edge()) else {
184                continue;
185            };
186            // Oriented traversal: the wire-ordered start vertex is the edge's
187            // end when the oriented edge is reversed.
188            let vid = if oe.is_forward() {
189                edge.start()
190            } else {
191                edge.end()
192            };
193            let Ok(v) = topo.vertex(vid) else {
194                continue;
195            };
196            let (u, vv) = surface.project_point(v.point());
197            us.push(u);
198            vs.push(vv);
199        }
200        if vs.is_empty() {
201            continue;
202        }
203        let v_min = vs.iter().copied().fold(f64::INFINITY, f64::min);
204        let v_max = vs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
205        // Constant-v latitude circle.
206        if v_max - v_min > 1e-6 {
207            continue;
208        }
209        // Full revolution in u: the unwrapped per-vertex deltas around the
210        // CLOSED loop (including the closing step back to the first vertex) sum
211        // to ≈ TAU. A single-edge closed circle has one vertex, so also accept
212        // holes whose sole edge is a closed circle curve.
213        let unwrapped_span = {
214            let n = us.len();
215            let mut acc = 0.0;
216            for i in 0..n {
217                let d = us[(i + 1) % n] - us[i];
218                acc += d - TAU * ((d + std::f64::consts::PI) / TAU).floor();
219            }
220            acc.abs()
221        };
222        let single_closed_circle = wire.edges().len() == 1
223            && wire.edges().first().is_some_and(|oe| {
224                topo.edge(oe.edge())
225                    .is_ok_and(|e| matches!(e.curve(), EdgeCurve::Circle(_)))
226            });
227        if unwrapped_span >= TAU - 1e-3 || single_closed_circle {
228            out.push(0.5 * (v_min + v_max));
229        }
230    }
231    out
232}
233
234/// Compute UV bounds for a parametric face by projecting boundary vertices
235/// onto the surface and taking the min/max of the resulting parameters.
236///
237/// For surfaces with periodic u or v coordinates (cylinders, cones, spheres,
238/// tori), sequentially unwraps the angular coordinates so that faces straddling
239/// the 0/2pi seam produce correct ranges.
240///
241/// When all projected vertices coincide (e.g. a full-revolution face),
242/// `full_domain` is returned instead.
243///
244/// **Limitation:** Only the outer wire is used for UV bounds. Inner wires
245/// (holes) are handled during Gauss integration by the UV containment check
246/// in `integrate_parametric_trimmed`, but the current containment only tests
247/// against the outer boundary. Faces with holes will over-integrate the hole
248/// region. A proper fix requires multi-polygon UV containment (outer minus
249/// holes).
250fn face_uv_bounds<S: ParametricSurface>(
251    topo: &Topology,
252    face_id: FaceId,
253    surface: &S,
254    periodic_u: bool,
255    periodic_v: bool,
256    full_domain: UvBounds,
257) -> Result<UvBounds, CheckError> {
258    let face = topo.face(face_id)?;
259    let wire = topo.wire(face.outer_wire())?;
260
261    let mut uvs = Vec::new();
262    for oe in wire.edges() {
263        let edge = topo.edge(oe.edge())?;
264        let vid = oe.oriented_start(edge);
265        let pt = topo.vertex(vid)?.point();
266        uvs.push(surface.project_point(pt));
267    }
268
269    if uvs.is_empty() {
270        return Err(CheckError::IntegrationFailed(
271            "face wire has no edges".into(),
272        ));
273    }
274
275    // Unwrap periodic coordinates sequentially so seam-straddling faces
276    // produce a contiguous range instead of the full [0, 2pi).
277    if periodic_u || periodic_v {
278        for i in 1..uvs.len() {
279            if periodic_u {
280                uvs[i].0 = unwrap_angle(uvs[i - 1].0, uvs[i].0);
281            }
282            if periodic_v {
283                uvs[i].1 = unwrap_angle(uvs[i - 1].1, uvs[i].1);
284            }
285        }
286    }
287
288    // Check for coincident vertices (all project to same point) — use full domain.
289    let coincident = uvs.len() < 3 || {
290        let ref_uv = uvs[0];
291        uvs.iter()
292            .all(|uv| (uv.0 - ref_uv.0).abs() < 1e-6 && (uv.1 - ref_uv.1).abs() < 1e-6)
293    };
294    if coincident {
295        return Ok(full_domain);
296    }
297
298    let u_min = uvs.iter().map(|uv| uv.0).fold(f64::INFINITY, f64::min);
299    let mut u_max = uvs.iter().map(|uv| uv.0).fold(f64::NEG_INFINITY, f64::max);
300    let v_min = uvs.iter().map(|uv| uv.1).fold(f64::INFINITY, f64::min);
301    let mut v_max = uvs.iter().map(|uv| uv.1).fold(f64::NEG_INFINITY, f64::max);
302
303    // All boundary vertices on the seam of a periodic axis (e.g. a
304    // full-revolution lateral face whose circles start/end at the seam)
305    // collapse that axis's range to zero — the face actually spans the
306    // full period.
307    if periodic_u && u_max - u_min < 1e-9 {
308        u_max = u_min + (full_domain.0.1 - full_domain.0.0);
309    }
310    if periodic_v && v_max - v_min < 1e-9 {
311        v_max = v_min + (full_domain.1.1 - full_domain.1.0);
312    }
313
314    if u_min >= u_max || v_min >= v_max {
315        // A degenerate projection (e.g. all boundary vertices on a sphere's
316        // pole seam) does not mean an empty face — it means the boundary failed
317        // to bound a sub-region, so the face spans the full analytic domain.
318        return Ok(full_domain);
319    }
320
321    Ok(((u_min, u_max), (v_min, v_max)))
322}
323
324/// Unwrap a step in a periodic (angular) coordinate to avoid discontinuities.
325///
326/// Adjusts `next` so that `next - prev` lies in `(-pi, pi]`, keeping the
327/// sequence monotonic through the 0/2pi seam.
328fn unwrap_angle(prev: f64, next: f64) -> f64 {
329    let tau = std::f64::consts::TAU;
330    let diff = next - prev;
331    prev + diff - tau * ((diff + std::f64::consts::PI) / tau).floor()
332}
333
334/// Integrate a planar face using polygon fan triangulation.
335///
336/// Inner wires (holes) are integrated the same way and subtracted from the
337/// outer-wire contribution.
338fn integrate_planar_face(
339    topo: &Topology,
340    face_id: FaceId,
341    normal: Vec3,
342) -> Result<FaceContribution, CheckError> {
343    let polygon = crate::util::face_polygon(topo, face_id)?;
344    let mut contrib = integrate_planar_polygon(&polygon, normal);
345
346    let face = topo.face(face_id)?;
347    let inner: Vec<_> = face.inner_wires().to_vec();
348    for wid in inner {
349        let hole = crate::util::wire_polygon(topo, wid)?;
350        let h = integrate_planar_polygon(&hole, normal);
351        contrib.area -= h.area;
352        contrib.volume -= h.volume;
353        contrib.volume_moment_x -= h.volume_moment_x;
354        contrib.volume_moment_y -= h.volume_moment_y;
355        contrib.volume_moment_z -= h.volume_moment_z;
356        contrib.centroid_x -= h.centroid_x;
357        contrib.centroid_y -= h.centroid_y;
358        contrib.centroid_z -= h.centroid_z;
359    }
360
361    Ok(contrib)
362}
363
364/// Integrate a planar polygon's contribution via fan triangulation.
365fn integrate_planar_polygon(polygon: &[Point3], normal: Vec3) -> FaceContribution {
366    if polygon.len() < 3 {
367        return FaceContribution {
368            area: 0.0,
369            volume: 0.0,
370            volume_moment_x: 0.0,
371            volume_moment_y: 0.0,
372            volume_moment_z: 0.0,
373            centroid_x: 0.0,
374            centroid_y: 0.0,
375            centroid_z: 0.0,
376        };
377    }
378
379    // Fan triangulation from vertex 0 with SIGNED triangle areas (projected
380    // onto the face normal): a fan over a NON-CONVEX polygon (a notched
381    // boolean cap) sweeps triangles across the notch, and an unsigned fan
382    // counts those positively — the notch region then adds instead of
383    // cancelling, over-counting by an amount that depends on where vertex 0
384    // happens to sit. Signed accumulation makes the fan exact for any
385    // simple planar polygon; a globally CW polygon nets negative and is
386    // flipped wholesale below.
387    let mut area = 0.0;
388    let mut vol = 0.0;
389    let mut mx = 0.0;
390    let mut my = 0.0;
391    let mut mz = 0.0;
392    let mut cx = 0.0;
393    let mut cy = 0.0;
394    let mut cz = 0.0;
395
396    for i in 1..polygon.len() - 1 {
397        let (a, b, c) = (polygon[0], polygon[i], polygon[i + 1]);
398        let ab = b - a;
399        let ac = c - a;
400        let cross = Vec3::new(
401            ab.y() * ac.z() - ab.z() * ac.y(),
402            ab.z() * ac.x() - ab.x() * ac.z(),
403            ab.x() * ac.y() - ab.y() * ac.x(),
404        );
405        let tri_area = cross.dot(normal) * 0.5;
406        area += tri_area;
407
408        // Volume contribution: (1/3) * centroid dot normal * area
409        let centroid = Point3::new(
410            (a.x() + b.x() + c.x()) / 3.0,
411            (a.y() + b.y() + c.y()) / 3.0,
412            (a.z() + b.z() + c.z()) / 3.0,
413        );
414        let pv = Vec3::new(centroid.x(), centroid.y(), centroid.z());
415        vol += pv.dot(normal) * tri_area / 3.0;
416
417        // Volume moments via divergence theorem: (1/2) integral of x^2 * n_x dA
418        // For a planar triangle with constant normal, integral of x^2 over triangle
419        // = (area/3) * (x_a^2 + x_b^2 + x_c^2 + x_a*x_b + x_a*x_c + x_b*x_c) / 2
420        // Simplified: use (x_a^2 + x_b^2 + x_c^2 + x_a*x_b + x_a*x_c + x_b*x_c)/6
421        let avg_x2 = (a.x() * a.x()
422            + b.x() * b.x()
423            + c.x() * c.x()
424            + a.x() * b.x()
425            + a.x() * c.x()
426            + b.x() * c.x())
427            / 6.0;
428        let avg_y2 = (a.y() * a.y()
429            + b.y() * b.y()
430            + c.y() * c.y()
431            + a.y() * b.y()
432            + a.y() * c.y()
433            + b.y() * c.y())
434            / 6.0;
435        let avg_z2 = (a.z() * a.z()
436            + b.z() * b.z()
437            + c.z() * c.z()
438            + a.z() * b.z()
439            + a.z() * c.z()
440            + b.z() * c.z())
441            / 6.0;
442        mx += 0.5 * avg_x2 * normal.x() * tri_area;
443        my += 0.5 * avg_y2 * normal.y() * tri_area;
444        mz += 0.5 * avg_z2 * normal.z() * tri_area;
445
446        cx += centroid.x() * tri_area;
447        cy += centroid.y() * tri_area;
448        cz += centroid.z() * tri_area;
449    }
450
451    // A polygon wound CW about `normal` nets a negative signed area; flip
452    // every accumulated quantity so callers keep the historical positive-
453    // area contract (hole handling in `integrate_planar_face` subtracts).
454    let flip = if area < 0.0 { -1.0 } else { 1.0 };
455    FaceContribution {
456        area: area * flip,
457        volume: vol * flip,
458        volume_moment_x: mx * flip,
459        volume_moment_y: my * flip,
460        volume_moment_z: mz * flip,
461        centroid_x: cx * flip,
462        centroid_y: cy * flip,
463        centroid_z: cz * flip,
464    }
465}
466
467/// Integrate a parametric surface using Gauss quadrature over the UV domain.
468#[allow(clippy::cast_precision_loss)]
469fn integrate_parametric<S: ParametricSurface>(
470    surface: &S,
471    u_range: (f64, f64),
472    v_range: (f64, f64),
473    gauss_order: usize,
474    sign: f64,
475) -> FaceContribution {
476    // Composite quadrature: tile the domain into patches no larger than ~PI/4
477    // so one Gauss rule resolves curved and periodic integrands. A single patch
478    // over a torus's full 2*PI period in both u and v under-resolves it (~0.5%
479    // error); several patches per period converge to machine precision. The
480    // patch count is capped so a long *linear* axis (e.g. a tall cylinder/cone
481    // whose v is axial distance) cannot make integration cost scale with model
482    // size — its integrand is low-degree, so a bounded number of patches stays
483    // exact. Angular axes never exceed 2*PI (= 8 patches), well under the cap.
484    const MAX_PATCHES: usize = 16;
485
486    let gauss_pts = gauss_legendre_points(gauss_order);
487    let patch = std::f64::consts::FRAC_PI_4;
488    let nu = (((u_range.1 - u_range.0).abs() / patch).ceil() as usize).clamp(1, MAX_PATCHES);
489    let nv = (((v_range.1 - v_range.0).abs() / patch).ceil() as usize).clamp(1, MAX_PATCHES);
490    let du_patch = (u_range.1 - u_range.0) / nu as f64;
491    let dv_patch = (v_range.1 - v_range.0) / nv as f64;
492    let u_scale = du_patch / 2.0;
493    let v_scale = dv_patch / 2.0;
494
495    let mut area = 0.0;
496    let mut vol = 0.0;
497    let mut mx = 0.0;
498    let mut my = 0.0;
499    let mut mz = 0.0;
500    let mut cx = 0.0;
501    let mut cy = 0.0;
502    let mut cz = 0.0;
503
504    for iu in 0..nu {
505        let u_mid = du_patch.mul_add(iu as f64, u_range.0) + u_scale;
506        for iv in 0..nv {
507            let v_mid = dv_patch.mul_add(iv as f64, v_range.0) + v_scale;
508            for gpu in gauss_pts {
509                let u = u_scale.mul_add(gpu.x, u_mid);
510                for gpv in gauss_pts {
511                    let v = v_scale.mul_add(gpv.x, v_mid);
512                    let w = gpu.w * gpv.w * u_scale * v_scale;
513
514                    let p = surface.evaluate(u, v);
515                    let du = surface.partial_u(u, v);
516                    let dv = surface.partial_v(u, v);
517
518                    // Normal = du x dv (unnormalized, includes Jacobian)
519                    let n = Vec3::new(
520                        du.y() * dv.z() - du.z() * dv.y(),
521                        du.z() * dv.x() - du.x() * dv.z(),
522                        du.x() * dv.y() - du.y() * dv.x(),
523                    );
524                    let n_len = n.length();
525
526                    area += w * n_len;
527
528                    // Volume: (1/3) P dot N (unnormalized N includes Jacobian)
529                    let pv = Vec3::new(p.x(), p.y(), p.z());
530                    vol += w * pv.dot(n) / 3.0;
531
532                    // Volume moments via divergence theorem:
533                    // CoM_x = (1/2V) surface_integral(x^2 * n_x dA)
534                    // n already includes Jacobian, so n.x() = N_x * |J|
535                    mx += w * 0.5 * p.x() * p.x() * n.x();
536                    my += w * 0.5 * p.y() * p.y() * n.y();
537                    mz += w * 0.5 * p.z() * p.z() * n.z();
538
539                    cx += w * p.x() * n_len;
540                    cy += w * p.y() * n_len;
541                    cz += w * p.z() * n_len;
542                }
543            }
544        }
545    }
546
547    FaceContribution {
548        area,
549        volume: vol * sign,
550        volume_moment_x: mx * sign,
551        volume_moment_y: my * sign,
552        volume_moment_z: mz * sign,
553        centroid_x: cx,
554        centroid_y: cy,
555        centroid_z: cz,
556    }
557}
558
559/// Absolute shoelace area of a UV polygon. Near-zero means the boundary has
560/// collapsed onto a line or point (a degenerate seam/pole projection).
561fn polygon_area(poly: &[(f64, f64)]) -> f64 {
562    let n = poly.len();
563    if n < 3 {
564        return 0.0;
565    }
566    let mut a = 0.0;
567    for i in 0..n {
568        let (x0, y0) = poly[i];
569        let (x1, y1) = poly[(i + 1) % n];
570        a += x0 * y1 - x1 * y0;
571    }
572    (a * 0.5).abs()
573}
574
575/// Dispatch to trimmed or untrimmed parametric integration based on whether
576/// a UV boundary polygon is available.
577#[allow(clippy::too_many_arguments)]
578fn integrate_with_trimming<S: ParametricSurface>(
579    surface: &S,
580    u_range: (f64, f64),
581    v_range: (f64, f64),
582    gauss_order: usize,
583    sign: f64,
584    uv_boundary: &[(f64, f64)],
585    u_periodic: bool,
586    hole_vs: &[f64],
587) -> FaceContribution {
588    if uv_boundary.len() < 3 {
589        return integrate_parametric(surface, u_range, v_range, gauss_order, sign);
590    }
591
592    // The dense boundary polygon is the reliable signal for a face's true
593    // parametric extent: `face_uv_bounds` samples only sparse edge endpoints and
594    // under-spans full-revolution faces (a cone's lateral face reports a narrow
595    // u-range though its boundary wraps the full 2pi). A face that wraps the
596    // full period in u, or whose boundary collapses onto a seam or pole, cannot
597    // be trimmed by a UV polygon — the apex/pole/seam folds the polygon and the
598    // point-in-polygon test rejects valid interior samples. Integrate the
599    // analytic surface untrimmed over its true domain in those cases.
600    let u_min = uv_boundary
601        .iter()
602        .map(|p| p.0)
603        .fold(f64::INFINITY, f64::min);
604    let v_min = uv_boundary
605        .iter()
606        .map(|p| p.1)
607        .fold(f64::INFINITY, f64::min);
608    let v_max = uv_boundary
609        .iter()
610        .map(|p| p.1)
611        .fold(f64::NEG_INFINITY, f64::max);
612
613    // Winding number of the boundary around the periodic u-axis: ±TAU for a
614    // face that wraps a full revolution, ~0 for a partially-trimmed face.
615    // Computed from shortest signed steps so it is independent of the
616    // boundary's discretization (segment count).
617    let tau = std::f64::consts::TAU;
618    let winding: f64 = (0..uv_boundary.len())
619        .map(|i| {
620            let d = uv_boundary[(i + 1) % uv_boundary.len()].0 - uv_boundary[i].0;
621            d - tau * ((d + std::f64::consts::PI) / tau).floor()
622        })
623        .sum();
624    let full_revolution = u_periodic && winding.abs() >= tau - 1e-3;
625    let v_degenerate = (v_max - v_min) <= 1e-9;
626
627    if full_revolution && v_degenerate {
628        // Polar cap (e.g. a sphere hemisphere bounded only by one latitude
629        // circle): the cap runs from that latitude to a pole. The winding sign
630        // (CCW vs CW boundary) selects which pole — the boundary's interior
631        // side — so the two hemispheres do not both integrate the whole sphere.
632        let v_pole = if winding >= 0.0 { v_range.1 } else { v_range.0 };
633        // A full-revolution hole at a latitude between the outer circle and the
634        // pole (the drilled-tunnel rim) clips the cap into a band: integrate
635        // only from the outer latitude to the hole, not on to the pole.
636        let v_far = hole_vs
637            .iter()
638            .copied()
639            // Same side of v_min as the pole (strict same sign → positive
640            // product), and not coincident with v_min.
641            .filter(|&hv| (hv - v_min) * (v_pole - v_min) > 0.0 && (hv - v_min).abs() > 1e-9)
642            .min_by(|a, b| (a - v_min).abs().total_cmp(&(b - v_min).abs()))
643            .unwrap_or(v_pole);
644        let v_dom = (v_min.min(v_far), v_min.max(v_far));
645        integrate_parametric(surface, (u_min, u_min + tau), v_dom, gauss_order, sign)
646    } else if full_revolution {
647        // Full-revolution band (cone/cylinder): integrate the whole revolution
648        // over the band's v-extent.
649        integrate_parametric(
650            surface,
651            (u_min, u_min + tau),
652            (v_min, v_max),
653            gauss_order,
654            sign,
655        )
656    } else if polygon_area(uv_boundary) <= 1e-12 {
657        // Collapsed polygon (e.g. a closed torus whose seam projects to a
658        // point): trust the analytic full-domain range from `face_uv_bounds`.
659        integrate_parametric(surface, u_range, v_range, gauss_order, sign)
660    } else {
661        integrate_parametric_trimmed(
662            surface,
663            u_range,
664            v_range,
665            gauss_order,
666            sign,
667            uv_boundary,
668            u_periodic,
669        )
670    }
671}
672
673/// Integrate a parametric surface with UV boundary trimming.
674///
675/// At each Gauss point, checks if the (u,v) coordinate falls inside the
676/// face's UV boundary polygon. Points outside are skipped (zero contribution).
677#[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
678fn integrate_parametric_trimmed<S: ParametricSurface>(
679    surface: &S,
680    u_range: (f64, f64),
681    v_range: (f64, f64),
682    gauss_order: usize,
683    sign: f64,
684    uv_boundary: &[(f64, f64)],
685    u_periodic: bool,
686) -> FaceContribution {
687    use brepkit_math::predicates::point_in_polygon;
688    use brepkit_math::vec::Point2;
689
690    let gauss_pts = gauss_legendre_points(gauss_order);
691    let u_scale = (u_range.1 - u_range.0) / 2.0;
692    let u_mid = f64::midpoint(u_range.0, u_range.1);
693    let v_scale = (v_range.1 - v_range.0) / 2.0;
694    let v_mid = f64::midpoint(v_range.0, v_range.1);
695
696    let uv_poly: Vec<Point2> = uv_boundary
697        .iter()
698        .map(|(u, v)| Point2::new(*u, *v))
699        .collect();
700
701    let u_bcenter = if u_periodic {
702        let bmin = uv_boundary
703            .iter()
704            .map(|(bu, _)| *bu)
705            .fold(f64::INFINITY, f64::min);
706        let bmax = uv_boundary
707            .iter()
708            .map(|(bu, _)| *bu)
709            .fold(f64::NEG_INFINITY, f64::max);
710        (bmin + bmax) * 0.5
711    } else {
712        0.0
713    };
714
715    let mut area = 0.0;
716    let mut vol = 0.0;
717    let mut mx = 0.0;
718    let mut my = 0.0;
719    let mut mz = 0.0;
720    let mut cx = 0.0;
721    let mut cy = 0.0;
722    let mut cz = 0.0;
723
724    for gpu in gauss_pts {
725        let u = u_scale.mul_add(gpu.x, u_mid);
726        for gpv in gauss_pts {
727            let v = v_scale.mul_add(gpv.x, v_mid);
728
729            let test_u = if u_periodic {
730                let tau = std::f64::consts::TAU;
731                let diff = u - u_bcenter;
732                u_bcenter + diff - tau * ((diff + std::f64::consts::PI) / tau).floor()
733            } else {
734                u
735            };
736
737            if !point_in_polygon(Point2::new(test_u, v), &uv_poly) {
738                continue;
739            }
740
741            let w = gpu.w * gpv.w * u_scale * v_scale;
742            let p = surface.evaluate(u, v);
743            let du = surface.partial_u(u, v);
744            let dv = surface.partial_v(u, v);
745            let n = Vec3::new(
746                du.y() * dv.z() - du.z() * dv.y(),
747                du.z() * dv.x() - du.x() * dv.z(),
748                du.x() * dv.y() - du.y() * dv.x(),
749            );
750            let n_len = n.length();
751
752            area += w * n_len;
753
754            let pv = Vec3::new(p.x(), p.y(), p.z());
755            vol += w * pv.dot(n) / 3.0;
756
757            mx += w * 0.5 * p.x() * p.x() * n.x();
758            my += w * 0.5 * p.y() * p.y() * n.y();
759            mz += w * 0.5 * p.z() * p.z() * n.z();
760
761            cx += w * p.x() * n_len;
762            cy += w * p.y() * n_len;
763            cz += w * p.z() * n_len;
764        }
765    }
766
767    FaceContribution {
768        area,
769        volume: vol * sign,
770        volume_moment_x: mx * sign,
771        volume_moment_y: my * sign,
772        volume_moment_z: mz * sign,
773        centroid_x: cx,
774        centroid_y: cy,
775        centroid_z: cz,
776    }
777}
778
779/// Build a UV boundary polygon from a face's outer wire.
780///
781/// Projects each boundary vertex onto the surface to obtain (u, v) coordinates,
782/// then unwraps periodic u-coordinates to avoid seam discontinuities.
783fn build_face_uv_boundary<F>(
784    topo: &Topology,
785    face_id: FaceId,
786    project: F,
787    u_periodic: bool,
788) -> Result<Vec<(f64, f64)>, CheckError>
789where
790    F: Fn(Point3) -> (f64, f64),
791{
792    let polygon = crate::util::face_polygon(topo, face_id)?;
793    if polygon.len() < 3 {
794        return Ok(vec![]);
795    }
796
797    let mut uv: Vec<(f64, f64)> = polygon.iter().map(|&p| project(p)).collect();
798
799    for i in 1..uv.len() {
800        if u_periodic {
801            uv[i].0 = unwrap_angle(uv[i - 1].0, uv[i].0);
802        }
803    }
804
805    Ok(uv)
806}
807
808#[cfg(test)]
809mod tests {
810    #![allow(clippy::unwrap_used, clippy::expect_used)]
811
812    use super::*;
813    use brepkit_math::vec::{Point3, Vec3};
814
815    #[test]
816    fn planar_fan_is_signed_on_nonconvex_polygons() {
817        // An L-shape (10x10 square minus a 5x5 corner notch, area 75). The
818        // fan pivot at (0,0) sweeps triangles across the notch; an unsigned
819        // fan counted them positively and measured 87.5.
820        let poly = [
821            Point3::new(0.0, 0.0, 2.0),
822            Point3::new(10.0, 0.0, 2.0),
823            Point3::new(10.0, 5.0, 2.0),
824            Point3::new(5.0, 5.0, 2.0),
825            Point3::new(5.0, 10.0, 2.0),
826            Point3::new(0.0, 10.0, 2.0),
827        ];
828        let up = Vec3::new(0.0, 0.0, 1.0);
829        let c = integrate_planar_polygon(&poly, up);
830        assert!((c.area - 75.0).abs() < 1e-9, "area {}", c.area);
831        assert!(
832            (c.volume - 2.0 * 75.0 / 3.0).abs() < 1e-9,
833            "vol {}",
834            c.volume
835        );
836
837        // The same polygon wound CW nets negative and must flip wholesale,
838        // preserving the positive-area contract the hole subtraction relies on.
839        let rev: Vec<Point3> = poly.iter().rev().copied().collect();
840        let c2 = integrate_planar_polygon(&rev, up);
841        assert!((c2.area - 75.0).abs() < 1e-9, "rev area {}", c2.area);
842    }
843}