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