Skip to main content

ogeom_offset/
wire2d.rs

1//! Offsetting a closed planar wire: the sketch-plane operation everything in
2//! this crate stands on.
3//!
4//! Each edge is offset on its own (a segment to the parallel segment, an arc
5//! to the concentric arc) and the corners decide the rest. Where the corner
6//! turns *away* from the offset side the pieces separate, and the gap is
7//! closed by the chosen join: an arc about the old corner, or the extension
8//! of both pieces to their meeting. Where it turns *toward* the offset side
9//! the pieces overlap, and both are trimmed to their intersection. The result
10//! is a new wire with history: every input edge modified into its offset,
11//! every join generated from the corner it rounds.
12//!
13//! The honest limits, refused by name rather than mishandled: edges that are
14//! neither straight nor circular, offsets that consume an edge whole, arcs
15//! whose concentric offset would have no radius left, and results that
16//! self-intersect; the global arrangement that trims a collapsed offset into
17//! its valid loops is recorded in docs/PARITY.md (offset.wire-offset).
18
19use ogeom_algo::{
20    Built, History, edge_vertices, find_plane, make_edge_between, make_vertex, make_wire,
21};
22use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
23use ogeom_geom::Curve3d as _;
24use ogeom_geom::{CircleCurve, Curve, LineCurve, PlanarCurve};
25use ogeom_math::{Circle, Frame, Point, Point2, Vector2};
26use ogeom_topo::{EdgeRepr, Filter, Model, Shape, ShapeType, explore};
27
28/// How a gap at a corner is closed: by a planar wire's offset, or where a
29/// thick solid's walls meet across an edge.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum Join {
32    /// An arc about the old corner, radius the offset: the rounded corner.
33    Arc,
34    /// Both pieces extended to their meeting: the sharp corner. Supported
35    /// where both sides are straight; extending arcs to a meeting that may
36    /// not exist is refused.
37    #[default]
38    Intersection,
39}
40
41/// A piece of the offset wire, in the plane's own coordinates.
42#[derive(Debug, Clone)]
43enum Piece {
44    Seg {
45        from: Point2,
46        to: Point2,
47    },
48    /// Angles are unwrapped along traversal: increasing for a
49    /// counter-clockwise piece, decreasing for a clockwise one.
50    Arc {
51        centre: Point2,
52        radius: f64,
53        start: f64,
54        end: f64,
55    },
56}
57
58impl Piece {
59    fn start_point(&self) -> Point2 {
60        match self {
61            Self::Seg { from, .. } => *from,
62            Self::Arc {
63                centre,
64                radius,
65                start,
66                ..
67            } => at_angle(*centre, *radius, *start),
68        }
69    }
70
71    fn end_point(&self) -> Point2 {
72        match self {
73            Self::Seg { to, .. } => *to,
74            Self::Arc {
75                centre,
76                radius,
77                end,
78                ..
79            } => at_angle(*centre, *radius, *end),
80        }
81    }
82
83    /// Unit tangent along traversal at the start or end.
84    fn tangent(&self, at_end: bool) -> Vector2 {
85        match self {
86            Self::Seg { from, to } => {
87                let d = *to - *from;
88                d / d.magnitude()
89            }
90            Self::Arc {
91                centre,
92                radius,
93                start,
94                end,
95            } => {
96                let a = if at_end { *end } else { *start };
97                let radial = (at_angle(*centre, *radius, a) - *centre) / *radius;
98                let ccw = end > start;
99                if ccw {
100                    Vector2::new(-radial.y, radial.x)
101                } else {
102                    Vector2::new(radial.y, -radial.x)
103                }
104            }
105        }
106    }
107}
108
109fn at_angle(centre: Point2, radius: f64, angle: f64) -> Point2 {
110    Point2::new(
111        radius.mul_add(angle.cos(), centre.x),
112        radius.mul_add(angle.sin(), centre.y),
113    )
114}
115
116/// Offset a closed planar wire by `offset`: positive moves outward, away from
117/// the enclosed region, negative moves inward.
118///
119/// The wire's edges must be straight or circular. Gaps at corners are closed
120/// per `join`; overlaps are trimmed to the pieces' intersection. The history
121/// reports every input edge modified into its offset piece, every join
122/// generated from the corner vertex it replaces, and the wire modified into
123/// the result.
124///
125/// # Errors
126///
127/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the wire is
128/// open or not planar, an edge is neither straight nor circular, the offset
129/// consumes an edge or an arc's radius, an `Intersection` join is asked of a
130/// curved side, or the offset self-intersects.
131pub fn offset_wire(
132    model: &mut Model,
133    wire: &Shape,
134    offset: f64,
135    join: Join,
136    tol: Tolerances,
137) -> OgeomResult<Built> {
138    if !offset.is_finite() || offset.abs() <= tol.confusion() {
139        ogeom_bail!(Construction, "an offset of {offset} moves nothing");
140    }
141    if model.kind_of(wire)? != ShapeType::Wire {
142        ogeom_bail!(Construction, "offsetting starts from a wire");
143    }
144    let open = !ogeom_algo::is_wire_closed(model, wire, tol)?;
145    let plane = match find_plane(model, wire, tol)? {
146        Some(plane) => plane,
147        None if open => {
148            // A straight open path spans no plane of its own; its outline
149            // is symmetric about it, so any plane holding the line serves.
150            let ends = explore(model, wire, Filter::OfType(ShapeType::Vertex))?;
151            let mut points = Vec::new();
152            for v in &ends {
153                if let Some(data) = model.node(v).and_then(|n| n.data().as_vertex()) {
154                    points.push(v.transform(model.datums())?.apply(data.point));
155                }
156            }
157            if points.len() < 2 {
158                ogeom_bail!(Construction, "the wire is not planar");
159            }
160            let along = ogeom_math::Direction::new(points[1] - points[0], tol)?;
161            let reference = if along.vector().cross(ogeom_math::Vector::Z).magnitude() > 0.5 {
162                ogeom_math::Direction::Z
163            } else {
164                ogeom_math::Direction::X
165            };
166            let normal = ogeom_math::Direction::new(along.vector().cross(reference.vector()), tol)?;
167            ogeom_math::Plane::through(points[0], normal)
168        }
169        None => ogeom_bail!(Construction, "the wire is not planar"),
170    };
171    let frame = plane.frame();
172    let flat = |p: Point| {
173        let local = frame.to_local(p);
174        Point2::new(local.x, local.y)
175    };
176
177    // Every edge as a piece in plane coordinates, traversal order.
178    let edges = explore(model, wire, Filter::OfType(ShapeType::Edge))?;
179    let mut pieces: Vec<Piece> = Vec::with_capacity(edges.len());
180    for edge in &edges {
181        let (curve, range) = {
182            let Some(node) = model.node(edge) else {
183                ogeom_bail!(Dangling, "edge is not in this model");
184            };
185            let Some(data) = node.data().as_edge() else {
186                ogeom_bail!(Construction, "edge node holds no edge data");
187            };
188            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
189                ogeom_bail!(Construction, "an edge has no curve to offset");
190            };
191            let Some(geometry) = model.geometry().curve(*curve) else {
192                ogeom_bail!(Dangling, "curve is not in this model");
193            };
194            (geometry.clone(), *range)
195        };
196        let Some((sv, ev)) = edge_vertices(model, edge)? else {
197            ogeom_bail!(Construction, "an edge has no bounding vertices");
198        };
199        let position = |v: &Shape| -> OgeomResult<Point> {
200            let Some(node) = model.node(v) else {
201                ogeom_bail!(Dangling, "vertex is not in this model");
202            };
203            let Some(data) = node.data().as_vertex() else {
204                ogeom_bail!(Construction, "vertex node holds no point");
205            };
206            Ok(v.transform(model.datums())?.apply(data.point))
207        };
208        let from = flat(position(&sv)?);
209        let to = flat(position(&ev)?);
210        match &curve {
211            Curve::Line(_) => pieces.push(Piece::Seg { from, to }),
212            Curve::Circle(c) => {
213                let centre = flat(c.circle().centre());
214                let radius = c.circle().radius();
215                let mid = flat(curve.point_at(f64::midpoint(range.0, range.1), tol)?);
216                // The traversal's sweep direction, read from three points of
217                // the arc rather than from flags that compose.
218                let ccw = (mid - from).cross(to - mid) > 0.0;
219                let a0 = (from - centre).y.atan2((from - centre).x);
220                let mut a1 = (to - centre).y.atan2((to - centre).x);
221                let tau = core::f64::consts::TAU;
222                if ccw {
223                    while a1 <= a0 + tol.parametric() {
224                        a1 += tau;
225                    }
226                } else {
227                    while a1 >= a0 - tol.parametric() {
228                        a1 -= tau;
229                    }
230                }
231                pieces.push(Piece::Arc {
232                    centre,
233                    radius,
234                    start: a0,
235                    end: a1,
236                });
237            }
238            _ => ogeom_bail!(
239                Construction,
240                "offsetting an edge that is neither straight nor circular \
241                 needs the offset-curve machinery; see docs/PARITY.md, offset.wire-offset"
242            ),
243        }
244    }
245
246    // An open wire offsets on both sides and closes over its ends: the
247    // outline of the thick path. The return pass is the same pieces walked
248    // backwards, and every corner rule below then applies unchanged: the
249    // ends become 180-degree corners, which is what the caps close.
250    let source_count = pieces.len();
251    if open {
252        for piece in pieces.clone().iter().rev() {
253            pieces.push(match piece {
254                Piece::Seg { from, to } => Piece::Seg {
255                    from: *to,
256                    to: *from,
257                },
258                Piece::Arc {
259                    centre,
260                    radius,
261                    start,
262                    end,
263                } => Piece::Arc {
264                    centre: *centre,
265                    radius: *radius,
266                    start: *end,
267                    end: *start,
268                },
269            });
270        }
271    }
272
273    // The wire's winding decides which side is out. Signed area by shoelace
274    // over a sampling fine enough for the decision it makes. An open path's
275    // outline encloses nothing yet; its offset surrounds it, so the side is
276    // fixed and the amount is the magnitude.
277    let offset = if open { offset.abs() } else { offset };
278    let winding = if open {
279        1.0
280    } else {
281        let mut area = 0.0;
282        let mut samples: Vec<Point2> = Vec::new();
283        for piece in &pieces {
284            match piece {
285                Piece::Seg { from, .. } => samples.push(*from),
286                Piece::Arc {
287                    centre,
288                    radius,
289                    start,
290                    end,
291                } => {
292                    for i in 0..32 {
293                        let a = start + (end - start) * f64::from(i) / 32.0;
294                        samples.push(at_angle(*centre, *radius, a));
295                    }
296                }
297            }
298        }
299        for i in 0..samples.len() {
300            let (p, q) = (samples[i], samples[(i + 1) % samples.len()]);
301            area += p.x.mul_add(q.y, -(q.x * p.y));
302        }
303        if area.abs() <= tol.confusion() {
304            ogeom_bail!(Construction, "the wire encloses no area to offset");
305        }
306        area.signum()
307    };
308    let _ = source_count;
309    // Rotating the traversal tangent by -90° (times the winding) points out
310    // of the enclosed region; `offset` moves along it.
311    let outward = |tangent: Vector2| Vector2::new(tangent.y, -tangent.x) * winding;
312
313    // Each piece offset on its own support.
314    let mut moved: Vec<Piece> = Vec::with_capacity(pieces.len());
315    for piece in &pieces {
316        match piece {
317            Piece::Seg { from, to } => {
318                let shift = outward(piece.tangent(false)) * offset;
319                moved.push(Piece::Seg {
320                    from: *from + shift,
321                    to: *to + shift,
322                });
323            }
324            Piece::Arc {
325                centre,
326                radius,
327                start,
328                end,
329            } => {
330                // Whether "outward" is radially out here depends on the arc's
331                // own sweep against the wire's winding; the midpoint says.
332                let mid = f64::midpoint(*start, *end);
333                let radial = (at_angle(*centre, *radius, mid) - *centre) / *radius;
334                let tangent_mid = if end > start {
335                    Vector2::new(-radial.y, radial.x)
336                } else {
337                    Vector2::new(radial.y, -radial.x)
338                };
339                let sign = outward(tangent_mid).dot(radial).signum();
340                let grown = radius + offset * sign;
341                if grown <= tol.confusion() {
342                    ogeom_bail!(
343                        Construction,
344                        "the offset consumes the arc's radius entirely"
345                    );
346                }
347                moved.push(Piece::Arc {
348                    centre: *centre,
349                    radius: grown,
350                    start: *start,
351                    end: *end,
352                });
353            }
354        }
355    }
356
357    let n = moved.len();
358    let mut chain: Vec<(Piece, Provenance)> = Vec::with_capacity(n * 2);
359    for (i, piece) in moved.iter().enumerate() {
360        chain.push((piece.clone(), Provenance::Offset(i)));
361    }
362    // Corners, walked over the original geometry: the turn between the
363    // incoming and outgoing tangents against the offset side says gap or
364    // overlap.
365    for i in 0..n {
366        let j = (i + 1) % n;
367        let turn = pieces[i].tangent(true).cross(pieces[j].tangent(false));
368        let at_i = chain
369            .iter()
370            .position(|(_, p)| *p == Provenance::Offset(i))
371            .unwrap_or(0);
372        let at_j = chain
373            .iter()
374            .position(|(_, p)| *p == Provenance::Offset(j))
375            .unwrap_or(0);
376        let e = chain[at_i].0.end_point();
377        let s = chain[at_j].0.start_point();
378        if e.distance(s) <= tol.confusion() * 10.0 {
379            continue; // Tangent-continuous: nothing to do.
380        }
381        let corner = pieces[i].end_point();
382        // A 180-degree corner (an open path's end) is a gap whatever the
383        // turn's vanishing cross product says.
384        let is_cap =
385            turn.abs() <= 1e-9 && pieces[i].tangent(true).dot(pieces[j].tangent(false)) < 0.0;
386        if is_cap && join == Join::Intersection {
387            // The square cap: both offsets extended one width past the end,
388            // joined across.
389            let d = pieces[i].tangent(true);
390            let e_ext = e + d * offset.abs();
391            let s_ext = s + d * offset.abs();
392            chain.insert(
393                at_i + 1,
394                (Piece::Seg { from: e, to: e_ext }, Provenance::Join(i)),
395            );
396            chain.insert(
397                at_i + 2,
398                (
399                    Piece::Seg {
400                        from: e_ext,
401                        to: s_ext,
402                    },
403                    Provenance::Join(i),
404                ),
405            );
406            chain.insert(
407                at_i + 3,
408                (Piece::Seg { from: s_ext, to: s }, Provenance::Join(i)),
409            );
410            continue;
411        }
412        if is_cap || turn * offset * winding > 0.0 {
413            // Gap.
414            match join {
415                Join::Arc => {
416                    let a0 = (e - corner).y.atan2((e - corner).x);
417                    let mut a1 = (s - corner).y.atan2((s - corner).x);
418                    // The short way round is the way the gap opens, and a
419                    // cap's exact semicircle has no short way, so the end
420                    // tangent breaks the tie: the cap bulges past the end,
421                    // not back through the path.
422                    let tau = core::f64::consts::TAU;
423                    while a1 - a0 > core::f64::consts::PI {
424                        a1 -= tau;
425                    }
426                    while a0 - a1 > core::f64::consts::PI {
427                        a1 += tau;
428                    }
429                    if ((a1 - a0).abs() - core::f64::consts::PI).abs() < 1e-9 {
430                        let mid = at_angle(corner, offset.abs(), f64::midpoint(a0, a1));
431                        let ahead = pieces[i].tangent(true);
432                        if (mid - corner).dot(ahead) < 0.0 {
433                            a1 -= tau * (a1 - a0).signum();
434                        }
435                    }
436                    chain.insert(
437                        at_i + 1,
438                        (
439                            Piece::Arc {
440                                centre: corner,
441                                radius: offset.abs(),
442                                start: a0,
443                                end: a1,
444                            },
445                            Provenance::Join(i),
446                        ),
447                    );
448                }
449                Join::Intersection => {
450                    let (Piece::Seg { from: f1, to: t1 }, Piece::Seg { from: f2, to: t2 }) =
451                        (chain[at_i].0.clone(), chain[at_j].0.clone())
452                    else {
453                        ogeom_bail!(
454                            Construction,
455                            "an intersection join between curved sides may \
456                             never meet; use the arc join"
457                        );
458                    };
459                    let met = intersect_lines(f1, t1, f2, t2, tol)?;
460                    if let Piece::Seg { to, .. } = &mut chain[at_i].0 {
461                        *to = met;
462                    }
463                    if let Piece::Seg { from, .. } = &mut chain[at_j].0 {
464                        *from = met;
465                    }
466                }
467            }
468        } else {
469            // Overlap: trim both to the crossing nearest the corner.
470            let met = nearest_crossing(&chain[at_i].0, &chain[at_j].0, corner, tol)?;
471            trim_end(&mut chain[at_i].0, met, tol)?;
472            trim_start(&mut chain[at_j].0, met, tol)?;
473        }
474    }
475    // Consumed pieces are the collapse announcing itself; drop them and let
476    // the distance filter and the reconnection below say what survives.
477    chain.retain(|(piece, _)| match piece {
478        Piece::Seg { from, to } => from.distance(*to) > tol.confusion(),
479        Piece::Arc { start, end, .. } => (end - start).abs() > tol.parametric(),
480    });
481    if chain.is_empty() {
482        ogeom_bail!(Construction, "the offset consumes the wire whole");
483    }
484
485    // Where the raw offset crosses itself, split; every sub-piece then
486    // stands or falls by the offset's own definition: a point of the true
487    // offset boundary is a full offset from the source, and a collapsed
488    // sliver is closer.
489    let m = chain.len();
490    let mut cuts: Vec<Vec<Point2>> = vec![Vec::new(); m];
491    for i in 0..m {
492        for j in i + 1..m {
493            if j == i + 1 || (i == 0 && j == m - 1) {
494                continue;
495            }
496            for p in crossings(&chain[i].0, &chain[j].0, tol)? {
497                if within(&chain[i].0, p, tol) && within(&chain[j].0, p, tol) {
498                    cuts[i].push(p);
499                    cuts[j].push(p);
500                }
501            }
502        }
503    }
504    let mut resolved: Vec<(Piece, Provenance)> = Vec::new();
505    for (k, (piece, provenance)) in chain.iter().enumerate() {
506        for sub in split_at(piece, &cuts[k]) {
507            resolved.push((sub, *provenance));
508        }
509    }
510
511    // The source, densely enough to measure against.
512    let source: Vec<Point2> = {
513        let mut out = Vec::new();
514        for piece in pieces.iter().take(source_count) {
515            match piece {
516                Piece::Seg { from, to } => {
517                    out.push(*from);
518                    out.push(*to);
519                }
520                Piece::Arc {
521                    centre,
522                    radius,
523                    start,
524                    end,
525                } => {
526                    for i in 0..=32 {
527                        let a = start + (end - start) * f64::from(i) / 32.0;
528                        out.push(at_angle(*centre, *radius, a));
529                    }
530                }
531            }
532        }
533        out
534    };
535    let source_distance = |p: Point2| -> f64 {
536        let mut best = f64::INFINITY;
537        for w in source.windows(2) {
538            let d = w[1] - w[0];
539            let len2 = d.dot(d);
540            let t = if len2 > 0.0 {
541                ((p - w[0]).dot(d) / len2).clamp(0.0, 1.0)
542            } else {
543                0.0
544            };
545            best = best.min(p.distance(w[0] + d * t));
546        }
547        best
548    };
549    let keep_beyond = offset.abs() - (tol.confusion() * 1e3).max(offset.abs() * 1e-3);
550    let had_cuts = cuts.iter().any(|c| !c.is_empty());
551    let survivors: Vec<(Piece, Provenance)> = resolved
552        .into_iter()
553        .filter(|(piece, _)| {
554            let mid = match piece {
555                Piece::Seg { from, to } => from.midpoint(*to),
556                Piece::Arc {
557                    centre,
558                    radius,
559                    start,
560                    end,
561                } => at_angle(*centre, *radius, f64::midpoint(*start, *end)),
562            };
563            source_distance(mid) >= keep_beyond
564        })
565        .collect();
566    if survivors.is_empty() {
567        ogeom_bail!(Construction, "the offset consumes the wire whole");
568    }
569
570    // Reconnect the survivors into loops by endpoint adjacency.
571    let loops: Vec<Vec<(Piece, Provenance)>> = if !had_cuts && survivors.len() == chain.len() {
572        vec![survivors]
573    } else {
574        let eps = tol.confusion() * 100.0;
575        let mut pool = survivors;
576        let mut out: Vec<Vec<(Piece, Provenance)>> = Vec::new();
577        while let Some(first) = pool.pop() {
578            let mut current = vec![first];
579            loop {
580                let tail = current.last().map(|(p, _)| p.end_point());
581                let Some(tail) = tail else { break };
582                let head = current.first().map(|(p, _)| p.start_point());
583                if head.is_some_and(|h| h.distance(tail) <= eps) && current.len() > 1 {
584                    out.push(current);
585                    break;
586                }
587                let Some(next) = pool
588                    .iter()
589                    .position(|(p, _)| p.start_point().distance(tail) <= eps)
590                else {
591                    // An open fragment closes nothing; the collapse ate its
592                    // continuation.
593                    break;
594                };
595                current.push(pool.remove(next));
596            }
597        }
598        if out.is_empty() {
599            ogeom_bail!(
600                Construction,
601                "the offset's survivors close no loop; the collapse consumed \
602                 the wire"
603            );
604        }
605        out
606    };
607
608    // Lift each loop back to space and rebuild, vertices shared along the
609    // way. One loop is a wire; a collapse that split the offset into islands
610    // is a compound of them.
611    let lift = |p: Point2| frame.origin() + frame.x().vector() * p.x + frame.y().vector() * p.y;
612    let normal = frame.z();
613    let x_ref = frame.x();
614    let mut history = History::new();
615    let mut wires: Vec<Shape> = Vec::new();
616    for ring in &loops {
617        let count = ring.len();
618        let mut new_edges: Vec<Shape> = Vec::with_capacity(count);
619        let vertices: Vec<Shape> = (0..count)
620            .map(|k| make_vertex(model, lift(ring[k].0.start_point())).shape)
621            .collect();
622        for (k, (piece, provenance)) in ring.iter().enumerate() {
623            let from = &vertices[k];
624            let to = &vertices[(k + 1) % count];
625            let built = match piece {
626                Piece::Seg { from: a, to: b } => {
627                    let line = LineCurve::segment(lift(*a), lift(*b), tol)?;
628                    let curve = Curve::Line(line);
629                    let domain = curve.domain();
630                    make_edge_between(model, curve, domain, from, to, tol)?.shape
631                }
632                Piece::Arc {
633                    centre,
634                    radius,
635                    start,
636                    end,
637                } => {
638                    // Always built counter-clockwise about the plane normal;
639                    // a clockwise piece enters the wire reversed.
640                    let ccw = end > start;
641                    let circle =
642                        Circle::new(Frame::new(lift(*centre), normal, x_ref, tol)?, *radius, tol)?;
643                    let curve = Curve::Circle(CircleCurve::new(circle));
644                    let (lo, hi) = if ccw { (*start, *end) } else { (*end, *start) };
645                    let (va, vb) = if ccw { (from, to) } else { (to, from) };
646                    let edge = make_edge_between(model, curve, (lo, hi), va, vb, tol)?.shape;
647                    if ccw { edge } else { edge.reversed() }
648                }
649            };
650            match provenance {
651                Provenance::Offset(i) => history.modify(&edges[i % edges.len()], built.clone()),
652                Provenance::Join(i) => {
653                    // The join stands where the corner vertex stood; for an
654                    // open path's cap, the end vertex itself.
655                    if let Some((_, corner_vertex)) = edge_vertices(model, &edges[i % edges.len()])?
656                    {
657                        history.generate(&corner_vertex, built.clone());
658                    }
659                }
660            }
661            new_edges.push(built);
662        }
663        wires.push(make_wire(model, &new_edges, tol)?.shape);
664    }
665    let shape = if wires.len() == 1 {
666        wires.remove(0)
667    } else {
668        ogeom_algo::build::make_compound(model, &wires)?.shape
669    };
670    history.modify(wire, shape.clone());
671    Ok(Built::new(shape, history))
672}
673
674/// Split a piece at the given points on it, in traversal order.
675fn split_at(piece: &Piece, points: &[Point2]) -> Vec<Piece> {
676    if points.is_empty() {
677        return vec![piece.clone()];
678    }
679    match piece {
680        Piece::Seg { from, to } => {
681            let d = *to - *from;
682            let len = d.magnitude();
683            let mut ts: Vec<f64> = points
684                .iter()
685                .map(|p| (*p - *from).dot(d / len))
686                .filter(|t| *t > 1e-12 && *t < len - 1e-12)
687                .collect();
688            ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
689            ts.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
690            let mut out = Vec::with_capacity(ts.len() + 1);
691            let mut last = *from;
692            for t in ts {
693                let p = *from + (d / len) * t;
694                out.push(Piece::Seg { from: last, to: p });
695                last = p;
696            }
697            out.push(Piece::Seg {
698                from: last,
699                to: *to,
700            });
701            out
702        }
703        Piece::Arc {
704            centre,
705            radius,
706            start,
707            end,
708        } => {
709            let toward = (end - start).signum();
710            let mut ts: Vec<f64> = points
711                .iter()
712                .filter_map(|p| {
713                    let v = *p - *centre;
714                    let mut a = v.y.atan2(v.x);
715                    let tau = core::f64::consts::TAU;
716                    while (a - start) * toward < 0.0 {
717                        a += tau * toward;
718                    }
719                    while (a - end) * toward > 0.0 {
720                        a -= tau * toward;
721                    }
722                    ((a - start) * toward > 1e-12 && (end - a) * toward > 1e-12).then_some(a)
723                })
724                .collect();
725            ts.sort_by(|a, b| {
726                ((a - start) * toward)
727                    .partial_cmp(&((b - start) * toward))
728                    .unwrap_or(core::cmp::Ordering::Equal)
729            });
730            ts.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
731            let mut out = Vec::with_capacity(ts.len() + 1);
732            let mut last = *start;
733            for t in ts {
734                out.push(Piece::Arc {
735                    centre: *centre,
736                    radius: *radius,
737                    start: last,
738                    end: t,
739                });
740                last = t;
741            }
742            out.push(Piece::Arc {
743                centre: *centre,
744                radius: *radius,
745                start: last,
746                end: *end,
747            });
748            out
749        }
750    }
751}
752
753#[derive(Debug, Clone, Copy, PartialEq, Eq)]
754enum Provenance {
755    Offset(usize),
756    Join(usize),
757}
758
759/// Where two infinite lines through the segments meet.
760fn intersect_lines(
761    f1: Point2,
762    t1: Point2,
763    f2: Point2,
764    t2: Point2,
765    tol: Tolerances,
766) -> OgeomResult<Point2> {
767    let d1 = t1 - f1;
768    let d2 = t2 - f2;
769    let cross = d1.cross(d2);
770    if cross.abs() <= tol.angular() * d1.magnitude() * d2.magnitude() {
771        ogeom_bail!(Construction, "parallel sides never meet at a corner");
772    }
773    let t = (f2 - f1).cross(d2) / cross;
774    Ok(f1 + d1 * t)
775}
776
777/// The crossing of two pieces nearest `corner`.
778fn nearest_crossing(a: &Piece, b: &Piece, corner: Point2, tol: Tolerances) -> OgeomResult<Point2> {
779    let candidates = crossings(a, b, tol)?;
780    candidates
781        .into_iter()
782        .min_by(|p, q| {
783            p.distance(corner)
784                .partial_cmp(&q.distance(corner))
785                .unwrap_or(core::cmp::Ordering::Equal)
786        })
787        .ok_or_else(|| {
788            ogeom_core::ogeom_err!(
789                Construction,
790                "overlapping offset pieces never cross; the offset collapses \
791                 here"
792            )
793        })
794}
795
796/// All crossings of the two pieces' supports.
797fn crossings(a: &Piece, b: &Piece, tol: Tolerances) -> OgeomResult<Vec<Point2>> {
798    let support = |p: &Piece| -> OgeomResult<PlanarCurve> {
799        Ok(match p {
800            Piece::Seg { from, to } => ogeom_geom::Line2d::segment(*from, *to, tol)?.into(),
801            Piece::Arc { centre, radius, .. } => {
802                ogeom_geom::Circle2d::new(ogeom_math::Circle2::new(
803                    ogeom_math::Frame2::new(*centre, ogeom_math::Direction2::X),
804                    *radius,
805                    tol,
806                )?)
807                .into()
808            }
809        })
810    };
811    let found = ogeom_intersect::intersect_curves_2d(
812        &support(a)?,
813        &support(b)?,
814        ogeom_intersect::CurveCurveOptions::default(),
815        tol,
816    )?;
817    Ok(found.crossings.into_iter().map(|c| c.point).collect())
818}
819
820/// Whether a support crossing lands within the piece's own span, endpoints
821/// excluded.
822fn within(piece: &Piece, p: Point2, tol: Tolerances) -> bool {
823    let margin = tol.confusion() * 100.0;
824    match piece {
825        Piece::Seg { from, to } => {
826            let d = *to - *from;
827            let len = d.magnitude();
828            let t = (p - *from).dot(d / len);
829            t > margin && t < len - margin
830        }
831        Piece::Arc {
832            centre,
833            radius,
834            start,
835            end,
836        } => {
837            let a = (p - *centre).y.atan2((p - *centre).x);
838            let (lo, hi) = if end > start {
839                (*start, *end)
840            } else {
841                (*end, *start)
842            };
843            let tau = core::f64::consts::TAU;
844            let mut folded = a;
845            while folded < lo {
846                folded += tau;
847            }
848            folded > lo + margin / radius && folded < hi - margin / radius
849        }
850    }
851}
852
853/// Trim a piece's end back to `at`.
854fn trim_end(piece: &mut Piece, at: Point2, tol: Tolerances) -> OgeomResult<()> {
855    match piece {
856        Piece::Seg { from, to } => {
857            let d = (*to - *from).magnitude();
858            let kept = (at - *from).dot((*to - *from) / d);
859            if kept <= tol.confusion() {
860                ogeom_bail!(Construction, "the trim consumes the offset edge whole");
861            }
862            *to = at;
863        }
864        Piece::Arc {
865            centre,
866            radius,
867            start,
868            end,
869        } => {
870            let a = (at - *centre).y.atan2((at - *centre).x);
871            *end = align_angle(a, *start, *end, *radius, tol)?;
872        }
873    }
874    Ok(())
875}
876
877/// Trim a piece's start forward to `at`.
878fn trim_start(piece: &mut Piece, at: Point2, tol: Tolerances) -> OgeomResult<()> {
879    match piece {
880        Piece::Seg { from, to } => {
881            let d = (*to - *from).magnitude();
882            let kept = (*to - at).dot((*to - *from) / d);
883            if kept <= tol.confusion() {
884                ogeom_bail!(Construction, "the trim consumes the offset edge whole");
885            }
886            *from = at;
887        }
888        Piece::Arc {
889            centre,
890            radius,
891            start,
892            end,
893        } => {
894            let a = (at - *centre).y.atan2((at - *centre).x);
895            *start = align_angle(a, *end, *start, *radius, tol)?;
896        }
897    }
898    Ok(())
899}
900
901/// Fold `angle` next to the span so the sweep from `anchor` keeps its sense
902/// and stays non-empty.
903fn align_angle(
904    angle: f64,
905    anchor: f64,
906    replaced: f64,
907    radius: f64,
908    tol: Tolerances,
909) -> OgeomResult<f64> {
910    let tau = core::f64::consts::TAU;
911    let mut a = angle;
912    // Choose the representative nearest the value being replaced.
913    while a - replaced > core::f64::consts::PI {
914        a -= tau;
915    }
916    while replaced - a > core::f64::consts::PI {
917        a += tau;
918    }
919    if ((a - anchor).abs() * radius) <= tol.confusion() {
920        ogeom_bail!(Construction, "the trim consumes the offset arc whole");
921    }
922    if (a - anchor).signum() != (replaced - anchor).signum() {
923        ogeom_bail!(Construction, "the trim runs past the offset arc's start");
924    }
925    Ok(a)
926}