Skip to main content

brepkit_operations/
offset_wire.rs

1//! Wire offset: produce a parallel wire at a given distance.
2//!
3//! Creates a new wire that is parallel to the input wire, offset by a
4//! specified distance.
5
6use 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/// How to join offset edges at corners.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum JoinType {
20    /// Extend adjacent offset edges until they intersect (sharp corners).
21    Intersection,
22    /// Insert a circular arc at each corner, centered on the original vertex.
23    Arc,
24    /// Connect adjacent offset edge endpoints with a straight line (bevel).
25    Chamfer,
26}
27
28/// Offset a planar wire by a given distance.
29///
30/// Positive `distance` offsets outward (away from interior), negative
31/// offsets inward. The wire must be closed and lie on a planar face.
32///
33/// Returns a new `WireId` for the offset wire.
34///
35/// This is a convenience wrapper that calls [`offset_wire_with_join`]
36/// with [`JoinType::Intersection`].
37///
38/// # Errors
39///
40/// Returns an error if the wire is not closed, the face is not planar,
41/// or offset produces degenerate geometry.
42pub 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/// Offset a planar wire by a given distance with a specific join type.
51///
52/// Positive `distance` offsets outward (away from interior), negative
53/// offsets inward. The wire must be closed and lie on a planar face.
54///
55/// Returns a new `WireId` for the offset wire.
56///
57/// # Join types
58///
59/// - [`JoinType::Intersection`]: Extend adjacent offset edges until they
60///   intersect, producing sharp corners. The result has the same number
61///   of edges as the input.
62/// - [`JoinType::Arc`]: Insert a circular arc at each corner, centered
63///   on the original vertex with radius equal to `|distance|`. The
64///   result has twice as many edges (alternating lines and arcs).
65/// - [`JoinType::Chamfer`]: Connect adjacent offset edge endpoints with
66///   a straight line (bevel). The result has twice as many edges
67///   (alternating original-direction lines and chamfer lines).
68///
69/// # Errors
70///
71/// Returns an error if the wire is not closed, the face is not planar,
72/// or offset produces degenerate geometry.
73#[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    // The `edge_dir x face_normal` convention below points outward only
107    // when the loop winds CCW as seen from the +face_normal side. The
108    // loop's traversal order comes from stored edge orientations and is
109    // not guaranteed to match the face normal's sign, so detect the
110    // actual winding and flip the effective distance to keep negative =
111    // inward. Twice the signed area as seen from +N is sum((Vi x Vj).N).
112    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    // Compute edge normals (outward-pointing, in the face plane).
130    // For a CCW-wound wire viewed from the face normal direction,
131    // the outward normal of edge (i -> i+1) is edge_direction x face_normal.
132    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
157/// Build offset wire with intersection joins (sharp corners).
158fn 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
227/// Build offset wire with arc joins (rounded corners).
228///
229/// At each corner where two offset edges meet, a circular arc is
230/// inserted. The arc is centered at the original (pre-offset) vertex
231/// with radius `|distance|`, sweeping from the endpoint of the
232/// previous offset edge to the start of the next offset edge.
233///
234/// Vertices are pre-allocated so that adjacent edges share vertex IDs,
235/// which is required for wire connectivity.
236fn 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    // For each edge i, compute the two endpoints of the offset edge
248    // (before any intersection trimming). The offset edge for edge i
249    // goes from verts[i] + offset to verts[i+1] + offset.
250    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    // Pre-allocate vertices so adjacent edges share IDs.
260    //
261    // At each corner between edge i and edge (i+1), the offset produces
262    // two points: offset_ends[i] and offset_starts[(i+1)%n]. If they
263    // are coincident (parallel adjacent edges), a single shared vertex
264    // is used and no arc is inserted. Otherwise, two vertices are
265    // created and an arc edge connects them.
266    //
267    // line_start_vids[i] = start vertex of line edge i
268    // line_end_vids[i]   = end vertex of line edge i (= arc start at corner i+1)
269    // For each corner, either:
270    //   - coincident: line_end_vids[i] == line_start_vids[(i+1)%n]  (shared vertex)
271    //   - non-coincident: arc from line_end_vids[i] to line_start_vids[(i+1)%n]
272
273    // First pass: determine which corners are coincident vs need arcs.
274    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    // Second pass: create vertices. For each edge i we need a start and end vertex.
281    // The start vertex of edge i is either:
282    //   - A new vertex at offset_starts[i] (if the previous corner had an arc, the
283    //     arc's end vertex will be this same ID), OR
284    //   - The same ID as the previous line edge's end vertex (if previous corner was coincident).
285    //
286    // Strategy: create all line-start and line-end vertices, then for
287    // coincident corners, make line_start[next] = line_end[i].
288
289    let mut line_start_vids = Vec::with_capacity(n);
290    let mut line_end_vids = Vec::with_capacity(n);
291
292    // Create line-end vertices (the offset_ends points) for all edges.
293    for i in 0..n {
294        line_end_vids.push(topo.add_vertex(Vertex::new(offset_ends[i], tol.linear)));
295    }
296
297    // Create line-start vertices. At corner between edge (prev) and
298    // edge i: if coincident, reuse line_end of prev edge; otherwise
299    // create a new vertex at offset_starts[i] (arc end will use this ID).
300    for i in 0..n {
301        let prev = if i == 0 { n - 1 } else { i - 1 };
302        if corner_coincident[prev] {
303            // Coincident corner: the end of the previous line IS the
304            // start of this line (no arc between them).
305            line_start_vids.push(line_end_vids[prev]);
306        } else {
307            // Non-coincident corner: the arc's end vertex is a distinct
308            // point at offset_starts[i].
309            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        // Line edge for offset edge i.
319        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        // Arc edge at corner (i+1): from line_end_vids[i] to line_start_vids[next].
327        if corner_coincident[i] {
328            // Coincident endpoints — vertices already shared, no arc needed.
329            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
348/// Build offset wire with chamfer joins (beveled corners).
349///
350/// At each corner, the two adjacent offset edge endpoints are
351/// connected by a straight line segment instead of intersecting
352/// the offset lines.
353///
354/// Vertices are pre-allocated so that adjacent edges share vertex IDs,
355/// which is required for wire connectivity.
356fn 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    // Compute offset edge endpoints (before intersection trimming).
366    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    // Pre-allocate vertices so adjacent edges share IDs.
376    // Same strategy as build_arc_wire: at each corner, if the two
377    // offset endpoints are coincident, share a single vertex; otherwise
378    // create two vertices connected by a chamfer edge.
379
380    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    // Create line-end vertices for all edges.
387    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    // Create line-start vertices. At the corner between edge (prev) and
393    // edge i: if coincident, reuse line_end of prev; otherwise create
394    // a new vertex at offset_starts[i].
395    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        // Line edge for offset edge i.
411        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        // Chamfer edge at corner (i+1): from line_end_vids[i] to line_start_vids[next].
419        if corner_coincident[i] {
420            // Coincident endpoints — vertices already shared, no chamfer needed.
421            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    /// Helper: make a unit square face on XY plane.
453    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    /// Helper: make a 20x20 face whose loop winds clockwise in the (x, y)
488    /// projection and whose surface normal points -Z, mirroring the bottom
489    /// face of a box. Loop order: (20,0) -> (0,0) -> (0,20) -> (20,20).
490    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        // Verify the offset wire is larger: check that all vertices are
577        // outside the original [-0.1, 1.1] range.
578        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        // Verify all vertices are inside the original square.
605        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        // Square has 4 edges -> 4 offset lines + 4 arcs = 8 edges.
648        assert_eq!(
649            wire.edges().len(),
650            8,
651            "arc-joined offset square should have 8 edges"
652        );
653
654        // Count line vs arc edges.
655        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        // The total perimeter of the arc-joined offset should be
670        // 4 * 1.0 (original edge length, preserved) + 4 * (pi/2 * 0.1)
671        // = 4.0 + 0.2*pi ~ 4.6283
672        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                    // Quarter-circle arc: pi/2 * radius.
683                    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        // Square has 4 edges -> 4 offset lines + 4 chamfer lines = 8 edges.
705        assert_eq!(
706            wire.edges().len(),
707            8,
708            "chamfer-joined offset square should have 8 edges"
709        );
710
711        // All edges should be lines.
712        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        // Each chamfer line connects two points at distance d from the
721        // corner, forming a 45-degree bevel. Length = d * sqrt(2).
722        // Total perimeter = 4 * 1.0 + 4 * d * sqrt(2)
723        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        // Verify that offset_wire_with_join(..., Intersection) produces
740        // the same result as the legacy offset_wire function.
741        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        // All vertices should be at the expected offset positions.
749        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            // For a unit square offset outward by 0.2, corners are at
754            // (-0.2, -0.2), (1.2, -0.2), (1.2, 1.2), (-0.2, 1.2).
755            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        // Should still produce 8 edges (4 lines + 4 arcs).
774        assert_eq!(wire.edges().len(), 8);
775
776        // All line edge vertices should be inside the original square.
777        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}