use brepkit_math::quadrature::gauss_legendre_points;
use brepkit_math::traits::ParametricSurface;
use brepkit_math::vec::{Point3, Vec3};
use brepkit_topology::Topology;
use brepkit_topology::edge::EdgeCurve;
use brepkit_topology::face::{FaceId, FaceSurface};
use crate::CheckError;
#[derive(Debug, Clone)]
pub struct FaceContribution {
pub area: f64,
pub volume: f64,
pub volume_moment_x: f64,
pub volume_moment_y: f64,
pub volume_moment_z: f64,
pub centroid_x: f64,
pub centroid_y: f64,
pub centroid_z: f64,
}
#[allow(clippy::too_many_lines)]
pub fn integrate_face(
topo: &Topology,
face_id: FaceId,
gauss_order: usize,
) -> Result<FaceContribution, CheckError> {
let face = topo.face(face_id)?;
let reversed = face.is_reversed();
let sign = if reversed { -1.0 } else { 1.0 };
match face.surface() {
FaceSurface::Plane { normal, .. } => {
let effective_normal = if reversed { -*normal } else { *normal };
integrate_planar_face(topo, face_id, effective_normal)
}
FaceSurface::Cylinder(s) => {
let full = (
(0.0, std::f64::consts::TAU),
(f64::NEG_INFINITY, f64::INFINITY),
);
let (u_range, v_range) = face_uv_bounds(topo, face_id, s, true, false, full)?;
let uv_boundary = build_face_uv_boundary(topo, face_id, |p| s.project_point(p), true)?;
Ok(integrate_with_trimming(
s,
u_range,
v_range,
gauss_order,
sign,
&uv_boundary,
true,
&[],
))
}
FaceSurface::Cone(s) => {
let full = (
(0.0, std::f64::consts::TAU),
(f64::NEG_INFINITY, f64::INFINITY),
);
let (u_range, v_range) = face_uv_bounds(topo, face_id, s, true, false, full)?;
let uv_boundary = build_face_uv_boundary(topo, face_id, |p| s.project_point(p), true)?;
Ok(integrate_with_trimming(
s,
u_range,
v_range,
gauss_order,
sign,
&uv_boundary,
true,
&[],
))
}
FaceSurface::Sphere(s) => {
let full = (
(0.0, std::f64::consts::TAU),
(-std::f64::consts::FRAC_PI_2, std::f64::consts::FRAC_PI_2),
);
let (u_range, v_range) = face_uv_bounds(topo, face_id, s, true, false, full)?;
let uv_boundary = build_face_uv_boundary(topo, face_id, |p| s.project_point(p), true)?;
let hole_vs = full_revolution_hole_vs(topo, face_id, s);
Ok(integrate_with_trimming(
s,
u_range,
v_range,
gauss_order,
sign,
&uv_boundary,
true,
&hole_vs,
))
}
FaceSurface::Torus(s) => {
let full = ((0.0, std::f64::consts::TAU), (0.0, std::f64::consts::TAU));
let (u_range, v_range) = face_uv_bounds(topo, face_id, s, true, true, full)?;
let uv_boundary = build_face_uv_boundary(topo, face_id, |p| s.project_point(p), true)?;
Ok(integrate_with_trimming(
s,
u_range,
v_range,
gauss_order,
sign,
&uv_boundary,
true,
&[],
))
}
FaceSurface::Nurbs(s) => {
let full = (s.domain_u(), s.domain_v());
let periodic_u = s.is_periodic_u();
let periodic_v = s.is_periodic_v();
let (u_range, v_range) =
face_uv_bounds(topo, face_id, s, periodic_u, periodic_v, full)?;
let uv_boundary =
build_face_uv_boundary(topo, face_id, |p| s.project_point(p), periodic_u)?;
Ok(integrate_with_trimming(
s,
u_range,
v_range,
gauss_order,
sign,
&uv_boundary,
periodic_u,
&[],
))
}
}
}
type UvBounds = ((f64, f64), (f64, f64));
fn full_revolution_hole_vs<S: ParametricSurface>(
topo: &Topology,
face_id: FaceId,
surface: &S,
) -> Vec<f64> {
use std::f64::consts::TAU;
let Ok(face) = topo.face(face_id) else {
return Vec::new();
};
let mut out = Vec::new();
for &wid in face.inner_wires() {
let Ok(wire) = topo.wire(wid) else { continue };
let mut us = Vec::new();
let mut vs = Vec::new();
for oe in wire.edges() {
let Ok(edge) = topo.edge(oe.edge()) else {
continue;
};
let vid = if oe.is_forward() {
edge.start()
} else {
edge.end()
};
let Ok(v) = topo.vertex(vid) else {
continue;
};
let (u, vv) = surface.project_point(v.point());
us.push(u);
vs.push(vv);
}
if vs.is_empty() {
continue;
}
let v_min = vs.iter().copied().fold(f64::INFINITY, f64::min);
let v_max = vs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
if v_max - v_min > 1e-6 {
continue;
}
let unwrapped_span = {
let n = us.len();
let mut acc = 0.0;
for i in 0..n {
let d = us[(i + 1) % n] - us[i];
acc += d - TAU * ((d + std::f64::consts::PI) / TAU).floor();
}
acc.abs()
};
let single_closed_circle = wire.edges().len() == 1
&& wire.edges().first().is_some_and(|oe| {
topo.edge(oe.edge())
.is_ok_and(|e| matches!(e.curve(), EdgeCurve::Circle(_)))
});
if unwrapped_span >= TAU - 1e-3 || single_closed_circle {
out.push(0.5 * (v_min + v_max));
}
}
out
}
fn face_uv_bounds<S: ParametricSurface>(
topo: &Topology,
face_id: FaceId,
surface: &S,
periodic_u: bool,
periodic_v: bool,
full_domain: UvBounds,
) -> Result<UvBounds, CheckError> {
let face = topo.face(face_id)?;
let wire = topo.wire(face.outer_wire())?;
let mut uvs = Vec::new();
for oe in wire.edges() {
let edge = topo.edge(oe.edge())?;
let vid = oe.oriented_start(edge);
let pt = topo.vertex(vid)?.point();
uvs.push(surface.project_point(pt));
}
if uvs.is_empty() {
return Err(CheckError::IntegrationFailed(
"face wire has no edges".into(),
));
}
if periodic_u || periodic_v {
for i in 1..uvs.len() {
if periodic_u {
uvs[i].0 = unwrap_angle(uvs[i - 1].0, uvs[i].0);
}
if periodic_v {
uvs[i].1 = unwrap_angle(uvs[i - 1].1, uvs[i].1);
}
}
}
let coincident = uvs.len() < 3 || {
let ref_uv = uvs[0];
uvs.iter()
.all(|uv| (uv.0 - ref_uv.0).abs() < 1e-6 && (uv.1 - ref_uv.1).abs() < 1e-6)
};
if coincident {
return Ok(full_domain);
}
let u_min = uvs.iter().map(|uv| uv.0).fold(f64::INFINITY, f64::min);
let mut u_max = uvs.iter().map(|uv| uv.0).fold(f64::NEG_INFINITY, f64::max);
let v_min = uvs.iter().map(|uv| uv.1).fold(f64::INFINITY, f64::min);
let mut v_max = uvs.iter().map(|uv| uv.1).fold(f64::NEG_INFINITY, f64::max);
if periodic_u && u_max - u_min < 1e-9 {
u_max = u_min + (full_domain.0.1 - full_domain.0.0);
}
if periodic_v && v_max - v_min < 1e-9 {
v_max = v_min + (full_domain.1.1 - full_domain.1.0);
}
if u_min >= u_max || v_min >= v_max {
return Ok(full_domain);
}
Ok(((u_min, u_max), (v_min, v_max)))
}
fn unwrap_angle(prev: f64, next: f64) -> f64 {
let tau = std::f64::consts::TAU;
let diff = next - prev;
prev + diff - tau * ((diff + std::f64::consts::PI) / tau).floor()
}
fn integrate_planar_face(
topo: &Topology,
face_id: FaceId,
normal: Vec3,
) -> Result<FaceContribution, CheckError> {
let polygon = crate::util::face_polygon(topo, face_id)?;
let mut contrib = integrate_planar_polygon(&polygon, normal);
let face = topo.face(face_id)?;
let inner: Vec<_> = face.inner_wires().to_vec();
for wid in inner {
let hole = crate::util::wire_polygon(topo, wid)?;
let h = integrate_planar_polygon(&hole, normal);
contrib.area -= h.area;
contrib.volume -= h.volume;
contrib.volume_moment_x -= h.volume_moment_x;
contrib.volume_moment_y -= h.volume_moment_y;
contrib.volume_moment_z -= h.volume_moment_z;
contrib.centroid_x -= h.centroid_x;
contrib.centroid_y -= h.centroid_y;
contrib.centroid_z -= h.centroid_z;
}
Ok(contrib)
}
fn integrate_planar_polygon(polygon: &[Point3], normal: Vec3) -> FaceContribution {
if polygon.len() < 3 {
return FaceContribution {
area: 0.0,
volume: 0.0,
volume_moment_x: 0.0,
volume_moment_y: 0.0,
volume_moment_z: 0.0,
centroid_x: 0.0,
centroid_y: 0.0,
centroid_z: 0.0,
};
}
let mut area = 0.0;
let mut vol = 0.0;
let mut mx = 0.0;
let mut my = 0.0;
let mut mz = 0.0;
let mut cx = 0.0;
let mut cy = 0.0;
let mut cz = 0.0;
for i in 1..polygon.len() - 1 {
let (a, b, c) = (polygon[0], polygon[i], polygon[i + 1]);
let ab = b - a;
let ac = c - a;
let cross = Vec3::new(
ab.y() * ac.z() - ab.z() * ac.y(),
ab.z() * ac.x() - ab.x() * ac.z(),
ab.x() * ac.y() - ab.y() * ac.x(),
);
let tri_area = cross.dot(normal) * 0.5;
area += tri_area;
let centroid = Point3::new(
(a.x() + b.x() + c.x()) / 3.0,
(a.y() + b.y() + c.y()) / 3.0,
(a.z() + b.z() + c.z()) / 3.0,
);
let pv = Vec3::new(centroid.x(), centroid.y(), centroid.z());
vol += pv.dot(normal) * tri_area / 3.0;
let avg_x2 = (a.x() * a.x()
+ b.x() * b.x()
+ c.x() * c.x()
+ a.x() * b.x()
+ a.x() * c.x()
+ b.x() * c.x())
/ 6.0;
let avg_y2 = (a.y() * a.y()
+ b.y() * b.y()
+ c.y() * c.y()
+ a.y() * b.y()
+ a.y() * c.y()
+ b.y() * c.y())
/ 6.0;
let avg_z2 = (a.z() * a.z()
+ b.z() * b.z()
+ c.z() * c.z()
+ a.z() * b.z()
+ a.z() * c.z()
+ b.z() * c.z())
/ 6.0;
mx += 0.5 * avg_x2 * normal.x() * tri_area;
my += 0.5 * avg_y2 * normal.y() * tri_area;
mz += 0.5 * avg_z2 * normal.z() * tri_area;
cx += centroid.x() * tri_area;
cy += centroid.y() * tri_area;
cz += centroid.z() * tri_area;
}
let flip = if area < 0.0 { -1.0 } else { 1.0 };
FaceContribution {
area: area * flip,
volume: vol * flip,
volume_moment_x: mx * flip,
volume_moment_y: my * flip,
volume_moment_z: mz * flip,
centroid_x: cx * flip,
centroid_y: cy * flip,
centroid_z: cz * flip,
}
}
#[allow(clippy::cast_precision_loss)]
fn integrate_parametric<S: ParametricSurface>(
surface: &S,
u_range: (f64, f64),
v_range: (f64, f64),
gauss_order: usize,
sign: f64,
) -> FaceContribution {
const MAX_PATCHES: usize = 16;
let gauss_pts = gauss_legendre_points(gauss_order);
let patch = std::f64::consts::FRAC_PI_4;
let nu = (((u_range.1 - u_range.0).abs() / patch).ceil() as usize).clamp(1, MAX_PATCHES);
let nv = (((v_range.1 - v_range.0).abs() / patch).ceil() as usize).clamp(1, MAX_PATCHES);
let du_patch = (u_range.1 - u_range.0) / nu as f64;
let dv_patch = (v_range.1 - v_range.0) / nv as f64;
let u_scale = du_patch / 2.0;
let v_scale = dv_patch / 2.0;
let mut area = 0.0;
let mut vol = 0.0;
let mut mx = 0.0;
let mut my = 0.0;
let mut mz = 0.0;
let mut cx = 0.0;
let mut cy = 0.0;
let mut cz = 0.0;
for iu in 0..nu {
let u_mid = du_patch.mul_add(iu as f64, u_range.0) + u_scale;
for iv in 0..nv {
let v_mid = dv_patch.mul_add(iv as f64, v_range.0) + v_scale;
for gpu in gauss_pts {
let u = u_scale.mul_add(gpu.x, u_mid);
for gpv in gauss_pts {
let v = v_scale.mul_add(gpv.x, v_mid);
let w = gpu.w * gpv.w * u_scale * v_scale;
let p = surface.evaluate(u, v);
let du = surface.partial_u(u, v);
let dv = surface.partial_v(u, v);
let n = Vec3::new(
du.y() * dv.z() - du.z() * dv.y(),
du.z() * dv.x() - du.x() * dv.z(),
du.x() * dv.y() - du.y() * dv.x(),
);
let n_len = n.length();
area += w * n_len;
let pv = Vec3::new(p.x(), p.y(), p.z());
vol += w * pv.dot(n) / 3.0;
mx += w * 0.5 * p.x() * p.x() * n.x();
my += w * 0.5 * p.y() * p.y() * n.y();
mz += w * 0.5 * p.z() * p.z() * n.z();
cx += w * p.x() * n_len;
cy += w * p.y() * n_len;
cz += w * p.z() * n_len;
}
}
}
}
FaceContribution {
area,
volume: vol * sign,
volume_moment_x: mx * sign,
volume_moment_y: my * sign,
volume_moment_z: mz * sign,
centroid_x: cx,
centroid_y: cy,
centroid_z: cz,
}
}
fn polygon_area(poly: &[(f64, f64)]) -> f64 {
let n = poly.len();
if n < 3 {
return 0.0;
}
let mut a = 0.0;
for i in 0..n {
let (x0, y0) = poly[i];
let (x1, y1) = poly[(i + 1) % n];
a += x0 * y1 - x1 * y0;
}
(a * 0.5).abs()
}
#[allow(clippy::too_many_arguments)]
fn integrate_with_trimming<S: ParametricSurface>(
surface: &S,
u_range: (f64, f64),
v_range: (f64, f64),
gauss_order: usize,
sign: f64,
uv_boundary: &[(f64, f64)],
u_periodic: bool,
hole_vs: &[f64],
) -> FaceContribution {
if uv_boundary.len() < 3 {
return integrate_parametric(surface, u_range, v_range, gauss_order, sign);
}
let u_min = uv_boundary
.iter()
.map(|p| p.0)
.fold(f64::INFINITY, f64::min);
let v_min = uv_boundary
.iter()
.map(|p| p.1)
.fold(f64::INFINITY, f64::min);
let v_max = uv_boundary
.iter()
.map(|p| p.1)
.fold(f64::NEG_INFINITY, f64::max);
let tau = std::f64::consts::TAU;
let winding: f64 = (0..uv_boundary.len())
.map(|i| {
let d = uv_boundary[(i + 1) % uv_boundary.len()].0 - uv_boundary[i].0;
d - tau * ((d + std::f64::consts::PI) / tau).floor()
})
.sum();
let full_revolution = u_periodic && winding.abs() >= tau - 1e-3;
let v_degenerate = (v_max - v_min) <= 1e-9;
if full_revolution && v_degenerate {
let v_pole = if winding >= 0.0 { v_range.1 } else { v_range.0 };
let v_far = hole_vs
.iter()
.copied()
.filter(|&hv| (hv - v_min) * (v_pole - v_min) > 0.0 && (hv - v_min).abs() > 1e-9)
.min_by(|a, b| (a - v_min).abs().total_cmp(&(b - v_min).abs()))
.unwrap_or(v_pole);
let v_dom = (v_min.min(v_far), v_min.max(v_far));
integrate_parametric(surface, (u_min, u_min + tau), v_dom, gauss_order, sign)
} else if full_revolution {
integrate_parametric(
surface,
(u_min, u_min + tau),
(v_min, v_max),
gauss_order,
sign,
)
} else if polygon_area(uv_boundary) <= 1e-12 {
integrate_parametric(surface, u_range, v_range, gauss_order, sign)
} else {
integrate_parametric_trimmed(
surface,
u_range,
v_range,
gauss_order,
sign,
uv_boundary,
u_periodic,
)
}
}
#[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
fn integrate_parametric_trimmed<S: ParametricSurface>(
surface: &S,
u_range: (f64, f64),
v_range: (f64, f64),
gauss_order: usize,
sign: f64,
uv_boundary: &[(f64, f64)],
u_periodic: bool,
) -> FaceContribution {
use brepkit_math::predicates::point_in_polygon;
use brepkit_math::vec::Point2;
let gauss_pts = gauss_legendre_points(gauss_order);
let u_scale = (u_range.1 - u_range.0) / 2.0;
let u_mid = f64::midpoint(u_range.0, u_range.1);
let v_scale = (v_range.1 - v_range.0) / 2.0;
let v_mid = f64::midpoint(v_range.0, v_range.1);
let uv_poly: Vec<Point2> = uv_boundary
.iter()
.map(|(u, v)| Point2::new(*u, *v))
.collect();
let u_bcenter = if u_periodic {
let bmin = uv_boundary
.iter()
.map(|(bu, _)| *bu)
.fold(f64::INFINITY, f64::min);
let bmax = uv_boundary
.iter()
.map(|(bu, _)| *bu)
.fold(f64::NEG_INFINITY, f64::max);
(bmin + bmax) * 0.5
} else {
0.0
};
let mut area = 0.0;
let mut vol = 0.0;
let mut mx = 0.0;
let mut my = 0.0;
let mut mz = 0.0;
let mut cx = 0.0;
let mut cy = 0.0;
let mut cz = 0.0;
for gpu in gauss_pts {
let u = u_scale.mul_add(gpu.x, u_mid);
for gpv in gauss_pts {
let v = v_scale.mul_add(gpv.x, v_mid);
let test_u = if u_periodic {
let tau = std::f64::consts::TAU;
let diff = u - u_bcenter;
u_bcenter + diff - tau * ((diff + std::f64::consts::PI) / tau).floor()
} else {
u
};
if !point_in_polygon(Point2::new(test_u, v), &uv_poly) {
continue;
}
let w = gpu.w * gpv.w * u_scale * v_scale;
let p = surface.evaluate(u, v);
let du = surface.partial_u(u, v);
let dv = surface.partial_v(u, v);
let n = Vec3::new(
du.y() * dv.z() - du.z() * dv.y(),
du.z() * dv.x() - du.x() * dv.z(),
du.x() * dv.y() - du.y() * dv.x(),
);
let n_len = n.length();
area += w * n_len;
let pv = Vec3::new(p.x(), p.y(), p.z());
vol += w * pv.dot(n) / 3.0;
mx += w * 0.5 * p.x() * p.x() * n.x();
my += w * 0.5 * p.y() * p.y() * n.y();
mz += w * 0.5 * p.z() * p.z() * n.z();
cx += w * p.x() * n_len;
cy += w * p.y() * n_len;
cz += w * p.z() * n_len;
}
}
FaceContribution {
area,
volume: vol * sign,
volume_moment_x: mx * sign,
volume_moment_y: my * sign,
volume_moment_z: mz * sign,
centroid_x: cx,
centroid_y: cy,
centroid_z: cz,
}
}
fn build_face_uv_boundary<F>(
topo: &Topology,
face_id: FaceId,
project: F,
u_periodic: bool,
) -> Result<Vec<(f64, f64)>, CheckError>
where
F: Fn(Point3) -> (f64, f64),
{
let polygon = crate::util::face_polygon(topo, face_id)?;
if polygon.len() < 3 {
return Ok(vec![]);
}
let mut uv: Vec<(f64, f64)> = polygon.iter().map(|&p| project(p)).collect();
for i in 1..uv.len() {
if u_periodic {
uv[i].0 = unwrap_angle(uv[i - 1].0, uv[i].0);
}
}
Ok(uv)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use brepkit_math::vec::{Point3, Vec3};
#[test]
fn planar_fan_is_signed_on_nonconvex_polygons() {
let poly = [
Point3::new(0.0, 0.0, 2.0),
Point3::new(10.0, 0.0, 2.0),
Point3::new(10.0, 5.0, 2.0),
Point3::new(5.0, 5.0, 2.0),
Point3::new(5.0, 10.0, 2.0),
Point3::new(0.0, 10.0, 2.0),
];
let up = Vec3::new(0.0, 0.0, 1.0);
let c = integrate_planar_polygon(&poly, up);
assert!((c.area - 75.0).abs() < 1e-9, "area {}", c.area);
assert!(
(c.volume - 2.0 * 75.0 / 3.0).abs() < 1e-9,
"vol {}",
c.volume
);
let rev: Vec<Point3> = poly.iter().rev().copied().collect();
let c2 = integrate_planar_polygon(&rev, up);
assert!((c2.area - 75.0).abs() < 1e-9, "rev area {}", c2.area);
}
}