use std::f64::consts::{FRAC_PI_2, PI};
use brepkit_math::nurbs::curve::NurbsCurve;
use brepkit_math::nurbs::surface::NurbsSurface;
use brepkit_math::tolerance::Tolerance;
use brepkit_math::vec::{Point3, Vec3};
use brepkit_topology::Topology;
use brepkit_topology::edge::{Edge, EdgeCurve};
use brepkit_topology::face::{Face, FaceId, FaceSurface};
use brepkit_topology::shell::Shell;
use brepkit_topology::solid::{Solid, SolidId};
use brepkit_topology::vertex::{Vertex, VertexId};
use brepkit_topology::wire::{OrientedEdge, Wire};
use crate::dot_normal_point;
const MIN_RADIAL_LEN: f64 = 1e-12;
const PLANARITY_REL_TOL: f64 = 1e-6;
fn rotate_point(point: Point3, origin: Point3, axis: Vec3, angle: f64) -> Point3 {
let v = point - origin;
let cos_a = angle.cos();
let sin_a = angle.sin();
let k_dot_v = axis.dot(v);
let k_cross_v = axis.cross(v);
let rotated = v * cos_a + k_cross_v * sin_a + axis * (k_dot_v * (1.0 - cos_a));
origin + rotated
}
fn rotate_vec(dir: Vec3, axis: Vec3, angle: f64) -> Vec3 {
let cos_a = angle.cos();
let sin_a = angle.sin();
let k_dot_v = axis.dot(dir);
let k_cross_v = axis.cross(dir);
dir * cos_a + k_cross_v * sin_a + axis * (k_dot_v * (1.0 - cos_a))
}
fn arc_segmentation(total_angle: f64) -> (usize, f64) {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let num_segs = ((total_angle / FRAC_PI_2).ceil() as usize).max(1);
#[allow(clippy::cast_precision_loss)]
let seg_angle = total_angle / (num_segs as f64);
(num_segs, seg_angle)
}
fn arc_mid_control_point(
start: Point3,
origin: Point3,
axis: Vec3,
half: f64,
w_mid: f64,
) -> Point3 {
let mid_on_arc = rotate_point(start, origin, axis, half);
let r_vec = mid_on_arc - origin;
let proj = axis * axis.dot(r_vec);
let radial = r_vec - proj;
if radial.length() > MIN_RADIAL_LEN {
origin + proj + radial * (1.0 / w_mid)
} else {
mid_on_arc
}
}
fn make_arc_curve(
start: Point3,
end: Point3,
origin: Point3,
axis: Vec3,
angle: f64,
) -> Result<NurbsCurve, brepkit_math::MathError> {
let half = angle / 2.0;
let w_mid = half.cos();
let mid_ctrl = arc_mid_control_point(start, origin, axis, half, w_mid);
NurbsCurve::new(
2,
vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
vec![start, mid_ctrl, end],
vec![1.0, w_mid, 1.0],
)
}
fn make_revolution_surface(
p0_start: Point3,
p0_end: Point3,
p1_start: Point3,
p1_end: Point3,
origin: Point3,
axis: Vec3,
seg_angle: f64,
) -> Result<NurbsSurface, brepkit_math::MathError> {
let half = seg_angle / 2.0;
let w_mid = half.cos();
let mid0 = arc_mid_control_point(p0_start, origin, axis, half, w_mid);
let mid1 = arc_mid_control_point(p1_start, origin, axis, half, w_mid);
NurbsSurface::new(
1, 2, vec![0.0, 0.0, 1.0, 1.0], vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], vec![vec![p0_start, mid0, p0_end], vec![p1_start, mid1, p1_end]],
vec![vec![1.0, w_mid, 1.0], vec![1.0, w_mid, 1.0]],
)
}
fn radial_axial(p: Point3, axis_origin: Point3, axis: Vec3) -> (f64, f64) {
let v = p - axis_origin;
let z = v.dot(axis);
((v - axis * z).length(), z)
}
#[allow(clippy::too_many_arguments)]
fn revolution_band_surface(
profile_curve: &EdgeCurve,
p0_start: Point3,
p0_end: Point3,
p1_start: Point3,
p1_end: Point3,
axis_origin: Point3,
axis: Vec3,
seg_angle: f64,
) -> Result<(FaceSurface, bool), brepkit_math::MathError> {
let nurbs = make_revolution_surface(
p0_start,
p0_end,
p1_start,
p1_end,
axis_origin,
axis,
seg_angle,
)?;
if let Some((center, radius)) = profile_arc_center_radius(profile_curve, p0_start, p1_start) {
if let Some(result) =
revolution_torus_band(center, radius, axis_origin, axis, &nurbs, seg_angle)?
{
return Ok(result);
}
return Ok((FaceSurface::Nurbs(nurbs), false));
}
if !matches!(profile_curve, EdgeCurve::Line) {
return Ok((FaceSurface::Nurbs(nurbs), false));
}
let tol = 1e-9;
let (r0, z0) = radial_axial(p0_start, axis_origin, axis);
let (r1, z1) = radial_axial(p1_start, axis_origin, axis);
let dr = r1 - r0;
let dz = z1 - z0;
if r0 < tol && r1 < tol {
return Ok((FaceSurface::Nurbs(nurbs), false));
}
let band_normal = nurbs.normal(0.5, 0.5)?;
let mid = rotate_point(p0_start, axis_origin, axis, seg_angle / 2.0);
let mid_radial = mid - (axis_origin + axis * (mid - axis_origin).dot(axis));
let natural_outward = mid_radial.normalize().unwrap_or(axis);
let outward_reversed = natural_outward.dot(band_normal) < 0.0;
if dr.abs() < tol {
let surface = brepkit_math::surfaces::CylindricalSurface::new(axis_origin, axis, r0)?;
return Ok((FaceSurface::Cylinder(surface), outward_reversed));
}
if dz.abs() < tol {
let plane_d = dot_normal_point(axis, p0_start);
return Ok((
FaceSurface::Plane {
normal: axis,
d: plane_d,
},
band_normal.dot(axis) < 0.0,
));
}
let half_angle = dz.abs().atan2(dr.abs());
if half_angle <= tol || half_angle >= FRAC_PI_2 - tol {
return Ok((FaceSurface::Nurbs(nurbs), false));
}
let apex = revolution_cone_apex(axis_origin, axis, r0, z0, dr, dz);
let wide_z = if r1 >= r0 { z1 } else { z0 };
let apex_z = axis.dot(apex - axis_origin);
let cone_axis = if wide_z >= apex_z { axis } else { -axis };
let surface = brepkit_math::surfaces::ConicalSurface::new(apex, cone_axis, half_angle)?;
Ok((FaceSurface::Cone(surface), outward_reversed))
}
fn revolution_cone_apex(
axis_origin: Point3,
axis: Vec3,
r_ref: f64,
z_ref: f64,
dr: f64,
dz: f64,
) -> Point3 {
let z_apex = z_ref - r_ref * (dz / dr);
axis_origin + axis * z_apex
}
fn profile_arc_center_radius(
curve: &EdgeCurve,
p_start: Point3,
p_end: Point3,
) -> Option<(Point3, f64)> {
match curve {
EdgeCurve::Circle(c) => Some((c.center(), c.radius())),
EdgeCurve::NurbsCurve(nc) => {
let scale = (p_end - p_start).length().max(1.0);
let tol = Tolerance::new().linear * 100.0 * scale;
match brepkit_geometry::convert::recognize_curve(nc, tol) {
brepkit_geometry::convert::RecognizedCurve::Circle { center, radius, .. } => {
Some((center, radius))
}
_ => None,
}
}
EdgeCurve::Line | EdgeCurve::Ellipse(_) => None,
}
}
fn revolution_torus_band(
arc_center: Point3,
arc_radius: f64,
axis_origin: Point3,
axis: Vec3,
nurbs: &NurbsSurface,
seg_angle: f64,
) -> Result<Option<(FaceSurface, bool)>, brepkit_math::MathError> {
let tol = 1e-9;
let to_center = arc_center - axis_origin;
let axial = axis * to_center.dot(axis);
let radial = to_center - axial;
let major = radial.length();
if major <= arc_radius + tol {
return Ok(None);
}
let torus_center = axis_origin + axial;
let surface =
brepkit_math::surfaces::ToroidalSurface::with_axis(torus_center, major, arc_radius, axis)?;
let _ = seg_angle;
let band_center = brepkit_math::traits::ParametricSurface::evaluate(nurbs, 0.5, 0.5);
let band_normal = nurbs.normal(0.5, 0.5)?;
let (tu, tv) = surface.project_point(band_center);
let outward = surface.normal(tu, tv);
Ok(Some((
FaceSurface::Torus(surface),
outward.dot(band_normal) < 0.0,
)))
}
const fn next_ring_index(seg: usize, num_segs: usize, is_full: bool) -> usize {
if is_full && seg == num_segs - 1 {
0
} else {
seg + 1
}
}
struct WireRevolveData {
ring_verts: Vec<Vec<VertexId>>,
arc_edges: Vec<Vec<brepkit_topology::edge::EdgeId>>,
ring_edges: Vec<Vec<brepkit_topology::edge::EdgeId>>,
input_oriented: Vec<OrientedEdge>,
n: usize,
}
enum RevEdge {
Cylinder { r: f64 },
Cone { apex: Point3, half_angle: f64 },
Plane,
Torus {
center: Point3,
major: f64,
minor: f64,
},
OnAxis,
}
struct ProfileEdge {
sp: Point3,
ep: Point3,
sv: VertexId,
edge: brepkit_topology::edge::EdgeId,
forward: bool,
class: RevEdge,
interior: Vec<Point3>,
}
fn classify_profile_edge(
curve: &EdgeCurve,
sp: Point3,
ep: Point3,
axis_origin: Point3,
axis: Vec3,
lin: f64,
) -> Option<RevEdge> {
let (r0, z0) = radial_axial(sp, axis_origin, axis);
let (r1, z1) = radial_axial(ep, axis_origin, axis);
let s_on_axis = r0 < lin;
let e_on_axis = r1 < lin;
if let Some((center, radius)) = profile_arc_center_radius(curve, sp, ep) {
if s_on_axis || e_on_axis || (sp - ep).length() < lin {
return None;
}
let to_c = center - axis_origin;
let major = (to_c - axis * to_c.dot(axis)).length();
if major <= radius + lin {
return None; }
return Some(RevEdge::Torus {
center,
major,
minor: radius,
});
}
if !matches!(curve, EdgeCurve::Line) {
return None; }
let dr = r1 - r0;
let dz = z1 - z0;
if s_on_axis && e_on_axis {
Some(RevEdge::OnAxis)
} else if dr.abs() < lin {
Some(RevEdge::Cylinder { r: r0 })
} else if dz.abs() < lin {
Some(RevEdge::Plane)
} else {
let apex = revolution_cone_apex(axis_origin, axis, r0, z0, dr, dz);
let half_angle = dz.abs().atan2(dr.abs());
if half_angle <= lin || half_angle >= FRAC_PI_2 - lin {
return None;
}
Some(RevEdge::Cone { apex, half_angle })
}
}
fn try_analytic_full_revolution(
topo: &mut Topology,
face: FaceId,
axis_origin: Point3,
axis: Vec3,
is_full: bool,
) -> Result<Option<SolidId>, crate::OperationsError> {
if !is_full {
return Ok(None);
}
let face_data = topo.face(face)?;
if !face_data.inner_wires().is_empty() {
return Ok(None);
}
let normal = match face_data.surface() {
FaceSurface::Plane { normal, .. } => *normal,
_ => return Ok(None),
};
if normal.dot(axis).abs() > 1e-9 {
return Ok(None);
}
let tol = Tolerance::new();
let lin = tol.linear;
let wire = topo.wire(face_data.outer_wire())?;
let oriented: Vec<OrientedEdge> = wire.edges().to_vec();
if oriented.len() < 2 {
return Ok(None);
}
let mut profile: Vec<ProfileEdge> = Vec::with_capacity(oriented.len());
for oe in &oriented {
let edge = topo.edge(oe.edge())?;
let (svid, evid) = if oe.is_forward() {
(edge.start(), edge.end())
} else {
(edge.end(), edge.start())
};
let sp = topo.vertex(svid)?.point();
let ep = topo.vertex(evid)?.point();
let curve = edge.curve().clone();
let ns = topo.vertex(edge.start())?.point();
let ne = topo.vertex(edge.end())?.point();
let Some(class) = classify_profile_edge(&curve, sp, ep, axis_origin, axis, lin) else {
return Ok(None);
};
let interior = if matches!(class, RevEdge::Torus { .. }) {
let (t0, t1) = curve.domain_with_endpoints(ns, ne);
let mut pts: Vec<Point3> = [0.25, 0.5, 0.75]
.iter()
.map(|f| curve.evaluate_with_endpoints((t1 - t0).mul_add(*f, t0), ns, ne))
.collect();
if !oe.is_forward() {
pts.reverse();
}
pts
} else {
Vec::new()
};
profile.push(ProfileEdge {
sp,
ep,
sv: svid,
edge: oe.edge(),
forward: oe.is_forward(),
class,
interior,
});
}
let mut e_r: Option<Vec3> = None;
let mut best_r = 0.0_f64;
for pe in &profile {
for &p in std::iter::once(&pe.sp).chain(pe.interior.iter()) {
let v = p - axis_origin;
let radial = v - axis * v.dot(axis);
let r = radial.length();
if r > best_r {
best_r = r;
e_r = radial.normalize().ok();
}
}
}
let Some(e_r) = e_r else {
return Ok(None); };
let chart = |p: Point3| {
let v = p - axis_origin;
(v.dot(e_r), v.dot(axis))
};
let mut pts2: Vec<(f64, f64)> = Vec::new();
for pe in &profile {
pts2.push(chart(pe.sp));
for &q in &pe.interior {
pts2.push(chart(q));
}
}
if pts2.iter().any(|&(x, _)| x < -lin) {
return Ok(None);
}
let mut area2 = 0.0_f64;
let mut scale = 0.0_f64;
for i in 0..pts2.len() {
let (x0, y0) = pts2[i];
let (x1, y1) = pts2[(i + 1) % pts2.len()];
area2 += x0.mul_add(y1, -(x1 * y0));
scale = scale.max(x0.abs()).max(y0.abs());
}
if area2.abs() <= scale * scale * 1e-9 {
return Ok(None); }
let ccw = area2 > 0.0;
Some(build_analytic_revolution(
topo,
axis_origin,
axis,
e_r,
ccw,
&profile,
))
.transpose()
}
#[allow(clippy::too_many_lines)]
fn build_analytic_revolution(
topo: &mut Topology,
axis_origin: Point3,
axis: Vec3,
e_r: Vec3,
ccw: bool,
profile: &[ProfileEdge],
) -> Result<SolidId, crate::OperationsError> {
use brepkit_topology::edge::EdgeId;
let lin = Tolerance::new().linear;
let n = profile.len();
let s = if ccw { 1.0 } else { -1.0 };
let chart = |p: Point3| {
let v = p - axis_origin;
(v.dot(e_r), v.dot(axis))
};
let mut rim_circle: Vec<Option<EdgeId>> = vec![None; n];
for (i, pe) in profile.iter().enumerate() {
let (r, z) = radial_axial(pe.sp, axis_origin, axis);
if r < lin {
continue;
}
let center = axis_origin + axis * z;
let circle = brepkit_math::curves::Circle3D::new(center, axis, r)
.map_err(crate::OperationsError::Math)?;
rim_circle[i] = Some(topo.add_edge(Edge::new(pe.sv, pe.sv, EdgeCurve::Circle(circle))));
}
let mut faces: Vec<FaceId> = Vec::with_capacity(n);
for (idx, pe) in profile.iter().enumerate() {
let next = (idx + 1) % n;
let (x0, y0) = chart(pe.sp);
let (x1, y1) = chart(pe.ep);
let (dx, dy) = (x1 - x0, y1 - y0);
let out2 = (s * dy, -(s * dx));
match pe.class {
RevEdge::OnAxis => {} RevEdge::Plane => {
let (outer_i, inner_i) = if x0 >= x1 { (idx, next) } else { (next, idx) };
let Some(outer_e) = rim_circle[outer_i] else {
continue; };
let cap_normal = if out2.1 >= 0.0 { axis } else { -axis };
let d = dot_normal_point(cap_normal, pe.sp);
let outer_fwd = cap_normal.dot(axis) > 0.0;
let cap_wire = Wire::new(vec![OrientedEdge::new(outer_e, outer_fwd)], true)
.map_err(crate::OperationsError::Topology)?;
let cap_wid = topo.add_wire(cap_wire);
let mut inner_wids = Vec::new();
if let Some(inner_e) = rim_circle[inner_i] {
let hole_wire = Wire::new(vec![OrientedEdge::new(inner_e, !outer_fwd)], true)
.map_err(crate::OperationsError::Topology)?;
inner_wids.push(topo.add_wire(hole_wire));
}
faces.push(topo.add_face(Face::new(
cap_wid,
inner_wids,
FaceSurface::Plane {
normal: cap_normal,
d,
},
)));
}
RevEdge::Cylinder { .. } | RevEdge::Cone { .. } | RevEdge::Torus { .. } => {
let (probe, outm) = if let RevEdge::Torus { .. } = pe.class {
let m = pe.interior[1];
let a = chart(pe.interior[0]);
let b = chart(pe.interior[2]);
let (tx, ty) = (b.0 - a.0, b.1 - a.1);
(m, (s * ty, -(s * tx)))
} else {
let m = Point3::new(
f64::midpoint(pe.sp.x(), pe.ep.x()),
f64::midpoint(pe.sp.y(), pe.ep.y()),
f64::midpoint(pe.sp.z(), pe.ep.z()),
);
(m, out2)
};
let outward = e_r * outm.0 + axis * outm.1;
let (surface, reversed) =
revolution_wall_surface(&pe.class, axis_origin, axis, probe, outward)?;
let rim_fwd = !reversed;
let wall_wire = match (rim_circle[idx], rim_circle[next]) {
(Some(bot_e), Some(top_e)) => Wire::new(
vec![
OrientedEdge::new(bot_e, rim_fwd),
OrientedEdge::new(pe.edge, pe.forward),
OrientedEdge::new(top_e, !rim_fwd),
OrientedEdge::new(pe.edge, !pe.forward),
],
true,
),
(Some(rim_e), None) => Wire::new(
vec![
OrientedEdge::new(rim_e, rim_fwd),
OrientedEdge::new(pe.edge, pe.forward),
OrientedEdge::new(pe.edge, !pe.forward),
],
true,
),
(None, Some(rim_e)) => Wire::new(
vec![
OrientedEdge::new(rim_e, rim_fwd),
OrientedEdge::new(pe.edge, !pe.forward),
OrientedEdge::new(pe.edge, pe.forward),
],
true,
),
(None, None) => {
return Err(crate::OperationsError::InvalidInput {
reason: "analytic revolution wall has no rim circle".into(),
});
}
}
.map_err(crate::OperationsError::Topology)?;
let wall_wid = topo.add_wire(wall_wire);
faces.push(if reversed {
topo.add_face(Face::new_reversed(wall_wid, vec![], surface))
} else {
topo.add_face(Face::new(wall_wid, vec![], surface))
});
}
}
}
if faces.is_empty() {
return Err(crate::OperationsError::InvalidInput {
reason: "analytic revolution produced no faces".into(),
});
}
let shell = Shell::new(faces).map_err(crate::OperationsError::Topology)?;
let shell_id = topo.add_shell(shell);
Ok(topo.add_solid(Solid::new(shell_id, vec![])))
}
fn revolution_wall_surface(
class: &RevEdge,
axis_origin: Point3,
axis: Vec3,
probe: Point3,
outward: Vec3,
) -> Result<(FaceSurface, bool), crate::OperationsError> {
match class {
RevEdge::Cylinder { r } => {
let sfc = brepkit_math::surfaces::CylindricalSurface::new(axis_origin, axis, *r)
.map_err(crate::OperationsError::Math)?;
let (u, v) = sfc.project_point(probe);
let nat = sfc.normal(u, v);
Ok((FaceSurface::Cylinder(sfc), nat.dot(outward) < 0.0))
}
RevEdge::Cone { apex, half_angle } => {
let apex_z = axis.dot(*apex - axis_origin);
let probe_z = axis.dot(probe - axis_origin);
let cone_axis = if probe_z >= apex_z { axis } else { -axis };
let sfc = brepkit_math::surfaces::ConicalSurface::new(*apex, cone_axis, *half_angle)
.map_err(crate::OperationsError::Math)?;
let (u, v) = sfc.project_point(probe);
let nat = sfc.normal(u, v);
Ok((FaceSurface::Cone(sfc), nat.dot(outward) < 0.0))
}
RevEdge::Torus {
center,
major,
minor,
} => {
let to_c = *center - axis_origin;
let torus_center = axis_origin + axis * to_c.dot(axis);
let sfc = brepkit_math::surfaces::ToroidalSurface::with_axis(
torus_center,
*major,
*minor,
axis,
)
.map_err(crate::OperationsError::Math)?;
let (u, v) = sfc.project_point(probe);
let nat = sfc.normal(u, v);
Ok((FaceSurface::Torus(sfc), nat.dot(outward) < 0.0))
}
RevEdge::Plane | RevEdge::OnAxis => Err(crate::OperationsError::InvalidInput {
reason: "revolution_wall_surface called on a non-wall edge".into(),
}),
}
}
#[allow(clippy::too_many_lines)]
fn try_circle_revolution_torus(
topo: &mut Topology,
face: FaceId,
axis_origin: Point3,
axis: Vec3,
angle: f64,
is_full: bool,
) -> Result<Option<SolidId>, crate::OperationsError> {
let face_data = topo.face(face)?;
if !face_data.inner_wires().is_empty() {
return Ok(None);
}
let normal = match face_data.surface() {
FaceSurface::Plane { normal, .. } => *normal,
_ => return Ok(None),
};
let wire = topo.wire(face_data.outer_wire())?;
let oriented = wire.edges();
if oriented.len() != 1 {
return Ok(None);
}
let profile_eid = oriented[0].edge();
let edge = topo.edge(profile_eid)?;
if edge.start() != edge.end() {
return Ok(None);
}
let profile_vid = edge.start();
let (center, radius, circ_normal) = match edge.curve() {
EdgeCurve::Circle(c) => (c.center(), c.radius(), c.normal()),
_ => return Ok(None),
};
let tol = Tolerance::new();
if normal.dot(axis).abs() > 1e-9 {
return Ok(None);
}
let to_center = center - axis_origin;
let along = to_center.dot(axis);
let major_radius = (to_center - axis * along).length();
if major_radius <= radius + tol.linear {
return Ok(None);
}
let torus_center = axis_origin + axis * along;
let surface = brepkit_math::surfaces::ToroidalSurface::with_axis(
torus_center,
major_radius,
radius,
axis,
)
.map_err(crate::OperationsError::Math)?;
if is_full {
let seam = surface.evaluate(0.0, 0.0);
let v0 = topo.add_vertex(Vertex::new(seam, tol.linear));
let ea = topo.add_edge(Edge::new(v0, v0, EdgeCurve::Line));
let eb = topo.add_edge(Edge::new(v0, v0, EdgeCurve::Line));
let wid = topo.add_wire(
Wire::new(
vec![
OrientedEdge::new(ea, true),
OrientedEdge::new(eb, true),
OrientedEdge::new(ea, false),
OrientedEdge::new(eb, false),
],
true,
)
.map_err(crate::OperationsError::Topology)?,
);
let face_id = topo.add_face(Face::new(wid, vec![], FaceSurface::Torus(surface)));
let shell_id =
topo.add_shell(Shell::new(vec![face_id]).map_err(crate::OperationsError::Topology)?);
return Ok(Some(topo.add_solid(Solid::new(shell_id, vec![]))));
}
let p0 = topo.vertex(profile_vid)?.point();
let (seam_r, seam_z) = radial_axial(p0, axis_origin, axis);
if seam_r < tol.linear {
return Ok(None); }
let p1 = rotate_point(p0, axis_origin, axis, angle);
let v1 = topo.add_vertex(Vertex::new(p1, tol.linear));
let end_center = rotate_point(center, axis_origin, axis, angle);
let end_normal = rotate_vec(circ_normal, axis, angle);
let end_circle = brepkit_math::curves::Circle3D::new(end_center, end_normal, radius)
.map_err(crate::OperationsError::Math)?;
let end_eid = topo.add_edge(Edge::new(v1, v1, EdgeCurve::Circle(end_circle)));
let seam_circle =
brepkit_math::curves::Circle3D::new(axis_origin + axis * seam_z, axis, seam_r)
.map_err(crate::OperationsError::Math)?;
let seam_eid = topo.add_edge(Edge::new(profile_vid, v1, EdgeCurve::Circle(seam_circle)));
let wall_wire = Wire::new(
vec![
OrientedEdge::new(profile_eid, true),
OrientedEdge::new(seam_eid, true),
OrientedEdge::new(end_eid, false),
OrientedEdge::new(seam_eid, false),
],
true,
)
.map_err(crate::OperationsError::Topology)?;
let wall_wid = topo.add_wire(wall_wire);
let radial_c = (center - torus_center)
.normalize()
.map_err(crate::OperationsError::Math)?;
let outer0 = center + radial_c * radius;
let probe = rotate_point(outer0, axis_origin, axis, angle / 2.0);
let (pu, pv) = surface.project_point(probe);
let nat = surface.normal(pu, pv);
let out_probe = probe - (axis_origin + axis * axis.dot(probe - axis_origin));
let wall_reversed = nat.dot(out_probe) < 0.0;
let mut faces = Vec::with_capacity(3);
faces.push(if wall_reversed {
topo.add_face(Face::new_reversed(
wall_wid,
vec![],
FaceSurface::Torus(surface),
))
} else {
topo.add_face(Face::new(wall_wid, vec![], FaceSurface::Torus(surface)))
});
let sweep0 = axis
.cross(center - torus_center)
.normalize()
.map_err(crate::OperationsError::Math)?;
let start_normal = -sweep0;
let start_fwd = circ_normal.dot(start_normal) > 0.0;
let start_wire = Wire::new(vec![OrientedEdge::new(profile_eid, start_fwd)], true)
.map_err(crate::OperationsError::Topology)?;
let start_wid = topo.add_wire(start_wire);
faces.push(topo.add_face(Face::new(
start_wid,
vec![],
FaceSurface::Plane {
normal: start_normal,
d: dot_normal_point(start_normal, p0),
},
)));
let final_normal = rotate_vec(sweep0, axis, angle);
let end_fwd = end_normal.dot(final_normal) > 0.0;
let end_wire = Wire::new(vec![OrientedEdge::new(end_eid, end_fwd)], true)
.map_err(crate::OperationsError::Topology)?;
let end_wid = topo.add_wire(end_wire);
faces.push(topo.add_face(Face::new(
end_wid,
vec![],
FaceSurface::Plane {
normal: final_normal,
d: dot_normal_point(final_normal, p1),
},
)));
let shell_id = topo.add_shell(Shell::new(faces).map_err(crate::OperationsError::Topology)?);
Ok(Some(topo.add_solid(Solid::new(shell_id, vec![]))))
}
fn profile_chart_is_ccw(
topo: &Topology,
oriented: &[OrientedEdge],
axis_origin: Point3,
axis: Vec3,
) -> Result<Option<bool>, crate::OperationsError> {
let mut pts: Vec<Point3> = Vec::with_capacity(oriented.len() * 4);
for oe in oriented {
let edge = topo.edge(oe.edge())?;
let ns = topo.vertex(edge.start())?.point();
let ne = topo.vertex(edge.end())?.point();
pts.push(if oe.is_forward() { ns } else { ne });
if !matches!(edge.curve(), EdgeCurve::Line) {
let curve = edge.curve();
let (t0, t1) = curve.domain_with_endpoints(ns, ne);
let mut interior: Vec<Point3> = [0.25, 0.5, 0.75]
.iter()
.map(|f| curve.evaluate_with_endpoints((t1 - t0).mul_add(*f, t0), ns, ne))
.collect();
if !oe.is_forward() {
interior.reverse();
}
pts.extend(interior);
}
}
let mut e_r: Option<Vec3> = None;
let mut best_r = 0.0_f64;
for &p in &pts {
let v = p - axis_origin;
let radial = v - axis * v.dot(axis);
let r = radial.length();
if r > best_r {
best_r = r;
e_r = radial.normalize().ok();
}
}
let Some(e_r) = e_r else {
return Ok(None); };
let chart: Vec<(f64, f64)> = pts
.iter()
.map(|p| {
let v = *p - axis_origin;
(v.dot(e_r), v.dot(axis))
})
.collect();
let mut area2 = 0.0_f64;
let mut scale = 0.0_f64;
for i in 0..chart.len() {
let (x0, y0) = chart[i];
let (x1, y1) = chart[(i + 1) % chart.len()];
area2 += x0.mul_add(y1, -(x1 * y0));
scale = scale.max(x0.abs()).max(y0.abs());
}
if area2.abs() <= scale * scale * 1e-9 {
return Ok(None);
}
Ok(Some(area2 > 0.0))
}
#[allow(clippy::too_many_lines)]
pub fn revolve(
topo: &mut Topology,
face: FaceId,
axis_origin: Point3,
axis_direction: Vec3,
angle_radians: f64,
) -> Result<SolidId, crate::OperationsError> {
let tol = Tolerance::new();
if tol.approx_eq(axis_direction.length_squared(), 0.0) {
return Err(crate::OperationsError::InvalidInput {
reason: "revolve axis direction is zero-length".into(),
});
}
let axis = axis_direction.normalize()?;
if angle_radians <= 0.0 || angle_radians > 2.0f64.mul_add(PI, tol.angular) {
return Err(crate::OperationsError::InvalidInput {
reason: format!("revolve angle must be in (0, 2π], got {angle_radians}"),
});
}
let is_full = angle_radians >= 2.0f64.mul_add(PI, -tol.angular);
let angle = if is_full { 2.0 * PI } else { angle_radians };
let face_data = topo.face(face)?;
let stored_plane_normal = match face_data.surface() {
FaceSurface::Plane { normal, .. } => Some(*normal),
FaceSurface::Cylinder(_)
| FaceSurface::Cone(_)
| FaceSurface::Sphere(_)
| FaceSurface::Torus(_)
| FaceSurface::Nurbs(_) => None,
};
let input_wire_id = face_data.outer_wire();
let inner_wire_ids: Vec<brepkit_topology::wire::WireId> = face_data.inner_wires().to_vec();
if let Some(solid) = try_circle_revolution_torus(topo, face, axis_origin, axis, angle, is_full)?
{
return Ok(solid);
}
if let Some(solid) = try_analytic_full_revolution(topo, face, axis_origin, axis, is_full)? {
return Ok(solid);
}
let input_normal = {
let wire = topo.wire(input_wire_id)?;
let oes: Vec<_> = wire.edges().to_vec();
let wire_positions: Vec<Point3> = oes
.iter()
.map(|oe| -> Result<Point3, crate::OperationsError> {
let edge = topo.edge(oe.edge())?;
let vid = if oe.is_forward() {
edge.start()
} else {
edge.end()
};
Ok(topo.vertex(vid)?.point())
})
.collect::<Result<_, _>>()?;
if let Some(normal) = stored_plane_normal {
if crate::winding::is_cw_winding(&wire_positions, &normal) {
-normal
} else {
normal
}
} else if is_full {
crate::winding::newell_normal(&wire_positions)
.normalize()
.unwrap_or(axis)
} else {
if wire_positions.len() < 3 {
return Err(crate::OperationsError::InvalidInput {
reason: "partial revolve of a non-planar profile requires a polygonal boundary"
.into(),
});
}
let normal = crate::winding::newell_normal(&wire_positions).normalize()?;
let plane_pt = wire_positions[0];
let max_dev = wire_positions
.iter()
.map(|p| (*p - plane_pt).dot(normal).abs())
.fold(0.0, f64::max);
let scale = wire_positions
.iter()
.map(|p| (*p - plane_pt).length())
.fold(0.0, f64::max);
if max_dev > PLANARITY_REL_TOL * scale {
return Err(crate::OperationsError::InvalidInput {
reason: "partial revolve of a non-planar profile boundary is not supported"
.into(),
});
}
normal
}
};
let flip_traversal = {
let oes = topo.wire(input_wire_id)?.edges().to_vec();
profile_chart_is_ccw(topo, &oes, axis_origin, axis)?.unwrap_or(false)
};
let input_normal = if flip_traversal {
-input_normal
} else {
input_normal
};
let (num_segs, seg_angle) = arc_segmentation(angle);
let num_boundaries = if is_full { num_segs } else { num_segs + 1 };
let revolve_wire = |topo: &mut Topology,
wire_id: brepkit_topology::wire::WireId|
-> Result<WireRevolveData, crate::OperationsError> {
let wire = topo.wire(wire_id)?;
let original_oriented: Vec<_> = wire.edges().to_vec();
let split_oriented = crate::extrude::maybe_split_closed_wire(
topo,
&original_oriented,
tol.linear,
crate::extrude::DEFAULT_DEFLECTION,
)?;
let input_oriented: Vec<OrientedEdge> = if flip_traversal {
split_oriented
.iter()
.rev()
.map(|oe| OrientedEdge::new(oe.edge(), !oe.is_forward()))
.collect()
} else {
split_oriented
};
let n = input_oriented.len();
let mut input_verts: Vec<VertexId> = Vec::with_capacity(n);
for oe in &input_oriented {
let edge = topo.edge(oe.edge())?;
let vid = if oe.is_forward() {
edge.start()
} else {
edge.end()
};
input_verts.push(vid);
}
let input_positions: Vec<Point3> = input_verts
.iter()
.map(|&vid| {
topo.vertex(vid)
.map(brepkit_topology::vertex::Vertex::point)
})
.collect::<Result<_, _>>()?;
let mut ring_verts: Vec<Vec<VertexId>> = Vec::with_capacity(num_boundaries);
ring_verts.push(input_verts.clone());
for k in 1..num_boundaries {
#[allow(clippy::cast_precision_loss)]
let theta = seg_angle * (k as f64);
let ring: Vec<VertexId> = input_positions
.iter()
.map(|&pos| {
let rotated = rotate_point(pos, axis_origin, axis, theta);
topo.add_vertex(Vertex::new(rotated, tol.linear))
})
.collect();
ring_verts.push(ring);
}
let mut arc_edges: Vec<Vec<brepkit_topology::edge::EdgeId>> = Vec::with_capacity(num_segs);
for seg in 0..num_segs {
let next = next_ring_index(seg, num_segs, is_full);
let mut seg_edges = Vec::with_capacity(n);
for (&start_vid, &end_vid) in ring_verts[seg].iter().zip(&ring_verts[next]) {
let start_pos = topo.vertex(start_vid)?.point();
let end_pos = topo.vertex(end_vid)?.point();
let curve = make_arc_curve(start_pos, end_pos, axis_origin, axis, seg_angle)?;
seg_edges.push(topo.add_edge(Edge::new(
start_vid,
end_vid,
EdgeCurve::NurbsCurve(curve),
)));
}
arc_edges.push(seg_edges);
}
let input_edge_ids: Vec<_> = input_oriented
.iter()
.map(brepkit_topology::wire::OrientedEdge::edge)
.collect();
let mut ring_edges: Vec<Vec<brepkit_topology::edge::EdgeId>> =
Vec::with_capacity(num_boundaries);
ring_edges.push(input_edge_ids);
for ring in ring_verts.iter().skip(1) {
let edges: Vec<_> = (0..n)
.map(|i| {
let next_i = (i + 1) % n;
topo.add_edge(Edge::new(ring[i], ring[next_i], EdgeCurve::Line))
})
.collect();
ring_edges.push(edges);
}
Ok(WireRevolveData {
ring_verts,
arc_edges,
ring_edges,
input_oriented,
n,
})
};
let outer = revolve_wire(topo, input_wire_id)?;
let mut inner_data: Vec<WireRevolveData> = Vec::new();
for &iw_id in &inner_wire_ids {
inner_data.push(revolve_wire(topo, iw_id)?);
}
let input_positions: Vec<Point3> = outer.ring_verts[0]
.iter()
.map(|&vid| {
topo.vertex(vid)
.map(brepkit_topology::vertex::Vertex::point)
})
.collect::<Result<_, _>>()?;
let mut all_faces = Vec::new();
if !is_full {
let reversed_edges: Vec<OrientedEdge> = outer
.input_oriented
.iter()
.rev()
.map(|oe| OrientedEdge::new(oe.edge(), !oe.is_forward()))
.collect();
let wire = Wire::new(reversed_edges, true).map_err(crate::OperationsError::Topology)?;
let wid = topo.add_wire(wire);
let mut bottom_inner_wires = Vec::new();
for iwd in &inner_data {
let inner_reversed: Vec<OrientedEdge> = iwd
.input_oriented
.iter()
.rev()
.map(|oe| OrientedEdge::new(oe.edge(), !oe.is_forward()))
.collect();
let iw = Wire::new(inner_reversed, true).map_err(crate::OperationsError::Topology)?;
bottom_inner_wires.push(topo.add_wire(iw));
}
let bottom_normal = -input_normal;
let bottom_d = dot_normal_point(bottom_normal, input_positions[0]);
let fid = topo.add_face(Face::new(
wid,
bottom_inner_wires,
FaceSurface::Plane {
normal: bottom_normal,
d: bottom_d,
},
));
all_faces.push(fid);
}
for seg in 0..num_segs {
let next = next_ring_index(seg, num_segs, is_full);
for i in 0..outer.n {
let next_i = (i + 1) % outer.n;
let fwd_seg = if seg == 0 {
outer.input_oriented[i].is_forward()
} else {
true
};
let fwd_next = if next == 0 {
outer.input_oriented[i].is_forward()
} else {
true
};
let p0_start = topo.vertex(outer.ring_verts[seg][i])?.point();
let p0_end = topo.vertex(outer.ring_verts[next][i])?.point();
let p1_start = topo.vertex(outer.ring_verts[seg][next_i])?.point();
let p1_end = topo.vertex(outer.ring_verts[next][next_i])?.point();
let profile_curve = topo.edge(outer.input_oriented[i].edge())?.curve().clone();
let (surface, reversed) = revolution_band_surface(
&profile_curve,
p0_start,
p0_end,
p1_start,
p1_end,
axis_origin,
axis,
seg_angle,
)?;
let side_wire = if reversed {
Wire::new(
vec![
OrientedEdge::new(outer.arc_edges[seg][i], true),
OrientedEdge::new(outer.ring_edges[next][i], fwd_next),
OrientedEdge::new(outer.arc_edges[seg][next_i], false),
OrientedEdge::new(outer.ring_edges[seg][i], !fwd_seg),
],
true,
)
} else {
Wire::new(
vec![
OrientedEdge::new(outer.ring_edges[seg][i], fwd_seg),
OrientedEdge::new(outer.arc_edges[seg][next_i], true),
OrientedEdge::new(outer.ring_edges[next][i], !fwd_next),
OrientedEdge::new(outer.arc_edges[seg][i], false),
],
true,
)
}
.map_err(crate::OperationsError::Topology)?;
let side_wire_id = topo.add_wire(side_wire);
let fid = if reversed {
topo.add_face(Face::new_reversed(side_wire_id, vec![], surface))
} else {
topo.add_face(Face::new(side_wire_id, vec![], surface))
};
all_faces.push(fid);
}
}
for iwd in &inner_data {
for seg in 0..num_segs {
let next = next_ring_index(seg, num_segs, is_full);
for i in 0..iwd.n {
let next_i = (i + 1) % iwd.n;
let fwd_seg = if seg == 0 {
iwd.input_oriented[i].is_forward()
} else {
true
};
let fwd_next = if next == 0 {
iwd.input_oriented[i].is_forward()
} else {
true
};
let side_wire = Wire::new(
vec![
OrientedEdge::new(iwd.arc_edges[seg][i], true),
OrientedEdge::new(iwd.ring_edges[next][i], fwd_next),
OrientedEdge::new(iwd.arc_edges[seg][next_i], false),
OrientedEdge::new(iwd.ring_edges[seg][i], !fwd_seg),
],
true,
)
.map_err(crate::OperationsError::Topology)?;
let side_wire_id = topo.add_wire(side_wire);
let p0_start = topo.vertex(iwd.ring_verts[seg][i])?.point();
let p0_end = topo.vertex(iwd.ring_verts[next][i])?.point();
let p1_start = topo.vertex(iwd.ring_verts[seg][next_i])?.point();
let p1_end = topo.vertex(iwd.ring_verts[next][next_i])?.point();
let surface = make_revolution_surface(
p0_start,
p0_end,
p1_start,
p1_end,
axis_origin,
axis,
seg_angle,
)?;
let fid =
topo.add_face(Face::new(side_wire_id, vec![], FaceSurface::Nurbs(surface)));
all_faces.push(fid);
}
}
}
if !is_full {
let last_ring = num_boundaries - 1;
let top_wire = Wire::new(
outer.ring_edges[last_ring]
.iter()
.map(|&eid| OrientedEdge::new(eid, true))
.collect(),
true,
)
.map_err(crate::OperationsError::Topology)?;
let top_wire_id = topo.add_wire(top_wire);
let mut top_inner_wires = Vec::new();
for iwd in &inner_data {
let inner_top_edges: Vec<OrientedEdge> = iwd.ring_edges[last_ring]
.iter()
.map(|&eid| OrientedEdge::new(eid, true))
.collect();
let iw = Wire::new(inner_top_edges, true).map_err(crate::OperationsError::Topology)?;
top_inner_wires.push(topo.add_wire(iw));
}
let rotated_normal = rotate_vec(input_normal, axis, angle);
let top_pos = topo.vertex(outer.ring_verts[last_ring][0])?.point();
let top_d = dot_normal_point(rotated_normal, top_pos);
let fid = topo.add_face(Face::new(
top_wire_id,
top_inner_wires,
FaceSurface::Plane {
normal: rotated_normal,
d: top_d,
},
));
all_faces.push(fid);
}
let shell = Shell::new(all_faces).map_err(crate::OperationsError::Topology)?;
let shell_id = topo.add_shell(shell);
let solid = topo.add_solid(Solid::new(shell_id, vec![]));
Ok(solid)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use std::f64::consts::PI;
use brepkit_math::tolerance::Tolerance;
use brepkit_topology::Topology;
use brepkit_topology::face::FaceSurface;
use brepkit_topology::test_utils::make_unit_square_face;
use crate::test_helpers::{assert_euler_genus0, euler_characteristic};
use super::*;
fn mesh_boundary_edges(mesh: &crate::tessellate::TriangleMesh) -> usize {
use std::collections::HashMap;
let mut ec: HashMap<(u32, u32), i32> = HashMap::new();
for t in mesh.indices.chunks(3) {
for k in 0..3 {
let (a, b) = (t[k], t[(k + 1) % 3]);
let key = if a < b { (a, b) } else { (b, a) };
*ec.entry(key).or_insert(0) += 1;
}
}
ec.values().filter(|&&c| c == 1).count()
}
#[test]
fn revolve_square_full_circle() {
let mut topo = Topology::new();
let face = make_unit_square_face(&mut topo);
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
2.0 * PI,
)
.unwrap();
let solid_data = topo.solid(solid).unwrap();
let shell = topo.shell(solid_data.outer_shell()).unwrap();
assert_eq!(
shell.faces().len(),
3,
"merges to 3 faces like make_cylinder"
);
let cyl_count = shell
.faces()
.iter()
.filter(|&&fid| matches!(topo.face(fid).unwrap().surface(), FaceSurface::Cylinder(_)))
.count();
assert_eq!(cyl_count, 1, "one periodic cylinder wall");
let plane_count = shell
.faces()
.iter()
.filter(|&&fid| matches!(topo.face(fid).unwrap().surface(), FaceSurface::Plane { .. }))
.count();
assert_eq!(plane_count, 2, "two planar disc caps");
for defl in [0.1_f64, 0.05, 0.02] {
let mesh = crate::tessellate::tessellate_solid(&topo, solid, defl).unwrap();
assert_eq!(
mesh_boundary_edges(&mesh),
0,
"watertight at deflection {defl}"
);
}
let vol = crate::measure::solid_volume(&topo, solid, 0.01).unwrap();
assert!(
(vol - PI).abs() / PI < 1e-9,
"expected exact unit cylinder volume π, got {vol}"
);
let chi = euler_characteristic(&topo, solid);
assert_eq!(chi, 2, "solid cylinder is genus-0 (χ=2), got {chi}");
}
#[test]
fn revolve_circle_full_turn_is_exact_torus() {
use brepkit_topology::builder::make_circle_edge;
let mut topo = Topology::new();
let circle = make_circle_edge(
&mut topo,
Point3::new(10.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
2.0,
1e-7,
)
.unwrap();
let wid = topo.add_wire(Wire::new(vec![OrientedEdge::new(circle, true)], true).unwrap());
let profile = topo.add_face(Face::new(
wid,
vec![],
FaceSurface::Plane {
normal: Vec3::new(0.0, 0.0, 1.0),
d: 0.0,
},
));
let solid = revolve(
&mut topo,
profile,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
2.0 * PI,
)
.unwrap();
let shell = topo
.shell(topo.solid(solid).unwrap().outer_shell())
.unwrap();
assert_eq!(
shell.faces().len(),
1,
"torus is a single doubly-periodic face"
);
assert!(matches!(
topo.face(shell.faces()[0]).unwrap().surface(),
FaceSurface::Torus(_)
));
let vol = crate::measure::solid_volume(&topo, solid, 0.01).unwrap();
let expected = 2.0 * PI * PI * 10.0 * 4.0;
assert!(
(vol - expected).abs() / expected < 1e-6,
"expected exact torus volume {expected}, got {vol}"
);
assert!(
crate::validate::validate_solid(&topo, solid)
.unwrap()
.is_valid()
);
}
#[test]
fn revolve_washer_walls_are_exact_cylinders() {
use brepkit_topology::builder::make_polygon_wire;
let mut topo = Topology::new();
let wire = make_polygon_wire(
&mut topo,
&[
Point3::new(5.0, 0.0, 0.0),
Point3::new(7.0, 0.0, 0.0),
Point3::new(7.0, 0.0, 5.0),
Point3::new(5.0, 0.0, 5.0),
],
1e-7,
)
.unwrap();
let face = topo.add_face(Face::new(
wire,
vec![],
FaceSurface::Plane {
normal: Vec3::new(0.0, 1.0, 0.0),
d: 0.0,
},
));
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
2.0 * PI,
)
.unwrap();
let shell = topo
.shell(topo.solid(solid).unwrap().outer_shell())
.unwrap();
assert_eq!(
shell.faces().len(),
4,
"washer merges to 2 periodic cylinder walls + 2 annulus caps"
);
let cyl_count = shell
.faces()
.iter()
.filter(|&&fid| matches!(topo.face(fid).unwrap().surface(), FaceSurface::Cylinder(_)))
.count();
assert_eq!(cyl_count, 2, "inner+outer walls are periodic cylinders");
let plane_count = shell
.faces()
.iter()
.filter(|&&fid| matches!(topo.face(fid).unwrap().surface(), FaceSurface::Plane { .. }))
.count();
assert_eq!(plane_count, 2, "top+bottom annulus caps are planar");
let holed_caps = shell
.faces()
.iter()
.filter(|&&fid| {
let f = topo.face(fid).unwrap();
matches!(f.surface(), FaceSurface::Plane { .. }) && f.inner_wires().len() == 1
})
.count();
assert_eq!(holed_caps, 2, "both annulus caps carry an inner rim hole");
for defl in [0.1_f64, 0.02] {
let mesh = crate::tessellate::tessellate_solid(&topo, solid, defl).unwrap();
assert_eq!(
mesh_boundary_edges(&mesh),
0,
"washer mesh must be watertight at deflection {defl}"
);
}
let expected = PI * (49.0 - 25.0) * 5.0;
let vol = crate::measure::solid_volume(&topo, solid, 0.01).unwrap();
let vol_fine = crate::measure::solid_volume(&topo, solid, 0.0001).unwrap();
assert!(
(vol - expected).abs() / expected < 1e-6,
"washer volume {expected}, got {vol}"
);
assert!(
(vol - vol_fine).abs() < 1e-9,
"washer volume must be analytic (deflection-independent): {vol} vs {vol_fine}"
);
assert!(
crate::validate::validate_solid(&topo, solid)
.unwrap()
.is_valid()
);
}
#[test]
fn revolve_frustum_walls_are_exact_cones() {
use brepkit_topology::builder::make_polygon_wire;
use brepkit_topology::explorer::solid_faces;
let mut topo = Topology::new();
let (r_bot, r_top, h) = (6.0_f64, 2.0_f64, 12.0_f64);
let wire = make_polygon_wire(
&mut topo,
&[
Point3::new(r_bot, 0.0, 0.0),
Point3::new(r_top, 0.0, h),
Point3::new(0.0, 0.0, h),
Point3::new(0.0, 0.0, 0.0),
],
1e-7,
)
.unwrap();
let face = topo.add_face(Face::new(
wire,
vec![],
FaceSurface::Plane {
normal: Vec3::new(0.0, 1.0, 0.0),
d: 0.0,
},
));
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
2.0 * PI,
)
.unwrap();
let faces = solid_faces(&topo, solid).unwrap();
let count = |pred: fn(&FaceSurface) -> bool| {
faces
.iter()
.filter(|&&fid| pred(topo.face(fid).unwrap().surface()))
.count()
};
assert_eq!(faces.len(), 3, "frustum merges to 3 faces like make_cone");
assert_eq!(
count(|s| matches!(s, FaceSurface::Cone(_))),
1,
"one cone wall"
);
assert_eq!(
count(|s| matches!(s, FaceSurface::Plane { .. })),
2,
"two planar disc caps"
);
assert_eq!(
count(|s| matches!(s, FaceSurface::Nurbs(_))),
0,
"no NURBS bands"
);
for defl in [0.1_f64, 0.05, 0.02] {
let mesh = crate::tessellate::tessellate_solid(&topo, solid, defl).unwrap();
assert_eq!(
mesh_boundary_edges(&mesh),
0,
"frustum mesh must be watertight at deflection {defl}"
);
}
let vol = crate::measure::solid_volume(&topo, solid, 0.01).unwrap();
let expected = PI * h / 3.0 * r_bot.mul_add(r_bot, r_bot.mul_add(r_top, r_top * r_top));
assert!(
(vol - expected).abs() / expected < 1e-9,
"frustum volume {expected}, got {vol}"
);
}
#[test]
fn revolve_arc_profile_edge_is_torus_band() {
use brepkit_math::curves::Circle3D;
use std::f64::consts::PI;
let mut topo = Topology::new();
let (d, rho) = (10.0_f64, 3.0_f64);
let circ = Circle3D::new(Point3::new(d, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0), rho).unwrap();
let p_bot = circ.evaluate(-std::f64::consts::FRAC_PI_2); let p_top = circ.evaluate(std::f64::consts::FRAC_PI_2); let v_bot = topo.add_vertex(Vertex::new(p_bot, 1e-7));
let v_top = topo.add_vertex(Vertex::new(p_top, 1e-7));
let e_arc = topo.add_edge(Edge::new(v_bot, v_top, EdgeCurve::Circle(circ)));
let e_dia = topo.add_edge(Edge::new(v_top, v_bot, EdgeCurve::Line));
let wire = Wire::new(
vec![
OrientedEdge::new(e_arc, true),
OrientedEdge::new(e_dia, true),
],
true,
)
.unwrap();
let wid = topo.add_wire(wire);
let face = topo.add_face(Face::new(
wid,
vec![],
FaceSurface::Plane {
normal: Vec3::new(0.0, 1.0, 0.0),
d: 0.0,
},
));
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
2.0 * PI,
)
.unwrap();
let shell = topo
.shell(topo.solid(solid).unwrap().outer_shell())
.unwrap();
assert_eq!(
shell.faces().len(),
2,
"half-disc merges to 1 periodic torus band + 1 annulus cap"
);
let torus_count = shell
.faces()
.iter()
.filter(|&&fid| matches!(topo.face(fid).unwrap().surface(), FaceSurface::Torus(_)))
.count();
assert_eq!(torus_count, 1, "the arc edge is ONE periodic torus band");
assert_eq!(
shell
.faces()
.iter()
.filter(|&&fid| matches!(topo.face(fid).unwrap().surface(), FaceSurface::Cone(_)))
.count(),
0,
"an arc edge must not be chorded into cone bands"
);
for defl in [0.1_f64, 0.02] {
let mesh = crate::tessellate::tessellate_solid(&topo, solid, defl).unwrap();
assert_eq!(
mesh_boundary_edges(&mesh),
0,
"half-disc mesh must be watertight at deflection {defl}"
);
}
let vol = crate::measure::solid_volume(&topo, solid, 0.01).unwrap();
let expected = PI * PI * d * rho * rho;
assert!(
(vol - expected).abs() / expected < 1e-9,
"half-torus tube volume {expected}, got {vol}"
);
}
#[test]
fn revolve_axis_straddling_profile_defers_analytic_path() {
let mut topo = Topology::new();
let corners = [
Point3::new(-1.0, 0.0, 0.0),
Point3::new(3.0, 0.0, 0.0),
Point3::new(3.0, 0.0, 2.0),
Point3::new(-1.0, 0.0, 2.0),
];
let vids: Vec<_> = corners
.iter()
.map(|&p| topo.add_vertex(Vertex::new(p, 1e-7)))
.collect();
let eids: Vec<_> = (0..4)
.map(|i| topo.add_edge(Edge::new(vids[i], vids[(i + 1) % 4], EdgeCurve::Line)))
.collect();
let wire = Wire::new(
eids.iter().map(|&e| OrientedEdge::new(e, true)).collect(),
true,
)
.unwrap();
let wid = topo.add_wire(wire);
let face = topo.add_face(Face::new(
wid,
vec![],
FaceSurface::Plane {
normal: Vec3::new(0.0, 1.0, 0.0),
d: 0.0,
},
));
let result = try_analytic_full_revolution(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
true,
)
.unwrap();
assert!(
result.is_none(),
"axis-straddling profile must defer to the segmented path"
);
}
#[test]
fn revolve_nurbs_circle_arc_profile_torus_band_watertight() {
use brepkit_math::curves::Circle3D;
use std::f64::consts::FRAC_PI_2;
let mut topo = Topology::new();
let (d, rho) = (10.0_f64, 3.0_f64);
let circ = Circle3D::new(Point3::new(d, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0), rho).unwrap();
let nurbs =
brepkit_geometry::convert::circle_to_nurbs(&circ, -FRAC_PI_2, FRAC_PI_2).unwrap();
let p_bot = circ.evaluate(-FRAC_PI_2);
let p_top = circ.evaluate(FRAC_PI_2);
let v_bot = topo.add_vertex(Vertex::new(p_bot, 1e-7));
let v_top = topo.add_vertex(Vertex::new(p_top, 1e-7));
let e_arc = topo.add_edge(Edge::new(v_bot, v_top, EdgeCurve::NurbsCurve(nurbs)));
let e_dia = topo.add_edge(Edge::new(v_top, v_bot, EdgeCurve::Line));
let wire = Wire::new(
vec![
OrientedEdge::new(e_arc, true),
OrientedEdge::new(e_dia, true),
],
true,
)
.unwrap();
let wid = topo.add_wire(wire);
let face = topo.add_face(Face::new(
wid,
vec![],
FaceSurface::Plane {
normal: Vec3::new(0.0, 1.0, 0.0),
d: 0.0,
},
));
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
2.0 * PI,
)
.unwrap();
let shell = topo
.shell(topo.solid(solid).unwrap().outer_shell())
.unwrap();
assert_eq!(
shell.faces().len(),
2,
"NURBS-arc half-disc takes the analytic path"
);
assert_eq!(
shell
.faces()
.iter()
.filter(|&&fid| matches!(topo.face(fid).unwrap().surface(), FaceSurface::Torus(_)))
.count(),
1,
"the recognised NURBS arc is ONE periodic torus band"
);
for defl in [0.1_f64, 0.02] {
let mesh = crate::tessellate::tessellate_solid(&topo, solid, defl).unwrap();
assert_eq!(
mesh_boundary_edges(&mesh),
0,
"NURBS-arc half-disc mesh must be watertight at deflection {defl}"
);
}
let vol = crate::measure::solid_volume(&topo, solid, 0.01).unwrap();
let expected = PI * PI * d * rho * rho;
assert!(
(vol - expected).abs() / expected < 1e-9,
"half-torus tube volume {expected}, got {vol}"
);
}
#[test]
fn revolve_arc_profile_reversed_edge_torus_band() {
use brepkit_math::curves::Circle3D;
use std::f64::consts::PI;
let mut topo = Topology::new();
let (d, rho) = (10.0_f64, 3.0_f64);
let circ = Circle3D::new(Point3::new(d, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0), rho).unwrap();
let p_bot = circ.evaluate(-std::f64::consts::FRAC_PI_2); let p_top = circ.evaluate(std::f64::consts::FRAC_PI_2); let v_bot = topo.add_vertex(Vertex::new(p_bot, 1e-7));
let v_top = topo.add_vertex(Vertex::new(p_top, 1e-7));
let e_arc = topo.add_edge(Edge::new(v_top, v_bot, EdgeCurve::Circle(circ)));
let e_dia = topo.add_edge(Edge::new(v_top, v_bot, EdgeCurve::Line));
let wire = Wire::new(
vec![
OrientedEdge::new(e_arc, false),
OrientedEdge::new(e_dia, true),
],
true,
)
.unwrap();
let wid = topo.add_wire(wire);
let face = topo.add_face(Face::new(
wid,
vec![],
FaceSurface::Plane {
normal: Vec3::new(0.0, 1.0, 0.0),
d: 0.0,
},
));
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
2.0 * PI,
)
.unwrap();
let shell = topo
.shell(topo.solid(solid).unwrap().outer_shell())
.unwrap();
assert_eq!(
shell.faces().len(),
2,
"reversed-arc half-disc merges to 1 torus band + 1 annulus cap"
);
for defl in [0.1_f64, 0.02] {
let mesh = crate::tessellate::tessellate_solid(&topo, solid, defl).unwrap();
assert_eq!(
mesh_boundary_edges(&mesh),
0,
"reversed-arc mesh must be watertight at deflection {defl}"
);
}
let vol = crate::measure::solid_volume(&topo, solid, 0.01).unwrap();
let expected = PI * PI * d * rho * rho;
assert!(
(vol - expected).abs() / expected < 1e-9,
"reversed-arc half-torus volume {expected}, got {vol}"
);
}
#[test]
fn revolve_pointed_cone_apex_band_volume_is_exact() {
use brepkit_topology::builder::make_polygon_wire;
let mut topo = Topology::new();
let (r, h) = (5.0_f64, 12.0_f64);
let wire = make_polygon_wire(
&mut topo,
&[
Point3::new(r, 0.0, 0.0),
Point3::new(0.0, 0.0, h),
Point3::new(0.0, 0.0, 0.0),
],
1e-7,
)
.unwrap();
let face = topo.add_face(Face::new(
wire,
vec![],
FaceSurface::Plane {
normal: Vec3::new(0.0, 1.0, 0.0),
d: 0.0,
},
));
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
2.0 * PI,
)
.unwrap();
let shell = topo
.shell(topo.solid(solid).unwrap().outer_shell())
.unwrap();
assert_eq!(
shell.faces().len(),
2,
"pointed cone merges to 1 periodic cone wall + 1 disc cap"
);
assert_eq!(
shell
.faces()
.iter()
.filter(|&&fid| matches!(topo.face(fid).unwrap().surface(), FaceSurface::Cone(_)))
.count(),
1,
"the apex wall is ONE periodic cone"
);
for defl in [0.1_f64, 0.02] {
let mesh = crate::tessellate::tessellate_solid(&topo, solid, defl).unwrap();
assert_eq!(
mesh_boundary_edges(&mesh),
0,
"pointed cone mesh must be watertight at deflection {defl}"
);
}
let vol = crate::measure::solid_volume(&topo, solid, 0.01).unwrap();
let expected = PI * r * r * h / 3.0;
assert!(
(vol - expected).abs() / expected < 1e-9,
"pointed cone volume {expected}, got {vol}"
);
}
#[test]
fn revolve_circle_partial_turn_is_trimmed_torus() {
use brepkit_math::curves::Circle3D;
let (big_r, rho, angle) = (6.0_f64, 2.0_f64, 2.0 * PI / 3.0);
let mut topo = Topology::new();
let circ =
Circle3D::new(Point3::new(big_r, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0), rho).unwrap();
let p0 = circ.evaluate(0.0);
let v0 = topo.add_vertex(Vertex::new(p0, 1e-7));
let eid = topo.add_edge(Edge::new(v0, v0, EdgeCurve::Circle(circ)));
let wire = Wire::new(vec![OrientedEdge::new(eid, true)], true).unwrap();
let wid = topo.add_wire(wire);
let face = topo.add_face(Face::new(
wid,
vec![],
FaceSurface::Plane {
normal: Vec3::new(0.0, 1.0, 0.0),
d: 0.0,
},
));
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
angle,
)
.unwrap();
let shell = topo
.shell(topo.solid(solid).unwrap().outer_shell())
.unwrap();
assert_eq!(
shell.faces().len(),
3,
"partial-turn circle revolve is 1 torus band + 2 disc caps"
);
assert_eq!(
shell
.faces()
.iter()
.filter(|&&fid| matches!(topo.face(fid).unwrap().surface(), FaceSurface::Torus(_)))
.count(),
1,
"the swept band is ONE trimmed torus face"
);
assert_eq!(
shell
.faces()
.iter()
.filter(|&&fid| matches!(
topo.face(fid).unwrap().surface(),
FaceSurface::Plane { .. }
))
.count(),
2,
"the sweep ends are planar disc caps"
);
let report = crate::validate::validate_solid(&topo, solid).unwrap();
assert!(report.is_valid(), "partial torus invalid: {report:?}");
let mid = angle / 2.0;
for (p, expect) in [
(
Point3::new(big_r * mid.cos(), big_r * mid.sin(), 0.0),
crate::classify::PointClassification::Inside,
),
(
Point3::new(big_r * mid.cos(), big_r * mid.sin(), 2.5),
crate::classify::PointClassification::Outside,
),
(
Point3::new(-big_r, 0.0, 0.0),
crate::classify::PointClassification::Outside,
),
] {
let got = crate::classify::classify_point(&topo, solid, p, 0.05, 1e-6).unwrap();
assert_eq!(got, expect, "probe {p:?}");
}
let expected = PI * big_r * rho * rho * angle;
for defl in [0.1_f64, 0.02] {
let mesh = crate::tessellate::tessellate_solid(&topo, solid, defl).unwrap();
assert_eq!(
mesh_boundary_edges(&mesh),
0,
"partial torus mesh must be watertight at deflection {defl}"
);
let mut vol6 = 0.0_f64;
for t in mesh.indices.chunks(3) {
let a = mesh.positions[t[0] as usize];
let b = mesh.positions[t[1] as usize];
let c = mesh.positions[t[2] as usize];
let av = Vec3::new(a.x(), a.y(), a.z());
let bv = Vec3::new(b.x(), b.y(), b.z());
let cv = Vec3::new(c.x(), c.y(), c.z());
vol6 += av.dot(bv.cross(cv));
}
let mesh_vol = vol6 / 6.0;
assert!(
mesh_vol > 0.0 && (expected - mesh_vol) / expected < 0.02 && mesh_vol < expected,
"inscribed mesh volume {mesh_vol} must approach {expected} from below \
at deflection {defl}"
);
}
let vol = crate::measure::solid_volume(&topo, solid, 0.01).unwrap();
assert!(
(vol - expected).abs() / expected < 1e-9,
"torus sector volume {expected}, got {vol}"
);
}
#[test]
fn revolve_square_half_circle() {
let mut topo = Topology::new();
let face = make_unit_square_face(&mut topo);
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
PI,
)
.unwrap();
let solid_data = topo.solid(solid).unwrap();
let shell = topo.shell(solid_data.outer_shell()).unwrap();
assert_eq!(shell.faces().len(), 10);
let mut plane_count = 0;
let mut cyl_count = 0;
let mut nurbs_count = 0;
for &fid in shell.faces() {
match topo.face(fid).unwrap().surface() {
FaceSurface::Plane { .. } => plane_count += 1,
FaceSurface::Cylinder(_) => cyl_count += 1,
FaceSurface::Nurbs(_) => nurbs_count += 1,
_ => {}
}
}
assert_eq!(plane_count, 6, "perpendicular bands + end caps are planar");
assert_eq!(
cyl_count, 2,
"the axis-parallel wall's 2 bands are cylinders"
);
assert_eq!(
nurbs_count, 2,
"only the degenerate on-axis bands stay NURBS"
);
assert_euler_genus0(&topo, solid);
}
#[test]
fn revolve_zero_angle_error() {
let mut topo = Topology::new();
let face = make_unit_square_face(&mut topo);
let result = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
0.0,
);
assert!(result.is_err());
}
#[test]
fn revolve_zero_axis_error() {
let mut topo = Topology::new();
let face = make_unit_square_face(&mut topo);
let result = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 0.0),
PI,
);
assert!(result.is_err());
}
#[test]
fn revolve_and_tessellate_roundtrip() {
use crate::tessellate::tessellate;
let mut topo = Topology::new();
let face = make_unit_square_face(&mut topo);
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
PI,
)
.unwrap();
let solid_data = topo.solid(solid).unwrap();
let shell = topo.shell(solid_data.outer_shell()).unwrap();
let tol = Tolerance::new();
for &fid in shell.faces() {
let mesh = tessellate(&topo, fid, 0.25).unwrap();
assert!(!mesh.positions.is_empty());
assert!(!mesh.indices.is_empty());
assert_eq!(mesh.positions.len(), mesh.normals.len());
for normal in &mesh.normals {
let len = normal.length();
assert!(
tol.approx_eq(len, 1.0) || tol.approx_eq(len, 0.0),
"normal length should be ~1.0, got {len}"
);
}
}
}
fn make_face_with_hole(topo: &mut Topology) -> FaceId {
let outer_pts = vec![
Point3::new(1.0, 0.0, 0.0),
Point3::new(3.0, 0.0, 0.0),
Point3::new(3.0, 1.0, 0.0),
Point3::new(1.0, 1.0, 0.0),
];
let outer_wire =
brepkit_topology::builder::make_polygon_wire(topo, &outer_pts, 1e-7).unwrap();
let inner_pts = vec![
Point3::new(1.5, 0.25, 0.0),
Point3::new(1.5, 0.75, 0.0),
Point3::new(2.5, 0.75, 0.0),
Point3::new(2.5, 0.25, 0.0),
];
let inner_wire =
brepkit_topology::builder::make_polygon_wire(topo, &inner_pts, 1e-7).unwrap();
let normal = Vec3::new(0.0, 0.0, 1.0);
let d = 0.0;
let face = Face::new(
outer_wire,
vec![inner_wire],
FaceSurface::Plane { normal, d },
);
topo.add_face(face)
}
#[test]
fn revolve_face_with_hole_full_circle() {
let mut topo = Topology::new();
let face = make_face_with_hole(&mut topo);
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
2.0 * PI,
)
.unwrap();
let solid_data = topo.solid(solid).unwrap();
let shell = topo.shell(solid_data.outer_shell()).unwrap();
assert_eq!(
shell.faces().len(),
32,
"full revolve with hole: 16 outer + 16 inner = 32 faces"
);
let chi = euler_characteristic(&topo, solid);
assert_eq!(chi, 0, "genus-1 revolve should have χ=0, got {chi}");
}
#[test]
fn revolve_face_with_hole_partial() {
let mut topo = Topology::new();
let face = make_face_with_hole(&mut topo);
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
PI, )
.unwrap();
let solid_data = topo.solid(solid).unwrap();
let shell = topo.shell(solid_data.outer_shell()).unwrap();
assert_eq!(
shell.faces().len(),
18,
"half revolve with hole: 8 outer + 8 inner + 2 caps = 18 faces"
);
let faces_with_holes = shell
.faces()
.iter()
.filter(|&&fid| !topo.face(fid).unwrap().inner_wires().is_empty())
.count();
assert_eq!(
faces_with_holes, 2,
"start and end caps should both have inner wire holes"
);
}
#[test]
fn revolve_face_with_hole_positive_volume() {
let mut topo = Topology::new();
let face = make_face_with_hole(&mut topo);
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
2.0 * PI,
)
.unwrap();
let vol = crate::measure::solid_volume(&topo, solid, 0.1).unwrap();
let expected = 6.0 * PI;
let rel_err = (vol - expected).abs() / expected;
assert!(
rel_err < 0.05,
"revolved hollow annular volume should be ~{expected:.2}, got {vol:.2} (rel_err={rel_err:.2e})"
);
}
#[test]
fn revolve_square_full_volume() {
let mut topo = Topology::new();
let face = make_unit_square_face(&mut topo);
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
2.0 * PI,
)
.unwrap();
let vol = crate::measure::solid_volume(&topo, solid, 0.05).unwrap();
let expected = PI;
let rel_err = (vol - expected).abs() / expected;
assert!(
rel_err < 0.05,
"full revolution of unit square should have volume π ≈ {expected:.4}, \
got {vol:.4} (rel_err={rel_err:.2e})"
);
}
#[test]
fn revolve_square_half_volume() {
let mut topo = Topology::new();
let face = make_unit_square_face(&mut topo);
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
PI,
)
.unwrap();
let vol = crate::measure::solid_volume(&topo, solid, 0.05).unwrap();
let expected = PI / 2.0;
let rel_err = (vol - expected).abs() / expected;
assert!(
rel_err < 0.05,
"half revolution of unit square should have volume π/2 ≈ {expected:.4}, \
got {vol:.4} (rel_err={rel_err:.2e})"
);
}
#[test]
fn revolve_offset_rectangle_volume() {
let mut topo = Topology::new();
let pts = vec![
Point3::new(2.0, 0.0, 0.0),
Point3::new(4.0, 0.0, 0.0),
Point3::new(4.0, 3.0, 0.0),
Point3::new(2.0, 3.0, 0.0),
];
let wire = brepkit_topology::builder::make_polygon_wire(&mut topo, &pts, 1e-7).unwrap();
let face = topo.add_face(brepkit_topology::face::Face::new(
wire,
vec![],
brepkit_topology::face::FaceSurface::Plane {
normal: Vec3::new(0.0, 0.0, 1.0),
d: 0.0,
},
));
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
2.0 * PI,
)
.unwrap();
let vol = crate::measure::solid_volume(&topo, solid, 0.05).unwrap();
let expected = 36.0 * PI;
let rel_err = (vol - expected).abs() / expected;
assert!(
rel_err < 0.05,
"revolved offset rectangle volume should be 36π ≈ {expected:.2}, \
got {vol:.2} (rel_err={rel_err:.2e})"
);
}
#[test]
fn revolve_cw_profile_produces_correct_solid() {
use brepkit_math::vec::Vec3;
use brepkit_topology::edge::{Edge, EdgeCurve};
use brepkit_topology::face::Face;
use brepkit_topology::vertex::Vertex;
use brepkit_topology::wire::{OrientedEdge, Wire};
let mut topo = Topology::new();
let tol_val = 1e-7;
let v0 = topo.add_vertex(Vertex::new(Point3::new(2.0, 0.0, 0.0), tol_val));
let v1 = topo.add_vertex(Vertex::new(Point3::new(2.0, 1.0, 0.0), tol_val));
let v2 = topo.add_vertex(Vertex::new(Point3::new(3.0, 1.0, 0.0), tol_val));
let v3 = topo.add_vertex(Vertex::new(Point3::new(3.0, 0.0, 0.0), tol_val));
let e0 = topo.add_edge(Edge::new(v0, v1, EdgeCurve::Line));
let e1 = topo.add_edge(Edge::new(v1, v2, EdgeCurve::Line));
let e2 = topo.add_edge(Edge::new(v2, v3, EdgeCurve::Line));
let e3 = topo.add_edge(Edge::new(v3, v0, EdgeCurve::Line));
let wire = Wire::new(
vec![
OrientedEdge::new(e0, true),
OrientedEdge::new(e1, true),
OrientedEdge::new(e2, true),
OrientedEdge::new(e3, true),
],
true,
)
.unwrap();
let wid = topo.add_wire(wire);
let face = topo.add_face(Face::new(
wid,
vec![],
brepkit_topology::face::FaceSurface::Plane {
normal: Vec3::new(0.0, 0.0, -1.0),
d: 0.0,
},
));
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
2.0 * PI,
)
.unwrap();
let vol = crate::measure::solid_volume(&topo, solid, 0.05).unwrap();
let expected = 5.0 * PI;
let rel_err = (vol - expected).abs() / expected;
assert!(
rel_err < 0.05,
"CW profile revolve volume should be 5π ≈ {expected:.2}, \
got {vol:.2} (rel_err={rel_err:.2e})"
);
}
fn cylinder_surface_rect(topo: &mut Topology) -> FaceId {
let pts = vec![
Point3::new(2.0, 0.0, 0.0),
Point3::new(4.0, 0.0, 0.0),
Point3::new(4.0, 3.0, 0.0),
Point3::new(2.0, 3.0, 0.0),
];
let wire = brepkit_topology::builder::make_polygon_wire(topo, &pts, 1e-7).unwrap();
let cyl = brepkit_math::surfaces::CylindricalSurface::new(
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
1.0,
)
.unwrap();
topo.add_face(brepkit_topology::face::Face::new(
wire,
vec![],
FaceSurface::Cylinder(cyl),
))
}
#[test]
fn revolve_nonplanar_surface_full_turn_volume() {
let mut topo = Topology::new();
let face = cylinder_surface_rect(&mut topo);
assert!(
!topo.face(face).unwrap().surface().is_planar(),
"profile surface is non-planar (a cylinder)"
);
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
2.0 * PI,
)
.unwrap();
let vol = crate::measure::solid_volume(&topo, solid, 0.05).unwrap();
let expected = 36.0 * PI;
assert!(
(vol - expected).abs() / expected < 0.05,
"non-planar-surface revolve volume should be 36π, got {vol}"
);
}
#[test]
fn revolve_nonplanar_surface_partial_volume() {
let mut topo = Topology::new();
let face = cylinder_surface_rect(&mut topo);
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
PI,
)
.unwrap();
let vol = crate::measure::solid_volume(&topo, solid, 0.05).unwrap();
let expected = 18.0 * PI;
assert!(
(vol - expected).abs() / expected < 0.05,
"non-planar-surface partial revolve volume should be 18π, got {vol}"
);
}
#[test]
fn revolve_partial_nonplanar_boundary_is_rejected() {
let mut topo = Topology::new();
let pts = vec![
Point3::new(2.0, 0.0, 0.0),
Point3::new(4.0, 0.0, 0.6),
Point3::new(4.0, 3.0, 0.0),
Point3::new(2.0, 3.0, 0.6),
];
let wire = brepkit_topology::builder::make_polygon_wire(&mut topo, &pts, 1e-7).unwrap();
let cyl = brepkit_math::surfaces::CylindricalSurface::new(
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
1.0,
)
.unwrap();
let face = topo.add_face(brepkit_topology::face::Face::new(
wire,
vec![],
FaceSurface::Cylinder(cyl),
));
let result = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
PI,
);
assert!(
result.is_err(),
"partial revolve of a non-planar boundary must be rejected"
);
}
#[test]
fn revolve_full_nonplanar_boundary_is_accepted() {
let mut topo = Topology::new();
let pts = vec![
Point3::new(2.0, 0.0, 0.0),
Point3::new(4.0, 0.0, 0.6),
Point3::new(4.0, 3.0, 0.0),
Point3::new(2.0, 3.0, 0.6),
];
let wire = brepkit_topology::builder::make_polygon_wire(&mut topo, &pts, 1e-7).unwrap();
let cyl = brepkit_math::surfaces::CylindricalSurface::new(
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
1.0,
)
.unwrap();
let face = topo.add_face(brepkit_topology::face::Face::new(
wire,
vec![],
FaceSurface::Cylinder(cyl),
));
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
2.0 * PI,
)
.unwrap();
let vol = crate::measure::solid_volume(&topo, solid, 0.1).unwrap();
assert!(
vol > 0.0,
"full revolve of a non-planar boundary should have positive volume, got {vol}"
);
}
#[test]
fn revolve_segmented_is_outward_for_either_winding() {
let (r0, r1, z0, z1) = (1.55, 4.75, 2.7, 20.8);
let angle = FRAC_PI_2 / 2.0;
for ccw in [true, false] {
for normal_y in [-1.0, 1.0] {
let mut topo = Topology::new();
let mut pts = vec![
Point3::new(r0, 0.0, z0),
Point3::new(r1, 0.0, z0),
Point3::new(r1, 0.0, z1),
Point3::new(r0, 0.0, z1),
];
if !ccw {
pts.reverse();
}
let wire =
brepkit_topology::builder::make_polygon_wire(&mut topo, &pts, 1e-7).unwrap();
let face = topo.add_face(brepkit_topology::face::Face::new(
wire,
vec![],
FaceSurface::Plane {
normal: Vec3::new(0.0, normal_y, 0.0),
d: 0.0,
},
));
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
angle,
)
.unwrap();
let expected = angle * (0.5 * (r0 + r1)) * ((r1 - r0) * (z1 - z0));
let vol = crate::measure::oriented_solid_volume(&topo, solid, 0.02).unwrap();
assert!(
vol > 0.0,
"wedge (ccw={ccw} normal_y={normal_y}) must be outward-oriented, \
got signed volume {vol:.3}"
);
let rel_err = (vol - expected).abs() / expected;
assert!(
rel_err < 0.05,
"wedge (ccw={ccw} normal_y={normal_y}) volume should be ~{expected:.2}, \
got {vol:.2} (rel_err={rel_err:.2e})"
);
}
}
}
#[test]
fn revolve_segmented_full_turn_is_outward_for_either_winding() {
for ccw in [true, false] {
let mut topo = Topology::new();
let mut pts = vec![
Point3::new(2.0, 0.0, 0.0),
Point3::new(4.0, 0.0, 0.0),
Point3::new(4.0, 3.0, 0.0),
Point3::new(2.0, 3.0, 0.0),
];
if !ccw {
pts.reverse();
}
let wire = brepkit_topology::builder::make_polygon_wire(&mut topo, &pts, 1e-7).unwrap();
let cyl = brepkit_math::surfaces::CylindricalSurface::new(
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
1.0,
)
.unwrap();
let face = topo.add_face(brepkit_topology::face::Face::new(
wire,
vec![],
FaceSurface::Cylinder(cyl),
));
let solid = revolve(
&mut topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
2.0 * PI,
)
.unwrap();
let expected = 2.0 * PI * 3.0 * 6.0;
let vol = crate::measure::oriented_solid_volume(&topo, solid, 0.02).unwrap();
assert!(
vol > 0.0,
"full segmented revolve (ccw={ccw}) must be outward-oriented, \
got signed volume {vol:.3}"
);
let rel_err = (vol - expected).abs() / expected;
assert!(
rel_err < 0.05,
"full segmented revolve (ccw={ccw}) volume should be ~{expected:.2}, \
got {vol:.2} (rel_err={rel_err:.2e})"
);
}
}
}