use brepkit_math::curves::Circle3D;
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::FaceSurface;
use brepkit_topology::vertex::Vertex;
use brepkit_topology::wire::{OrientedEdge, Wire, WireId};
use crate::boolean::face_polygon;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JoinType {
Intersection,
Arc,
Chamfer,
}
pub fn offset_wire(
topo: &mut Topology,
face_id: brepkit_topology::face::FaceId,
distance: f64,
) -> Result<WireId, crate::OperationsError> {
offset_wire_with_join(topo, face_id, distance, JoinType::Intersection)
}
#[allow(clippy::too_many_lines)]
pub fn offset_wire_with_join(
topo: &mut Topology,
face_id: brepkit_topology::face::FaceId,
distance: f64,
join_type: JoinType,
) -> Result<WireId, crate::OperationsError> {
let tol = Tolerance::new();
if tol.approx_eq(distance, 0.0) {
return Err(crate::OperationsError::InvalidInput {
reason: "offset distance is zero".into(),
});
}
let face = topo.face(face_id)?;
let face_normal = match face.surface() {
FaceSurface::Plane { normal, .. } => *normal,
_ => {
return Err(crate::OperationsError::InvalidInput {
reason: "wire offset on non-planar faces is not supported".into(),
});
}
};
let verts = face_polygon(topo, face_id)?;
let n = verts.len();
if n < 3 {
return Err(crate::OperationsError::InvalidInput {
reason: "wire must have at least 3 vertices".into(),
});
}
let area2 = {
let mut acc = Vec3::new(0.0, 0.0, 0.0);
for i in 0..n {
let j = (i + 1) % n;
let vi = Vec3::new(verts[i].x(), verts[i].y(), verts[i].z());
let vj = Vec3::new(verts[j].x(), verts[j].y(), verts[j].z());
acc += vi.cross(vj);
}
acc.dot(face_normal)
};
if area2.abs() < tol.linear {
return Err(crate::OperationsError::InvalidInput {
reason: "wire loop has degenerate (zero) signed area".into(),
});
}
let distance = if area2 < 0.0 { -distance } else { distance };
let mut edge_normals = Vec::with_capacity(n);
for i in 0..n {
let j = (i + 1) % n;
let edge_dir = verts[j] - verts[i];
let outward = edge_dir.cross(face_normal);
let len = outward.length();
if len < tol.linear {
return Err(crate::OperationsError::InvalidInput {
reason: format!("degenerate edge at vertex {i}"),
});
}
edge_normals.push(Vec3::new(
outward.x() / len,
outward.y() / len,
outward.z() / len,
));
}
match join_type {
JoinType::Intersection => build_intersection_wire(topo, &verts, &edge_normals, distance),
JoinType::Arc => build_arc_wire(topo, &verts, &edge_normals, distance, face_normal),
JoinType::Chamfer => build_chamfer_wire(topo, &verts, &edge_normals, distance),
}
}
fn build_intersection_wire(
topo: &mut Topology,
verts: &[Point3],
edge_normals: &[Vec3],
distance: f64,
) -> Result<WireId, crate::OperationsError> {
let tol = Tolerance::new();
let n = verts.len();
let mut offset_verts = Vec::with_capacity(n);
for i in 0..n {
let prev = if i == 0 { n - 1 } else { i - 1 };
let offset_prev = edge_normals[prev] * distance;
let offset_curr = edge_normals[i] * distance;
let p0_prev = verts[prev] + offset_prev;
let p1_prev = verts[i] + offset_prev;
let p0_curr = verts[i] + offset_curr;
let p1_curr = verts[(i + 1) % n] + offset_curr;
let d_prev = p1_prev - p0_prev;
let d_curr = p1_curr - p0_curr;
let diff = p0_curr - p0_prev;
let cross = d_prev.cross(d_curr);
let cross_len_sq = cross.length_squared();
if cross_len_sq < tol.linear * tol.linear {
#[allow(clippy::manual_midpoint)]
let mid = Point3::new(
(p1_prev.x() + p0_curr.x()) / 2.0,
(p1_prev.y() + p0_curr.y()) / 2.0,
(p1_prev.z() + p0_curr.z()) / 2.0,
);
offset_verts.push(mid);
} else {
let t = diff.cross(d_curr).dot(cross) / cross_len_sq;
let intersection = Point3::new(
d_prev.x().mul_add(t, p0_prev.x()),
d_prev.y().mul_add(t, p0_prev.y()),
d_prev.z().mul_add(t, p0_prev.z()),
);
offset_verts.push(intersection);
}
}
let vert_ids: Vec<_> = offset_verts
.iter()
.map(|&p| topo.add_vertex(Vertex::new(p, tol.linear)))
.collect();
let edges: Vec<_> = (0..n)
.map(|i| {
let next = (i + 1) % n;
topo.add_edge(Edge::new(vert_ids[i], vert_ids[next], EdgeCurve::Line))
})
.collect();
let oriented: Vec<_> = edges
.iter()
.map(|&eid| OrientedEdge::new(eid, true))
.collect();
let wire = Wire::new(oriented, true).map_err(crate::OperationsError::Topology)?;
Ok(topo.add_wire(wire))
}
fn build_arc_wire(
topo: &mut Topology,
verts: &[Point3],
edge_normals: &[Vec3],
distance: f64,
face_normal: Vec3,
) -> Result<WireId, crate::OperationsError> {
let tol = Tolerance::new();
let n = verts.len();
let radius = distance.abs();
let mut offset_starts = Vec::with_capacity(n);
let mut offset_ends = Vec::with_capacity(n);
for i in 0..n {
let j = (i + 1) % n;
let offset = edge_normals[i] * distance;
offset_starts.push(verts[i] + offset);
offset_ends.push(verts[j] + offset);
}
let mut corner_coincident = Vec::with_capacity(n);
for i in 0..n {
let next = (i + 1) % n;
corner_coincident.push((offset_ends[i] - offset_starts[next]).length() < tol.linear);
}
let mut line_start_vids = Vec::with_capacity(n);
let mut line_end_vids = Vec::with_capacity(n);
for i in 0..n {
line_end_vids.push(topo.add_vertex(Vertex::new(offset_ends[i], tol.linear)));
}
for i in 0..n {
let prev = if i == 0 { n - 1 } else { i - 1 };
if corner_coincident[prev] {
line_start_vids.push(line_end_vids[prev]);
} else {
line_start_vids.push(topo.add_vertex(Vertex::new(offset_starts[i], tol.linear)));
}
}
let mut oriented_edges = Vec::with_capacity(2 * n);
for i in 0..n {
let next = (i + 1) % n;
let line_edge = topo.add_edge(Edge::new(
line_start_vids[i],
line_end_vids[i],
EdgeCurve::Line,
));
oriented_edges.push(OrientedEdge::new(line_edge, true));
if corner_coincident[i] {
continue;
}
let center = verts[next];
let circle =
Circle3D::new(center, face_normal, radius).map_err(crate::OperationsError::Math)?;
let arc_edge = topo.add_edge(Edge::new(
line_end_vids[i],
line_start_vids[next],
EdgeCurve::Circle(circle),
));
oriented_edges.push(OrientedEdge::new(arc_edge, true));
}
let wire = Wire::new(oriented_edges, true).map_err(crate::OperationsError::Topology)?;
Ok(topo.add_wire(wire))
}
fn build_chamfer_wire(
topo: &mut Topology,
verts: &[Point3],
edge_normals: &[Vec3],
distance: f64,
) -> Result<WireId, crate::OperationsError> {
let tol = Tolerance::new();
let n = verts.len();
let mut offset_starts = Vec::with_capacity(n);
let mut offset_ends = Vec::with_capacity(n);
for i in 0..n {
let j = (i + 1) % n;
let offset = edge_normals[i] * distance;
offset_starts.push(verts[i] + offset);
offset_ends.push(verts[j] + offset);
}
let mut corner_coincident = Vec::with_capacity(n);
for i in 0..n {
let next = (i + 1) % n;
corner_coincident.push((offset_ends[i] - offset_starts[next]).length() < tol.linear);
}
let mut line_end_vids = Vec::with_capacity(n);
for i in 0..n {
line_end_vids.push(topo.add_vertex(Vertex::new(offset_ends[i], tol.linear)));
}
let mut line_start_vids = Vec::with_capacity(n);
for i in 0..n {
let prev = if i == 0 { n - 1 } else { i - 1 };
if corner_coincident[prev] {
line_start_vids.push(line_end_vids[prev]);
} else {
line_start_vids.push(topo.add_vertex(Vertex::new(offset_starts[i], tol.linear)));
}
}
let mut oriented_edges = Vec::with_capacity(2 * n);
for i in 0..n {
let next = (i + 1) % n;
let line_edge = topo.add_edge(Edge::new(
line_start_vids[i],
line_end_vids[i],
EdgeCurve::Line,
));
oriented_edges.push(OrientedEdge::new(line_edge, true));
if corner_coincident[i] {
continue;
}
let chamfer_edge = topo.add_edge(Edge::new(
line_end_vids[i],
line_start_vids[next],
EdgeCurve::Line,
));
oriented_edges.push(OrientedEdge::new(chamfer_edge, true));
}
let wire = Wire::new(oriented_edges, true).map_err(crate::OperationsError::Topology)?;
Ok(topo.add_wire(wire))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::f64::consts::PI;
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, FaceSurface};
use brepkit_topology::vertex::Vertex;
use brepkit_topology::wire::{OrientedEdge, Wire};
use super::*;
fn make_square(topo: &mut Topology) -> brepkit_topology::face::FaceId {
let tol_val = 1e-7;
let v0 = topo.add_vertex(Vertex::new(Point3::new(0.0, 0.0, 0.0), tol_val));
let v1 = topo.add_vertex(Vertex::new(Point3::new(1.0, 0.0, 0.0), tol_val));
let v2 = topo.add_vertex(Vertex::new(Point3::new(1.0, 1.0, 0.0), tol_val));
let v3 = topo.add_vertex(Vertex::new(Point3::new(0.0, 1.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);
topo.add_face(Face::new(
wid,
vec![],
FaceSurface::Plane {
normal: Vec3::new(0.0, 0.0, 1.0),
d: 0.0,
},
))
}
fn make_cw_bottom_face(topo: &mut Topology) -> brepkit_topology::face::FaceId {
let tol_val = 1e-7;
let v0 = topo.add_vertex(Vertex::new(Point3::new(20.0, 0.0, 0.0), tol_val));
let v1 = topo.add_vertex(Vertex::new(Point3::new(0.0, 0.0, 0.0), tol_val));
let v2 = topo.add_vertex(Vertex::new(Point3::new(0.0, 20.0, 0.0), tol_val));
let v3 = topo.add_vertex(Vertex::new(Point3::new(20.0, 20.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);
topo.add_face(Face::new(
wid,
vec![],
FaceSurface::Plane {
normal: Vec3::new(0.0, 0.0, -1.0),
d: 0.0,
},
))
}
fn wire_signed_area(topo: &Topology, wid: brepkit_topology::wire::WireId) -> f64 {
let wire = topo.wire(wid).unwrap();
let pts: Vec<Point3> = wire
.edges()
.iter()
.map(|oe| {
let edge = topo.edge(oe.edge()).unwrap();
topo.vertex(edge.start()).unwrap().point()
})
.collect();
let n = pts.len();
let mut acc = 0.0;
for i in 0..n {
let j = (i + 1) % n;
acc += pts[i].x() * pts[j].y() - pts[j].x() * pts[i].y();
}
acc * 0.5
}
#[test]
fn offset_cw_bottom_face_inward() {
let mut topo = Topology::new();
let face = make_cw_bottom_face(&mut topo);
let inward = offset_wire(&mut topo, face, -2.0).unwrap();
let wire = topo.wire(inward).unwrap();
assert!(wire.is_closed());
assert_eq!(wire.edges().len(), 4);
assert!(
(wire_signed_area(&topo, inward).abs() - 256.0).abs() < 1e-6,
"CW bottom face offset -2 should enclose area 256, got {}",
wire_signed_area(&topo, inward).abs()
);
let outward = offset_wire(&mut topo, face, 2.0).unwrap();
assert!(
(wire_signed_area(&topo, outward).abs() - 576.0).abs() < 1e-6,
"CW bottom face offset +2 should enclose area 576, got {}",
wire_signed_area(&topo, outward).abs()
);
}
#[test]
fn offset_outward_square() {
let mut topo = Topology::new();
let face = make_square(&mut topo);
let offset_wid = offset_wire(&mut topo, face, 0.1).unwrap();
let wire = topo.wire(offset_wid).unwrap();
assert_eq!(wire.edges().len(), 4, "offset square should have 4 edges");
for oe in wire.edges() {
let edge = topo.edge(oe.edge()).unwrap();
let start = topo.vertex(edge.start()).unwrap().point();
let tol = Tolerance::new();
assert!(
start.x() < -tol.linear
|| start.x() > 1.0 + tol.linear
|| start.y() < -tol.linear
|| start.y() > 1.0 + tol.linear,
"offset vertex should be outside original square: ({}, {})",
start.x(),
start.y()
);
}
}
#[test]
fn offset_inward_square() {
let mut topo = Topology::new();
let face = make_square(&mut topo);
let offset_wid = offset_wire(&mut topo, face, -0.1).unwrap();
let wire = topo.wire(offset_wid).unwrap();
assert_eq!(wire.edges().len(), 4);
let tol = Tolerance::new();
for oe in wire.edges() {
let edge = topo.edge(oe.edge()).unwrap();
let start = topo.vertex(edge.start()).unwrap().point();
assert!(
start.x() > tol.linear
&& start.x() < 1.0 - tol.linear
&& start.y() > tol.linear
&& start.y() < 1.0 - tol.linear,
"inward offset vertex should be inside square: ({}, {})",
start.x(),
start.y()
);
}
}
#[test]
fn offset_zero_distance_error() {
let mut topo = Topology::new();
let face = make_square(&mut topo);
assert!(offset_wire(&mut topo, face, 0.0).is_err());
}
#[test]
fn offset_preserves_edge_count() {
let mut topo = Topology::new();
let face = make_square(&mut topo);
let offset_wid = offset_wire(&mut topo, face, 0.5).unwrap();
let wire = topo.wire(offset_wid).unwrap();
assert_eq!(wire.edges().len(), 4, "offset should preserve edge count");
}
#[test]
fn offset_arc_join_square() {
let mut topo = Topology::new();
let face = make_square(&mut topo);
let d = 0.1;
let offset_wid = offset_wire_with_join(&mut topo, face, d, JoinType::Arc).unwrap();
let wire = topo.wire(offset_wid).unwrap();
assert_eq!(
wire.edges().len(),
8,
"arc-joined offset square should have 8 edges"
);
let mut lines = 0;
let mut arcs = 0;
for oe in wire.edges() {
let edge = topo.edge(oe.edge()).unwrap();
match edge.curve() {
EdgeCurve::Line => lines += 1,
EdgeCurve::Circle(_) => arcs += 1,
_ => {}
}
}
assert_eq!(lines, 4, "should have 4 line edges");
assert_eq!(arcs, 4, "should have 4 arc edges");
assert_eq!(lines + arcs, 8, "all edges should be lines or arcs");
let mut perimeter = 0.0;
for oe in wire.edges() {
let edge = topo.edge(oe.edge()).unwrap();
match edge.curve() {
EdgeCurve::Line => {
let s = topo.vertex(edge.start()).unwrap().point();
let e = topo.vertex(edge.end()).unwrap().point();
perimeter += (e - s).length();
}
EdgeCurve::Circle(c) => {
perimeter += (PI / 2.0) * c.radius();
}
_ => {}
}
}
let expected = 4.0 + 2.0 * PI * d;
assert!(
(perimeter - expected).abs() < 1e-6,
"arc perimeter {perimeter} should be ~{expected}"
);
}
#[test]
fn offset_chamfer_join_square() {
let mut topo = Topology::new();
let face = make_square(&mut topo);
let d = 0.1;
let offset_wid = offset_wire_with_join(&mut topo, face, d, JoinType::Chamfer).unwrap();
let wire = topo.wire(offset_wid).unwrap();
assert_eq!(
wire.edges().len(),
8,
"chamfer-joined offset square should have 8 edges"
);
for oe in wire.edges() {
let edge = topo.edge(oe.edge()).unwrap();
assert!(
matches!(edge.curve(), EdgeCurve::Line),
"chamfer offset should only contain line edges"
);
}
let mut perimeter = 0.0;
for oe in wire.edges() {
let edge = topo.edge(oe.edge()).unwrap();
let s = topo.vertex(edge.start()).unwrap().point();
let e = topo.vertex(edge.end()).unwrap().point();
perimeter += (e - s).length();
}
let expected = 4.0 + 4.0 * d * std::f64::consts::SQRT_2;
assert!(
(perimeter - expected).abs() < 1e-6,
"chamfer perimeter {perimeter} should be ~{expected}"
);
}
#[test]
fn offset_intersection_matches_legacy() {
let mut topo = Topology::new();
let face = make_square(&mut topo);
let wid = offset_wire_with_join(&mut topo, face, 0.2, JoinType::Intersection).unwrap();
let wire = topo.wire(wid).unwrap();
assert_eq!(wire.edges().len(), 4);
let tol = Tolerance::new();
for oe in wire.edges() {
let edge = topo.edge(oe.edge()).unwrap();
let pt = topo.vertex(edge.start()).unwrap().point();
let x = pt.x();
let y = pt.y();
assert!(
(tol.approx_eq(x, -0.2) || tol.approx_eq(x, 1.2))
&& (tol.approx_eq(y, -0.2) || tol.approx_eq(y, 1.2)),
"intersection vertex at ({x}, {y}) should be a corner of offset square"
);
}
}
#[test]
fn offset_arc_inward_square() {
let mut topo = Topology::new();
let face = make_square(&mut topo);
let offset_wid = offset_wire_with_join(&mut topo, face, -0.1, JoinType::Arc).unwrap();
let wire = topo.wire(offset_wid).unwrap();
assert_eq!(wire.edges().len(), 8);
let tol = Tolerance::new();
for oe in wire.edges() {
let edge = topo.edge(oe.edge()).unwrap();
let start = topo.vertex(edge.start()).unwrap().point();
assert!(
start.x() > -tol.linear
&& start.x() < 1.0 + tol.linear
&& start.y() > -tol.linear
&& start.y() < 1.0 + tol.linear,
"inward arc offset vertex should be inside original square: ({}, {})",
start.x(),
start.y()
);
}
}
}