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