use brepkit_math::vec::{Point3, Vec3};
use brepkit_topology::Topology;
use brepkit_topology::face::{FaceId, FaceSurface};
use brepkit_topology::solid::SolidId;
use crate::tessellate;
use super::helpers::{collect_solid_vertex_points, compute_angular_range};
fn sphere_outer_wire_constant_v(
topo: &Topology,
face_id: FaceId,
sphere: &brepkit_math::surfaces::SphericalSurface,
) -> bool {
let Ok(face) = topo.face(face_id) else {
return false;
};
let Ok(wire) = topo.wire(face.outer_wire()) else {
return false;
};
let mut v_min = f64::INFINITY;
let mut v_max = f64::NEG_INFINITY;
for oe in wire.edges() {
let Ok(edge) = topo.edge(oe.edge()) else {
return false;
};
let (Ok(sv), Ok(ev)) = (topo.vertex(edge.start()), topo.vertex(edge.end())) else {
return false;
};
let (sp, ep) = (sv.point(), ev.point());
let (t0, t1) = edge.curve().domain_with_endpoints(sp, ep);
for i in 0..=8 {
let t = t0 + (t1 - t0) * (f64::from(i) / 8.0);
let (_, v) = sphere.project_point(edge.curve().evaluate_with_endpoints(t, sp, ep));
v_min = v_min.min(v);
v_max = v_max.max(v);
}
}
(v_max - v_min) <= 1e-7
}
fn solid_has_scalloped_sphere_collar(topo: &Topology, solid: SolidId) -> bool {
let Ok(faces) = brepkit_topology::explorer::solid_faces(topo, solid) else {
return false;
};
faces.iter().any(|&fid| {
topo.face(fid).is_ok_and(|f| match f.surface() {
FaceSurface::Sphere(s) => {
!f.inner_wires().is_empty() && !sphere_outer_wire_constant_v(topo, fid, s)
}
_ => false,
})
})
}
fn solid_has_torus_notch_band(topo: &Topology, solid: SolidId) -> bool {
let Ok(faces) = brepkit_topology::explorer::solid_faces(topo, solid) else {
return false;
};
faces.iter().any(|&fid| {
topo.face(fid).is_ok_and(|f| match f.surface() {
FaceSurface::Torus(t) => {
f.inner_wires().len() == 1 && torus_wire_wraps_tube(topo, f.outer_wire(), t)
}
_ => false,
})
})
}
fn torus_wire_wraps_tube(
topo: &Topology,
wire_id: brepkit_topology::wire::WireId,
torus: &brepkit_math::surfaces::ToroidalSurface,
) -> bool {
let Ok(wire) = topo.wire(wire_id) else {
return false;
};
let mut vs: Vec<f64> = Vec::new();
for oe in wire.edges() {
let Ok(e) = topo.edge(oe.edge()) else {
return false;
};
let (Ok(sv), Ok(ev)) = (topo.vertex(e.start()), topo.vertex(e.end())) else {
return false;
};
let (sp, ep) = (sv.point(), ev.point());
for k in 0..=8 {
let f = f64::from(k) / 8.0;
let p = e.curve().evaluate_with_endpoints(f, sp, ep);
vs.push(torus.project_point(p).1);
}
}
if vs.len() < 3 {
return false;
}
vs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let max_gap = vs
.windows(2)
.map(|w| w[1] - w[0])
.chain(std::iter::once(
vs[0] + std::f64::consts::TAU - vs[vs.len() - 1],
))
.fold(0.0_f64, f64::max);
max_gap < std::f64::consts::PI
}
fn mesh_boundary_edge_count(mesh: &tessellate::TriangleMesh) -> usize {
use brepkit_math::det_hash::DetHashMap;
let mut counts: DetHashMap<(u32, u32), usize> = DetHashMap::default();
for tri in mesh.indices.chunks_exact(3) {
for &(i, j) in &[(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])] {
let key = if i < j { (i, j) } else { (j, i) };
*counts.entry(key).or_insert(0) += 1;
}
}
counts.values().filter(|&&c| c != 2).count()
}
fn all_planar_line_solid_volume(topo: &Topology, solid: SolidId) -> Option<f64> {
use brepkit_topology::edge::EdgeCurve;
use brepkit_topology::explorer::solid_faces;
let faces = solid_faces(topo, solid).ok()?;
if faces.is_empty() {
return None;
}
let mut vol6 = 0.0;
for &fid in &faces {
let face = topo.face(fid).ok()?;
let FaceSurface::Plane { normal, d } = face.surface() else {
return None;
};
let n_unit = normal.normalize().ok()?;
let d_unit = d / normal.length();
let sign = if face.is_reversed() { -1.0 } else { 1.0 };
let planar_eps = 1e-6 * (1.0 + d_unit.abs());
let ring_area2 = |wid| -> Option<f64> {
let wire = topo.wire(wid).ok()?;
let edges = wire.edges();
if edges.len() < 3 {
return None;
}
let mut area2 = Vec3::new(0.0, 0.0, 0.0);
let mut first: Option<Vec3> = None;
let mut prev: Option<Vec3> = None;
for oe in edges {
let e = topo.edge(oe.edge()).ok()?;
if !matches!(e.curve(), EdgeCurve::Line) {
return None;
}
let vid = if oe.is_forward() { e.start() } else { e.end() };
let p = topo.vertex(vid).ok()?.point();
let p = Vec3::new(p.x(), p.y(), p.z());
if (p.dot(n_unit) - d_unit).abs() > planar_eps {
return None;
}
if let Some(q) = prev {
area2 += q.cross(p);
} else {
first = Some(p);
}
prev = Some(p);
}
if let (Some(last), Some(first)) = (prev, first) {
area2 += last.cross(first);
}
Some(area2.dot(n_unit).abs())
};
let mut face_area2 = ring_area2(face.outer_wire())?;
for &iw in face.inner_wires() {
face_area2 -= ring_area2(iw)?;
}
if face_area2 < 0.0 {
return None;
}
vol6 += sign * d_unit * face_area2;
}
Some((vol6 / 6.0).abs())
}
fn analytic_faces_solid_volume(topo: &Topology, solid: SolidId) -> Option<f64> {
use brepkit_topology::explorer::solid_faces;
let faces = solid_faces(topo, solid).ok()?;
if faces.is_empty() {
return None;
}
if solid_is_steinmetz_lens_fuse(topo, &faces) {
return steinmetz_lens_fuse_volume(topo, &faces);
}
let mut has_bored_quadric = false;
for &fid in &faces {
let face = topo.face(fid).ok()?;
match face.surface() {
FaceSurface::Nurbs(_) => return None,
FaceSurface::Sphere(s) if !face.inner_wires().is_empty() => {
if !sphere_outer_wire_constant_v(topo, fid, s) {
return None;
}
has_bored_quadric = true;
}
FaceSurface::Cylinder(_) | FaceSurface::Cone(_) if !face.inner_wires().is_empty() => {
return None;
}
FaceSurface::Torus(_) if !face.inner_wires().is_empty() => return None,
_ => {}
}
}
if !has_bored_quadric {
return None;
}
let gauss_order = brepkit_check::properties::PropertiesOptions::default().gauss_order;
let mut total = 0.0;
for &fid in &faces {
total += brepkit_check::properties::face_integrator::integrate_face(topo, fid, gauss_order)
.ok()?
.volume;
}
Some(total.abs())
}
fn analytic_revolution_solid_volume(topo: &Topology, solid: SolidId) -> Option<f64> {
use brepkit_topology::explorer::solid_faces;
let faces = solid_faces(topo, solid).ok()?;
if faces.is_empty() {
return None;
}
let mut axis: Option<(Point3, Vec3)> = None;
let mut has_wall = false;
let axis_tol = 1e-7;
let set_or_check_axis = |axis: &mut Option<(Point3, Vec3)>, o: Point3, d: Vec3| -> bool {
let d = match d.normalize() {
Ok(d) => d,
Err(_) => return false,
};
match axis {
None => {
*axis = Some((o, d));
true
}
Some((o0, d0)) => {
if d0.cross(d).length() > 1e-6 {
return false;
}
let off = o - *o0;
(off - *d0 * off.dot(*d0)).length() <= axis_tol * off.length().max(1.0)
}
}
};
for &fid in &faces {
let face = topo.face(fid).ok()?;
if !face.inner_wires().is_empty() {
if !matches!(face.surface(), FaceSurface::Plane { .. }) {
return None;
}
}
match face.surface() {
FaceSurface::Sphere(_) => return None,
FaceSurface::Nurbs(_) => {}
FaceSurface::Cylinder(c) => {
has_wall = true;
if !set_or_check_axis(&mut axis, c.origin(), c.axis()) {
return None;
}
}
FaceSurface::Cone(c) => {
has_wall = true;
if !set_or_check_axis(&mut axis, c.apex(), c.axis()) {
return None;
}
}
FaceSurface::Torus(t) => {
has_wall = true;
if !set_or_check_axis(&mut axis, t.center(), t.z_axis()) {
return None;
}
}
FaceSurface::Plane { .. } => {} }
}
let (axis_o, axis_d) = axis?;
if !has_wall {
return None;
}
let mut cap_volumes: std::collections::HashMap<FaceId, f64> = std::collections::HashMap::new();
for &fid in &faces {
let face = topo.face(fid).ok()?;
match face.surface() {
FaceSurface::Plane { normal, .. } => {
if normal.normalize().ok()?.cross(axis_d).length() > 1e-6 {
return None; }
if !planar_face_arcs_centered_on_axis(topo, fid, axis_o, axis_d) {
return None;
}
let v = planar_cap_signed_volume(topo, fid).ok()??;
cap_volumes.insert(fid, v);
}
FaceSurface::Nurbs(_) if !nurbs_band_is_on_axis(topo, fid, axis_o, axis_d) => {
return None;
}
_ => {}
}
}
let mut total = 0.0;
for &fid in &faces {
let face = topo.face(fid).ok()?;
let c = match face.surface() {
FaceSurface::Cylinder(_) => analytic_cylinder_signed_volume(topo, fid).ok()?,
FaceSurface::Cone(_) => analytic_cone_signed_volume(topo, fid).ok()?,
FaceSurface::Torus(_) => analytic_torus_signed_volume(topo, fid).ok()?,
FaceSurface::Plane { .. } => *cap_volumes.get(&fid)?,
FaceSurface::Nurbs(_) => 0.0, FaceSurface::Sphere(_) => return None,
};
total += c;
}
Some(total.abs())
}
fn nurbs_band_is_on_axis(topo: &Topology, face_id: FaceId, axis_o: Point3, axis_d: Vec3) -> bool {
let Ok(face) = topo.face(face_id) else {
return false;
};
let tol = 1e-7;
let Ok(wire) = topo.wire(face.outer_wire()) else {
return false;
};
for oe in wire.edges() {
let Ok(edge) = topo.edge(oe.edge()) else {
return false;
};
for vid in [edge.start(), edge.end()] {
let Ok(v) = topo.vertex(vid) else {
return false;
};
let off = v.point() - axis_o;
let radial = off - axis_d * off.dot(axis_d);
if radial.length() > tol {
return false;
}
}
}
true
}
fn planar_face_arcs_centered_on_axis(
topo: &Topology,
face_id: FaceId,
axis_o: Point3,
axis_d: Vec3,
) -> bool {
let Ok(face) = topo.face(face_id) else {
return false;
};
let tol = 1e-6;
for wire_id in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
let Ok(wire) = topo.wire(wire_id) else {
return false;
};
for oe in wire.edges() {
let Ok(edge) = topo.edge(oe.edge()) else {
return false;
};
let center = match edge.curve() {
brepkit_topology::edge::EdgeCurve::Circle(c) => Some(c.center()),
brepkit_topology::edge::EdgeCurve::NurbsCurve(nc) => {
let rtol = brepkit_math::tolerance::Tolerance::default().linear * 100.0;
match brepkit_geometry::convert::recognize_curve(nc, rtol) {
brepkit_geometry::convert::RecognizedCurve::Circle { center, .. } => {
Some(center)
}
_ => None,
}
}
_ => None,
};
if let Some(center) = center {
let off = center - axis_o;
let perp = off - axis_d * off.dot(axis_d);
if perp.length() > tol * off.length().max(1.0) {
return false;
}
}
}
}
true
}
fn steinmetz_lens_fuse_volume(topo: &Topology, faces: &[FaceId]) -> Option<f64> {
use std::f64::consts::PI;
let mut r: Option<f64> = None;
let mut heights: Vec<f64> = Vec::new();
for &fid in faces {
let face = topo.face(fid).ok()?;
let FaceSurface::Cylinder(cyl) = face.surface() else {
continue;
};
if face.inner_wires().is_empty() {
continue; }
match r {
None => r = Some(cyl.radius()),
Some(r0) if (r0 - cyl.radius()).abs() > 1e-6 * r0.max(1.0) => return None,
Some(_) => {}
}
let wire = topo.wire(face.outer_wire()).ok()?;
let mut v_min = f64::INFINITY;
let mut v_max = f64::NEG_INFINITY;
for oe in wire.edges() {
let e = topo.edge(oe.edge()).ok()?;
for vid in [e.start(), e.end()] {
let p = topo.vertex(vid).ok()?.point();
let (_, v) = cyl.project_point(p);
v_min = v_min.min(v);
v_max = v_max.max(v);
}
}
if !v_min.is_finite() || !v_max.is_finite() || v_max <= v_min {
return None;
}
heights.push(v_max - v_min);
}
let r = r?;
if heights.len() != 2 {
return None;
}
let v_cyls = PI * r * r * (heights[0] + heights[1]);
let v_steinmetz = 16.0 / 3.0 * r * r * r;
Some(v_cyls - v_steinmetz)
}
fn solid_is_steinmetz_lens_fuse(topo: &Topology, faces: &[FaceId]) -> bool {
use std::collections::HashSet;
let mut holed_cyl_walls: Vec<FaceId> = Vec::new();
let mut planar_normals: Vec<Vec3> = Vec::new();
for &fid in faces {
let Ok(face) = topo.face(fid) else {
return false;
};
match face.surface() {
FaceSurface::Cylinder(_) if !face.inner_wires().is_empty() => holed_cyl_walls.push(fid),
FaceSurface::Cylinder(_) => return false,
FaceSurface::Plane { normal, .. } => planar_normals.push(*normal),
_ => return false,
}
}
if holed_cyl_walls.len() != 2 {
return false;
}
let inner_edges = |fid: FaceId| -> HashSet<usize> {
let mut s = HashSet::new();
if let Ok(face) = topo.face(fid) {
for &wid in face.inner_wires() {
if let Ok(wire) = topo.wire(wid) {
for oe in wire.edges() {
s.insert(oe.edge().index());
}
}
}
}
s
};
let a = inner_edges(holed_cyl_walls[0]);
let b = inner_edges(holed_cyl_walls[1]);
if a.is_empty() || a != b {
return false;
}
let (Ok(f0), Ok(f1)) = (topo.face(holed_cyl_walls[0]), topo.face(holed_cyl_walls[1])) else {
return false;
};
let (FaceSurface::Cylinder(c0), FaceSurface::Cylinder(c1)) = (f0.surface(), f1.surface())
else {
return false;
};
let Some(axis_isect) = cylinders_perpendicular_and_intersecting(c0, c1) else {
return false;
};
let a0 = c0.axis();
let a1 = c1.axis();
let thr = (1.0 - 1e-6) * (1.0 - 1e-6);
let mut caps_a0 = 0_usize;
let mut caps_a1 = 0_usize;
for n in &planar_normals {
let nn = n.dot(*n);
if nn < 1e-20 {
return false;
}
let na0 = n.dot(a0);
let na1 = n.dot(a1);
if na0 * na0 >= thr * nn * a0.dot(a0) {
caps_a0 += 1;
} else if na1 * na1 >= thr * nn * a1.dot(a1) {
caps_a1 += 1;
} else {
return false;
}
}
if caps_a0 != 2 || caps_a1 != 2 {
return false;
}
let r = c0.radius();
wall_extends_past(topo, holed_cyl_walls[0], c0, axis_isect, r)
&& wall_extends_past(topo, holed_cyl_walls[1], c1, axis_isect, r)
}
fn wall_extends_past(
topo: &Topology,
wall: FaceId,
cyl: &brepkit_math::surfaces::CylindricalSurface,
axis_isect: Point3,
r: f64,
) -> bool {
let Ok(face) = topo.face(wall) else {
return false;
};
let Ok(wire) = topo.wire(face.outer_wire()) else {
return false;
};
let mut v_min = f64::INFINITY;
let mut v_max = f64::NEG_INFINITY;
for oe in wire.edges() {
let Ok(e) = topo.edge(oe.edge()) else {
return false;
};
for vid in [e.start(), e.end()] {
let Ok(v) = topo.vertex(vid) else {
return false;
};
let (_, vv) = cyl.project_point(v.point());
v_min = v_min.min(vv);
v_max = v_max.max(vv);
}
}
if !v_min.is_finite() || !v_max.is_finite() {
return false;
}
let (_, v_isect) = cyl.project_point(axis_isect);
let tol = 1e-6 * r.max(1.0);
v_isect - v_min >= r - tol && v_max - v_isect >= r - tol
}
fn cylinders_perpendicular_and_intersecting(
c0: &brepkit_math::surfaces::CylindricalSurface,
c1: &brepkit_math::surfaces::CylindricalSurface,
) -> Option<Point3> {
let r0 = c0.radius();
if (r0 - c1.radius()).abs() > 1e-6 * r0.max(1.0) {
return None; }
let a0 = c0.axis();
let a1 = c1.axis();
if a0.dot(a1).abs() > 1e-6 {
return None; }
let o0 = c0.origin();
let o1 = c1.origin();
let w0 = Vec3::new(o0.x() - o1.x(), o0.y() - o1.y(), o0.z() - o1.z());
let s = -w0.dot(a0);
let t = w0.dot(a1);
let p0 = Point3::new(
o0.x() + a0.x() * s,
o0.y() + a0.y() * s,
o0.z() + a0.z() * s,
);
let p1 = Point3::new(
o1.x() + a1.x() * t,
o1.y() + a1.y() * t,
o1.z() + a1.z() * t,
);
if (p0 - p1).length() <= 1e-6 * r0.max(1.0) {
Some(Point3::new(
0.5 * (p0.x() + p1.x()),
0.5 * (p0.y() + p1.y()),
0.5 * (p0.z() + p1.z()),
))
} else {
None
}
}
#[allow(clippy::too_many_lines)]
fn try_analytic_solid_volume(topo: &Topology, solid: SolidId) -> Option<f64> {
use std::f64::consts::PI;
let solid_data = topo.solid(solid).ok()?;
let shell = topo.shell(solid_data.outer_shell()).ok()?;
let mut sphere_r: Option<f64> = None;
let mut cyl: Option<(Point3, Vec3, f64)> = None; let mut cone_params: Option<(Point3, Vec3)> = None; let mut torus_params: Option<(f64, f64)> = None; let mut torus_face_id: Option<FaceId> = None;
let mut planes: Vec<(Vec3, f64)> = Vec::new();
let mut plane_face_ids: Vec<FaceId> = Vec::new();
for &fid in shell.faces() {
let face = topo.face(fid).ok()?;
if !face.inner_wires().is_empty() {
return None;
}
match face.surface() {
FaceSurface::Nurbs(_) => return None,
FaceSurface::Plane { normal, d } => {
planes.push((*normal, *d));
plane_face_ids.push(fid);
}
FaceSurface::Sphere(s) => {
let r = s.radius();
match sphere_r {
None => sphere_r = Some(r),
Some(existing) if (r - existing).abs() > existing * 1e-6 => return None,
Some(_) => {}
}
}
FaceSurface::Cylinder(c) => {
if cyl.is_some() {
return None;
}
cyl = Some((c.origin(), c.axis(), c.radius()));
}
FaceSurface::Cone(c) => {
if cone_params.is_some() {
return None;
}
cone_params = Some((c.apex(), c.axis()));
}
FaceSurface::Torus(t) => {
if torus_params.is_some() {
return None;
}
torus_params = Some((t.major_radius(), t.minor_radius()));
torus_face_id = Some(fid);
}
}
}
if let Some(r) = sphere_r
&& cyl.is_none()
&& cone_params.is_none()
&& torus_params.is_none()
&& planes.is_empty()
{
let sphere_faces: Vec<_> = shell.faces().to_vec();
let center = if let Ok(f) = topo.face(sphere_faces[0]) {
if let FaceSurface::Sphere(s) = f.surface() {
s.center()
} else {
return None;
}
} else {
return None;
};
let mut max_dist = 0.0_f64;
let mut min_dist = f64::INFINITY;
for &fid in &sphere_faces {
if let Ok(face) = topo.face(fid)
&& let Ok(wire) = topo.wire(face.outer_wire())
{
for oe in wire.edges() {
if let Ok(e) = topo.edge(oe.edge())
&& let Ok(v) = topo.vertex(e.start())
{
let d = (v.point() - center).length();
max_dist = max_dist.max(d);
min_dist = min_dist.min(d);
}
}
}
}
if (max_dist - min_dist).abs() < r * 0.01 {
return Some(4.0 / 3.0 * PI * r * r * r);
}
return None;
}
if let Some((origin, axis, r)) = cyl
&& cone_params.is_none()
&& torus_params.is_none()
&& sphere_r.is_none()
&& planes.len() == 2
{
let origin_vec = Vec3::new(origin.x(), origin.y(), origin.z());
let mut ts = cap_t_values(origin_vec, axis, &planes);
if ts.len() >= 2 {
ts.sort_by(f64::total_cmp);
if let (Some(&t_min), Some(&t_max)) = (ts.first(), ts.last()) {
return Some(PI * r * r * (t_max - t_min));
}
}
}
if let Some((apex, axis)) = cone_params
&& cyl.is_none()
&& torus_params.is_none()
&& sphere_r.is_none()
{
let apex_vec = Vec3::new(apex.x(), apex.y(), apex.z());
let mut cap_circles: Vec<(Point3, f64)> = Vec::new();
for &fid in &plane_face_ids {
if let Some(cap) = find_cap_circle(topo, fid) {
cap_circles.push(cap);
}
}
if cap_circles.len() != plane_face_ids.len() {
return None;
}
match cap_circles.as_slice() {
[(c, r)] => {
let c_vec = Vec3::new(c.x(), c.y(), c.z());
let h = (c_vec - apex_vec).dot(axis).abs();
return Some(PI / 3.0 * r * r * h);
}
[(c1, r1), (c2, r2)] => {
let c1_vec = Vec3::new(c1.x(), c1.y(), c1.z());
let c2_vec = Vec3::new(c2.x(), c2.y(), c2.z());
let h = (c2_vec - c1_vec).dot(axis).abs();
return Some(PI * h / 3.0 * (r1 * r1 + r1 * r2 + r2 * r2));
}
_ => {}
}
}
if let Some((r_major, r_minor)) = torus_params
&& cyl.is_none()
&& cone_params.is_none()
&& sphere_r.is_none()
{
if planes.is_empty() {
return Some(2.0 * PI * PI * r_major * r_minor * r_minor);
}
if planes.len() == 2
&& let Some(tid) = torus_face_id
&& let Some(v) =
partial_torus_sector_volume(topo, tid, r_major, r_minor, &planes, &plane_face_ids)
{
return Some(v);
}
return None;
}
None
}
fn partial_torus_sector_volume(
topo: &Topology,
torus_face: FaceId,
r_major: f64,
r_minor: f64,
planes: &[(Vec3, f64)],
plane_face_ids: &[FaceId],
) -> Option<f64> {
use std::f64::consts::{PI, TAU};
let face = topo.face(torus_face).ok()?;
let FaceSurface::Torus(t) = face.surface() else {
return None;
};
let center = t.center();
let center_vec = Vec3::new(center.x(), center.y(), center.z());
let axis_d = t.z_axis().normalize().ok()?;
let tol = 1e-7 * r_major.max(1.0);
for &(n, d) in planes {
let n_unit = n.normalize().ok()?;
if n_unit.dot(axis_d).abs() > 1e-9 {
return None;
}
if (n.dot(center_vec) - d).abs() > tol {
return None;
}
}
for &fid in plane_face_ids {
let cap = topo.face(fid).ok()?;
if !cap.inner_wires().is_empty() {
return None;
}
let wire = topo.wire(cap.outer_wire()).ok()?;
let edges = wire.edges();
if edges.len() != 1 {
return None;
}
let edge = topo.edge(edges[0].edge()).ok()?;
if edge.start() != edge.end() {
return None;
}
let brepkit_topology::edge::EdgeCurve::Circle(c) = edge.curve() else {
return None;
};
if (c.radius() - r_minor).abs() > tol {
return None;
}
let off = c.center() - center;
let perp = off - axis_d * off.dot(axis_d);
if (perp.length() - r_major).abs() > tol {
return None;
}
}
let wire = topo.wire(face.outer_wire()).ok()?;
let mut sweep: Option<f64> = None;
for oe in wire.edges() {
let edge = topo.edge(oe.edge()).ok()?;
if edge.start() == edge.end() {
continue;
}
let brepkit_topology::edge::EdgeCurve::Circle(c) = edge.curve() else {
continue;
};
let off = c.center() - center;
let perp = off - axis_d * off.dot(axis_d);
if perp.length() > tol
|| c.normal().cross(axis_d).length() > 1e-9
|| c.normal().dot(axis_d) < 0.0
{
continue;
}
let sp = topo.vertex(edge.start()).ok()?.point();
let ep = topo.vertex(edge.end()).ok()?.point();
let delta = (c.project(ep) - c.project(sp)).rem_euclid(TAU);
match sweep {
None => sweep = Some(delta),
Some(prev) if (prev - delta).abs() < 1e-9 => {}
Some(_) => return None,
}
}
let du = sweep?;
if !(1e-12..=TAU - 1e-12).contains(&du) {
return None;
}
let n0 = planes[0].0.normalize().ok()?;
let n1 = planes[1].0.normalize().ok()?;
if (n0.dot(n1) + du.cos()).abs() > 1e-6 {
return None;
}
Some(PI * r_major * r_minor * r_minor * du)
}
const AXIS_PARALLEL_MIN_DOT: f64 = 0.99;
fn cap_t_values(ref_pt: Vec3, axis: Vec3, planes: &[(Vec3, f64)]) -> Vec<f64> {
let mut ts = Vec::new();
for &(n, d) in planes {
let nd = n.dot(axis);
if nd.abs() > AXIS_PARALLEL_MIN_DOT {
ts.push((d - n.dot(ref_pt)) / nd);
}
}
ts
}
fn find_cap_circle(topo: &Topology, face_id: FaceId) -> Option<(Point3, f64)> {
let face = topo.face(face_id).ok()?;
let wire = topo.wire(face.outer_wire()).ok()?;
for oe in wire.edges() {
let Ok(edge) = topo.edge(oe.edge()) else {
continue;
};
if let brepkit_topology::edge::EdgeCurve::Circle(c) = edge.curve() {
return Some((c.center(), c.radius()));
}
}
None
}
fn volume_tessellation_deflection(topo: &Topology, solid: SolidId, requested: f64) -> f64 {
let Ok(pts) = collect_solid_vertex_points(topo, solid) else {
return requested;
};
let Some((&first, rest)) = pts.split_first() else {
return requested;
};
let (mut lo, mut hi) = (first, first);
for p in rest {
lo = Point3::new(lo.x().min(p.x()), lo.y().min(p.y()), lo.z().min(p.z()));
hi = Point3::new(hi.x().max(p.x()), hi.y().max(p.y()), hi.z().max(p.z()));
}
let diag = (hi - lo).length();
if !diag.is_finite() || diag <= 0.0 {
return requested;
}
requested.min((diag * 5e-5).max(1e-9))
}
fn vol_trace_enabled() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| std::env::var("BK_VOL_TRACE").is_ok())
}
pub fn solid_volume(
topo: &Topology,
solid: SolidId,
deflection: f64,
) -> Result<f64, crate::OperationsError> {
if let Some(v) = try_analytic_solid_volume(topo, solid) {
if vol_trace_enabled() {
log::debug!("VOL_TRACE try_analytic -> {v}");
}
return Ok(v);
}
if let Some(v) = all_planar_line_solid_volume(topo, solid) {
if vol_trace_enabled() {
log::debug!("VOL_TRACE all_planar_line -> {v}");
}
return Ok(v);
}
if let Some(v) = analytic_faces_solid_volume(topo, solid) {
if vol_trace_enabled() {
log::debug!("VOL_TRACE analytic_faces -> {v}");
}
return Ok(v);
}
if let Some(v) = analytic_revolution_solid_volume(topo, solid) {
if vol_trace_enabled() {
log::debug!("VOL_TRACE revolution -> {v}");
}
return Ok(v);
}
let deflection = volume_tessellation_deflection(topo, solid, deflection);
if solid_has_scalloped_sphere_collar(topo, solid) {
let mesh = tessellate::tessellate_solid(topo, solid, deflection)?;
if !mesh.indices.is_empty() && mesh_boundary_edge_count(&mesh) == 0 {
return Ok(signed_volume_from_mesh(&mesh));
}
}
if solid_has_torus_notch_band(topo, solid) {
let mesh = tessellate::tessellate_solid(topo, solid, deflection)?;
if !mesh.indices.is_empty() && mesh_boundary_edge_count(&mesh) == 0 {
return Ok(signed_volume_from_mesh(&mesh));
}
}
if let Ok(v) = solid_volume_from_faces(topo, solid, deflection) {
return Ok(v);
}
let needs_direct_tessellation = {
let s = topo.solid(solid)?;
let sh = topo.shell(s.outer_shell())?;
sh.faces().iter().any(|&fid| {
topo.face(fid).is_ok_and(|f| {
!f.inner_wires().is_empty()
|| (f.is_reversed() && !matches!(f.surface(), FaceSurface::Plane { .. }))
})
})
};
if needs_direct_tessellation {
return volume_from_direct_face_tessellation(topo, solid, deflection);
}
let mesh = tessellate::tessellate_solid(topo, solid, deflection)?;
if !mesh.indices.is_empty() {
let vol = signed_volume_from_mesh(&mesh);
if vol > 1e-12 {
return Ok(vol);
}
}
volume_from_per_face_tessellation(topo, solid, deflection)
}
pub fn oriented_solid_volume(
topo: &Topology,
solid: SolidId,
deflection: f64,
) -> Result<f64, crate::OperationsError> {
let mesh = tessellate::tessellate_solid(topo, solid, deflection)?;
let idx = &mesh.indices;
let pos = &mesh.positions;
let mut total = 0.0;
for t in 0..idx.len() / 3 {
let v0 = pos[idx[t * 3] as usize];
let v1 = pos[idx[t * 3 + 1] as usize];
let v2 = pos[idx[t * 3 + 2] as usize];
let a = Vec3::new(v0.x(), v0.y(), v0.z());
let b = Vec3::new(v1.x(), v1.y(), v1.z());
let c = Vec3::new(v2.x(), v2.y(), v2.z());
total += a.dot(b.cross(c));
}
Ok(total / 6.0)
}
fn signed_volume_from_mesh(mesh: &tessellate::TriangleMesh) -> f64 {
let idx = &mesh.indices;
let pos = &mesh.positions;
let tri_count = idx.len() / 3;
let mut total = 0.0;
for t in 0..tri_count {
let v0 = pos[idx[t * 3] as usize];
let v1 = pos[idx[t * 3 + 1] as usize];
let v2 = pos[idx[t * 3 + 2] as usize];
let a = Vec3::new(v0.x(), v0.y(), v0.z());
let b = Vec3::new(v1.x(), v1.y(), v1.z());
let c = Vec3::new(v2.x(), v2.y(), v2.z());
total += a.dot(b.cross(c));
}
(total / 6.0).abs()
}
fn volume_from_per_face_tessellation(
topo: &Topology,
solid: SolidId,
deflection: f64,
) -> Result<f64, crate::OperationsError> {
let solid_data = topo.solid(solid)?;
let shell = topo.shell(solid_data.outer_shell())?;
let mut total: f64 = 0.0;
for &fid in shell.faces() {
let mesh = tessellate::tessellate(topo, fid, deflection)?;
let idx = &mesh.indices;
if vol_trace_enabled() {
log::debug!("VOL_TRACE direct plane face {fid:?} tris={}", idx.len() / 3);
}
let pos = &mesh.positions;
let tri_count = idx.len() / 3;
for t in 0..tri_count {
let v0 = pos[idx[t * 3] as usize];
let v1 = pos[idx[t * 3 + 1] as usize];
let v2 = pos[idx[t * 3 + 2] as usize];
let a = Vec3::new(v0.x(), v0.y(), v0.z());
let b = Vec3::new(v1.x(), v1.y(), v1.z());
let c = Vec3::new(v2.x(), v2.y(), v2.z());
total += a.dot(b.cross(c));
}
}
let signed_volume = total / 6.0;
if signed_volume < 0.0 {
log::debug!(
"volume_from_per_face_tessellation: raw signed volume is negative ({signed_volume:.6}), \
possible face orientation issue"
);
}
Ok(signed_volume.abs())
}
fn analytic_cylinder_signed_volume(
topo: &Topology,
face_id: FaceId,
) -> Result<f64, crate::OperationsError> {
let face = topo.face(face_id)?;
let cyl = match face.surface() {
FaceSurface::Cylinder(c) => c,
_ => {
return Err(crate::OperationsError::InvalidInput {
reason: "analytic_cylinder_signed_volume requires a cylinder face".into(),
});
}
};
let wire = topo.wire(face.outer_wire())?;
let mut u_vals = Vec::new();
let mut v_vals = Vec::new();
for oe in wire.edges() {
if let Ok(edge) = topo.edge(oe.edge()) {
for &vid in &[edge.start(), edge.end()] {
if let Ok(vtx) = topo.vertex(vid) {
let (u, v) = cyl.project_point(vtx.point());
u_vals.push(u);
v_vals.push(v);
}
}
if !edge.is_closed()
&& let brepkit_topology::edge::EdgeCurve::Circle(circle) = edge.curve()
&& let (Ok(sv), Ok(ev)) = (topo.vertex(edge.start()), topo.vertex(edge.end()))
{
let ts = circle.project(sv.point());
let te = circle.project(ev.point());
let fwd = (te - ts).rem_euclid(std::f64::consts::TAU);
let mid_t = if fwd <= std::f64::consts::PI {
ts + fwd * 0.5
} else {
ts - (std::f64::consts::TAU - fwd) * 0.5
};
let mid = circle.evaluate(mid_t);
let (u, _) = cyl.project_point(mid);
u_vals.push(u);
}
if !edge.is_closed()
&& let brepkit_topology::edge::EdgeCurve::NurbsCurve(nc) = edge.curve()
&& let (Ok(sv), Ok(ev)) = (topo.vertex(edge.start()), topo.vertex(edge.end()))
{
let (t0, t1) = edge.curve().domain_with_endpoints(sv.point(), ev.point());
let (u, _) = cyl.project_point(nc.evaluate(f64::midpoint(t0, t1)));
u_vals.push(u);
}
}
}
let v_min = v_vals.iter().copied().fold(f64::INFINITY, f64::min);
let v_max = v_vals.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let h = v_max - v_min;
if h.abs() < 1e-15 {
return Ok(0.0);
}
let u_range = compute_angular_range(&mut u_vals);
let r = cyl.radius();
let x_axis = cyl.x_axis();
let y_axis = cyl.y_axis();
let o_vec = Vec3::new(cyl.origin().x(), cyl.origin().y(), cyl.origin().z());
let ox = o_vec.dot(x_axis);
let oy = o_vec.dot(y_axis);
let (u1, u2) = u_range;
let (sin1, cos1) = u1.sin_cos();
let (sin2, cos2) = u2.sin_cos();
if vol_trace_enabled() {
log::debug!(
"VOL_TRACE cyl {face_id:?} u_vals={u_vals:?} u_range=({u1},{u2}) h={h} r={r} ox={ox} oy={oy}"
);
}
let vol = (r / 3.0) * h * (ox * (sin2 - sin1) + oy * (-cos2 + cos1) + r * (u2 - u1));
Ok(if face.is_reversed() { -vol } else { vol })
}
fn planar_cap_signed_volume(
topo: &Topology,
face_id: FaceId,
) -> Result<Option<f64>, crate::OperationsError> {
let face = topo.face(face_id)?;
let FaceSurface::Plane { normal, d } = face.surface() else {
return Ok(None);
};
let normal = *normal;
let d = *d;
let frame = match brepkit_math::frame::Frame3::from_normal(Point3::new(0.0, 0.0, 0.0), normal) {
Ok(f) => f,
Err(_) => return Ok(None),
};
let (ex, ey) = (frame.x, frame.y);
let (outer_area2, mut arc_edges) =
match planar_wire_signed_area2(topo, face.outer_wire(), ex, ey)? {
Some(r) => r,
None => return Ok(None),
};
let mut area_mag2 = outer_area2.abs();
for &iw in face.inner_wires() {
let Some((hole_area2, hole_arcs)) = planar_wire_signed_area2(topo, iw, ex, ey)? else {
return Ok(None);
};
arc_edges += hole_arcs;
area_mag2 -= hole_area2.abs();
}
if area_mag2 < 0.0 {
return Ok(None); }
if arc_edges == 0 {
return Ok(None);
}
let area = area_mag2 / 2.0;
let d_out = if face.is_reversed() { -d } else { d };
Ok(Some(d_out * area / 3.0))
}
fn planar_wire_signed_area2(
topo: &Topology,
wire_id: brepkit_topology::wire::WireId,
ex: Vec3,
ey: Vec3,
) -> Result<Option<(f64, usize)>, crate::OperationsError> {
let to_2d = |p: Point3| {
let v = Vec3::new(p.x(), p.y(), p.z());
(v.dot(ex), v.dot(ey))
};
let tol_lin = brepkit_math::tolerance::Tolerance::default().linear;
let mut area2: f64 = 0.0; let mut arc_edges = 0_usize;
{
let wire = topo.wire(wire_id)?;
for oe in wire.edges() {
let edge = topo.edge(oe.edge())?;
let (sv, ev) = if oe.is_forward() {
(edge.start(), edge.end())
} else {
(edge.end(), edge.start())
};
let pa = topo.vertex(sv)?.point();
let pb = topo.vertex(ev)?.point();
let (ax, ay) = to_2d(pa);
let (bx, by) = to_2d(pb);
area2 += ax * by - bx * ay;
let is_closed_circle =
matches!(edge.curve(), brepkit_topology::edge::EdgeCurve::Circle(_))
&& edge.start() == edge.end();
if (pa - pb).length() < tol_lin && !is_closed_circle {
continue;
}
let arc = match edge.curve() {
brepkit_topology::edge::EdgeCurve::Line => None,
brepkit_topology::edge::EdgeCurve::Ellipse(_) => return Ok(None),
brepkit_topology::edge::EdgeCurve::Circle(c) => Some((c.center(), c.radius())),
brepkit_topology::edge::EdgeCurve::NurbsCurve(nc) => {
let tol = brepkit_math::tolerance::Tolerance::default().linear * 100.0;
match brepkit_geometry::convert::recognize_curve(nc, tol) {
brepkit_geometry::convert::RecognizedCurve::Circle {
center,
radius,
..
} => Some((center, radius)),
brepkit_geometry::convert::RecognizedCurve::Line { .. } => None,
_ => return Ok(None),
}
}
};
if let Some((center, radius)) = arc {
arc_edges += 1;
let nat_alpha = if is_closed_circle {
std::f64::consts::TAU
} else {
let nat_start = topo.vertex(edge.start())?.point();
let nat_end = topo.vertex(edge.end())?.point();
let (t0, t1) = edge.curve().domain_with_endpoints(nat_start, nat_end);
let mid_pt = edge.curve().evaluate_with_endpoints(
f64::midpoint(t0, t1),
nat_start,
nat_end,
);
let (cx, cy) = to_2d(center);
let (sx, sy) = to_2d(nat_start);
let (ex, ey) = to_2d(nat_end);
let (mx, my) = to_2d(mid_pt);
let va = (sx - cx, sy - cy);
let vm = (mx - cx, my - cy);
let vb = (ex - cx, ey - cy);
let ang = |u: (f64, f64), w: (f64, f64)| -> f64 {
(u.0 * w.1 - u.1 * w.0).atan2(u.0 * w.0 + u.1 * w.1)
};
ang(va, vm) + ang(vm, vb)
};
let alpha = if oe.is_forward() {
nat_alpha
} else {
-nat_alpha
};
area2 += alpha.signum() * radius * radius * (alpha.abs() - alpha.abs().sin());
}
}
}
Ok(Some((area2, arc_edges)))
}
fn analytic_cone_signed_volume(
topo: &Topology,
face_id: FaceId,
) -> Result<f64, crate::OperationsError> {
let face = topo.face(face_id)?;
let cone = match face.surface() {
FaceSurface::Cone(c) => c,
_ => {
return Err(crate::OperationsError::InvalidInput {
reason: "analytic_cone_signed_volume requires a cone face".into(),
});
}
};
let wire = topo.wire(face.outer_wire())?;
let mut u_vals = Vec::new();
let mut v_vals = Vec::new();
for oe in wire.edges() {
if let Ok(edge) = topo.edge(oe.edge()) {
for &vid in &[edge.start(), edge.end()] {
if let Ok(vtx) = topo.vertex(vid) {
let (u, v) = cone.project_point(vtx.point());
if v.abs() > 1e-9 {
u_vals.push(u);
}
v_vals.push(v);
}
}
if !edge.is_closed()
&& let brepkit_topology::edge::EdgeCurve::Circle(circle) = edge.curve()
&& let (Ok(sv), Ok(ev)) = (topo.vertex(edge.start()), topo.vertex(edge.end()))
{
let ts = circle.project(sv.point());
let te = circle.project(ev.point());
let fwd = (te - ts).rem_euclid(std::f64::consts::TAU);
let mid_t = if fwd <= std::f64::consts::PI {
ts + fwd * 0.5
} else {
ts - (std::f64::consts::TAU - fwd) * 0.5
};
let mid = circle.evaluate(mid_t);
let (u, _) = cone.project_point(mid);
u_vals.push(u);
}
if !edge.is_closed()
&& let brepkit_topology::edge::EdgeCurve::NurbsCurve(nc) = edge.curve()
&& let (Ok(sv), Ok(ev)) = (topo.vertex(edge.start()), topo.vertex(edge.end()))
{
let (t0, t1) = edge.curve().domain_with_endpoints(sv.point(), ev.point());
let (u, v) = cone.project_point(nc.evaluate(f64::midpoint(t0, t1)));
if v.abs() > 1e-9 {
u_vals.push(u);
}
}
}
}
let v_min = v_vals.iter().copied().fold(f64::INFINITY, f64::min);
let v_max = v_vals.iter().copied().fold(f64::NEG_INFINITY, f64::max);
if (v_max - v_min).abs() < 1e-15 {
return Ok(0.0);
}
let u_range = compute_angular_range(&mut u_vals);
let (sin_a, cos_a) = cone.half_angle().sin_cos();
let x_axis = cone.x_axis();
let y_axis = cone.y_axis();
let axis = cone.axis();
let apex = cone.apex();
let a_vec = Vec3::new(apex.x(), apex.y(), apex.z());
let ax = a_vec.dot(x_axis);
let ay = a_vec.dot(y_axis);
let az = a_vec.dot(axis);
let v2_half = (v_max * v_max - v_min * v_min) / 2.0;
let (u1, u2) = u_range;
let (sin1, cos1) = u1.sin_cos();
let (sin2, cos2) = u2.sin_cos();
let u_integral = sin_a * (ax * (sin2 - sin1) + ay * (-cos2 + cos1)) - cos_a * az * (u2 - u1);
let vol = (cos_a / 3.0) * v2_half * u_integral;
Ok(if face.is_reversed() { -vol } else { vol })
}
#[allow(clippy::too_many_lines)]
fn analytic_sphere_signed_volume(
topo: &Topology,
face_id: FaceId,
) -> Result<f64, crate::OperationsError> {
let face = topo.face(face_id)?;
let sph = match face.surface() {
FaceSurface::Sphere(s) => s,
_ => {
return Err(crate::OperationsError::InvalidInput {
reason: "analytic_sphere_signed_volume requires a sphere face".into(),
});
}
};
let wire = topo.wire(face.outer_wire())?;
let mut u_vals = Vec::new();
let mut v_vals = Vec::new();
for oe in wire.edges() {
if let Ok(edge) = topo.edge(oe.edge()) {
for &vid in &[edge.start(), edge.end()] {
if let Ok(vtx) = topo.vertex(vid) {
let (u, v) = sph.project_point(vtx.point());
u_vals.push(u);
v_vals.push(v);
}
}
if !edge.is_closed()
&& let brepkit_topology::edge::EdgeCurve::Circle(circle) = edge.curve()
&& let (Ok(sv), Ok(ev)) = (topo.vertex(edge.start()), topo.vertex(edge.end()))
{
let ts = circle.project(sv.point());
let te = circle.project(ev.point());
let fwd = (te - ts).rem_euclid(std::f64::consts::TAU);
let mid_t = if fwd <= std::f64::consts::PI {
ts + fwd * 0.5
} else {
ts - (std::f64::consts::TAU - fwd) * 0.5
};
let mid = circle.evaluate(mid_t);
let (u, _) = sph.project_point(mid);
u_vals.push(u);
}
}
}
let mut v_min = v_vals.iter().copied().fold(f64::INFINITY, f64::min);
let mut v_max = v_vals.iter().copied().fold(f64::NEG_INFINITY, f64::max);
if (v_max - v_min).abs() < 0.01 {
let v_boundary = f64::midpoint(v_min, v_max);
let positions = crate::boolean::face_polygon(topo, face_id)?;
if positions.is_empty() {
return Ok(0.0);
}
let n = positions.len() as f64;
let avg = Point3::new(
positions.iter().map(|p| p.x()).sum::<f64>() / n,
positions.iter().map(|p| p.y()).sum::<f64>() / n,
positions.iter().map(|p| p.z()).sum::<f64>() / n,
);
let (_, v_interior) = sph.project_point(avg);
if v_interior > v_boundary {
v_min = v_boundary;
v_max = std::f64::consts::FRAC_PI_2;
} else {
v_min = -std::f64::consts::FRAC_PI_2;
v_max = v_boundary;
}
}
let u_range = compute_angular_range(&mut u_vals);
let r = sph.radius();
let x_axis = sph.x_axis();
let y_axis = sph.y_axis();
let z_axis = sph.z_axis();
let c = sph.center();
let c_vec = Vec3::new(c.x(), c.y(), c.z());
let cx = c_vec.dot(x_axis);
let cy = c_vec.dot(y_axis);
let cz = c_vec.dot(z_axis);
let (u1, u2) = u_range;
let (sin_u1, cos_u1) = u1.sin_cos();
let (sin_u2, cos_u2) = u2.sin_cos();
let du = u2 - u1;
let vv_integral = |v: f64| -> f64 { v / 2.0 + (2.0 * v).sin() / 4.0 };
let cos2_v = vv_integral(v_max) - vv_integral(v_min);
let cos_v_int = v_max.sin() - v_min.sin();
let sincos_v = (v_max.sin().powi(2) - v_min.sin().powi(2)) / 2.0;
let vol = (r * r / 3.0)
* (cx * cos2_v * (sin_u2 - sin_u1)
+ cy * cos2_v * (-cos_u2 + cos_u1)
+ cz * sincos_v * du
+ r * cos_v_int * du);
Ok(if face.is_reversed() { -vol } else { vol })
}
#[allow(clippy::too_many_lines)]
fn analytic_torus_signed_volume(
topo: &Topology,
face_id: FaceId,
) -> Result<f64, crate::OperationsError> {
let face = topo.face(face_id)?;
let tor = match face.surface() {
FaceSurface::Torus(t) => t,
_ => {
return Err(crate::OperationsError::InvalidInput {
reason: "analytic_torus_signed_volume requires a torus face".into(),
});
}
};
let wire = topo.wire(face.outer_wire())?;
let mut u_vals = Vec::new();
let mut v_vals = Vec::new();
for oe in wire.edges() {
if let Ok(edge) = topo.edge(oe.edge()) {
for &vid in &[edge.start(), edge.end()] {
if let Ok(vtx) = topo.vertex(vid) {
let (u, v) = tor.project_point(vtx.point());
u_vals.push(u);
v_vals.push(v);
}
}
if !edge.is_closed()
&& let (Ok(sv), Ok(ev)) = (topo.vertex(edge.start()), topo.vertex(edge.end()))
{
let mid = match edge.curve() {
brepkit_topology::edge::EdgeCurve::Circle(circle) => {
let ts = circle.project(sv.point());
let te = circle.project(ev.point());
let fwd = (te - ts).rem_euclid(std::f64::consts::TAU);
let mid_t = if fwd <= std::f64::consts::PI {
ts + fwd * 0.5
} else {
ts - (std::f64::consts::TAU - fwd) * 0.5
};
Some(circle.evaluate(mid_t))
}
brepkit_topology::edge::EdgeCurve::NurbsCurve(nc) => {
let (t0, t1) = edge.curve().domain_with_endpoints(sv.point(), ev.point());
Some(nc.evaluate(f64::midpoint(t0, t1)))
}
_ => None,
};
if let Some(mid) = mid {
let (u, v) = tor.project_point(mid);
u_vals.push(u);
v_vals.push(v);
}
}
}
}
let v_min = v_vals.iter().copied().fold(f64::INFINITY, f64::min);
let v_max = v_vals.iter().copied().fold(f64::NEG_INFINITY, f64::max);
if (v_max - v_min).abs() < 1e-15 {
return Ok(0.0);
}
let (v_min, v_max) = {
let mut sorted = v_vals.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
sorted.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
if sorted.len() < 3 {
if (v_max - v_min) > std::f64::consts::PI + 1e-9 {
return Err(crate::OperationsError::InvalidInput {
reason:
"torus band minor range is ambiguous (seam-straddling, no interior sample)"
.into(),
});
}
(v_min, v_max)
} else {
compute_angular_range(&mut v_vals)
}
};
let u_range = compute_angular_range(&mut u_vals);
let big_r = tor.major_radius();
let small_r = tor.minor_radius();
let x_axis = tor.x_axis();
let y_axis = tor.y_axis();
let z_axis = tor.z_axis();
let c = tor.center();
let c_vec = Vec3::new(c.x(), c.y(), c.z());
let cx = c_vec.dot(x_axis);
let cy = c_vec.dot(y_axis);
let cz = c_vec.dot(z_axis);
let (u1, u2) = u_range;
let (sin_u1, cos_u1) = u1.sin_cos();
let (sin_u2, cos_u2) = u2.sin_cos();
let du = u2 - u1;
let sv1 = v_min.sin();
let sv2 = v_max.sin();
let cv1 = v_min.cos();
let cv2 = v_max.cos();
let dv = v_max - v_min;
let i_cos = sv2 - sv1;
let i_cos2 =
(v_max / 2.0 + (2.0 * v_max).sin() / 4.0) - (v_min / 2.0 + (2.0 * v_min).sin() / 4.0);
let i_sin = -cv2 + cv1;
let i_sincos = (sv2 * sv2 - sv1 * sv1) / 2.0;
let s_u_integral = cx * (sin_u2 - sin_u1) + cy * (-cos_u2 + cos_u1);
let s_coeff = small_r * (big_r * i_cos + small_r * i_cos2);
let cz_coeff = small_r * cz * (big_r * i_sin + small_r * i_sincos);
let rcos_coeff = small_r * big_r * (big_r * i_cos + small_r * i_cos2);
let const_coeff = small_r * small_r * (big_r * dv + small_r * i_cos);
let vol = (1.0 / 3.0) * (s_coeff * s_u_integral + (cz_coeff + rcos_coeff + const_coeff) * du);
Ok(if face.is_reversed() { -vol } else { vol })
}
pub fn volume_from_direct_face_tessellation(
topo: &Topology,
solid: SolidId,
deflection: f64,
) -> Result<f64, crate::OperationsError> {
let solid_data = topo.solid(solid)?;
let shell = topo.shell(solid_data.outer_shell())?;
let mut total: f64 = 0.0;
for &fid in shell.faces() {
let face = topo.face(fid)?;
match face.surface() {
FaceSurface::Cylinder(_) => {
let v = analytic_cylinder_signed_volume(topo, fid)? * 6.0;
if vol_trace_enabled() {
log::debug!("VOL_TRACE direct cyl face {:?} -> {}", fid, v / 6.0);
}
total += v;
continue;
}
FaceSurface::Cone(_) => {
total += analytic_cone_signed_volume(topo, fid)? * 6.0;
continue;
}
FaceSurface::Sphere(_) => {
total += analytic_sphere_signed_volume(topo, fid)? * 6.0;
continue;
}
FaceSurface::Torus(_) => {
total += analytic_torus_signed_volume(topo, fid)? * 6.0;
continue;
}
FaceSurface::Plane { .. } | FaceSurface::Nurbs(_) => {}
}
let mesh = tessellate::tessellate(topo, fid, deflection)?;
let idx = &mesh.indices;
let pos = &mesh.positions;
let tri_count = idx.len() / 3;
let mut face_total = 0.0;
for t in 0..tri_count {
let v0 = pos[idx[t * 3] as usize];
let v1 = pos[idx[t * 3 + 1] as usize];
let v2 = pos[idx[t * 3 + 2] as usize];
let a = Vec3::new(v0.x(), v0.y(), v0.z());
let b = Vec3::new(v1.x(), v1.y(), v1.z());
let c = Vec3::new(v2.x(), v2.y(), v2.z());
face_total += a.dot(b.cross(c));
}
if vol_trace_enabled() {
log::debug!(
"VOL_TRACE direct planar face {:?} -> {} (tris={})",
fid,
face_total / 6.0,
tri_count
);
}
total += face_total;
}
Ok((total / 6.0).abs())
}
pub fn solid_volume_from_faces(
topo: &Topology,
solid: SolidId,
_deflection: f64,
) -> Result<f64, crate::OperationsError> {
use brepkit_topology::edge::EdgeCurve;
use brepkit_topology::face::FaceSurface;
let solid_data = topo.solid(solid)?;
let shell = topo.shell(solid_data.outer_shell())?;
let mut total = 0.0;
let mut all_planar_triangles = true;
for &fid in shell.faces() {
let face = topo.face(fid)?;
if !matches!(face.surface(), FaceSurface::Plane { .. }) {
all_planar_triangles = false;
break;
}
let wire = topo.wire(face.outer_wire())?;
let edges = wire.edges();
if edges.len() != 3 {
all_planar_triangles = false;
break;
}
let mut pts = Vec::with_capacity(3);
for oe in edges {
let edge = topo.edge(oe.edge())?;
if !matches!(edge.curve(), EdgeCurve::Line) {
all_planar_triangles = false;
break;
}
let vid = if oe.is_forward() {
edge.start()
} else {
edge.end()
};
pts.push(topo.vertex(vid)?.point());
}
if !all_planar_triangles {
break;
}
let a = Vec3::new(pts[0].x(), pts[0].y(), pts[0].z());
let b = Vec3::new(pts[1].x(), pts[1].y(), pts[1].z());
let c = Vec3::new(pts[2].x(), pts[2].y(), pts[2].z());
total += a.dot(b.cross(c));
}
if all_planar_triangles {
Ok((total / 6.0).abs())
} else {
Err(crate::OperationsError::InvalidInput {
reason: "solid contains non-planar or non-triangular faces".to_string(),
})
}
}
pub fn solid_center_of_mass(
topo: &Topology,
solid: SolidId,
deflection: f64,
) -> Result<Point3, crate::OperationsError> {
if let Ok(com) = center_of_mass_from_faces(topo, solid) {
return Ok(com);
}
let solid_data = topo.solid(solid)?;
let shell = topo.shell(solid_data.outer_shell())?;
let mut total_vol: f64 = 0.0;
let mut cx = 0.0;
let mut cy = 0.0;
let mut cz = 0.0;
for &fid in shell.faces() {
let mesh = tessellate::tessellate(topo, fid, deflection)?;
let idx = &mesh.indices;
let pos = &mesh.positions;
let tri_count = idx.len() / 3;
for t in 0..tri_count {
let v0 = pos[idx[t * 3] as usize];
let v1 = pos[idx[t * 3 + 1] as usize];
let v2 = pos[idx[t * 3 + 2] as usize];
let a = Vec3::new(v0.x(), v0.y(), v0.z());
let b = Vec3::new(v1.x(), v1.y(), v1.z());
let c = Vec3::new(v2.x(), v2.y(), v2.z());
let signed_vol = a.dot(b.cross(c));
total_vol += signed_vol;
cx += signed_vol * (v0.x() + v1.x() + v2.x());
cy += signed_vol * (v0.y() + v1.y() + v2.y());
cz += signed_vol * (v0.z() + v1.z() + v2.z());
}
}
if total_vol.abs() < 1e-15 {
let vertex_points = collect_solid_vertex_points(topo, solid)?;
let n = vertex_points.len().max(1) as f64;
let (mut sx, mut sy, mut sz) = (0.0, 0.0, 0.0);
for p in &vertex_points {
sx += p.x();
sy += p.y();
sz += p.z();
}
return Ok(Point3::new(sx / n, sy / n, sz / n));
}
let denom = 4.0 * total_vol;
Ok(Point3::new(cx / denom, cy / denom, cz / denom))
}
fn center_of_mass_from_faces(
topo: &Topology,
solid: SolidId,
) -> Result<Point3, crate::OperationsError> {
use brepkit_topology::edge::EdgeCurve;
use brepkit_topology::face::FaceSurface;
let solid_data = topo.solid(solid)?;
let shell = topo.shell(solid_data.outer_shell())?;
let mut total_vol = 0.0;
let mut cx = 0.0;
let mut cy = 0.0;
let mut cz = 0.0;
for &fid in shell.faces() {
let face = topo.face(fid)?;
if !matches!(face.surface(), FaceSurface::Plane { .. }) {
return Err(crate::OperationsError::InvalidInput {
reason: "non-planar face".to_string(),
});
}
let wire = topo.wire(face.outer_wire())?;
let edges = wire.edges();
if edges.len() != 3 {
return Err(crate::OperationsError::InvalidInput {
reason: "non-triangular face".to_string(),
});
}
let mut pts = Vec::with_capacity(3);
for oe in edges {
let edge = topo.edge(oe.edge())?;
if !matches!(edge.curve(), EdgeCurve::Line) {
return Err(crate::OperationsError::InvalidInput {
reason: "non-line edge".to_string(),
});
}
let vid = if oe.is_forward() {
edge.start()
} else {
edge.end()
};
pts.push(topo.vertex(vid)?.point());
}
let a = Vec3::new(pts[0].x(), pts[0].y(), pts[0].z());
let b = Vec3::new(pts[1].x(), pts[1].y(), pts[1].z());
let c = Vec3::new(pts[2].x(), pts[2].y(), pts[2].z());
let signed_vol = a.dot(b.cross(c));
total_vol += signed_vol;
cx += signed_vol * (pts[0].x() + pts[1].x() + pts[2].x());
cy += signed_vol * (pts[0].y() + pts[1].y() + pts[2].y());
cz += signed_vol * (pts[0].z() + pts[1].z() + pts[2].z());
}
if total_vol.abs() < 1e-15 {
return Err(crate::OperationsError::InvalidInput {
reason: "solid has zero volume, center of mass is undefined".into(),
});
}
let denom = 4.0 * total_vol;
Ok(Point3::new(cx / denom, cy / denom, cz / denom))
}
#[cfg(test)]
mod regression_tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use brepkit_topology::builder::{make_face_from_wire, make_polygon_wire};
use brepkit_topology::face::FaceSurface;
fn unit_square_extrude_volume() -> (f64, bool) {
let mut topo = Topology::new();
let pts = vec![
Point3::new(0.0, 0.0, 0.0),
Point3::new(1.0, 0.0, 0.0),
Point3::new(1.0, 1.0, 0.0),
Point3::new(0.0, 1.0, 0.0),
];
let wire = make_polygon_wire(&mut topo, &pts, 1e-7).unwrap();
let face = make_face_from_wire(&mut topo, wire).unwrap();
let cap_is_plane = matches!(
topo.face(face).unwrap().surface(),
FaceSurface::Plane { .. }
);
let solid =
crate::extrude::extrude(&mut topo, face, Vec3::new(0.0, 0.0, 1.0), 1.0).unwrap();
let vol = solid_volume(&topo, solid, 0.01).unwrap();
(vol, cap_is_plane)
}
#[test]
fn unit_square_extrude_volume_is_one() {
let (vol, cap_is_plane) = unit_square_extrude_volume();
assert!(
cap_is_plane,
"axis-aligned square cap must be a planar face"
);
assert!((vol - 1.0).abs() < 1e-6, "expected 1.0, got {vol}");
}
#[test]
fn rectangle_extrude_volume_matches_box() {
let mut topo = Topology::new();
let pts = vec![
Point3::new(0.0, 0.0, 0.0),
Point3::new(5.0, 0.0, 0.0),
Point3::new(5.0, 2.0, 0.0),
Point3::new(0.0, 2.0, 0.0),
];
let wire = make_polygon_wire(&mut topo, &pts, 1e-7).unwrap();
let face = make_face_from_wire(&mut topo, wire).unwrap();
let solid =
crate::extrude::extrude(&mut topo, face, Vec3::new(0.0, 0.0, 1.0), 3.0).unwrap();
let vol = solid_volume(&topo, solid, 0.01).unwrap();
assert!((vol - 30.0).abs() < 1e-6, "expected 30.0, got {vol}");
}
fn steinmetz_fuse_census() -> (Topology, SolidId) {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let c1 = crate::primitives::make_cylinder(&mut topo, 3.0, 20.0).unwrap();
crate::transform::transform_solid(&mut topo, c1, &Mat4::translation(0.0, 0.0, -10.0))
.unwrap();
let c2 = crate::primitives::make_cylinder(&mut topo, 3.0, 20.0).unwrap();
crate::transform::transform_solid(
&mut topo,
c2,
&Mat4::rotation_y(std::f64::consts::FRAC_PI_2),
)
.unwrap();
crate::transform::transform_solid(&mut topo, c2, &Mat4::translation(-10.0, 0.0, 0.0))
.unwrap();
let res =
crate::boolean::boolean(&mut topo, crate::boolean::BooleanOp::Fuse, c1, c2).unwrap();
(topo, res)
}
#[test]
fn steinmetz_lens_fuse_closed_form_volume() {
let (topo, res) = steinmetz_fuse_census();
let faces = brepkit_topology::explorer::solid_faces(&topo, res).unwrap();
assert!(
solid_is_steinmetz_lens_fuse(&topo, &faces),
"the perpendicular cyl∪cyl fuse must be detected as the lens fuse"
);
let v = steinmetz_lens_fuse_volume(&topo, &faces).expect("closed form");
let expect = std::f64::consts::PI * 9.0 * 40.0 - 16.0 / 3.0 * 27.0;
assert!(
(v - expect).abs() < 1e-9,
"closed-form lens volume {v} should equal {expect} (986.97)"
);
let vol = solid_volume(&topo, res, 0.01).unwrap();
assert!(
(vol - expect).abs() < 1e-6,
"solid_volume {vol} should match closed form {expect}"
);
}
#[test]
fn steinmetz_gate_does_not_fire_on_plain_or_coaxial_cylinders() {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let cyl = crate::primitives::make_cylinder(&mut topo, 3.0, 20.0).unwrap();
let faces = brepkit_topology::explorer::solid_faces(&topo, cyl).unwrap();
assert!(
!solid_is_steinmetz_lens_fuse(&topo, &faces),
"a plain cylinder is not the lens fuse"
);
let mut topo2 = Topology::new();
let a = crate::primitives::make_cylinder(&mut topo2, 5.0, 20.0).unwrap();
let b = crate::primitives::make_cylinder(&mut topo2, 5.0, 20.0).unwrap();
crate::transform::transform_solid(&mut topo2, b, &Mat4::translation(0.0, 0.0, 10.0))
.unwrap();
let inter = crate::boolean::boolean(&mut topo2, crate::boolean::BooleanOp::Intersect, a, b)
.unwrap();
let f2 = brepkit_topology::explorer::solid_faces(&topo2, inter).unwrap();
assert!(
!solid_is_steinmetz_lens_fuse(&topo2, &f2),
"coaxial cyl∩cyl is not the lens fuse"
);
}
#[test]
fn cyl_perp_intersecting_predicate() {
use brepkit_math::surfaces::CylindricalSurface;
let cyl = |o: Point3, a: Vec3, r: f64| CylindricalSurface::new(o, a, r).unwrap();
let z = cyl(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 3.0);
let x = cyl(Point3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), 3.0);
let isect = cylinders_perpendicular_and_intersecting(&z, &x).expect("axes meet");
assert!((isect - Point3::new(0.0, 0.0, 0.0)).length() < 1e-9);
let x_big = cyl(Point3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), 4.0);
assert!(cylinders_perpendicular_and_intersecting(&z, &x_big).is_none());
let diag = cyl(Point3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 1.0), 3.0);
assert!(cylinders_perpendicular_and_intersecting(&z, &diag).is_none());
let z_off = cyl(Point3::new(2.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 3.0);
assert!(cylinders_perpendicular_and_intersecting(&z, &z_off).is_none());
let x_skew = cyl(Point3::new(0.0, 5.0, 8.0), Vec3::new(1.0, 0.0, 0.0), 3.0);
assert!(cylinders_perpendicular_and_intersecting(&z, &x_skew).is_none());
let x_high = cyl(Point3::new(0.0, 0.0, 4.0), Vec3::new(1.0, 0.0, 0.0), 3.0);
let isect_high = cylinders_perpendicular_and_intersecting(&z, &x_high).expect("axes meet");
assert!((isect_high - Point3::new(0.0, 0.0, 4.0)).length() < 1e-9);
}
#[test]
fn drilled_cylinder_volume_subtracts_the_bore() {
use std::f64::consts::PI;
let mut topo = Topology::new();
let outer = crate::primitives::make_cylinder(&mut topo, 5.0, 20.0).unwrap();
let bore = crate::primitives::make_cylinder(&mut topo, 2.0, 20.0).unwrap();
let tube = crate::boolean::boolean(&mut topo, crate::boolean::BooleanOp::Cut, outer, bore)
.unwrap();
assert!(
try_analytic_solid_volume(&topo, tube).is_none(),
"the holed tube must not hit the whole-solid analytic primitive path"
);
assert!(
analytic_faces_solid_volume(&topo, tube).is_none(),
"the holed tube must not hit the per-face analytic path (it ignores holes)"
);
let expect = PI * (25.0 - 4.0) * 20.0; let vol = solid_volume(&topo, tube, 0.005).unwrap();
let solid_cyl = PI * 25.0 * 20.0; assert!(
(vol - expect).abs() < expect * 0.01,
"drilled tube volume {vol} should be the bore-subtracted {expect}, \
not the hole-filled {solid_cyl}"
);
assert!(
(vol - solid_cyl).abs() > solid_cyl * 0.05,
"drilled tube volume {vol} must be clearly LESS than the solid cylinder \
{solid_cyl} (the bore is really removed)"
);
}
#[test]
fn plain_primitives_still_use_the_analytic_fast_path() {
use std::f64::consts::PI;
let mut t = Topology::new();
let cyl = crate::primitives::make_cylinder(&mut t, 3.0, 10.0).unwrap();
let v = try_analytic_solid_volume(&t, cyl).expect("plain cylinder fast-path");
assert!((v - PI * 9.0 * 10.0).abs() < 1e-9);
let mut t = Topology::new();
let cone = crate::primitives::make_cone(&mut t, 4.0, 0.0, 9.0).unwrap();
let v = try_analytic_solid_volume(&t, cone).expect("plain cone fast-path");
assert!((v - PI / 3.0 * 16.0 * 9.0).abs() < 1e-6);
let mut t = Topology::new();
let sph = crate::primitives::make_sphere(&mut t, 5.0, 32).unwrap();
let v = try_analytic_solid_volume(&t, sph).expect("plain sphere fast-path");
assert!((v - 4.0 / 3.0 * PI * 125.0).abs() < 1e-6);
let mut t = Topology::new();
let tor = crate::primitives::make_torus(&mut t, 6.0, 2.0, 32).unwrap();
let v = try_analytic_solid_volume(&t, tor).expect("plain torus fast-path");
assert!((v - 2.0 * PI * PI * 6.0 * 4.0).abs() < 1e-6);
}
#[test]
fn truncated_perpendicular_fuse_gate_defers() {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let c1 = crate::primitives::make_cylinder(&mut topo, 3.0, 20.0).unwrap();
crate::transform::transform_solid(&mut topo, c1, &Mat4::translation(0.0, 0.0, -10.0))
.unwrap();
let c2 = crate::primitives::make_cylinder(&mut topo, 3.0, 2.0).unwrap();
crate::transform::transform_solid(
&mut topo,
c2,
&Mat4::rotation_y(std::f64::consts::FRAC_PI_2),
)
.unwrap();
crate::transform::transform_solid(&mut topo, c2, &Mat4::translation(-1.0, 0.0, 0.0))
.unwrap();
let res =
crate::boolean::boolean(&mut topo, crate::boolean::BooleanOp::Fuse, c1, c2).unwrap();
let faces = brepkit_topology::explorer::solid_faces(&topo, res).unwrap();
assert!(
!solid_is_steinmetz_lens_fuse(&topo, &faces),
"a truncated (short) perpendicular fuse must not use the infinite-cylinder closed form"
);
}
#[test]
fn gate_rejects_extra_face_beyond_the_lens_fuse() {
let (mut topo, res) = steinmetz_fuse_census();
let census_faces = brepkit_topology::explorer::solid_faces(&topo, res).unwrap();
assert!(solid_is_steinmetz_lens_fuse(&topo, &census_faces));
let extra = crate::primitives::make_cylinder(&mut topo, 1.0, 4.0).unwrap();
let extra_faces = brepkit_topology::explorer::solid_faces(&topo, extra).unwrap();
let extra_cyl = extra_faces
.iter()
.copied()
.find(|&f| {
topo.face(f)
.is_ok_and(|fc| matches!(fc.surface(), FaceSurface::Cylinder(_)))
})
.expect("plain cylinder wall face");
let extra_cap = extra_faces
.iter()
.copied()
.find(|&f| {
topo.face(f)
.is_ok_and(|fc| matches!(fc.surface(), FaceSurface::Plane { .. }))
})
.expect("plain cylinder cap face");
let mut with_extra_cyl = census_faces.clone();
with_extra_cyl.push(extra_cyl);
assert!(
!solid_is_steinmetz_lens_fuse(&topo, &with_extra_cyl),
"an extra unholed cylinder face must make the gate decline"
);
let tilted = crate::primitives::make_cylinder(&mut topo, 1.0, 4.0).unwrap();
crate::transform::transform_solid(
&mut topo,
tilted,
&brepkit_math::mat::Mat4::rotation_x(0.7),
)
.unwrap();
let tilted_cap = brepkit_topology::explorer::solid_faces(&topo, tilted)
.unwrap()
.into_iter()
.find(|&f| {
topo.face(f)
.is_ok_and(|fc| matches!(fc.surface(), FaceSurface::Plane { .. }))
})
.expect("tilted cylinder cap");
let mut with_foreign_cap = census_faces.clone();
with_foreign_cap.push(tilted_cap);
assert!(
!solid_is_steinmetz_lens_fuse(&topo, &with_foreign_cap),
"a planar cap not aligned with either lens axis must make the gate decline"
);
let mut with_aligned_cap = census_faces;
with_aligned_cap.push(extra_cap);
assert!(
!solid_is_steinmetz_lens_fuse(&topo, &with_aligned_cap),
"an extra axis-aligned cap beyond the exactly-four lens caps must make the gate decline"
);
}
#[test]
fn closed_circle_disc_cap_volume_is_exact() {
use brepkit_topology::explorer::solid_faces;
use std::f64::consts::{PI, TAU};
let mut topo = Topology::new();
let wire = make_polygon_wire(
&mut topo,
&[
Point3::new(6.0, 0.0, 0.0),
Point3::new(4.0, 0.0, 6.0),
Point3::new(2.0, 0.0, 12.0),
Point3::new(0.0, 0.0, 12.0),
Point3::new(0.0, 0.0, 0.0),
],
1e-7,
)
.unwrap();
let face = topo.add_face(brepkit_topology::face::Face::new(
wire,
vec![],
FaceSurface::Plane {
normal: Vec3::new(0.0, 1.0, 0.0),
d: 0.0,
},
));
let solid = crate::revolve::revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
TAU,
)
.unwrap();
let fr = |rb: f64, rt: f64, h: f64| PI * h / 3.0 * rb.mul_add(rb, rb.mul_add(rt, rt * rt));
let expected = fr(6.0, 4.0, 6.0) + fr(4.0, 2.0, 6.0);
let v_fine = solid_volume(&topo, solid, 0.0001).unwrap();
let v_coarse = solid_volume(&topo, solid, 0.1).unwrap();
assert!(
(v_fine - expected).abs() / expected < 1e-9,
"two-section revolve volume {expected}, got {v_fine}"
);
assert!(
(v_fine - v_coarse).abs() < 1e-9,
"volume must be analytic (deflection-independent): {v_fine} vs {v_coarse}"
);
let top_cap = solid_faces(&topo, solid)
.unwrap()
.into_iter()
.find(|&fid| {
let f = topo.face(fid).unwrap();
matches!(f.surface(), FaceSurface::Plane { .. })
&& topo.wire(f.outer_wire()).unwrap().edges().iter().all(|oe| {
topo.vertex(topo.edge(oe.edge()).unwrap().start())
.unwrap()
.point()
.z()
> 11.0
})
})
.expect("top disc cap");
let cap_v = planar_cap_signed_volume(&topo, top_cap).unwrap().unwrap();
assert!(
(cap_v.abs() - 12.0 * PI * 4.0 / 3.0).abs() < 1e-9,
"closed-circle disc cap contribution should be (1/3)·12·π·4, got {cap_v}"
);
}
#[test]
fn annular_cap_volume_is_exact() {
use brepkit_math::curves::Circle3D;
use brepkit_topology::edge::{Edge, EdgeCurve};
use brepkit_topology::face::Face;
use brepkit_topology::vertex::Vertex;
use brepkit_topology::wire::{OrientedEdge, Wire};
use std::f64::consts::PI;
let (r_out, r_in, h) = (7.0_f64, 5.0_f64, 4.0_f64);
let axis = Vec3::new(0.0, 0.0, 1.0);
let mut topo = Topology::new();
let v_out = topo.add_vertex(Vertex::new(Point3::new(r_out, 0.0, h), 1e-7));
let outer_c = Circle3D::new(Point3::new(0.0, 0.0, h), axis, r_out).unwrap();
let e_out = topo.add_edge(Edge::new(v_out, v_out, EdgeCurve::Circle(outer_c)));
let outer_wire =
topo.add_wire(Wire::new(vec![OrientedEdge::new(e_out, true)], true).unwrap());
let v_in = topo.add_vertex(Vertex::new(Point3::new(r_in, 0.0, h), 1e-7));
let inner_c = Circle3D::new(Point3::new(0.0, 0.0, h), axis, r_in).unwrap();
let e_in = topo.add_edge(Edge::new(v_in, v_in, EdgeCurve::Circle(inner_c)));
let inner_wire =
topo.add_wire(Wire::new(vec![OrientedEdge::new(e_in, false)], true).unwrap());
let cap = topo.add_face(Face::new(
outer_wire,
vec![inner_wire],
FaceSurface::Plane { normal: axis, d: h },
));
let cap_v = planar_cap_signed_volume(&topo, cap).unwrap().unwrap();
let expected = h * PI * (r_out * r_out - r_in * r_in) / 3.0;
assert!(
(cap_v.abs() - expected).abs() < 1e-9,
"annular cap contribution should subtract the inner segment: \
expected {expected}, got {cap_v} (inflated would be {})",
h * PI * (r_out * r_out + r_in * r_in) / 3.0
);
}
}