use std::collections::{HashMap, HashSet};
use brepkit_math::tolerance::Tolerance;
use brepkit_math::vec::{Point3, Vec3};
use brepkit_topology::Topology;
use brepkit_topology::edge::EdgeId;
use brepkit_topology::face::{FaceId, FaceSurface};
use brepkit_topology::solid::SolidId;
use brepkit_topology::vertex::VertexId;
use crate::boolean::{FaceSpec, assemble_solid_mixed};
use crate::dot_normal_point;
pub fn chamfer(
topo: &mut Topology,
solid: SolidId,
edges: &[EdgeId],
distance: f64,
) -> Result<SolidId, crate::OperationsError> {
if distance <= 0.0 {
return Err(crate::OperationsError::InvalidInput {
reason: format!("chamfer distance must be positive, got {distance}"),
});
}
if edges.is_empty() {
return Err(crate::OperationsError::InvalidInput {
reason: "no edges specified for chamfer".into(),
});
}
if let Some(result) = chamfer_circular_rims(topo, solid, edges, distance)? {
return Ok(result);
}
chamfer_core(topo, solid, edges, ChamferDistances::Symmetric(distance))
}
pub fn chamfer_asymmetric(
topo: &mut Topology,
solid: SolidId,
edges: &[EdgeId],
d1: f64,
d2: f64,
) -> Result<SolidId, crate::OperationsError> {
if d1 <= 0.0 || d2 <= 0.0 {
return Err(crate::OperationsError::InvalidInput {
reason: format!("chamfer distances must be positive, got d1={d1}, d2={d2}"),
});
}
if edges.is_empty() {
return Err(crate::OperationsError::InvalidInput {
reason: "no edges specified for chamfer".into(),
});
}
chamfer_core(topo, solid, edges, ChamferDistances::Asymmetric { d1, d2 })
}
enum ChamferDistances {
Symmetric(f64),
Asymmetric { d1: f64, d2: f64 },
}
impl ChamferDistances {
fn distance_for(
&self,
edge_index: usize,
face_id: FaceId,
edge_to_faces: &HashMap<usize, Vec<FaceId>>,
) -> f64 {
match self {
Self::Symmetric(d) => *d,
Self::Asymmetric { d1, d2 } => {
if let Some(faces) = edge_to_faces.get(&edge_index)
&& faces.len() == 2
{
if faces[0] == face_id {
return *d1;
}
if faces[1] == face_id {
return *d2;
}
}
*d1
}
}
}
fn max_distance(&self) -> f64 {
match self {
Self::Symmetric(d) => *d,
Self::Asymmetric { d1, d2 } => d1.max(*d2),
}
}
}
#[allow(clippy::too_many_lines)]
fn chamfer_core(
topo: &mut Topology,
solid: SolidId,
edges: &[EdgeId],
distances: ChamferDistances,
) -> Result<SolidId, crate::OperationsError> {
let tol = Tolerance::new();
let solid_data = topo.solid(solid)?;
let shell = topo.shell(solid_data.outer_shell())?;
let shell_face_ids: Vec<FaceId> = shell.faces().to_vec();
let mut edge_to_faces: HashMap<usize, Vec<FaceId>> = HashMap::new();
let mut face_polygons: HashMap<usize, FacePolygon> = HashMap::new();
for &face_id in &shell_face_ids {
let face = topo.face(face_id)?;
let wire = topo.wire(face.outer_wire())?;
let mut vertex_ids = Vec::with_capacity(wire.edges().len());
let mut positions = Vec::with_capacity(wire.edges().len());
let mut wire_edge_ids = Vec::with_capacity(wire.edges().len());
for oe in wire.edges() {
let edge = topo.edge(oe.edge())?;
let vid = oe.oriented_start(edge);
vertex_ids.push(vid);
positions.push(topo.vertex(vid)?.point());
wire_edge_ids.push(oe.edge());
edge_to_faces
.entry(oe.edge().index())
.or_default()
.push(face_id);
}
for &inner_wire_id in face.inner_wires() {
let inner_wire = topo.wire(inner_wire_id)?;
for oe in inner_wire.edges() {
edge_to_faces
.entry(oe.edge().index())
.or_default()
.push(face_id);
}
}
let normal = match face.surface() {
FaceSurface::Plane { normal, .. } => *normal,
_ => continue,
};
face_polygons.insert(
face_id.index(),
FacePolygon {
vertex_ids,
positions,
wire_edge_ids,
normal,
},
);
}
let filtered_edges: Vec<EdgeId> = edges
.iter()
.copied()
.filter(|edge_id| {
edge_to_faces
.get(&edge_id.index())
.is_some_and(|faces| faces.len() == 2)
})
.collect();
if filtered_edges.is_empty() {
return Err(crate::OperationsError::InvalidInput {
reason: "no manifold edges to chamfer (all edges are boundary or missing)".into(),
});
}
let target_set: HashSet<usize> = filtered_edges.iter().map(|e| e.index()).collect();
let mut vertex_chamfer_endpoints: HashSet<usize> = HashSet::new();
let mut vertex_max_distance: HashMap<usize, f64> = HashMap::new();
for &edge_id in &filtered_edges {
let edge = topo.edge(edge_id)?;
let max_d = distances.max_distance();
for vid in [edge.start(), edge.end()] {
vertex_chamfer_endpoints.insert(vid.index());
let entry = vertex_max_distance.entry(vid.index()).or_insert(0.0_f64);
if max_d > *entry {
*entry = max_d;
}
}
}
let mut chamfer_data: HashMap<usize, ChamferEdgeData> = HashMap::new();
let mut result_specs: Vec<FaceSpec> = Vec::new();
let mut corner_data: HashMap<usize, (Point3, Vec<(FaceId, Point3)>)> = HashMap::new();
let mut vertex_face_count: HashMap<usize, usize> = HashMap::new();
for poly in face_polygons.values() {
for vid in &poly.vertex_ids {
*vertex_face_count.entry(vid.index()).or_default() += 1;
}
}
for &face_id in &shell_face_ids {
let Some(poly) = face_polygons.get(&face_id.index()) else {
let face = topo.face(face_id)?;
let verts = crate::boolean::face_polygon(topo, face_id)?;
result_specs.push(FaceSpec::Surface {
vertices: verts,
surface: face.surface().clone(),
reversed: false,
inner_wires: vec![],
});
continue;
};
let n = poly.positions.len();
let mut new_verts: Vec<Point3> = Vec::with_capacity(n + target_set.len());
for i in 0..n {
let prev_i = if i == 0 { n - 1 } else { i - 1 };
let next_i = (i + 1) % n;
let before_chamfered = target_set.contains(&poly.wire_edge_ids[prev_i].index());
let after_chamfered = target_set.contains(&poly.wire_edge_ids[i].index());
let pos = poly.positions[i];
let prev_pos = poly.positions[prev_i];
let next_pos = poly.positions[next_i];
let at_chamfer_endpoint =
vertex_chamfer_endpoints.contains(&poly.vertex_ids[i].index());
match (before_chamfered, after_chamfered, at_chamfer_endpoint) {
(false, false, false) => {
new_verts.push(pos);
}
(false, false, true) => {
let side_dist = vertex_max_distance
.get(&poly.vertex_ids[i].index())
.copied()
.unwrap_or_else(|| distances.max_distance());
let dir_prev = (prev_pos - pos).normalize()?;
new_verts.push(pos + dir_prev * side_dist);
let dir_next = (next_pos - pos).normalize()?;
new_verts.push(pos + dir_next * side_dist);
}
(true, false, _) => {
let dist = distances.distance_for(
poly.wire_edge_ids[prev_i].index(),
face_id,
&edge_to_faces,
);
let dir = (next_pos - pos).normalize()?;
let c = pos + dir * dist;
new_verts.push(c);
record_chamfer_point(
&mut chamfer_data,
poly.wire_edge_ids[prev_i].index(),
poly.vertex_ids[i],
face_id,
c,
);
}
(false, true, _) => {
let dist = distances.distance_for(
poly.wire_edge_ids[i].index(),
face_id,
&edge_to_faces,
);
let dir = (prev_pos - pos).normalize()?;
let c = pos + dir * dist;
new_verts.push(c);
record_chamfer_point(
&mut chamfer_data,
poly.wire_edge_ids[i].index(),
poly.vertex_ids[i],
face_id,
c,
);
}
(true, true, _) => {
let dist_after = distances.distance_for(
poly.wire_edge_ids[i].index(),
face_id,
&edge_to_faces,
);
let dist_before = distances.distance_for(
poly.wire_edge_ids[prev_i].index(),
face_id,
&edge_to_faces,
);
let dir_next = (next_pos - pos).normalize()?;
let dir_from_prev = (pos - prev_pos).normalize()?;
let p1 = poly.normal.cross(dir_next);
let p2 = poly.normal.cross(dir_from_prev);
let cos_angle = p1.dot(p2);
let denom = 1.0 + cos_angle;
let intersection = if denom.abs() < 1e-12 {
let mid = Point3::new(
(prev_pos.x() + next_pos.x()) * 0.5,
(prev_pos.y() + next_pos.y()) * 0.5,
(prev_pos.z() + next_pos.z()) * 0.5,
);
let dir = (mid - pos).normalize()?;
let avg_dist = (dist_before + dist_after) * 0.5;
pos + dir * avg_dist
} else {
let a = p1 * dist_after;
let b = p2 * dist_before;
let diff = b - a;
let v = dir_from_prev * (-1.0);
let u_cross_v = dir_next.cross(v);
let det = u_cross_v.dot(poly.normal);
if det.abs() < 1e-12 {
pos + (a + b) * 0.5
} else {
let diff_cross_v = diff.cross(v);
let t = diff_cross_v.dot(poly.normal) / det;
pos + a + dir_next * t
}
};
new_verts.push(intersection);
record_chamfer_point(
&mut chamfer_data,
poly.wire_edge_ids[i].index(),
poly.vertex_ids[i],
face_id,
intersection,
);
record_chamfer_point(
&mut chamfer_data,
poly.wire_edge_ids[prev_i].index(),
poly.vertex_ids[i],
face_id,
intersection,
);
corner_data
.entry(poly.vertex_ids[i].index())
.or_insert_with(|| (pos, Vec::new()))
.1
.push((face_id, intersection));
}
}
}
let new_d = dot_normal_point(poly.normal, new_verts[0]);
result_specs.push(FaceSpec::Planar {
vertices: new_verts,
normal: poly.normal,
d: new_d,
inner_wires: vec![],
});
}
for &edge_id in &filtered_edges {
let data = chamfer_data.get(&edge_id.index()).ok_or_else(|| {
crate::OperationsError::InvalidInput {
reason: format!(
"failed to compute chamfer data for edge {}",
edge_id.index()
),
}
})?;
let edge = topo.edge(edge_id)?;
let v_start = edge.start();
let v_end = edge.end();
let face_list = &edge_to_faces[&edge_id.index()];
let f1 = face_list[0];
let f2 = face_list[1];
let c1_start = data.get_point(f1, v_start)?;
let c1_end = data.get_point(f1, v_end)?;
let c2_start = data.get_point(f2, v_start)?;
let c2_end = data.get_point(f2, v_end)?;
let n1 = face_polygons[&f1.index()].normal;
let n2 = face_polygons[&f2.index()].normal;
let avg_normal = n1 + n2;
let edge_a = c2_start - c1_start;
let edge_b = c1_end - c1_start;
let raw_normal = edge_a.cross(edge_b);
let (quad, normal) = if raw_normal.dot(avg_normal) >= 0.0 {
(
vec![c1_start, c2_start, c2_end, c1_end],
raw_normal.normalize()?,
)
} else {
let flipped = edge_b.cross(edge_a);
(
vec![c1_start, c1_end, c2_end, c2_start],
flipped.normalize()?,
)
};
let d = dot_normal_point(normal, quad[0]);
result_specs.push(FaceSpec::Planar {
vertices: quad,
normal,
d,
inner_wires: vec![],
});
}
let solid_center = {
let mut cx = 0.0;
let mut cy = 0.0;
let mut cz = 0.0;
let mut count = 0.0;
for poly in face_polygons.values() {
for p in &poly.positions {
cx += p.x();
cy += p.y();
cz += p.z();
count += 1.0;
}
}
Point3::new(cx / count, cy / count, cz / count)
};
for (vid, (orig_pos, entries)) in corner_data {
let expected = vertex_face_count.get(&vid).copied().unwrap_or(0);
if entries.len() != expected || entries.len() < 3 {
continue;
}
let outward = (orig_pos - solid_center)
.normalize()
.unwrap_or(Vec3::new(0.0, 0.0, 1.0));
let pts: Vec<Point3> = entries.iter().map(|(_, p)| *p).collect();
let e1 = pts[1] - pts[0];
let e2 = pts[2] - pts[0];
let tri_normal = e1.cross(e2);
let mut corner_verts: Vec<Point3> = if tri_normal.dot(outward) >= 0.0 {
pts
} else {
let mut rev = pts;
rev.reverse();
rev
};
if corner_verts.len() > 3 {
let center = Point3::new(
corner_verts.iter().map(|p| p.x()).sum::<f64>() / corner_verts.len() as f64,
corner_verts.iter().map(|p| p.y()).sum::<f64>() / corner_verts.len() as f64,
corner_verts.iter().map(|p| p.z()).sum::<f64>() / corner_verts.len() as f64,
);
let ref_dir = (corner_verts[0] - center)
.normalize()
.unwrap_or(Vec3::new(1.0, 0.0, 0.0));
let binormal = outward.cross(ref_dir);
corner_verts.sort_by(|a, b| {
let da = *a - center;
let db = *b - center;
let angle_a = da.dot(binormal).atan2(da.dot(ref_dir));
let angle_b = db.dot(binormal).atan2(db.dot(ref_dir));
angle_a
.partial_cmp(&angle_b)
.unwrap_or(std::cmp::Ordering::Equal)
});
let se1 = corner_verts[1] - corner_verts[0];
let se2 = corner_verts[2] - corner_verts[0];
if se1.cross(se2).dot(outward) < 0.0 {
corner_verts.reverse();
}
}
let cn = {
let ce1 = corner_verts[1] - corner_verts[0];
let ce2 = corner_verts[2] - corner_verts[0];
ce1.cross(ce2)
.normalize()
.unwrap_or(Vec3::new(0.0, 0.0, 1.0))
};
let cd = dot_normal_point(cn, corner_verts[0]);
result_specs.push(FaceSpec::Planar {
vertices: corner_verts,
normal: cn,
d: cd,
inner_wires: vec![],
});
}
assemble_solid_mixed(topo, &result_specs, tol)
}
struct FacePolygon {
vertex_ids: Vec<VertexId>,
positions: Vec<Point3>,
wire_edge_ids: Vec<EdgeId>,
normal: Vec3,
}
struct ChamferEdgeData {
points: HashMap<(usize, usize), Point3>,
}
impl ChamferEdgeData {
fn new() -> Self {
Self {
points: HashMap::new(),
}
}
fn insert(&mut self, face_id: FaceId, vertex_id: VertexId, point: Point3) {
self.points
.insert((face_id.index(), vertex_id.index()), point);
}
fn get_point(
&self,
face_id: FaceId,
vertex_id: VertexId,
) -> Result<Point3, crate::OperationsError> {
self.points
.get(&(face_id.index(), vertex_id.index()))
.copied()
.ok_or_else(|| crate::OperationsError::InvalidInput {
reason: format!(
"missing chamfer point for face {} vertex {}",
face_id.index(),
vertex_id.index()
),
})
}
}
fn record_chamfer_point(
data: &mut HashMap<usize, ChamferEdgeData>,
edge_index: usize,
vertex_id: VertexId,
face_id: FaceId,
point: Point3,
) {
data.entry(edge_index)
.or_insert_with(ChamferEdgeData::new)
.insert(face_id, vertex_id, point);
}
struct Rim {
edge: EdgeId,
circle: brepkit_math::curves::Circle3D,
cap: FaceId,
wall: FaceId,
}
fn find_rim(
topo: &Topology,
solid: SolidId,
edge: EdgeId,
) -> Result<Option<Rim>, crate::OperationsError> {
use brepkit_topology::edge::EdgeCurve;
let data = topo.edge(edge)?;
let EdgeCurve::Circle(circle) = data.curve() else {
return Ok(None);
};
if data.start() != data.end() {
return Ok(None);
}
let mut faces = Vec::new();
for fid in brepkit_topology::explorer::solid_faces(topo, solid)? {
let face = topo.face(fid)?;
for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
if topo.wire(wid)?.edges().iter().any(|oe| oe.edge() == edge) {
faces.push(fid);
}
}
}
let [a, b] = faces[..] else {
return Ok(None);
};
let (cap, wall) = match (topo.face(a)?.surface(), topo.face(b)?.surface()) {
(FaceSurface::Plane { .. }, FaceSurface::Cylinder(_) | FaceSurface::Cone(_)) => (a, b),
(FaceSurface::Cylinder(_) | FaceSurface::Cone(_), FaceSurface::Plane { .. }) => (b, a),
_ => return Ok(None),
};
let (origin, axis) = match topo.face(wall)?.surface() {
FaceSurface::Cylinder(c) => (c.origin(), c.axis()),
FaceSurface::Cone(c) => (c.apex(), c.axis()),
_ => return Ok(None),
};
let FaceSurface::Plane { normal, .. } = topo.face(cap)?.surface() else {
return Ok(None);
};
if !plain_wall(topo, wall, edge)? || !cap_edges_bounded(topo, cap)? {
return Ok(None);
}
let offset = circle.center() - origin;
let off_axis = (offset - axis * offset.dot(axis)).length();
let coaxial = circle.normal().dot(axis).abs() > 1.0 - 1e-9
&& normal.dot(axis).abs() > 1.0 - 1e-9
&& off_axis < 1e-9 * circle.radius().max(1.0);
Ok(coaxial.then(|| Rim {
edge,
circle: circle.clone(),
cap,
wall,
}))
}
fn plain_wall(topo: &Topology, wall: FaceId, edge: EdgeId) -> Result<bool, crate::OperationsError> {
use brepkit_topology::edge::EdgeCurve;
let face = topo.face(wall)?;
if !face.inner_wires().is_empty() {
return Ok(false);
}
let mut circles = 0;
let mut seams = HashSet::new();
for oe in topo.wire(face.outer_wire())?.edges() {
let data = topo.edge(oe.edge())?;
match data.curve() {
EdgeCurve::Circle(_) if data.start() == data.end() => {
if oe.edge() != edge {
circles += 1;
}
}
EdgeCurve::Line => {
seams.insert(oe.edge());
}
_ => return Ok(false),
}
}
Ok(circles <= 1 && seams.len() == 1)
}
fn cap_edges_bounded(topo: &Topology, cap: FaceId) -> Result<bool, crate::OperationsError> {
use brepkit_topology::edge::EdgeCurve;
let face = topo.face(cap)?;
for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
for oe in topo.wire(wid)?.edges() {
if !matches!(
topo.edge(oe.edge())?.curve(),
EdgeCurve::Line | EdgeCurve::Circle(_)
) {
return Ok(false);
}
}
}
Ok(true)
}
fn radial_range(
topo: &Topology,
edge: EdgeId,
center: Point3,
normal: Vec3,
) -> Result<Option<(f64, f64)>, crate::OperationsError> {
use brepkit_topology::edge::EdgeCurve;
let data = topo.edge(edge)?;
let flat = |p: Point3| {
let offset = p - center;
offset - normal * offset.dot(normal)
};
Ok(match data.curve() {
EdgeCurve::Line => {
let a = flat(topo.vertex(data.start())?.point());
let b = flat(topo.vertex(data.end())?.point());
let along = b - a;
let length_sq = along.dot(along);
let t = if length_sq > 0.0 {
(-a.dot(along) / length_sq).clamp(0.0, 1.0)
} else {
0.0
};
Some(((a + along * t).length(), a.length().max(b.length())))
}
EdgeCurve::Circle(c) => {
let off = flat(c.center()).length();
Some(((off - c.radius()).abs(), off + c.radius()))
}
EdgeCurve::Ellipse(_) | EdgeCurve::NurbsCurve(_) => None,
})
}
fn replace_in_wire(
topo: &mut Topology,
wire_id: brepkit_topology::wire::WireId,
swaps: &[(EdgeId, EdgeId)],
) -> Result<brepkit_topology::wire::WireId, crate::OperationsError> {
use brepkit_topology::wire::{OrientedEdge, Wire};
let wire = topo.wire(wire_id)?;
let closed = wire.is_closed();
let edges: Vec<OrientedEdge> = wire
.edges()
.iter()
.map(|oe| {
let edge = swaps
.iter()
.find(|(old, _)| *old == oe.edge())
.map_or_else(|| oe.edge(), |&(_, new)| new);
OrientedEdge::new(edge, oe.is_forward())
})
.collect();
Ok(topo.add_wire(Wire::new(edges, closed)?))
}
#[allow(clippy::too_many_lines)]
fn chamfer_circular_rims(
topo: &mut Topology,
solid: SolidId,
edges: &[EdgeId],
distance: f64,
) -> Result<Option<SolidId>, crate::OperationsError> {
use brepkit_math::curves::Circle3D;
use brepkit_math::surfaces::ConicalSurface;
use brepkit_topology::edge::{Edge, EdgeCurve};
use brepkit_topology::face::Face;
use brepkit_topology::shell::Shell;
use brepkit_topology::solid::Solid;
use brepkit_topology::vertex::Vertex;
use brepkit_topology::wire::{OrientedEdge, Wire};
let mut circles = Vec::with_capacity(edges.len());
for &edge in edges {
let Some(rim) = find_rim(topo, solid, edge)? else {
return Ok(None);
};
circles.push(rim.circle);
}
let copy = crate::copy::copy_solid(topo, solid)?;
let tol = Tolerance::new().linear;
let mut bands = Vec::with_capacity(circles.len());
for circle in &circles {
let image = brepkit_topology::explorer::solid_edges(topo, copy)?
.into_iter()
.find(|&e| {
topo.edge(e).is_ok_and(|edge| match edge.curve() {
EdgeCurve::Circle(c) => {
edge.start() == edge.end()
&& (c.center() - circle.center()).length() < tol
&& (c.radius() - circle.radius()).abs() < tol
&& c.normal().dot(circle.normal()).abs() > 1.0 - 1e-9
}
_ => false,
})
});
let Some(image) = image else {
return Ok(None);
};
let Some(rim) = find_rim(topo, copy, image)? else {
return Ok(None);
};
let rim_vertex = topo.edge(rim.edge)?.start();
let rim_point = topo.vertex(rim_vertex)?.point();
let center = rim.circle.center();
let normal = rim.circle.normal();
let radial = (rim_point - center).normalize()?;
let cap = topo.face(rim.cap)?;
let on_outer = topo
.wire(cap.outer_wire())?
.edges()
.iter()
.any(|oe| oe.edge() == rim.edge);
let cap_radius = if on_outer {
rim.circle.radius() - distance
} else {
rim.circle.radius() + distance
};
let (ring_lo, ring_hi) = (
cap_radius.min(rim.circle.radius()),
cap_radius.max(rim.circle.radius()),
);
let mut ring_clear = cap_radius > tol;
for wid in std::iter::once(cap.outer_wire()).chain(cap.inner_wires().iter().copied()) {
for oe in topo.wire(wid)?.edges() {
if oe.edge() == rim.edge {
continue;
}
let Some((near, far)) = radial_range(topo, oe.edge(), center, normal)? else {
return Ok(None);
};
if near <= ring_hi + tol && far >= ring_lo - tol {
ring_clear = false;
}
}
}
if !ring_clear {
return Err(crate::OperationsError::InvalidInput {
reason: format!("chamfer distance {distance} consumes the cap"),
});
}
let wall = topo.face(rim.wall)?;
let wall_surface = wall.surface().clone();
let v_of = |p: Point3| wall_surface.project_point(p).map(|(_, v)| v);
let v_rim = v_of(rim_point).ok_or_else(|| crate::OperationsError::InvalidInput {
reason: "rim does not lie on its wall".into(),
})?;
let mut v_far = v_rim;
for oe in topo.wire(wall.outer_wire())?.edges() {
let edge = topo.edge(oe.edge())?;
for vid in [edge.start(), edge.end()] {
if let Some(v) = v_of(topo.vertex(vid)?.point())
&& (v - v_rim).abs() > (v_far - v_rim).abs()
{
v_far = v;
}
}
}
if (v_far - v_rim).abs() <= distance + tol {
return Err(crate::OperationsError::InvalidInput {
reason: format!("chamfer distance {distance} consumes the wall"),
});
}
let v_new = v_rim + distance * (v_far - v_rim).signum();
let (wall_center, wall_radius) = match &wall_surface {
FaceSurface::Cylinder(c) => (c.origin() + c.axis() * v_new, c.radius()),
FaceSurface::Cone(c) => {
let (sin_a, cos_a) = c.half_angle().sin_cos();
(c.apex() + c.axis() * (v_new * sin_a), v_new * cos_a)
}
_ => return Ok(None),
};
let cap_point = center + radial * cap_radius;
let wall_point = wall_center + radial * wall_radius;
let cap_vertex = topo.add_vertex(Vertex::new(cap_point, tol));
let wall_vertex = topo.add_vertex(Vertex::new(wall_point, tol));
let cap_circle = Circle3D::new_with_ref(center, normal, cap_radius, radial)?;
let wall_circle = Circle3D::new_with_ref(wall_center, normal, wall_radius, radial)?;
let cap_edge = topo.add_edge(Edge::new(
cap_vertex,
cap_vertex,
EdgeCurve::Circle(cap_circle),
));
let wall_edge = topo.add_edge(Edge::new(
wall_vertex,
wall_vertex,
EdgeCurve::Circle(wall_circle),
));
let band_seam = topo.add_edge(Edge::new(cap_vertex, wall_vertex, EdgeCurve::Line));
let cap = topo.face(rim.cap)?;
let (cap_outer, cap_inners) = (cap.outer_wire(), cap.inner_wires().to_vec());
let cap_flag = std::iter::once(cap_outer)
.chain(cap_inners.iter().copied())
.find_map(|w| {
topo.wire(w)
.ok()?
.edges()
.iter()
.find(|oe| oe.edge() == rim.edge)
.map(brepkit_topology::wire::OrientedEdge::is_forward)
})
.unwrap_or(true);
let swaps = [(rim.edge, cap_edge)];
let new_outer = replace_in_wire(topo, cap_outer, &swaps)?;
let mut new_inners = Vec::with_capacity(cap_inners.len());
for w in cap_inners {
new_inners.push(replace_in_wire(topo, w, &swaps)?);
}
let cap_face = topo.face_mut(rim.cap)?;
cap_face.set_outer_wire(new_outer);
*cap_face.inner_wires_mut() = new_inners;
let wall = topo.face(rim.wall)?;
let wall_outer = wall.outer_wire();
let mut wall_flag = true;
let mut seam_swap = None;
for oe in topo.wire(wall_outer)?.edges() {
if oe.edge() == rim.edge {
wall_flag = oe.is_forward();
}
let edge = topo.edge(oe.edge())?;
if matches!(edge.curve(), EdgeCurve::Line) && seam_swap.is_none() {
if edge.start() == rim_vertex {
seam_swap = Some((oe.edge(), wall_vertex, edge.end(), true));
} else if edge.end() == rim_vertex {
seam_swap = Some((oe.edge(), edge.start(), wall_vertex, false));
}
}
}
let Some((old_seam, from, to, _)) = seam_swap else {
return Ok(None);
};
let new_seam = topo.add_edge(Edge::new(from, to, EdgeCurve::Line));
let new_wall_outer = replace_in_wire(
topo,
wall_outer,
&[(rim.edge, wall_edge), (old_seam, new_seam)],
)?;
topo.face_mut(rim.wall)?.set_outer_wire(new_wall_outer);
let (r1, r2) = (cap_radius, wall_radius);
if (r1 - r2).abs() <= tol {
return Ok(None);
}
let t0 = -r1 / (r2 - r1);
let apex = center + (wall_center - center) * t0;
let toward = ((center + (wall_center - center) * 0.5) - apex).normalize()?;
let height = (center - apex).dot(toward).abs();
let cone = ConicalSurface::new(apex, toward, height.atan2(r1))?;
let cap_face = topo.face(rim.cap)?;
let cap_out = match cap_face.surface() {
FaceSurface::Plane { normal, .. } => {
if cap_face.is_reversed() {
-*normal
} else {
*normal
}
}
_ => return Ok(None),
};
let wall_face = topo.face(rim.wall)?;
let wall_out = wall_face
.surface()
.project_point(rim_point)
.map(|(u, v)| wall_face.surface().normal(u, v))
.map(|n| if wall_face.is_reversed() { -n } else { n })
.ok_or_else(|| crate::OperationsError::InvalidInput {
reason: "rim does not lie on its wall".into(),
})?;
let mid = cap_point + (wall_point - cap_point) * 0.5;
let (u, v) = cone.project_point(mid);
let reversed = cone.normal(u, v).dot(cap_out + wall_out) < 0.0;
let (cap_use, wall_use) = (
cap_flag != cap_face.is_reversed(),
wall_flag != wall_face.is_reversed(),
);
let band_wire = topo.add_wire(Wire::new(
vec![
OrientedEdge::new(cap_edge, cap_use == reversed),
OrientedEdge::new(band_seam, true),
OrientedEdge::new(wall_edge, wall_use == reversed),
OrientedEdge::new(band_seam, false),
],
true,
)?);
let mut band = Face::new(band_wire, vec![], FaceSurface::Cone(cone));
band.set_reversed(reversed);
bands.push((rim.cap, topo.add_face(band)));
}
let data = topo.solid(copy)?;
let (outer, inners) = (data.outer_shell(), data.inner_shells().to_vec());
let mut rebuilt = Vec::with_capacity(1 + inners.len());
for shell_id in std::iter::once(outer).chain(inners) {
let mut faces = topo.shell(shell_id)?.faces().to_vec();
for &(cap, band) in &bands {
if faces.contains(&cap) {
faces.push(band);
}
}
rebuilt.push(topo.add_shell(Shell::new(faces)?));
}
let outer = rebuilt.remove(0);
Ok(Some(topo.add_solid(Solid::new(outer, rebuilt))))
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, deprecated)]
mod tests {
use brepkit_topology::test_utils::make_unit_cube_manifold;
use brepkit_topology::validation::validate_shell_manifold;
use super::*;
fn solid_edge_ids(topo: &Topology, solid_id: SolidId) -> Vec<EdgeId> {
let solid = topo.solid(solid_id).expect("test solid");
let shell = topo.shell(solid.outer_shell()).expect("test shell");
let mut seen = HashSet::new();
let mut edges = Vec::new();
for &fid in shell.faces() {
let face = topo.face(fid).expect("test face");
let wire = topo.wire(face.outer_wire()).expect("test wire");
for oe in wire.edges() {
if seen.insert(oe.edge().index()) {
edges.push(oe.edge());
}
}
}
edges
}
#[test]
fn chamfer_single_edge() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let target = edges[0];
let result = chamfer(&mut topo, cube, &[target], 0.2).expect("chamfer should succeed");
let result_solid = topo.solid(result).expect("result solid");
let result_shell = topo.shell(result_solid.outer_shell()).expect("shell");
assert_eq!(
result_shell.faces().len(),
7,
"expected 7 faces after single-edge chamfer"
);
}
#[test]
fn chamfer_zero_distance_error() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let result = chamfer(&mut topo, cube, &[edges[0]], 0.0);
assert!(result.is_err(), "zero distance should fail");
}
#[test]
fn chamfer_negative_distance_error() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let result = chamfer(&mut topo, cube, &[edges[0]], -0.5);
assert!(result.is_err(), "negative distance should fail");
}
#[test]
fn chamfer_invalid_edge_error() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let v0 = topo.add_vertex(brepkit_topology::vertex::Vertex::new(
Point3::new(99.0, 99.0, 99.0),
1e-7,
));
let v1 = topo.add_vertex(brepkit_topology::vertex::Vertex::new(
Point3::new(100.0, 100.0, 100.0),
1e-7,
));
let stray = topo.add_edge(brepkit_topology::edge::Edge::new(
v0,
v1,
brepkit_topology::edge::EdgeCurve::Line,
));
let result = chamfer(&mut topo, cube, &[stray], 0.2);
assert!(result.is_err(), "invalid edge should fail");
}
#[test]
fn chamfer_result_is_manifold() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let result = chamfer(&mut topo, cube, &[edges[0]], 0.2).expect("chamfer should succeed");
let result_solid = topo.solid(result).expect("result solid");
let result_shell = topo.shell(result_solid.outer_shell()).expect("shell");
validate_shell_manifold(result_shell, &topo).expect("result should be manifold");
}
#[test]
fn chamfer_parallel_edges() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let mut pair = None;
'outer: for (i, &ea) in edges.iter().enumerate() {
let data_a = topo.edge(ea).expect("edge");
let va = [data_a.start().index(), data_a.end().index()];
for &eb in &edges[i + 1..] {
let data_b = topo.edge(eb).expect("edge");
let vb = [data_b.start().index(), data_b.end().index()];
if !va.iter().any(|v| vb.contains(v)) {
pair = Some([ea, eb]);
break 'outer;
}
}
}
let targets = pair.expect("should find non-adjacent edges on a cube");
let result =
chamfer(&mut topo, cube, &targets, 0.2).expect("parallel chamfer should succeed");
let result_solid = topo.solid(result).expect("result solid");
let result_shell = topo.shell(result_solid.outer_shell()).expect("shell");
assert_eq!(
result_shell.faces().len(),
8,
"expected 8 faces after 2 non-adjacent chamfers"
);
validate_shell_manifold(result_shell, &topo).expect("result should be manifold");
}
#[test]
fn chamfer_all_edges_volume() {
let mut topo = Topology::new();
let cube = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
let edges = solid_edge_ids(&topo, cube);
assert_eq!(edges.len(), 12, "box should have 12 edges");
let result = chamfer(&mut topo, cube, &edges, 1.0).unwrap();
let s = topo.solid(result).unwrap();
let sh = topo.shell(s.outer_shell()).unwrap();
assert_eq!(sh.faces().len(), 26, "chamfered box should have 26 faces");
let vol = crate::measure::solid_volume(&topo, result, 0.1).unwrap();
let expected = 950.0;
let rel_err = (vol - expected).abs() / expected;
assert!(
rel_err < 0.05,
"chamfered 10³ box with d=1 should have volume ~{expected}, got {vol} \
(rel_err={rel_err:.2e}). Was previously 800-1000 tolerance."
);
}
#[test]
fn chamfer_single_edge_volume() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let result = chamfer(&mut topo, cube, &[edges[0]], 0.2).unwrap();
let vol = crate::measure::solid_volume(&topo, result, 0.01).unwrap();
let expected = 0.98;
let rel_err = (vol - expected).abs() / expected;
assert!(
rel_err < 1e-4,
"single-edge chamfer d=0.2 on unit cube: expected {expected}, got {vol} \
(rel_err={rel_err:.2e})"
);
}
#[test]
fn chamfer_asymmetric_single_edge() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let result = chamfer_asymmetric(&mut topo, cube, &[edges[0]], 0.2, 0.3)
.expect("asymmetric chamfer should succeed");
let result_solid = topo.solid(result).expect("result solid");
let result_shell = topo.shell(result_solid.outer_shell()).expect("shell");
assert_eq!(
result_shell.faces().len(),
7,
"expected 7 faces after single-edge chamfer"
);
validate_shell_manifold(result_shell, &topo).expect("result should be manifold");
}
#[test]
fn chamfer_asymmetric_single_edge_volume() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let result = chamfer_asymmetric(&mut topo, cube, &[edges[0]], 0.2, 0.3).unwrap();
let vol = crate::measure::solid_volume(&topo, result, 0.01).unwrap();
let expected = 0.965;
let rel_err = (vol - expected).abs() / expected;
assert!(
rel_err < 1e-3,
"asymmetric chamfer d1=0.2, d2=0.3 on unit cube: expected {expected}, got {vol} \
(rel_err={rel_err:.2e})"
);
}
#[test]
fn chamfer_asymmetric_equal_matches_symmetric() {
let mut topo_sym = Topology::new();
let cube_sym = make_unit_cube_manifold(&mut topo_sym);
let edges_sym = solid_edge_ids(&topo_sym, cube_sym);
let result_sym = chamfer(&mut topo_sym, cube_sym, &[edges_sym[0]], 0.2).unwrap();
let vol_sym = crate::measure::solid_volume(&topo_sym, result_sym, 0.01).unwrap();
let mut topo_asym = Topology::new();
let cube_asym = make_unit_cube_manifold(&mut topo_asym);
let edges_asym = solid_edge_ids(&topo_asym, cube_asym);
let result_asym =
chamfer_asymmetric(&mut topo_asym, cube_asym, &[edges_asym[0]], 0.2, 0.2).unwrap();
let vol_asym = crate::measure::solid_volume(&topo_asym, result_asym, 0.01).unwrap();
let rel_err = (vol_sym - vol_asym).abs() / vol_sym;
assert!(
rel_err < 1e-6,
"asymmetric(d,d) should match symmetric(d): sym={vol_sym}, asym={vol_asym} \
(rel_err={rel_err:.2e})"
);
}
#[test]
fn chamfer_asymmetric_zero_d1_error() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let result = chamfer_asymmetric(&mut topo, cube, &[edges[0]], 0.0, 0.3);
assert!(result.is_err(), "zero d1 should fail");
}
#[test]
fn chamfer_asymmetric_negative_d2_error() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let result = chamfer_asymmetric(&mut topo, cube, &[edges[0]], 0.2, -0.1);
assert!(result.is_err(), "negative d2 should fail");
}
}