1use brepkit_math::curves::Circle3D;
7use brepkit_math::tolerance::Tolerance;
8use brepkit_math::vec::{Point3, Vec3};
9use brepkit_topology::Topology;
10use brepkit_topology::edge::{Edge, EdgeCurve};
11use brepkit_topology::face::FaceSurface;
12use brepkit_topology::vertex::Vertex;
13use brepkit_topology::wire::{OrientedEdge, Wire, WireId};
14
15use crate::boolean::face_polygon;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum JoinType {
20 Intersection,
22 Arc,
24 Chamfer,
26}
27
28pub fn offset_wire(
43 topo: &mut Topology,
44 face_id: brepkit_topology::face::FaceId,
45 distance: f64,
46) -> Result<WireId, crate::OperationsError> {
47 offset_wire_with_join(topo, face_id, distance, JoinType::Intersection)
48}
49
50#[allow(clippy::too_many_lines)]
74pub fn offset_wire_with_join(
75 topo: &mut Topology,
76 face_id: brepkit_topology::face::FaceId,
77 distance: f64,
78 join_type: JoinType,
79) -> Result<WireId, crate::OperationsError> {
80 let tol = Tolerance::new();
81
82 if tol.approx_eq(distance, 0.0) {
83 return Err(crate::OperationsError::InvalidInput {
84 reason: "offset distance is zero".into(),
85 });
86 }
87
88 let face = topo.face(face_id)?;
89 let face_normal = match face.surface() {
90 FaceSurface::Plane { normal, .. } => *normal,
91 _ => {
92 return Err(crate::OperationsError::InvalidInput {
93 reason: "wire offset on non-planar faces is not supported".into(),
94 });
95 }
96 };
97
98 let verts = face_polygon(topo, face_id)?;
99 let n = verts.len();
100 if n < 3 {
101 return Err(crate::OperationsError::InvalidInput {
102 reason: "wire must have at least 3 vertices".into(),
103 });
104 }
105
106 let area2 = {
113 let mut acc = Vec3::new(0.0, 0.0, 0.0);
114 for i in 0..n {
115 let j = (i + 1) % n;
116 let vi = Vec3::new(verts[i].x(), verts[i].y(), verts[i].z());
117 let vj = Vec3::new(verts[j].x(), verts[j].y(), verts[j].z());
118 acc += vi.cross(vj);
119 }
120 acc.dot(face_normal)
121 };
122 if area2.abs() < tol.linear {
123 return Err(crate::OperationsError::InvalidInput {
124 reason: "wire loop has degenerate (zero) signed area".into(),
125 });
126 }
127 let distance = if area2 < 0.0 { -distance } else { distance };
128
129 let mut edge_normals = Vec::with_capacity(n);
133 for i in 0..n {
134 let j = (i + 1) % n;
135 let edge_dir = verts[j] - verts[i];
136 let outward = edge_dir.cross(face_normal);
137 let len = outward.length();
138 if len < tol.linear {
139 return Err(crate::OperationsError::InvalidInput {
140 reason: format!("degenerate edge at vertex {i}"),
141 });
142 }
143 edge_normals.push(Vec3::new(
144 outward.x() / len,
145 outward.y() / len,
146 outward.z() / len,
147 ));
148 }
149
150 match join_type {
151 JoinType::Intersection => build_intersection_wire(topo, &verts, &edge_normals, distance),
152 JoinType::Arc => build_arc_wire(topo, &verts, &edge_normals, distance, face_normal),
153 JoinType::Chamfer => build_chamfer_wire(topo, &verts, &edge_normals, distance),
154 }
155}
156
157fn build_intersection_wire(
159 topo: &mut Topology,
160 verts: &[Point3],
161 edge_normals: &[Vec3],
162 distance: f64,
163) -> Result<WireId, crate::OperationsError> {
164 let tol = Tolerance::new();
165 let n = verts.len();
166
167 let mut offset_verts = Vec::with_capacity(n);
168
169 for i in 0..n {
170 let prev = if i == 0 { n - 1 } else { i - 1 };
171
172 let offset_prev = edge_normals[prev] * distance;
173 let offset_curr = edge_normals[i] * distance;
174
175 let p0_prev = verts[prev] + offset_prev;
176 let p1_prev = verts[i] + offset_prev;
177 let p0_curr = verts[i] + offset_curr;
178 let p1_curr = verts[(i + 1) % n] + offset_curr;
179
180 let d_prev = p1_prev - p0_prev;
181 let d_curr = p1_curr - p0_curr;
182
183 let diff = p0_curr - p0_prev;
184 let cross = d_prev.cross(d_curr);
185 let cross_len_sq = cross.length_squared();
186
187 if cross_len_sq < tol.linear * tol.linear {
188 #[allow(clippy::manual_midpoint)]
189 let mid = Point3::new(
190 (p1_prev.x() + p0_curr.x()) / 2.0,
191 (p1_prev.y() + p0_curr.y()) / 2.0,
192 (p1_prev.z() + p0_curr.z()) / 2.0,
193 );
194 offset_verts.push(mid);
195 } else {
196 let t = diff.cross(d_curr).dot(cross) / cross_len_sq;
197 let intersection = Point3::new(
198 d_prev.x().mul_add(t, p0_prev.x()),
199 d_prev.y().mul_add(t, p0_prev.y()),
200 d_prev.z().mul_add(t, p0_prev.z()),
201 );
202 offset_verts.push(intersection);
203 }
204 }
205
206 let vert_ids: Vec<_> = offset_verts
207 .iter()
208 .map(|&p| topo.add_vertex(Vertex::new(p, tol.linear)))
209 .collect();
210
211 let edges: Vec<_> = (0..n)
212 .map(|i| {
213 let next = (i + 1) % n;
214 topo.add_edge(Edge::new(vert_ids[i], vert_ids[next], EdgeCurve::Line))
215 })
216 .collect();
217
218 let oriented: Vec<_> = edges
219 .iter()
220 .map(|&eid| OrientedEdge::new(eid, true))
221 .collect();
222
223 let wire = Wire::new(oriented, true).map_err(crate::OperationsError::Topology)?;
224 Ok(topo.add_wire(wire))
225}
226
227fn build_arc_wire(
237 topo: &mut Topology,
238 verts: &[Point3],
239 edge_normals: &[Vec3],
240 distance: f64,
241 face_normal: Vec3,
242) -> Result<WireId, crate::OperationsError> {
243 let tol = Tolerance::new();
244 let n = verts.len();
245 let radius = distance.abs();
246
247 let mut offset_starts = Vec::with_capacity(n);
251 let mut offset_ends = Vec::with_capacity(n);
252 for i in 0..n {
253 let j = (i + 1) % n;
254 let offset = edge_normals[i] * distance;
255 offset_starts.push(verts[i] + offset);
256 offset_ends.push(verts[j] + offset);
257 }
258
259 let mut corner_coincident = Vec::with_capacity(n);
275 for i in 0..n {
276 let next = (i + 1) % n;
277 corner_coincident.push((offset_ends[i] - offset_starts[next]).length() < tol.linear);
278 }
279
280 let mut line_start_vids = Vec::with_capacity(n);
290 let mut line_end_vids = Vec::with_capacity(n);
291
292 for i in 0..n {
294 line_end_vids.push(topo.add_vertex(Vertex::new(offset_ends[i], tol.linear)));
295 }
296
297 for i in 0..n {
301 let prev = if i == 0 { n - 1 } else { i - 1 };
302 if corner_coincident[prev] {
303 line_start_vids.push(line_end_vids[prev]);
306 } else {
307 line_start_vids.push(topo.add_vertex(Vertex::new(offset_starts[i], tol.linear)));
310 }
311 }
312
313 let mut oriented_edges = Vec::with_capacity(2 * n);
314
315 for i in 0..n {
316 let next = (i + 1) % n;
317
318 let line_edge = topo.add_edge(Edge::new(
320 line_start_vids[i],
321 line_end_vids[i],
322 EdgeCurve::Line,
323 ));
324 oriented_edges.push(OrientedEdge::new(line_edge, true));
325
326 if corner_coincident[i] {
328 continue;
330 }
331
332 let center = verts[next];
333 let circle =
334 Circle3D::new(center, face_normal, radius).map_err(crate::OperationsError::Math)?;
335
336 let arc_edge = topo.add_edge(Edge::new(
337 line_end_vids[i],
338 line_start_vids[next],
339 EdgeCurve::Circle(circle),
340 ));
341 oriented_edges.push(OrientedEdge::new(arc_edge, true));
342 }
343
344 let wire = Wire::new(oriented_edges, true).map_err(crate::OperationsError::Topology)?;
345 Ok(topo.add_wire(wire))
346}
347
348fn build_chamfer_wire(
357 topo: &mut Topology,
358 verts: &[Point3],
359 edge_normals: &[Vec3],
360 distance: f64,
361) -> Result<WireId, crate::OperationsError> {
362 let tol = Tolerance::new();
363 let n = verts.len();
364
365 let mut offset_starts = Vec::with_capacity(n);
367 let mut offset_ends = Vec::with_capacity(n);
368 for i in 0..n {
369 let j = (i + 1) % n;
370 let offset = edge_normals[i] * distance;
371 offset_starts.push(verts[i] + offset);
372 offset_ends.push(verts[j] + offset);
373 }
374
375 let mut corner_coincident = Vec::with_capacity(n);
381 for i in 0..n {
382 let next = (i + 1) % n;
383 corner_coincident.push((offset_ends[i] - offset_starts[next]).length() < tol.linear);
384 }
385
386 let mut line_end_vids = Vec::with_capacity(n);
388 for i in 0..n {
389 line_end_vids.push(topo.add_vertex(Vertex::new(offset_ends[i], tol.linear)));
390 }
391
392 let mut line_start_vids = Vec::with_capacity(n);
396 for i in 0..n {
397 let prev = if i == 0 { n - 1 } else { i - 1 };
398 if corner_coincident[prev] {
399 line_start_vids.push(line_end_vids[prev]);
400 } else {
401 line_start_vids.push(topo.add_vertex(Vertex::new(offset_starts[i], tol.linear)));
402 }
403 }
404
405 let mut oriented_edges = Vec::with_capacity(2 * n);
406
407 for i in 0..n {
408 let next = (i + 1) % n;
409
410 let line_edge = topo.add_edge(Edge::new(
412 line_start_vids[i],
413 line_end_vids[i],
414 EdgeCurve::Line,
415 ));
416 oriented_edges.push(OrientedEdge::new(line_edge, true));
417
418 if corner_coincident[i] {
420 continue;
422 }
423
424 let chamfer_edge = topo.add_edge(Edge::new(
425 line_end_vids[i],
426 line_start_vids[next],
427 EdgeCurve::Line,
428 ));
429 oriented_edges.push(OrientedEdge::new(chamfer_edge, true));
430 }
431
432 let wire = Wire::new(oriented_edges, true).map_err(crate::OperationsError::Topology)?;
433 Ok(topo.add_wire(wire))
434}
435
436#[cfg(test)]
437mod tests {
438 #![allow(clippy::unwrap_used, clippy::expect_used)]
439
440 use std::f64::consts::PI;
441
442 use brepkit_math::tolerance::Tolerance;
443 use brepkit_math::vec::{Point3, Vec3};
444 use brepkit_topology::Topology;
445 use brepkit_topology::edge::{Edge, EdgeCurve};
446 use brepkit_topology::face::{Face, FaceSurface};
447 use brepkit_topology::vertex::Vertex;
448 use brepkit_topology::wire::{OrientedEdge, Wire};
449
450 use super::*;
451
452 fn make_square(topo: &mut Topology) -> brepkit_topology::face::FaceId {
454 let tol_val = 1e-7;
455 let v0 = topo.add_vertex(Vertex::new(Point3::new(0.0, 0.0, 0.0), tol_val));
456 let v1 = topo.add_vertex(Vertex::new(Point3::new(1.0, 0.0, 0.0), tol_val));
457 let v2 = topo.add_vertex(Vertex::new(Point3::new(1.0, 1.0, 0.0), tol_val));
458 let v3 = topo.add_vertex(Vertex::new(Point3::new(0.0, 1.0, 0.0), tol_val));
459
460 let e0 = topo.add_edge(Edge::new(v0, v1, EdgeCurve::Line));
461 let e1 = topo.add_edge(Edge::new(v1, v2, EdgeCurve::Line));
462 let e2 = topo.add_edge(Edge::new(v2, v3, EdgeCurve::Line));
463 let e3 = topo.add_edge(Edge::new(v3, v0, EdgeCurve::Line));
464
465 let wire = Wire::new(
466 vec![
467 OrientedEdge::new(e0, true),
468 OrientedEdge::new(e1, true),
469 OrientedEdge::new(e2, true),
470 OrientedEdge::new(e3, true),
471 ],
472 true,
473 )
474 .unwrap();
475 let wid = topo.add_wire(wire);
476
477 topo.add_face(Face::new(
478 wid,
479 vec![],
480 FaceSurface::Plane {
481 normal: Vec3::new(0.0, 0.0, 1.0),
482 d: 0.0,
483 },
484 ))
485 }
486
487 fn make_cw_bottom_face(topo: &mut Topology) -> brepkit_topology::face::FaceId {
491 let tol_val = 1e-7;
492 let v0 = topo.add_vertex(Vertex::new(Point3::new(20.0, 0.0, 0.0), tol_val));
493 let v1 = topo.add_vertex(Vertex::new(Point3::new(0.0, 0.0, 0.0), tol_val));
494 let v2 = topo.add_vertex(Vertex::new(Point3::new(0.0, 20.0, 0.0), tol_val));
495 let v3 = topo.add_vertex(Vertex::new(Point3::new(20.0, 20.0, 0.0), tol_val));
496
497 let e0 = topo.add_edge(Edge::new(v0, v1, EdgeCurve::Line));
498 let e1 = topo.add_edge(Edge::new(v1, v2, EdgeCurve::Line));
499 let e2 = topo.add_edge(Edge::new(v2, v3, EdgeCurve::Line));
500 let e3 = topo.add_edge(Edge::new(v3, v0, EdgeCurve::Line));
501
502 let wire = Wire::new(
503 vec![
504 OrientedEdge::new(e0, true),
505 OrientedEdge::new(e1, true),
506 OrientedEdge::new(e2, true),
507 OrientedEdge::new(e3, true),
508 ],
509 true,
510 )
511 .unwrap();
512 let wid = topo.add_wire(wire);
513
514 topo.add_face(Face::new(
515 wid,
516 vec![],
517 FaceSurface::Plane {
518 normal: Vec3::new(0.0, 0.0, -1.0),
519 d: 0.0,
520 },
521 ))
522 }
523
524 fn wire_signed_area(topo: &Topology, wid: brepkit_topology::wire::WireId) -> f64 {
525 let wire = topo.wire(wid).unwrap();
526 let pts: Vec<Point3> = wire
527 .edges()
528 .iter()
529 .map(|oe| {
530 let edge = topo.edge(oe.edge()).unwrap();
531 topo.vertex(edge.start()).unwrap().point()
532 })
533 .collect();
534 let n = pts.len();
535 let mut acc = 0.0;
536 for i in 0..n {
537 let j = (i + 1) % n;
538 acc += pts[i].x() * pts[j].y() - pts[j].x() * pts[i].y();
539 }
540 acc * 0.5
541 }
542
543 #[test]
544 fn offset_cw_bottom_face_inward() {
545 let mut topo = Topology::new();
546 let face = make_cw_bottom_face(&mut topo);
547
548 let inward = offset_wire(&mut topo, face, -2.0).unwrap();
549 let wire = topo.wire(inward).unwrap();
550 assert!(wire.is_closed());
551 assert_eq!(wire.edges().len(), 4);
552 assert!(
553 (wire_signed_area(&topo, inward).abs() - 256.0).abs() < 1e-6,
554 "CW bottom face offset -2 should enclose area 256, got {}",
555 wire_signed_area(&topo, inward).abs()
556 );
557
558 let outward = offset_wire(&mut topo, face, 2.0).unwrap();
559 assert!(
560 (wire_signed_area(&topo, outward).abs() - 576.0).abs() < 1e-6,
561 "CW bottom face offset +2 should enclose area 576, got {}",
562 wire_signed_area(&topo, outward).abs()
563 );
564 }
565
566 #[test]
567 fn offset_outward_square() {
568 let mut topo = Topology::new();
569 let face = make_square(&mut topo);
570
571 let offset_wid = offset_wire(&mut topo, face, 0.1).unwrap();
572
573 let wire = topo.wire(offset_wid).unwrap();
574 assert_eq!(wire.edges().len(), 4, "offset square should have 4 edges");
575
576 for oe in wire.edges() {
579 let edge = topo.edge(oe.edge()).unwrap();
580 let start = topo.vertex(edge.start()).unwrap().point();
581 let tol = Tolerance::new();
582 assert!(
583 start.x() < -tol.linear
584 || start.x() > 1.0 + tol.linear
585 || start.y() < -tol.linear
586 || start.y() > 1.0 + tol.linear,
587 "offset vertex should be outside original square: ({}, {})",
588 start.x(),
589 start.y()
590 );
591 }
592 }
593
594 #[test]
595 fn offset_inward_square() {
596 let mut topo = Topology::new();
597 let face = make_square(&mut topo);
598
599 let offset_wid = offset_wire(&mut topo, face, -0.1).unwrap();
600
601 let wire = topo.wire(offset_wid).unwrap();
602 assert_eq!(wire.edges().len(), 4);
603
604 let tol = Tolerance::new();
606 for oe in wire.edges() {
607 let edge = topo.edge(oe.edge()).unwrap();
608 let start = topo.vertex(edge.start()).unwrap().point();
609 assert!(
610 start.x() > tol.linear
611 && start.x() < 1.0 - tol.linear
612 && start.y() > tol.linear
613 && start.y() < 1.0 - tol.linear,
614 "inward offset vertex should be inside square: ({}, {})",
615 start.x(),
616 start.y()
617 );
618 }
619 }
620
621 #[test]
622 fn offset_zero_distance_error() {
623 let mut topo = Topology::new();
624 let face = make_square(&mut topo);
625 assert!(offset_wire(&mut topo, face, 0.0).is_err());
626 }
627
628 #[test]
629 fn offset_preserves_edge_count() {
630 let mut topo = Topology::new();
631 let face = make_square(&mut topo);
632
633 let offset_wid = offset_wire(&mut topo, face, 0.5).unwrap();
634 let wire = topo.wire(offset_wid).unwrap();
635 assert_eq!(wire.edges().len(), 4, "offset should preserve edge count");
636 }
637
638 #[test]
639 fn offset_arc_join_square() {
640 let mut topo = Topology::new();
641 let face = make_square(&mut topo);
642 let d = 0.1;
643
644 let offset_wid = offset_wire_with_join(&mut topo, face, d, JoinType::Arc).unwrap();
645
646 let wire = topo.wire(offset_wid).unwrap();
647 assert_eq!(
649 wire.edges().len(),
650 8,
651 "arc-joined offset square should have 8 edges"
652 );
653
654 let mut lines = 0;
656 let mut arcs = 0;
657 for oe in wire.edges() {
658 let edge = topo.edge(oe.edge()).unwrap();
659 match edge.curve() {
660 EdgeCurve::Line => lines += 1,
661 EdgeCurve::Circle(_) => arcs += 1,
662 _ => {}
663 }
664 }
665 assert_eq!(lines, 4, "should have 4 line edges");
666 assert_eq!(arcs, 4, "should have 4 arc edges");
667 assert_eq!(lines + arcs, 8, "all edges should be lines or arcs");
668
669 let mut perimeter = 0.0;
673 for oe in wire.edges() {
674 let edge = topo.edge(oe.edge()).unwrap();
675 match edge.curve() {
676 EdgeCurve::Line => {
677 let s = topo.vertex(edge.start()).unwrap().point();
678 let e = topo.vertex(edge.end()).unwrap().point();
679 perimeter += (e - s).length();
680 }
681 EdgeCurve::Circle(c) => {
682 perimeter += (PI / 2.0) * c.radius();
684 }
685 _ => {}
686 }
687 }
688 let expected = 4.0 + 2.0 * PI * d;
689 assert!(
690 (perimeter - expected).abs() < 1e-6,
691 "arc perimeter {perimeter} should be ~{expected}"
692 );
693 }
694
695 #[test]
696 fn offset_chamfer_join_square() {
697 let mut topo = Topology::new();
698 let face = make_square(&mut topo);
699 let d = 0.1;
700
701 let offset_wid = offset_wire_with_join(&mut topo, face, d, JoinType::Chamfer).unwrap();
702
703 let wire = topo.wire(offset_wid).unwrap();
704 assert_eq!(
706 wire.edges().len(),
707 8,
708 "chamfer-joined offset square should have 8 edges"
709 );
710
711 for oe in wire.edges() {
713 let edge = topo.edge(oe.edge()).unwrap();
714 assert!(
715 matches!(edge.curve(), EdgeCurve::Line),
716 "chamfer offset should only contain line edges"
717 );
718 }
719
720 let mut perimeter = 0.0;
724 for oe in wire.edges() {
725 let edge = topo.edge(oe.edge()).unwrap();
726 let s = topo.vertex(edge.start()).unwrap().point();
727 let e = topo.vertex(edge.end()).unwrap().point();
728 perimeter += (e - s).length();
729 }
730 let expected = 4.0 + 4.0 * d * std::f64::consts::SQRT_2;
731 assert!(
732 (perimeter - expected).abs() < 1e-6,
733 "chamfer perimeter {perimeter} should be ~{expected}"
734 );
735 }
736
737 #[test]
738 fn offset_intersection_matches_legacy() {
739 let mut topo = Topology::new();
742 let face = make_square(&mut topo);
743
744 let wid = offset_wire_with_join(&mut topo, face, 0.2, JoinType::Intersection).unwrap();
745 let wire = topo.wire(wid).unwrap();
746 assert_eq!(wire.edges().len(), 4);
747
748 let tol = Tolerance::new();
750 for oe in wire.edges() {
751 let edge = topo.edge(oe.edge()).unwrap();
752 let pt = topo.vertex(edge.start()).unwrap().point();
753 let x = pt.x();
756 let y = pt.y();
757 assert!(
758 (tol.approx_eq(x, -0.2) || tol.approx_eq(x, 1.2))
759 && (tol.approx_eq(y, -0.2) || tol.approx_eq(y, 1.2)),
760 "intersection vertex at ({x}, {y}) should be a corner of offset square"
761 );
762 }
763 }
764
765 #[test]
766 fn offset_arc_inward_square() {
767 let mut topo = Topology::new();
768 let face = make_square(&mut topo);
769
770 let offset_wid = offset_wire_with_join(&mut topo, face, -0.1, JoinType::Arc).unwrap();
771
772 let wire = topo.wire(offset_wid).unwrap();
773 assert_eq!(wire.edges().len(), 8);
775
776 let tol = Tolerance::new();
778 for oe in wire.edges() {
779 let edge = topo.edge(oe.edge()).unwrap();
780 let start = topo.vertex(edge.start()).unwrap().point();
781 assert!(
782 start.x() > -tol.linear
783 && start.x() < 1.0 + tol.linear
784 && start.y() > -tol.linear
785 && start.y() < 1.0 + tol.linear,
786 "inward arc offset vertex should be inside original square: ({}, {})",
787 start.x(),
788 start.y()
789 );
790 }
791 }
792}