Skip to main content

ogeom_heal/
split.rs

1//! Splitting a face along curves: edges lying on it, or dropped onto it
2//! along its normals or along a direction.
3//!
4//! Each curve is first given its trace in the face's chart: its own pcurve
5//! where it carries one on the surface, the exact or fitted one where it
6//! lies on the surface, or, for a projection, the curve through its landed
7//! points fitted together with their chart positions, so curve and trace
8//! share a parameter. A trace leaving the face's window of a chart that
9//! wraps round (it repeats, or its two sides are one line of points) is cut
10//! there and brought back by a period, and the pieces of all the curves are
11//! chained end to end into paths.
12//!
13//! Each path is then cut where it meets the face's boundary, and each
14//! stretch of it inside the face divides the face it runs through: a
15//! stretch from one point of a boundary loop to another point of the same
16//! loop cuts that loop in two, and a path closing on itself inside the face
17//! cuts out the region it encloses, leaving a hole in the rest. A closed
18//! path that meets the boundary is walked from a meeting, so it is a
19//! stretch from the boundary to the boundary. Stretches are cut one at a
20//! time, so a later path meets the pieces of the earlier ones as boundary
21//! and may end on them.
22//!
23//! The boundary edges a stretch ends on are cut there in every face that
24//! holds them, so the rest of the shape stays joined to the pieces. Pieces
25//! a piece of the face's own seam parts are joined again into one face,
26//! whose boundary jumps a period where it crosses the seam.
27
28use ogeom_core::{FastMap, FastSet};
29
30use ogeom_algo::{Built, edge_vertices, project_on_surface, project_on_surface_from};
31use ogeom_core::{OgeomResult, Tolerance, Tolerances, ogeom_bail, ogeom_err};
32use ogeom_geom::{
33    Curve, Curve2d as _, Curve3d as _, PlanarCurve, Surface as _, SurfaceGeometry,
34    Transformable as _,
35};
36use ogeom_math::{Direction, Point, Point2, Transform2, Vector2};
37use ogeom_topo::{
38    EdgeData, EdgeRepr, Location, Model, NodeData, Shape, ShapeType, SurfaceId, TShapeId,
39    VertexData, explore_unique,
40};
41
42use crate::Reshape;
43use crate::divide::{
44    Bounds, Occurrence, bounded_natural, chart_bounds, edge_data, face_data, forward, inside,
45    placed_baked, rings, signed_area, split_edge, vertex_point,
46};
47
48/// How the curves to split along reach the face.
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub enum Projection {
51    /// The curves lie on the face's surface already.
52    OnFace,
53    /// Each point of a curve is taken to its nearest point on the surface.
54    AlongNormals,
55    /// Each point of a curve is carried along the direction, either way,
56    /// to where it meets the surface.
57    Along(Direction),
58}
59
60/// Cut `face` of `shape` along `edges` and rebuild `shape` around the
61/// pieces.
62///
63/// `edges` are edges, or wires and compounds of them, taken onto the face
64/// as `projection` says; curves meeting end to end are followed as one.
65/// Every stretch of a curve inside the face must run from the face's
66/// boundary to its boundary, the boundary including the stretches cut
67/// before it, or close on itself inside the face; a curve that runs past
68/// the face is cut where it leaves it. The pieces share the new edges, and
69/// the boundary edges a cut ends on are cut there in every face that holds
70/// them. A region a seam of the face runs through is one piece, its
71/// boundary crossing the seam. A face given on its own comes back as a
72/// shell of its pieces.
73///
74/// The history records `face` modified into its pieces and each edge in
75/// `edges` generating the edges cut along it.
76///
77/// # Errors
78///
79/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction), by
80/// reason, where `face` is not a face of `shape`, no edge is given, an edge
81/// asked to lie on the face does not, a curve or its projection misses the
82/// face, a curve ends inside the face, runs along its boundary, or runs from
83/// one boundary loop of it to another.
84pub fn split_face(
85    model: &mut Model,
86    shape: &Shape,
87    face: &Shape,
88    edges: &[Shape],
89    projection: Projection,
90    tol: Tolerances,
91) -> OgeomResult<Built> {
92    if model.kind_of(face)? != ShapeType::Face {
93        ogeom_bail!(Construction, "only a face can be split along curves");
94    }
95    if !explore_unique(model, shape, ShapeType::Face)?
96        .iter()
97        .any(|f| f.node() == face.node())
98    {
99        ogeom_bail!(Construction, "the face to split is not a face of the shape");
100    }
101    let mut sources: Vec<Shape> = Vec::new();
102    for given in edges {
103        for edge in explore_unique(model, given, ShapeType::Edge)? {
104            if !edge_data(model, &edge)?.degenerate {
105                sources.push(edge);
106            }
107        }
108    }
109    if sources.is_empty() {
110        ogeom_bail!(Construction, "a face is split along at least one edge");
111    }
112
113    // A face given alone is held in a shell, so its pieces have a home.
114    let root = if model.kind_of(shape)? == ShapeType::Face {
115        model.add_shell(std::slice::from_ref(shape))?
116    } else {
117        shape.clone()
118    };
119    let start = placed_baked(model, &root, tol)?;
120    let mut current = Built::new(start.shape.clone(), start.history);
121    let Some(mut target) = current.history.trace(face).first().cloned() else {
122        ogeom_bail!(Construction, "the face to split is lost in its placement");
123    };
124    if face_data(model, &target)?.natural_restriction {
125        let Some(bounded) = bounded_natural(model, &target, tol)? else {
126            ogeom_bail!(
127                Construction,
128                "a face covering an unbounded surface has no boundary to cut to"
129            );
130        };
131        let mut reshape = Reshape::new();
132        reshape.replace(&forward(&target), bounded.clone());
133        let next = reshape.apply(model, &current.shape)?;
134        current = Built::new(next.shape, current.history.then(&next.history));
135        target = bounded;
136    }
137    let data = face_data(model, &target)?;
138    let Some(surface) = model.geometry().surface(data.surface).cloned() else {
139        ogeom_bail!(Dangling, "surface is not in this model");
140    };
141    ogeom_algo::build::trimmed_where_bare(model, &target, tol)?;
142    let window = chart_bounds(&rings(model, &forward(&target), data.surface, tol)?, tol);
143
144    let mut segments: Vec<Segment> = Vec::new();
145    for source in &sources {
146        let found = segments_of(model, source, &surface, data.surface, projection, tol)?;
147        if found.is_empty() {
148            ogeom_bail!(Construction, "the projection of an edge misses the face");
149        }
150        for segment in found {
151            segments.extend(unfold(segment, &surface, window, tol)?);
152        }
153    }
154    let mut cutters = chain(segments, &surface, tol)?;
155
156    let mut targets: Vec<Shape> = vec![target];
157    let mut pending: Vec<usize> = (0..cutters.len()).collect();
158    loop {
159        let mut progressed = false;
160        let mut held: Vec<usize> = Vec::new();
161        for &k in &pending {
162            let dangling = loop {
163                let mut found = None;
164                let mut dangling = false;
165                for face in &targets {
166                    match next_stretch(model, face, &cutters[k], &surface, tol)? {
167                        Found::Nothing => {}
168                        Found::Dangling => dangling = true,
169                        Found::Rebase(t) => {
170                            found = Some(Next::Rebase(t));
171                            break;
172                        }
173                        Found::Cut(stretch) => {
174                            found = Some(Next::Cut(face.clone(), Box::new(stretch)));
175                            break;
176                        }
177                    }
178                }
179                let (face, stretch) = match found {
180                    None => break dangling,
181                    Some(Next::Rebase(t)) => {
182                        cutters[k] = cutters[k].rebased(t, tol)?;
183                        continue;
184                    }
185                    Some(Next::Cut(face, stretch)) => (face, *stretch),
186                };
187                let made = cut(model, &face, &cutters[k], &stretch, tol)?;
188                let mut step = made.reshape.apply(model, &current.shape)?;
189                for (source, edge) in &made.edges {
190                    step.history.generate(source, edge.clone());
191                }
192                let mut next_targets = Vec::new();
193                for t in &targets {
194                    next_targets.extend(step.history.trace(t).iter().cloned());
195                }
196                targets = next_targets;
197                for cutter in &mut cutters {
198                    for (old, pieces) in &made.splits {
199                        if cutter.own.remove(old) {
200                            cutter.own.extend(pieces.iter().map(Shape::node));
201                        }
202                    }
203                }
204                cutters[k]
205                    .own
206                    .extend(made.edges.iter().map(|(_, e)| e.node()));
207                cutters[k].consumed.push(stretch.span);
208                current = Built::new(step.shape, current.history.then(&step.history));
209                progressed = true;
210            };
211            if dangling {
212                held.push(k);
213            } else if cutters[k].consumed.is_empty() {
214                ogeom_bail!(
215                    Construction,
216                    "a curve to split along does not cross the face"
217                );
218            }
219        }
220        if held.is_empty() {
221            break;
222        }
223        if !progressed {
224            ogeom_bail!(
225                Construction,
226                "a curve to split along ends inside the face; it must run from \
227                 boundary to boundary"
228            );
229        }
230        pending = held;
231    }
232    let current = join_across_seams(model, current, &targets, data.surface, tol)?;
233    // A cut lands on the face's surface through a fitted pcurve where no
234    // closed form projects it (a circle dropped onto a drum along the
235    // normals), and the fit stands off its edge between its samples: every
236    // edge the split made states what its pcurves measure along it. The
237    // shape's own edges are the caller's, and are left as they were.
238    let held: ogeom_core::FastSet<_> = explore_unique(model, shape, ShapeType::Edge)?
239        .iter()
240        .map(Shape::node)
241        .collect();
242    let made: Vec<Shape> = explore_unique(model, &current.shape, ShapeType::Edge)?
243        .into_iter()
244        .filter(|e| !held.contains(&e.node()))
245        .collect();
246    ogeom_algo::state_pcurve_gaps_of(model, &made, tol)?;
247    Ok(current)
248}
249
250/// What the search for a path's next stretch turned up.
251enum Next {
252    Rebase(f64),
253    Cut(Shape, Box<Planned>),
254}
255
256// --- paths ---------------------------------------------------------------------
257
258/// A piece of a path: a curve over a range of its parameter, with its trace
259/// on the surface at the same parameter.
260#[derive(Debug, Clone)]
261struct Segment {
262    /// The edge it came from.
263    source: Shape,
264    curve: Curve,
265    pcurve: PlanarCurve,
266    /// The range it spans, increasing.
267    range: (f64, f64),
268    /// Whether the path walks it from `range.1` to `range.0`.
269    backwards: bool,
270    /// How far the curve and the surface along its trace part.
271    tolerance: f64,
272}
273
274impl Segment {
275    /// The curve's parameter a fraction `f` along the walk.
276    fn local(&self, f: f64) -> f64 {
277        let (a, b) = if self.backwards {
278            (self.range.1, self.range.0)
279        } else {
280            self.range
281        };
282        (b - a).mul_add(f, a)
283    }
284
285    fn chart(&self, f: f64, tol: Tolerances) -> OgeomResult<Point2> {
286        self.pcurve.point_at(self.local(f), tol)
287    }
288
289    fn point(&self, f: f64, tol: Tolerances) -> OgeomResult<Point> {
290        self.curve.point_at(self.local(f), tol)
291    }
292
293    /// The part between fractions `f0 < f1` of the walk.
294    fn part(&self, f0: f64, f1: f64) -> Self {
295        let (a, b) = (self.local(f0), self.local(f1));
296        Self {
297            range: (a.min(b), a.max(b)),
298            ..self.clone()
299        }
300    }
301
302    fn reversed(mut self) -> Self {
303        self.backwards = !self.backwards;
304        self
305    }
306
307    /// The segment with its trace moved across the chart by `by`.
308    fn shifted(mut self, by: Vector2, tol: Tolerances) -> OgeomResult<Self> {
309        if by.x != 0.0 || by.y != 0.0 {
310            self.pcurve = self.pcurve.transformed(&Transform2::translation(by), tol)?;
311        }
312        Ok(self)
313    }
314}
315
316/// A path to cut along: segments end to end, walked by a parameter that
317/// runs from `k` to `k + 1` along segment `k`.
318struct Cutter {
319    path: Vec<Segment>,
320    closed: bool,
321    /// The edges already cut along it: its own stretches, never a boundary
322    /// it crosses.
323    own: FastSet<TShapeId>,
324    /// The parameter spans already cut.
325    consumed: Vec<(f64, f64)>,
326}
327
328impl Cutter {
329    #[expect(clippy::cast_precision_loss, reason = "a segment count")]
330    fn range(&self) -> (f64, f64) {
331        (0.0, self.path.len() as f64)
332    }
333
334    /// The segment the parameter `t` falls in, and how far along it.
335    fn locate(&self, t: f64) -> (&Segment, f64) {
336        #[expect(
337            clippy::cast_possible_truncation,
338            clippy::cast_sign_loss,
339            reason = "a parameter clamped to the segment count"
340        )]
341        let k = (t.floor().max(0.0) as usize).min(self.path.len() - 1);
342        #[expect(clippy::cast_precision_loss, reason = "a segment index")]
343        let f = t - k as f64;
344        (&self.path[k], f)
345    }
346
347    fn chart(&self, t: f64, tol: Tolerances) -> OgeomResult<Point2> {
348        let (s, f) = self.locate(t);
349        s.chart(f, tol)
350    }
351
352    fn point(&self, t: f64, tol: Tolerances) -> OgeomResult<Point> {
353        let (s, f) = self.locate(t);
354        s.point(f, tol)
355    }
356
357    /// The trace's derivative by the path's parameter.
358    fn chart_d1(&self, t: f64, tol: Tolerances) -> OgeomResult<Vector2> {
359        let (s, f) = self.locate(t);
360        let rate = if s.backwards {
361            s.range.0 - s.range.1
362        } else {
363            s.range.1 - s.range.0
364        };
365        Ok(s.pcurve.d1_at(s.local(f), tol)? * rate)
366    }
367
368    fn tolerance(&self) -> f64 {
369        self.path.iter().map(|s| s.tolerance).fold(0.0, f64::max)
370    }
371
372    fn resolution(&self, tol: Tolerances) -> f64 {
373        (self.range().1 - self.range().0) * 1e-9 + tol.parametric()
374    }
375
376    fn is_consumed(&self, a: f64, b: f64) -> bool {
377        let mid = 0.5 * (a + b);
378        self.consumed.iter().any(|(lo, hi)| mid > *lo && mid < *hi)
379    }
380
381    /// The closed path walked from `t` round to `t`, open.
382    fn rebased(&self, t: f64, tol: Tolerances) -> OgeomResult<Self> {
383        #[expect(
384            clippy::cast_possible_truncation,
385            clippy::cast_sign_loss,
386            reason = "a parameter clamped to the segment count"
387        )]
388        let k = (t.floor().max(0.0) as usize).min(self.path.len() - 1);
389        #[expect(clippy::cast_precision_loss, reason = "a segment index")]
390        let f = t - k as f64;
391        let eps = self.resolution(tol);
392        let mut path = Vec::with_capacity(self.path.len() + 1);
393        if f < 1.0 - eps {
394            path.push(self.path[k].part(f, 1.0));
395        }
396        path.extend(self.path[k + 1..].iter().cloned());
397        path.extend(self.path[..k].iter().cloned());
398        if f > eps {
399            path.push(self.path[k].part(0.0, f));
400        }
401        Ok(Self {
402            path,
403            closed: false,
404            own: FastSet::default(),
405            consumed: Vec::new(),
406        })
407    }
408}
409
410/// The segments `edge` gives on the surface.
411fn segments_of(
412    model: &Model,
413    edge: &Shape,
414    surface: &SurfaceGeometry,
415    surface_id: SurfaceId,
416    projection: Projection,
417    tol: Tolerances,
418) -> OgeomResult<Vec<Segment>> {
419    let data = edge_data(model, edge)?;
420    let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d().cloned() else {
421        ogeom_bail!(Construction, "an edge to split along has no curve in space");
422    };
423    let Some(geometry) = model.geometry().curve(curve).cloned() else {
424        ogeom_bail!(Dangling, "curve is not in this model");
425    };
426    let unplaced = edge.location().is_identity();
427    let geometry = if unplaced {
428        geometry
429    } else {
430        geometry.transformed(&edge.transform(model.datums())?, tol)?
431    };
432    let mut out = Vec::new();
433    match projection {
434        Projection::OnFace => {
435            let cap = data.tolerance.get().max(tol.confusion());
436            let mut worst: f64 = 0.0;
437            for k in 0..=32 {
438                let t = (range.1 - range.0).mul_add(f64::from(k) / 32.0, range.0);
439                let p = geometry.point_at(t, tol)?;
440                worst = worst.max(project_on_surface(surface, p, 24, tol)?.distance);
441            }
442            if worst > cap {
443                ogeom_bail!(
444                    Construction,
445                    "an edge to split along does not lie on the face: it stands \
446                     {worst:.3e} off the surface; project it instead"
447                );
448            }
449            let own = if unplaced && data.same_parameter() {
450                match data.pcurve_for(surface_id, edge.location()) {
451                    Some(EdgeRepr::PCurve {
452                        curve, range: r, ..
453                    }) if *r == range => model.geometry().pcurve(*curve).cloned(),
454                    _ => None,
455                }
456            } else {
457                None
458            };
459            let exact =
460                own.or_else(|| ogeom_intersect::exact_pcurve_over(&geometry, range, surface, tol));
461            if exact.is_none()
462                && let Some(pieces) = through_poles(&geometry, range, surface, cap, tol)?
463            {
464                for (span, pcurve, fit) in pieces {
465                    out.push(segment(
466                        edge,
467                        geometry.clone(),
468                        pcurve,
469                        span,
470                        fit,
471                        surface,
472                        tol,
473                    )?);
474                }
475                return Ok(out);
476            }
477            let (pcurve, fit) = match exact {
478                Some(p) => (p, 0.0),
479                None => {
480                    let (p, error, _, off, _) =
481                        ogeom_algo::pcurve_fit::fit_projected_pcurve_capped(
482                            &geometry, range, surface, cap, tol,
483                        )?;
484                    (p, error.max(off))
485                }
486            };
487            out.push(segment(edge, geometry, pcurve, range, fit, surface, tol)?);
488        }
489        Projection::AlongNormals | Projection::Along(_) => {
490            for (span, seed) in landed_runs(&geometry, range, surface, projection, tol)? {
491                out.push(projected(
492                    edge, &geometry, span, seed, surface, projection, tol,
493                )?);
494            }
495        }
496    }
497    Ok(out)
498}
499
500/// A piece of a curve: the range of its parameter, its trace, and how far
501/// the trace's fit misses it.
502type Piece = ((f64, f64), PlanarCurve, f64);
503
504/// A curve on a sphere that runs through a pole, cut there into pieces,
505/// each with its trace: `None` where the surface is no sphere or the curve
506/// meets no pole.
507///
508/// A trace in the sphere's chart jumps at a pole, from the longitude the
509/// curve arrives along to the one it leaves along, so no one trace follows
510/// it; each piece's trace ends on the pole's row at the longitude of the
511/// curve's own tangent there, and is fitted in space through the curve's
512/// chart positions, continued across the chart's period, to `cap`. The
513/// curve meets a pole where its nearest approach, found among samples and
514/// refined by golden sections to the parameter's resolution, comes within
515/// `cap` of it.
516///
517/// # Errors
518///
519/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) where
520/// a piece's trace cannot be fitted within `cap`; whatever the curve
521/// refuses.
522fn through_poles(
523    curve: &Curve,
524    range: (f64, f64),
525    surface: &SurfaceGeometry,
526    cap: f64,
527    tol: Tolerances,
528) -> OgeomResult<Option<Vec<Piece>>> {
529    const SAMPLES: u32 = 256;
530    let SurfaceGeometry::Sphere(sphere) = surface else {
531        return Ok(None);
532    };
533    let sphere = sphere.sphere();
534    let frame = sphere.frame();
535    let (x, y, z) = (frame.x().vector(), frame.y().vector(), frame.z().vector());
536    let half = core::f64::consts::FRAC_PI_2;
537    let poles = [
538        (sphere.centre() + z * sphere.radius(), half),
539        (sphere.centre() - z * sphere.radius(), -half),
540    ];
541    let off = |t: f64| -> OgeomResult<(f64, usize)> {
542        let p = curve.point_at(t, tol)?;
543        let (n, s) = (p.distance(poles[0].0), p.distance(poles[1].0));
544        Ok(if n <= s { (n, 0) } else { (s, 1) })
545    };
546    let at = |k: u32| (range.1 - range.0).mul_add(f64::from(k) / f64::from(SAMPLES), range.0);
547    let distances = (0..=SAMPLES)
548        .map(|k| off(at(k)).map(|d| d.0))
549        .collect::<OgeomResult<Vec<_>>>()?;
550    let resolution = f64::EPSILON * range.0.abs().max(range.1.abs()).max(range.1 - range.0);
551    let ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
552    let mut hits: Vec<(f64, usize)> = Vec::new();
553    for k in 0..=SAMPLES {
554        let i = usize::try_from(k).unwrap_or(0);
555        let left = if k == 0 {
556            f64::INFINITY
557        } else {
558            distances[i - 1]
559        };
560        let right = if k == SAMPLES {
561            f64::INFINITY
562        } else {
563            distances[i + 1]
564        };
565        if distances[i] > left || distances[i] > right {
566            continue;
567        }
568        let (mut a, mut b) = (at(k.saturating_sub(1)), at((k + 1).min(SAMPLES)));
569        while b - a > resolution {
570            let (c, d) = ((b - a).mul_add(-ratio, b), (b - a).mul_add(ratio, a));
571            if off(c)?.0 <= off(d)?.0 {
572                b = d;
573            } else {
574                a = c;
575            }
576        }
577        let mut t = f64::midpoint(a, b);
578        let (d, pole) = off(t)?;
579        // A hit closer to an end of the range than its samples can stand
580        // apart is at that end.
581        let apart = resolution * f64::from(SAMPLES);
582        if t - range.0 <= apart {
583            t = range.0;
584        } else if range.1 - t <= apart {
585            t = range.1;
586        }
587        if d <= cap && hits.last().is_none_or(|h| t - h.0 > apart) {
588            hits.push((t, pole));
589        }
590    }
591    if hits.is_empty() {
592        return Ok(None);
593    }
594    let period = core::f64::consts::TAU;
595    let longitude = |v: ogeom_math::Vector| v.dot(y).atan2(v.dot(x));
596    let chart = |p: Point| {
597        let l = p - sphere.centre();
598        let (a, b, c) = (l.dot(x), l.dot(y), l.dot(z));
599        Point2::new(b.atan2(a), c.atan2(a.hypot(b)))
600    };
601    let mut cuts: Vec<f64> = vec![range.0];
602    cuts.extend(hits.iter().map(|h| h.0));
603    cuts.push(range.1);
604    let mut pieces = Vec::new();
605    for pair in cuts.windows(2) {
606        let (lo, hi) = (pair[0], pair[1]);
607        if hi <= lo {
608            continue;
609        }
610        // The pole ends, at the longitude the curve leaves or arrives
611        // along: its tangent, read in the sphere's frame.
612        let mut ends: Vec<(f64, Point2)> = Vec::new();
613        for &(t, pole) in &hits {
614            if t == lo {
615                ends.push((
616                    t,
617                    Point2::new(longitude(curve.d1_at(t, tol)?), poles[pole].1),
618                ));
619            } else if t == hi {
620                ends.push((
621                    t,
622                    Point2::new(longitude(-curve.d1_at(t, tol)?), poles[pole].1),
623                ));
624            }
625        }
626        let raw = |t: f64| -> OgeomResult<Point2> {
627            match ends.iter().find(|e| e.0 == t) {
628                Some(e) => Ok(e.1),
629                None => Ok(chart(curve.point_at(t, tol)?)),
630            }
631        };
632        let near = |uv: Point2, guide: Point2| {
633            Point2::new(
634                ((guide.x - uv.x) / period).round().mul_add(period, uv.x),
635                uv.y,
636            )
637        };
638        // The chart positions continued along the piece, at samples close
639        // enough that the longitude never turns a quarter period between
640        // two of them where the curve stays off the pole.
641        let ts: Vec<f64> = (0..=SAMPLES)
642            .map(|k| (hi - lo).mul_add(f64::from(k) / f64::from(SAMPLES), lo))
643            .collect();
644        let mut table: Vec<Point2> = Vec::with_capacity(ts.len());
645        for &t in &ts {
646            let uv = raw(t)?;
647            table.push(match table.last() {
648                Some(previous) => near(uv, *previous),
649                None => uv,
650            });
651        }
652        let continued = |t: f64| -> OgeomResult<Point2> {
653            let k = ts.partition_point(|s| *s <= t).clamp(1, ts.len() - 1);
654            let f = ((t - ts[k - 1]) / (ts[k] - ts[k - 1])).clamp(0.0, 1.0);
655            let guide = table[k - 1] + (table[k] - table[k - 1]) * f;
656            Ok(near(raw(t)?, guide))
657        };
658        let (fitted, image) = ogeom_geom::fit::fit_trace_sampled(
659            |t| Ok((curve.point_at(t, tol)?, continued(t)?)),
660            |uv| surface.point_at(uv.x, uv.y, tol),
661            &ts,
662            3,
663            cap,
664            tol,
665        )?;
666        if !fitted.met {
667            ogeom_bail!(
668                Construction,
669                "a curve to split along runs through the sphere's pole, and its trace \
670                 there misses it by {:.3e}, over the {cap:.3e} it may",
671                fitted.error
672            );
673        }
674        pieces.push(((lo, hi), image.into(), fitted.error));
675    }
676    Ok(Some(pieces))
677}
678
679/// A segment, its tolerance measured: how far the curve and the surface
680/// along its trace part, and no less than the fit's own error.
681fn segment(
682    edge: &Shape,
683    curve: Curve,
684    pcurve: PlanarCurve,
685    range: (f64, f64),
686    fit: f64,
687    surface: &SurfaceGeometry,
688    tol: Tolerances,
689) -> OgeomResult<Segment> {
690    let mut gap = fit;
691    for k in 0..=64 {
692        let t = (range.1 - range.0).mul_add(f64::from(k) / 64.0, range.0);
693        let uv = pcurve.point_at(t, tol)?;
694        let on = surface.point_at(uv.x, uv.y, tol)?;
695        gap = gap.max(on.distance(curve.point_at(t, tol)?));
696    }
697    Ok(Segment {
698        source: edge.clone(),
699        curve,
700        pcurve,
701        range,
702        backwards: false,
703        tolerance: gap,
704    })
705}
706
707/// The periods of the surface's chart, zero where a direction does not
708/// wrap round. A direction wraps where the chart repeats or where its two
709/// sides are one line of points (a tube swept from a closed B-spline ring):
710/// the surface reads a parameter past either side as one a domain's length
711/// back, so a trace crossing the seam runs on there.
712fn periods(surface: &SurfaceGeometry, tol: Tolerances) -> (f64, f64) {
713    let ((u0, u1), (v0, v1)) = surface.domain();
714    let (wraps_u, wraps_v) = wraps(surface, tol);
715    (
716        if wraps_u { u1 - u0 } else { 0.0 },
717        if wraps_v { v1 - v0 } else { 0.0 },
718    )
719}
720
721/// Whether each direction of the surface's chart wraps round: it repeats,
722/// or it closes on itself.
723fn wraps(surface: &SurfaceGeometry, tol: Tolerances) -> (bool, bool) {
724    (
725        surface.is_periodic_u() || surface.is_closed_u(tol),
726        surface.is_periodic_v() || surface.is_closed_v(tol),
727    )
728}
729
730/// The segment cut where its trace crosses a line a whole number of periods
731/// from the face's window, each part moved by the periods that bring it
732/// into the window. The lines stand half a period from the window's middle,
733/// off a face narrower than a period: one at the window's own edge would cut
734/// a curve entering the face across that edge into a piece on the face and
735/// a piece a period away that never meets it.
736fn unfold(
737    segment: Segment,
738    surface: &SurfaceGeometry,
739    window: Bounds,
740    tol: Tolerances,
741) -> OgeomResult<Vec<Segment>> {
742    let (pu, pv) = periods(surface, tol);
743    let mut parts = vec![segment];
744    for (axis, period, (lo, hi)) in [(0, pu, window.0), (1, pv, window.1)] {
745        if period <= 0.0 || !lo.is_finite() || !hi.is_finite() {
746            continue;
747        }
748        let low = lo - 0.5 * (period - (hi - lo)).max(0.0);
749        // Which copy of the window a chart point is in.
750        let copy = |p: Point2| -> f64 {
751            let x = if axis == 0 { p.x } else { p.y };
752            ((x - low) / period + 1e-9).floor()
753        };
754        let mut next = Vec::new();
755        for part in parts {
756            const N: usize = 128;
757            let mut cuts = vec![0.0];
758            let mut last = copy(part.chart(0.0, tol)?);
759            for i in 1..=N {
760                #[expect(clippy::cast_precision_loss, reason = "a sample index")]
761                let f = i as f64 / N as f64;
762                let here = copy(part.chart(f, tol)?);
763                if here != last {
764                    #[expect(clippy::cast_precision_loss, reason = "a sample index")]
765                    let (mut a, mut b) = ((i - 1) as f64 / N as f64, f);
766                    for _ in 0..60 {
767                        let mid = 0.5 * (a + b);
768                        if copy(part.chart(mid, tol)?) == last {
769                            a = mid;
770                        } else {
771                            b = mid;
772                        }
773                    }
774                    cuts.push(0.5 * (a + b));
775                    last = here;
776                }
777            }
778            cuts.push(1.0);
779            // A crossing within the confusion distance of a neighbouring
780            // one, or of an end (a closed curve starting on a seam the
781            // window's edge stands a hair off), cuts no piece: the part
782            // beside it takes the sliver.
783            let mut kept = vec![0.0];
784            for &c in &cuts[1..] {
785                let from = kept[kept.len() - 1];
786                let sliver = c - from <= 1e-6
787                    && part.point(from, tol)?.distance(part.point(c, tol)?) <= tol.confusion();
788                if !sliver {
789                    kept.push(c);
790                } else if c >= 1.0 {
791                    if kept.len() > 1 {
792                        let last = kept.len() - 1;
793                        kept[last] = 1.0;
794                    } else {
795                        kept.push(1.0);
796                    }
797                }
798            }
799            for w in kept.windows(2) {
800                if w[1] - w[0] <= 1e-9 {
801                    continue;
802                }
803                let piece = part.part(w[0], w[1]);
804                let turns = copy(piece.chart(0.5, tol)?);
805                let by = if axis == 0 {
806                    Vector2::new(-turns * period, 0.0)
807                } else {
808                    Vector2::new(0.0, -turns * period)
809                };
810                next.push(piece.shifted(by, tol)?);
811            }
812        }
813        parts = next;
814    }
815    Ok(parts)
816}
817
818/// The segments chained end to end into paths: two ends join where they
819/// meet in space and their traces meet in the chart, not a period apart.
820fn chain(
821    mut segments: Vec<Segment>,
822    surface: &SurfaceGeometry,
823    tol: Tolerances,
824) -> OgeomResult<Vec<Cutter>> {
825    let (pu, pv) = periods(surface, tol);
826    let meet = |a: &Segment, fa: f64, b: &Segment, fb: f64| -> OgeomResult<bool> {
827        let reach = a.tolerance + b.tolerance + tol.confusion();
828        if a.point(fa, tol)?.distance(b.point(fb, tol)?) > reach {
829            return Ok(false);
830        }
831        let (p, q) = (a.chart(fa, tol)?, b.chart(fb, tol)?);
832        Ok((pu <= 0.0 || (p.x - q.x).abs() < 0.25 * pu)
833            && (pv <= 0.0 || (p.y - q.y).abs() < 0.25 * pv))
834    };
835    let mut out = Vec::new();
836    while let Some(first) = segments.pop() {
837        let mut path = std::collections::VecDeque::from([first]);
838        loop {
839            let mut grown = false;
840            let mut k = 0;
841            while k < segments.len() {
842                let (head, tail) = (&path[0], &path[path.len() - 1]);
843                let s = &segments[k];
844                if meet(tail, 1.0, s, 0.0)? {
845                    path.push_back(segments.swap_remove(k));
846                } else if meet(tail, 1.0, s, 1.0)? {
847                    path.push_back(segments.swap_remove(k).reversed());
848                } else if meet(head, 0.0, s, 1.0)? {
849                    path.push_front(segments.swap_remove(k));
850                } else if meet(head, 0.0, s, 0.0)? {
851                    path.push_front(segments.swap_remove(k).reversed());
852                } else {
853                    k += 1;
854                    continue;
855                }
856                grown = true;
857            }
858            if !grown {
859                break;
860            }
861        }
862        let path: Vec<Segment> = path.into_iter().collect();
863        let closed = meet(&path[path.len() - 1], 1.0, &path[0], 0.0)?;
864        out.push(Cutter {
865            path,
866            closed,
867            own: FastSet::default(),
868            consumed: Vec::new(),
869        });
870    }
871    Ok(out)
872}
873
874// --- projection --------------------------------------------------------------
875
876/// Where `p` lands on the surface, by `projection`, from a nearby landing
877/// where one is known: the point and its chart position. `None` where it
878/// does not land: no hit along the direction, or a nearest point held at the
879/// edge of the surface's window, which is not where the normal through `p`
880/// meets the surface.
881fn land(
882    surface: &SurfaceGeometry,
883    p: Point,
884    seed: Option<Point2>,
885    projection: Projection,
886    tol: Tolerances,
887) -> OgeomResult<Option<(Point, Point2)>> {
888    match projection {
889        Projection::OnFace | Projection::AlongNormals => {
890            let found = match seed
891                .and_then(|s| project_on_surface_from(surface, p, (s.x, s.y), tol).ok())
892            {
893                Some(found) => found,
894                None => project_on_surface(surface, p, 24, tol)?,
895            };
896            let (u, v) = found.parameters;
897            let gap = p - found.point;
898            let d = gap.magnitude();
899            if d > tol.confusion() {
900                let (su, sv) = surface.d1_at(u, v, tol)?;
901                for t in [su, sv] {
902                    let m = t.magnitude();
903                    if m > 0.0 && gap.dot(t).abs() > 1e-7 * m * d {
904                        return Ok(None);
905                    }
906                }
907            }
908            Ok(Some((found.point, Point2::new(u, v))))
909        }
910        Projection::Along(direction) => {
911            let start = match seed {
912                Some(s) => (s.x, s.y),
913                None => project_on_surface(surface, p, 24, tol)?.parameters,
914            };
915            Ok(ray_hit(surface, p, direction.vector(), start, tol)?
916                .map(|(at, u, v)| (at, Point2::new(u, v))))
917        }
918    }
919}
920
921/// Newton on `S(u, v) = p + s d` from `start`, the parameters held to the
922/// surface's window. `None` where it does not converge to a hit inside it.
923fn ray_hit(
924    surface: &SurfaceGeometry,
925    p: Point,
926    d: ogeom_math::Vector,
927    start: (f64, f64),
928    tol: Tolerances,
929) -> OgeomResult<Option<(Point, f64, f64)>> {
930    let ((u0, u1), (v0, v1)) = surface.domain();
931    let (wraps_u, wraps_v) = wraps(surface, tol);
932    let hold = |x: f64, lo: f64, hi: f64, periodic: bool| {
933        if periodic && hi > lo {
934            lo + (x - lo).rem_euclid(hi - lo)
935        } else {
936            x.clamp(lo, hi)
937        }
938    };
939    let (mut u, mut v) = start;
940    let mut s = (surface.point_at(u, v, tol)? - p).dot(d);
941    for _ in 0..64 {
942        let (at, su, sv) = surface.point_d1_at(u, v, tol)?;
943        let f = at - (p + d * s);
944        if f.magnitude() <= tol.confusion() * 1e-3 {
945            return Ok(Some((at, u, v)));
946        }
947        let j = nalgebra::Matrix3::new(su.x, sv.x, -d.x, su.y, sv.y, -d.y, su.z, sv.z, -d.z);
948        let Some(step) = j.lu().solve(&nalgebra::Vector3::new(-f.x, -f.y, -f.z)) else {
949            return Ok(None);
950        };
951        u = hold(u + step[0], u0, u1, wraps_u);
952        v = hold(v + step[1], v0, v1, wraps_v);
953        s += step[2];
954    }
955    let at = surface.point_at(u, v, tol)?;
956    if at.distance(p + d * s) <= tol.confusion() {
957        return Ok(Some((at, u, v)));
958    }
959    Ok(None)
960}
961
962/// The parameter spans of the curve that land on the surface, each run's
963/// ends pressed to where the landing stops, with the chart point its start
964/// landed on.
965fn landed_runs(
966    curve: &Curve,
967    range: (f64, f64),
968    surface: &SurfaceGeometry,
969    projection: Projection,
970    tol: Tolerances,
971) -> OgeomResult<Vec<((f64, f64), Point2)>> {
972    const STATIONS: usize = 128;
973    let mut stations: Vec<(f64, Option<Point2>)> = Vec::with_capacity(STATIONS + 1);
974    let mut seed = None;
975    for k in 0..=STATIONS {
976        #[expect(
977            clippy::cast_precision_loss,
978            reason = "a station index, far below the mantissa"
979        )]
980        let t = (range.1 - range.0).mul_add(k as f64 / STATIONS as f64, range.0);
981        let landed = land(surface, curve.point_at(t, tol)?, seed, projection, tol)?;
982        seed = landed.map(|(_, uv)| uv);
983        stations.push((t, seed));
984    }
985    // Between a station that lands and one that does not, bisected to
986    // where the landing stops.
987    let edge_of = |inside: (f64, Point2), outside: f64| -> OgeomResult<(f64, Point2)> {
988        let (mut good, mut seed) = inside;
989        let mut bad = outside;
990        for _ in 0..60 {
991            let mid = 0.5 * (good + bad);
992            match land(
993                surface,
994                curve.point_at(mid, tol)?,
995                Some(seed),
996                projection,
997                tol,
998            )? {
999                Some((_, uv)) => (good, seed) = (mid, uv),
1000                None => bad = mid,
1001            }
1002        }
1003        Ok((good, seed))
1004    };
1005    let mut runs = Vec::new();
1006    let mut k = 0;
1007    while k < stations.len() {
1008        let Some(uv) = stations[k].1 else {
1009            k += 1;
1010            continue;
1011        };
1012        let first = k;
1013        while k + 1 < stations.len() && stations[k + 1].1.is_some() {
1014            k += 1;
1015        }
1016        let (lo, seed) = if first == 0 {
1017            (range.0, uv)
1018        } else {
1019            edge_of((stations[first].0, uv), stations[first - 1].0)?
1020        };
1021        let hi = match (stations[k].1, stations.get(k + 1)) {
1022            (Some(last), Some(next)) => edge_of((stations[k].0, last), next.0)?.0,
1023            _ => range.1,
1024        };
1025        if hi - lo > (range.1 - range.0) * 1e-9 {
1026            runs.push(((lo, hi), seed));
1027        }
1028        k += 1;
1029    }
1030    Ok(runs)
1031}
1032
1033/// The curve through the landings of `span` of the edge, fitted together
1034/// with its chart trace, the first landing sought from `start`.
1035fn projected(
1036    edge: &Shape,
1037    curve: &Curve,
1038    span: (f64, f64),
1039    start: Point2,
1040    surface: &SurfaceGeometry,
1041    projection: Projection,
1042    tol: Tolerances,
1043) -> OgeomResult<Segment> {
1044    const SAMPLES: usize = 96;
1045    let mut points = Vec::with_capacity(SAMPLES + 1);
1046    let mut chart: Vec<Point2> = Vec::with_capacity(SAMPLES + 1);
1047    let mut seed = Some(start);
1048    for k in 0..=SAMPLES {
1049        #[expect(
1050            clippy::cast_precision_loss,
1051            reason = "a sample index, far below the mantissa"
1052        )]
1053        let t = (span.1 - span.0).mul_add(k as f64 / SAMPLES as f64, span.0);
1054        // A run's end is where the landing stops, found to rounding; it is
1055        // read a hair inside where it falls on the wrong side of that line.
1056        let inward = (span.1 - span.0) * if k == 0 { 1e-12 } else { -1e-12 };
1057        let tries = if k == 0 || k == SAMPLES { 16 } else { 0 };
1058        let mut landed = None;
1059        for step in 0..=tries {
1060            let at = curve.point_at(f64::from(step).mul_add(inward, t), tol)?;
1061            landed = land(surface, at, seed, projection, tol)?;
1062            if landed.is_some() {
1063                break;
1064            }
1065        }
1066        let Some((at, uv)) = landed else {
1067            ogeom_bail!(
1068                Construction,
1069                "the projection of an edge breaks off between two landings"
1070            );
1071        };
1072        points.push(at);
1073        chart.push(uv);
1074        seed = Some(uv);
1075    }
1076    unwrap(&mut chart, surface, tol);
1077    let (fitted, trace, _) =
1078        ogeom_geom::fit::fit_points_joint(&points, &chart, &chart, 3, tol.confusion(), tol)?;
1079    let error = fitted.error;
1080    let fitted: Curve = Curve::BSpline(fitted.curve);
1081    let range = fitted.domain();
1082    segment(
1083        edge,
1084        fitted,
1085        PlanarCurve::BSpline(trace),
1086        range,
1087        error,
1088        surface,
1089        tol,
1090    )
1091}
1092
1093/// Undo the folding a chart that wraps round applies: every step longer
1094/// than half a period is that period the other way.
1095fn unwrap(chart: &mut [Point2], surface: &SurfaceGeometry, tol: Tolerances) {
1096    let (pu, pv) = periods(surface, tol);
1097    for k in 1..chart.len() {
1098        let previous = chart[k - 1];
1099        let here = &mut chart[k];
1100        if pu > 0.0 {
1101            here.x -= ((here.x - previous.x) / pu).round() * pu;
1102        }
1103        if pv > 0.0 {
1104            here.y -= ((here.y - previous.y) / pv).round() * pv;
1105        }
1106    }
1107}
1108
1109// --- where a path meets a face -----------------------------------------------
1110
1111/// Where a path meets a face's boundary.
1112#[derive(Debug, Clone)]
1113struct Meeting {
1114    /// The path's parameter.
1115    t: f64,
1116    /// The path's chart point there.
1117    at: Point2,
1118    on: On,
1119}
1120
1121#[derive(Debug, Clone)]
1122enum On {
1123    /// At a vertex of the boundary.
1124    Vertex(Shape),
1125    /// Inside a boundary edge (forward), a fraction of the way along it.
1126    Inside(Shape, f64),
1127}
1128
1129/// One cut to make.
1130#[derive(Debug, Clone)]
1131enum Stretch {
1132    /// From one boundary meeting to the next along the path.
1133    Open(Meeting, Meeting),
1134    /// The whole closed path, inside the face.
1135    Loop,
1136}
1137
1138/// A stretch with its path's parameter span.
1139struct Planned {
1140    stretch: Stretch,
1141    span: (f64, f64),
1142}
1143
1144enum Found {
1145    Nothing,
1146    /// The path ends inside the face.
1147    Dangling,
1148    /// The closed path meets the boundary at this parameter: walk it from
1149    /// there.
1150    Rebase(f64),
1151    Cut(Planned),
1152}
1153
1154/// The face's boundary in its chart.
1155struct Chart {
1156    rings: Vec<Vec<Occurrence>>,
1157    outlines: Vec<Vec<Point2>>,
1158    /// Chart resolution.
1159    eps: f64,
1160}
1161
1162fn chart_of(model: &Model, face: &Shape, tol: Tolerances) -> OgeomResult<Chart> {
1163    let face_fwd = forward(face);
1164    let data = face_data(model, &face_fwd)?;
1165    let rings = rings(model, &face_fwd, data.surface, tol)?;
1166    let bounds = chart_bounds(&rings, tol);
1167    let span = (bounds.0.1 - bounds.0.0).max(bounds.1.1 - bounds.1.0);
1168    let outlines = rings
1169        .iter()
1170        .map(|ring| -> OgeomResult<Vec<Point2>> {
1171            let mut out = Vec::new();
1172            for o in ring {
1173                let mut line = o.polyline(64, tol)?;
1174                line.pop();
1175                out.extend(line);
1176            }
1177            Ok(out)
1178        })
1179        .collect::<OgeomResult<_>>()?;
1180    Ok(Chart {
1181        rings,
1182        outlines,
1183        eps: span * 1e-9 + tol.parametric(),
1184    })
1185}
1186
1187/// The next stretch of `cutter` to cut in `face`, if any.
1188fn next_stretch(
1189    model: &Model,
1190    face: &Shape,
1191    cutter: &Cutter,
1192    surface: &SurfaceGeometry,
1193    tol: Tolerances,
1194) -> OgeomResult<Found> {
1195    let chart = chart_of(model, face, tol)?;
1196    let meetings = meetings(model, &chart, cutter, surface, tol)?;
1197    let t_eps = cutter.resolution(tol);
1198    let (lo, hi) = cutter.range();
1199
1200    // Whether the stretch `(a, b)` lies inside the face, refusing one that
1201    // runs along its boundary.
1202    let within = |a: f64, b: f64| -> OgeomResult<bool> {
1203        let mid = 0.5 * (a + b);
1204        let at = cutter.chart(mid, tol)?;
1205        let p = cutter.point(mid, tol)?;
1206        for o in chart.rings.iter().flatten() {
1207            if cutter.own.contains(&o.edge.node()) {
1208                continue;
1209            }
1210            let (_, near) = nearest_on(o, at, tol)?;
1211            let reach = boundary_reach(model, o, cutter, tol)?;
1212            if surface.point_at(near.x, near.y, tol)?.distance(p) <= reach {
1213                ogeom_bail!(
1214                    Construction,
1215                    "a curve to split along runs along the face's boundary"
1216                );
1217            }
1218        }
1219        Ok(inside(&chart.outlines, at))
1220    };
1221
1222    if meetings.is_empty() {
1223        if cutter.is_consumed(lo, hi) || !within(lo, hi)? {
1224            return Ok(Found::Nothing);
1225        }
1226        if cutter.closed {
1227            return Ok(Found::Cut(Planned {
1228                stretch: Stretch::Loop,
1229                span: (lo, hi),
1230            }));
1231        }
1232        return Ok(Found::Dangling);
1233    }
1234    if cutter.closed {
1235        return Ok(Found::Rebase(meetings[0].t));
1236    }
1237    // A span whose ends and middle stand within the path's own reach of
1238    // each other is no stretch: a closed path walked from where it crosses
1239    // a seam meets the seam's two sides a hair apart, at its start and
1240    // again just after. A path closing on itself has its ends together
1241    // too, and its middle far off.
1242    let negligible = |a: f64, b: f64| -> OgeomResult<bool> {
1243        if b - a <= t_eps {
1244            return Ok(true);
1245        }
1246        let reach = cutter.tolerance() + tol.confusion() * 10.0;
1247        let start = cutter.point(a, tol)?;
1248        Ok(start.distance(cutter.point(b, tol)?) <= reach
1249            && start.distance(cutter.point(f64::midpoint(a, b), tol)?) <= reach)
1250    };
1251    let (first, last) = (&meetings[0], &meetings[meetings.len() - 1]);
1252    for (a, b) in [(lo, first.t), (last.t, hi)] {
1253        if !negligible(a, b)? && !cutter.is_consumed(a, b) && within(a, b)? {
1254            return Ok(Found::Dangling);
1255        }
1256    }
1257    for pair in meetings.windows(2) {
1258        let (a, b) = (&pair[0], &pair[1]);
1259        if negligible(a.t, b.t)? || cutter.is_consumed(a.t, b.t) {
1260            continue;
1261        }
1262        if within(a.t, b.t)? {
1263            return Ok(Found::Cut(Planned {
1264                stretch: Stretch::Open(a.clone(), b.clone()),
1265                span: (a.t, b.t),
1266            }));
1267        }
1268    }
1269    Ok(Found::Nothing)
1270}
1271
1272/// How near a path must come to a boundary edge to be on it.
1273fn boundary_reach(
1274    model: &Model,
1275    o: &Occurrence,
1276    cutter: &Cutter,
1277    tol: Tolerances,
1278) -> OgeomResult<f64> {
1279    Ok(edge_data(model, &o.edge)?.tolerance.get() + cutter.tolerance() + tol.confusion())
1280}
1281
1282/// The fraction along `o` nearest the chart point `p`, and its point.
1283fn nearest_on(o: &Occurrence, p: Point2, tol: Tolerances) -> OgeomResult<(f64, Point2)> {
1284    const N: usize = 64;
1285    let line = o.polyline(N, tol)?;
1286    let mut best = (0, f64::INFINITY);
1287    for (i, q) in line.iter().enumerate() {
1288        let d = q.distance(p);
1289        if d < best.1 {
1290            best = (i, d);
1291        }
1292    }
1293    #[expect(clippy::cast_precision_loss, reason = "a sample index")]
1294    let step = 1.0 / N as f64;
1295    #[expect(clippy::cast_precision_loss, reason = "a sample index")]
1296    let centre = best.0 as f64 * step;
1297    let (mut a, mut b) = ((centre - step).max(0.0), (centre + step).min(1.0));
1298    let g = 0.5 * (5.0_f64.sqrt() - 1.0);
1299    let far = |s: f64| -> OgeomResult<f64> { Ok(o.at(s, tol)?.distance(p)) };
1300    for _ in 0..80 {
1301        let (x, y) = (b - g * (b - a), a + g * (b - a));
1302        if far(x)? <= far(y)? {
1303            b = y;
1304        } else {
1305            a = x;
1306        }
1307    }
1308    let s = 0.5 * (a + b);
1309    Ok((s, o.at(s, tol)?))
1310}
1311
1312/// Every meeting of the path with the face's boundary, in order along the
1313/// path, one per point.
1314fn meetings(
1315    model: &Model,
1316    chart: &Chart,
1317    cutter: &Cutter,
1318    surface: &SurfaceGeometry,
1319    tol: Tolerances,
1320) -> OgeomResult<Vec<Meeting>> {
1321    const ALONG: usize = 128;
1322    const ACROSS: usize = 64;
1323    let (lo, hi) = cutter.range();
1324    let samples = ALONG * cutter.path.len();
1325    let track: Vec<(f64, Point2)> = (0..=samples)
1326        .map(|i| {
1327            #[expect(clippy::cast_precision_loss, reason = "a sample index")]
1328            let t = (hi - lo).mul_add(i as f64 / samples as f64, lo);
1329            Ok((t, cutter.chart(t, tol)?))
1330        })
1331        .collect::<OgeomResult<_>>()?;
1332    let mut found: Vec<Meeting> = Vec::new();
1333    for o in chart.rings.iter().flatten() {
1334        if cutter.own.contains(&o.edge.node()) {
1335            continue;
1336        }
1337        let line = o.polyline(ACROSS, tol)?;
1338        for j in 1..line.len() {
1339            for i in 1..track.len() {
1340                let Some((x, y)) =
1341                    segments_cross((track[i - 1].1, track[i].1), (line[j - 1], line[j]))
1342                else {
1343                    continue;
1344                };
1345                let t = (track[i].0 - track[i - 1].0).mul_add(x, track[i - 1].0);
1346                #[expect(clippy::cast_precision_loss, reason = "a sample index")]
1347                let s = ((j - 1) as f64 + y) / ACROSS as f64;
1348                if let Some((t, s)) = polish(cutter, o, t, s, chart.eps, tol)? {
1349                    found.push(meeting(model, o, cutter, t, s, tol)?);
1350                }
1351            }
1352        }
1353        // An open path's ends, where they stand on this edge.
1354        if !cutter.closed {
1355            let reach = boundary_reach(model, o, cutter, tol)?;
1356            for t in [lo, hi] {
1357                let at = cutter.chart(t, tol)?;
1358                let (s, near) = nearest_on(o, at, tol)?;
1359                let p = cutter.point(t, tol)?;
1360                if surface.point_at(near.x, near.y, tol)?.distance(p) <= reach {
1361                    found.push(meeting(model, o, cutter, t, s, tol)?);
1362                }
1363            }
1364        }
1365    }
1366    found.sort_by(|a, b| a.t.total_cmp(&b.t));
1367    // One meeting per point: a crossing found on two edges at their shared
1368    // vertex is that vertex.
1369    let t_eps = cutter.resolution(tol);
1370    let mut out: Vec<Meeting> = Vec::new();
1371    for m in found {
1372        match out.last_mut() {
1373            Some(held) if (m.t - held.t).abs() <= t_eps * 1e3 => {
1374                if matches!(m.on, On::Vertex(_)) {
1375                    *held = m;
1376                }
1377            }
1378            _ => out.push(m),
1379        }
1380    }
1381    on_pole_rows(model, chart, &mut out, tol)?;
1382    Ok(out)
1383}
1384
1385/// Meetings the path makes with one pole at two places of the pole's row,
1386/// read as inside the pole's degenerate edge.
1387///
1388/// A pole is one point in space, so every meeting with it stands on its
1389/// vertex there; in the chart the path reaches the pole's row at the
1390/// longitude it arrives along, and a path through a pole and back to it
1391/// (a circle through it) reaches the row at two longitudes. The region
1392/// it closes runs along the row between them, so the row is cut at each,
1393/// as any other boundary edge is. A path meeting a pole once is left at
1394/// its vertex.
1395fn on_pole_rows(
1396    model: &Model,
1397    chart: &Chart,
1398    out: &mut [Meeting],
1399    tol: Tolerances,
1400) -> OgeomResult<()> {
1401    for i in 0..out.len() {
1402        for j in i + 1..out.len() {
1403            let (On::Vertex(a), On::Vertex(b)) = (&out[i].on, &out[j].on) else {
1404                continue;
1405            };
1406            if a.node() != b.node() || out[i].at.distance(out[j].at) <= chart.eps {
1407                continue;
1408            }
1409            let v = a.node();
1410            for o in chart.rings.iter().flatten() {
1411                let edge = forward(&o.edge);
1412                if !edge_data(model, &edge)?.degenerate {
1413                    continue;
1414                }
1415                let at_pole = edge_vertices(model, &edge)?
1416                    .is_some_and(|(first, last)| first.node() == v || last.node() == v);
1417                if !at_pole {
1418                    continue;
1419                }
1420                let mut reread = Vec::with_capacity(2);
1421                for k in [i, j] {
1422                    let (s, near) = nearest_on(o, out[k].at, tol)?;
1423                    if near.distance(out[k].at) > chart.eps {
1424                        break;
1425                    }
1426                    let forward_s = if o.reversed() { 1.0 - s } else { s };
1427                    reread.push((k, (forward_s * 1e12).round() / 1e12));
1428                }
1429                if reread.len() == 2 {
1430                    for (k, s) in reread {
1431                        out[k].on = On::Inside(edge.clone(), s);
1432                    }
1433                    break;
1434                }
1435            }
1436        }
1437    }
1438    Ok(())
1439}
1440
1441/// Newton on `path(t) = o(s)` from a seed, in the chart.
1442fn polish(
1443    cutter: &Cutter,
1444    o: &Occurrence,
1445    mut t: f64,
1446    mut s: f64,
1447    eps: f64,
1448    tol: Tolerances,
1449) -> OgeomResult<Option<(f64, f64)>> {
1450    let (lo, hi) = cutter.range();
1451    let h = 1e-7;
1452    for _ in 0..40 {
1453        let f = cutter.chart(t, tol)? - o.at(s, tol)?;
1454        if f.magnitude() <= eps * 1e-3 {
1455            break;
1456        }
1457        let dc = cutter.chart_d1(t, tol)?;
1458        let (s0, s1) = ((s - h).max(0.0), (s + h).min(1.0));
1459        let dq = (o.at(s1, tol)? - o.at(s0, tol)?) / (s1 - s0);
1460        // Solve [dc, -dq] (dt, ds) = -f.
1461        let det = dc.x.mul_add(-dq.y, dq.x * dc.y);
1462        if det.abs() <= f64::MIN_POSITIVE {
1463            return Ok(None);
1464        }
1465        let dt = (-f.x).mul_add(-dq.y, dq.x * (-f.y)) / det;
1466        let ds = dc.x.mul_add(-f.y, f.x * dc.y) / det;
1467        t = (t + dt).clamp(lo, hi);
1468        s = (s + ds).clamp(0.0, 1.0);
1469    }
1470    let f = cutter.chart(t, tol)? - o.at(s, tol)?;
1471    Ok((f.magnitude() <= eps).then_some((t, s)))
1472}
1473
1474/// The meeting a fraction `s` along `o`, at the path's `t`: a vertex where
1475/// it stands on one.
1476fn meeting(
1477    model: &Model,
1478    o: &Occurrence,
1479    cutter: &Cutter,
1480    t: f64,
1481    s: f64,
1482    tol: Tolerances,
1483) -> OgeomResult<Meeting> {
1484    let at = cutter.chart(t, tol)?;
1485    let edge = forward(&o.edge);
1486    let forward_s = if o.reversed() { 1.0 - s } else { s };
1487    // Rounded so two traversals of one seam name the same point.
1488    let forward_s = (forward_s * 1e12).round() / 1e12;
1489    let p = cutter.point(t, tol)?;
1490    if let Some((first, last)) = edge_vertices(model, &edge)? {
1491        for v in [first, last] {
1492            let held = model
1493                .node(&v)
1494                .and_then(|n| n.data().as_vertex())
1495                .map_or(0.0, |d| d.tolerance.get());
1496            if vertex_point(model, &v)?.distance(p) <= held + cutter.tolerance() + tol.confusion() {
1497                return Ok(Meeting {
1498                    t,
1499                    at,
1500                    on: On::Vertex(v),
1501                });
1502            }
1503        }
1504    }
1505    Ok(Meeting {
1506        t,
1507        at,
1508        on: On::Inside(edge, forward_s),
1509    })
1510}
1511
1512/// Where two chart segments cross, as fractions along each, their ends
1513/// included.
1514fn segments_cross(a: (Point2, Point2), b: (Point2, Point2)) -> Option<(f64, f64)> {
1515    let da = a.1 - a.0;
1516    let db = b.1 - b.0;
1517    let cross = da.x.mul_add(db.y, -(da.y * db.x));
1518    if cross.abs() <= f64::MIN_POSITIVE {
1519        return None;
1520    }
1521    let w = b.0 - a.0;
1522    let x = w.x.mul_add(db.y, -(w.y * db.x)) / cross;
1523    let y = w.x.mul_add(da.y, -(w.y * da.x)) / cross;
1524    let slack = 1e-9;
1525    ((-slack..=1.0 + slack).contains(&x) && (-slack..=1.0 + slack).contains(&y))
1526        .then_some((x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)))
1527}
1528
1529// --- seams -------------------------------------------------------------------
1530
1531/// The pieces of the face that a piece of its own seam parts, joined into
1532/// one face each: a seam's two sides bound one face, and a region the seam
1533/// runs through is one face whose boundary jumps a whole period where it
1534/// crosses the seam, as a boundary read through the chart may.
1535///
1536/// Pieces are joined along a tree of the seam pieces between them, each
1537/// dropped from the joined ring; a seam piece the ring then walks both ways
1538/// in a row (one running into a corner where two seams meet, inside the
1539/// region) is dropped too. Pieces are left as they are where a seam piece
1540/// between them lies on a hole, is bounded by a face outside them, or stays
1541/// in the joined ring, or where the ring would walk any edge twice other
1542/// than down a seam's two sides (a cut running the length of a drum, whose
1543/// two sides would bound one face).
1544fn join_across_seams(
1545    model: &mut Model,
1546    current: Built,
1547    pieces: &[Shape],
1548    surface: SurfaceId,
1549    tol: Tolerances,
1550) -> OgeomResult<Built> {
1551    // A seam whose two sides stand a period apart along a direction the
1552    // surface repeats in: a ring crossing it reads on, a period over. On a
1553    // chart that only closes, a ring crossing its join has nowhere to go.
1554    let repeats = model
1555        .geometry()
1556        .surface(surface)
1557        .map_or([false; 2], |s| [s.is_periodic_u(), s.is_periodic_v()]);
1558    let seam = |model: &Model, edge: &Shape| -> OgeomResult<bool> {
1559        let data = edge_data(model, edge)?;
1560        let Some(EdgeRepr::Seam {
1561            forward,
1562            reversed,
1563            range,
1564            ..
1565        }) = data.pcurve_for(surface, edge.location())
1566        else {
1567            return Ok(false);
1568        };
1569        let (Some(a), Some(b)) = (
1570            model.geometry().pcurve(*forward),
1571            model.geometry().pcurve(*reversed),
1572        ) else {
1573            return Ok(false);
1574        };
1575        let middle = f64::midpoint(range.0, range.1);
1576        let apart = b.point_at(middle, tol)? - a.point_at(middle, tol)?;
1577        Ok(if apart.x.abs() >= apart.y.abs() {
1578            repeats[0]
1579        } else {
1580            repeats[1]
1581        })
1582    };
1583    // Each piece's rings as its node walks them, the outer one first.
1584    let mut rings: Vec<Vec<Vec<Shape>>> = Vec::with_capacity(pieces.len());
1585    for piece in pieces {
1586        let mut own = Vec::new();
1587        for wire in model.children_of(&forward(piece))? {
1588            own.push(model.ordered_children_of(&wire)?);
1589        }
1590        rings.push(own);
1591    }
1592    let mut bounding: FastMap<TShapeId, usize> = FastMap::default();
1593    for face in explore_unique(model, &current.shape, ShapeType::Face)? {
1594        for edge in explore_unique(model, &face, ShapeType::Edge)? {
1595            *bounding.entry(edge.node()).or_default() += 1;
1596        }
1597    }
1598    let mut uses: FastMap<TShapeId, Vec<(usize, usize, &Shape)>> = FastMap::default();
1599    let mut order: Vec<TShapeId> = Vec::new();
1600    for (i, own) in rings.iter().enumerate() {
1601        for (r, ring) in own.iter().enumerate() {
1602            for edge in ring {
1603                let at = uses.entry(edge.node()).or_default();
1604                if at.is_empty() {
1605                    order.push(edge.node());
1606                }
1607                at.push((i, r, edge));
1608            }
1609        }
1610    }
1611    // The seam pieces two pieces of one sense each walk once, opposite ways,
1612    // on their outer rings, and no other face bounds.
1613    let mut parting: FastMap<TShapeId, (usize, usize)> = FastMap::default();
1614    for node in &order {
1615        let [(i, 0, a), (j, 0, b)] = uses[node].as_slice() else {
1616            continue;
1617        };
1618        if i != j
1619            && pieces[*i].orientation() == pieces[*j].orientation()
1620            && a.orientation() != b.orientation()
1621            && bounding.get(node) == Some(&2)
1622            && seam(model, a)?
1623        {
1624            parting.insert(*node, (*i, *j));
1625        }
1626    }
1627    if parting.is_empty() {
1628        return Ok(current);
1629    }
1630
1631    let mut group: Vec<usize> = (0..pieces.len()).collect();
1632    fn root(group: &mut [usize], mut i: usize) -> usize {
1633        while group[i] != i {
1634            group[i] = group[group[i]];
1635            i = group[i];
1636        }
1637        i
1638    }
1639    for node in &order {
1640        if let Some(&(i, j)) = parting.get(node) {
1641            let (ri, rj) = (root(&mut group, i), root(&mut group, j));
1642            group[ri] = rj;
1643        }
1644    }
1645    let mut reshape = crate::Reshape::new();
1646    for keeper in 0..pieces.len() {
1647        let members: Vec<usize> = (0..pieces.len())
1648            .filter(|&k| root(&mut group, k) == root(&mut group, keeper))
1649            .collect();
1650        if members.len() < 2 || members[0] != keeper {
1651            continue;
1652        }
1653        // Splice each member's outer ring into the ring at the seam piece
1654        // it shares with a member already joined.
1655        let mut ring = rings[keeper][0].clone();
1656        let mut joined: FastSet<usize> = FastSet::default();
1657        joined.insert(keeper);
1658        let mut k = 0;
1659        while k < ring.len() {
1660            let other = parting
1661                .get(&ring[k].node())
1662                .and_then(|&(i, j)| [i, j].into_iter().find(|m| !joined.contains(m)));
1663            let Some(other) = other else {
1664                k += 1;
1665                continue;
1666            };
1667            let theirs = &rings[other][0];
1668            let Some(at) = theirs.iter().position(|e| e.node() == ring[k].node()) else {
1669                break;
1670            };
1671            let spliced: Vec<Shape> = (1..theirs.len())
1672                .map(|s| theirs[(at + s) % theirs.len()].clone())
1673                .collect();
1674            ring.splice(k..=k, spliced);
1675            joined.insert(other);
1676        }
1677        // A seam piece walked out and straight back runs into a corner of
1678        // seams inside the region, and goes with it.
1679        let mut kept: Vec<Shape> = Vec::with_capacity(ring.len());
1680        for edge in ring {
1681            match kept.last() {
1682                Some(last)
1683                    if last.node() == edge.node()
1684                        && last.orientation() != edge.orientation()
1685                        && parting.contains_key(&edge.node()) =>
1686                {
1687                    kept.pop();
1688                }
1689                _ => kept.push(edge),
1690            }
1691        }
1692        while kept.len() > 1 {
1693            let (first, last) = (&kept[0], &kept[kept.len() - 1]);
1694            if first.node() == last.node()
1695                && first.orientation() != last.orientation()
1696                && parting.contains_key(&first.node())
1697            {
1698                kept.pop();
1699                kept.remove(0);
1700            } else {
1701                break;
1702            }
1703        }
1704        let mut walked: FastMap<TShapeId, usize> = FastMap::default();
1705        for edge in &kept {
1706            *walked.entry(edge.node()).or_default() += 1;
1707        }
1708        let mut sound = joined.len() == members.len() && !kept.is_empty();
1709        for edge in &kept {
1710            if !sound {
1711                break;
1712            }
1713            let times = walked[&edge.node()];
1714            sound = !parting.contains_key(&edge.node())
1715                && (times == 1 || (times == 2 && seam(model, edge)?));
1716        }
1717        if !sound {
1718            continue;
1719        }
1720        let mut wires = vec![model.add_wire(&kept)?];
1721        for &m in &members {
1722            wires.extend(model.children_of(&forward(&pieces[m]))?.into_iter().skip(1));
1723        }
1724        let Some(NodeData::Face(data)) = model.node(&pieces[keeper]).map(|n| n.data().clone())
1725        else {
1726            ogeom_bail!(Dangling, "a piece of the face is not in this model");
1727        };
1728        let mut data = *data;
1729        data.triangulation = None;
1730        let face = model.add_face(data, &wires)?;
1731        reshape.replace(&forward(&pieces[keeper]), face);
1732        for &m in &members[1..] {
1733            reshape.remove(&forward(&pieces[m]));
1734        }
1735    }
1736    if reshape.is_empty() {
1737        return Ok(current);
1738    }
1739    let step = reshape.apply(model, &current.shape)?;
1740    Ok(Built::new(step.shape, current.history.then(&step.history)))
1741}
1742
1743// --- one cut -----------------------------------------------------------------
1744
1745/// What one cut made.
1746struct Made {
1747    reshape: Reshape,
1748    /// The edges cut along, each with the edge it came from.
1749    edges: Vec<(Shape, Shape)>,
1750    /// The boundary edges cut, and their pieces.
1751    splits: Vec<(TShapeId, Vec<Shape>)>,
1752}
1753
1754/// A boundary edge's pieces, each with the fractions of the edge it spans.
1755type Parts = Vec<(Shape, f64, f64)>;
1756
1757/// One directed piece of a ring.
1758#[derive(Debug, Clone)]
1759struct Item {
1760    edge: Shape,
1761    from: TShapeId,
1762    polyline: Vec<Point2>,
1763}
1764
1765/// Cut `face` along one stretch of `cutter`.
1766#[allow(clippy::too_many_lines)]
1767fn cut(
1768    model: &mut Model,
1769    face: &Shape,
1770    cutter: &Cutter,
1771    planned: &Planned,
1772    tol: Tolerances,
1773) -> OgeomResult<Made> {
1774    let face_fwd = forward(face);
1775    let data = face_data(model, &face_fwd)?;
1776    let chart = chart_of(model, face, tol)?;
1777    let mut reshape = Reshape::new();
1778    let mut splits: Vec<(TShapeId, Vec<Shape>)> = Vec::new();
1779
1780    // The boundary edges the stretch ends inside, cut there.
1781    let ends: Vec<Meeting> = match &planned.stretch {
1782        Stretch::Open(a, b) => vec![a.clone(), b.clone()],
1783        Stretch::Loop => Vec::new(),
1784    };
1785    let mut fractions: Vec<(Shape, Vec<f64>)> = Vec::new();
1786    for m in &ends {
1787        if let On::Inside(edge, s) = &m.on {
1788            match fractions.iter_mut().find(|(e, _)| e.node() == edge.node()) {
1789                Some((_, list)) => list.push(*s),
1790                None => fractions.push((edge.clone(), vec![*s])),
1791            }
1792        }
1793    }
1794    let mut pieces_of: Vec<(TShapeId, Parts)> = Vec::new();
1795    let mut vertex_at: Vec<((TShapeId, u64), Shape)> = Vec::new();
1796    for (edge, mut given) in fractions {
1797        given.sort_by(f64::total_cmp);
1798        // Fractions a whisker apart are one cut, made once; each fraction
1799        // given keeps the vertex of the cut it joined.
1800        let mut list: Vec<f64> = Vec::with_capacity(given.len());
1801        let mut joined: Vec<(f64, usize)> = Vec::with_capacity(given.len());
1802        for &s in &given {
1803            if list.last().is_none_or(|last| (s - last).abs() > 1e-12) {
1804                list.push(s);
1805            }
1806            joined.push((s, list.len() - 1));
1807        }
1808        let (pieces, vertices) = split_edge(model, &edge, &list, Some(data.surface), tol)?;
1809        for (s, k) in joined {
1810            vertex_at.push(((edge.node(), s.to_bits()), vertices[k].clone()));
1811        }
1812        let mut bounds = vec![0.0];
1813        bounds.extend(&list);
1814        bounds.push(1.0);
1815        pieces_of.push((
1816            edge.node(),
1817            pieces
1818                .iter()
1819                .zip(bounds.windows(2))
1820                .map(|(p, w)| (p.clone(), w[0], w[1]))
1821                .collect(),
1822        ));
1823        reshape.split(&edge, pieces.clone());
1824        splits.push((edge.node(), pieces));
1825    }
1826    let vertex_of = |m: &Meeting| -> OgeomResult<Shape> {
1827        match &m.on {
1828            On::Vertex(v) => Ok(v.clone()),
1829            On::Inside(edge, s) => vertex_at
1830                .iter()
1831                .find(|(key, _)| *key == (edge.node(), s.to_bits()))
1832                .map(|(_, v)| v.clone())
1833                .ok_or_else(|| ogeom_err!(Construction, "a cut vertex is lost")),
1834        }
1835    };
1836
1837    // The rings as directed pieces.
1838    let mut items: Vec<Vec<Item>> = Vec::new();
1839    for ring in &chart.rings {
1840        let mut out = Vec::new();
1841        for o in ring {
1842            let edge = forward(&o.edge);
1843            let parts = pieces_of
1844                .iter()
1845                .find(|(node, _)| *node == edge.node())
1846                .map_or_else(|| vec![(edge.clone(), 0.0, 1.0)], |(_, p)| p.clone());
1847            let ordered: Parts = if o.reversed() {
1848                parts
1849                    .into_iter()
1850                    .rev()
1851                    .map(|(p, a, b)| (p.reversed(), 1.0 - b, 1.0 - a))
1852                    .collect()
1853            } else {
1854                parts
1855            };
1856            for (piece, a, b) in ordered {
1857                let polyline: Vec<Point2> = (0..=16)
1858                    .map(|i| o.at((b - a).mul_add(f64::from(i) / 16.0, a), tol))
1859                    .collect::<OgeomResult<_>>()?;
1860                let Some((from, _)) = edge_vertices(model, &piece)? else {
1861                    ogeom_bail!(Construction, "a boundary piece has no vertices");
1862                };
1863                out.push(Item {
1864                    edge: piece,
1865                    from: from.node(),
1866                    polyline,
1867                });
1868            }
1869        }
1870        items.push(out);
1871    }
1872    let areas: Vec<f64> = items
1873        .iter()
1874        .map(|ring| signed_area(&outline(ring)))
1875        .collect();
1876    let outer = (0..areas.len())
1877        .max_by(|a, b| areas[*a].abs().total_cmp(&areas[*b].abs()))
1878        .unwrap_or(0);
1879    let outer_sign = areas.get(outer).copied().unwrap_or(1.0).signum();
1880
1881    // The new edges, one per segment the stretch runs through.
1882    let (va, vb) = match &planned.stretch {
1883        Stretch::Open(a, b) => (vertex_of(a)?, vertex_of(b)?),
1884        Stretch::Loop => {
1885            let p = cutter.point(planned.span.0, tol)?;
1886            let v = model.add_vertex(VertexData::new(p));
1887            (v.clone(), v)
1888        }
1889    };
1890    let (along, edges) = cut_edges(model, cutter, planned.span, &va, &vb, data.surface, tol)?;
1891    let back: Vec<Item> = along
1892        .iter()
1893        .rev()
1894        .map(|item| -> OgeomResult<Item> {
1895            let Some((from, _)) = edge_vertices(model, &item.edge.reversed())? else {
1896                ogeom_bail!(Construction, "a cut edge has no vertices");
1897            };
1898            Ok(Item {
1899                edge: item.edge.reversed(),
1900                from: from.node(),
1901                polyline: item.polyline.iter().rev().copied().collect(),
1902            })
1903        })
1904        .collect::<OgeomResult<_>>()?;
1905
1906    // The pieces' rings: (outer, holes).
1907    let mut pieces: Vec<(Vec<Item>, Vec<Vec<Item>>)> = Vec::new();
1908    let probe = |ring: &[Item]| ring[0].polyline[ring[0].polyline.len() / 2];
1909    match &planned.stretch {
1910        Stretch::Open(a, b) => {
1911            let (ra, ia) = junction(&items, va.node(), a.at)?;
1912            let (rb, ib) = junction(&items, vb.node(), b.at)?;
1913            if ra != rb {
1914                ogeom_bail!(
1915                    Construction,
1916                    "a curve running from one boundary loop of the face to \
1917                     another does not divide it"
1918                );
1919            }
1920            let ring = &items[ra];
1921            let n = ring.len();
1922            // The ring from one junction up to the other; where both ends
1923            // are one junction, nothing one way and the whole ring the other.
1924            let arc = |from: usize, to: usize, whole: bool| -> Vec<Item> {
1925                let mut out = Vec::new();
1926                let mut k = from;
1927                while out.len() < n && (k != to || (whole && out.is_empty())) {
1928                    out.push(ring[k].clone());
1929                    k = (k + 1) % n;
1930                }
1931                out
1932            };
1933            let mut first = arc(ia, ib, false);
1934            first.extend(back.iter().cloned());
1935            let mut second = arc(ib, ia, true);
1936            second.extend(along.iter().cloned());
1937            let (s1, s2) = (
1938                signed_area(&outline(&first)) * outer_sign,
1939                signed_area(&outline(&second)) * outer_sign,
1940            );
1941            if ra == outer {
1942                if s1 <= 0.0 || s2 <= 0.0 {
1943                    ogeom_bail!(Construction, "the cut does not divide the face in two");
1944                }
1945                let (mut h1, mut h2) = (Vec::new(), Vec::new());
1946                let line1 = outline(&first);
1947                for (k, hole) in items.iter().enumerate() {
1948                    if k == ra {
1949                        continue;
1950                    }
1951                    if inside(std::slice::from_ref(&line1), probe(hole)) {
1952                        h1.push(hole.clone());
1953                    } else {
1954                        h2.push(hole.clone());
1955                    }
1956                }
1957                pieces.push((first, h1));
1958                pieces.push((second, h2));
1959            } else {
1960                // A cut from a hole back to it: one loop is a new piece,
1961                // the other the hole the rest keeps.
1962                let (fresh, hole) = match (s1 > 0.0, s2 > 0.0) {
1963                    (true, false) => (first, second),
1964                    (false, true) => (second, first),
1965                    _ => ogeom_bail!(Construction, "the cut does not divide the face in two"),
1966                };
1967                let line = outline(&fresh);
1968                let (mut held, mut rest) = (Vec::new(), vec![hole]);
1969                for (k, ring) in items.iter().enumerate() {
1970                    if k == ra || k == outer {
1971                        continue;
1972                    }
1973                    if inside(std::slice::from_ref(&line), probe(ring)) {
1974                        held.push(ring.clone());
1975                    } else {
1976                        rest.push(ring.clone());
1977                    }
1978                }
1979                pieces.push((items[outer].clone(), rest));
1980                pieces.push((fresh, held));
1981            }
1982        }
1983        Stretch::Loop => {
1984            let (disk, hole) = if signed_area(&outline(&along)) * outer_sign > 0.0 {
1985                (along, back)
1986            } else {
1987                (back, along)
1988            };
1989            let line = outline(&disk);
1990            let (mut held, mut rest) = (Vec::new(), vec![hole]);
1991            for (k, ring) in items.iter().enumerate() {
1992                if k == outer {
1993                    continue;
1994                }
1995                if inside(std::slice::from_ref(&line), probe(ring)) {
1996                    held.push(ring.clone());
1997                } else {
1998                    rest.push(ring.clone());
1999                }
2000            }
2001            pieces.push((items[outer].clone(), rest));
2002            pieces.push((disk, held));
2003        }
2004    }
2005
2006    let mut faces = Vec::with_capacity(pieces.len());
2007    for (outer_ring, holes) in pieces {
2008        let mut wires = vec![model.add_wire(&edges_of(&outer_ring))?];
2009        for hole in holes {
2010            wires.push(model.add_wire(&edges_of(&hole))?);
2011        }
2012        let mut fresh = data.clone();
2013        fresh.triangulation = None;
2014        fresh.natural_restriction = false;
2015        faces.push(model.add_face(fresh, &wires)?);
2016    }
2017    reshape.split(&face_fwd, faces);
2018    Ok(Made {
2019        reshape,
2020        edges,
2021        splits,
2022    })
2023}
2024
2025fn edges_of(ring: &[Item]) -> Vec<Shape> {
2026    ring.iter().map(|i| i.edge.clone()).collect()
2027}
2028
2029fn outline(ring: &[Item]) -> Vec<Point2> {
2030    ring.iter()
2031        .flat_map(|i| i.polyline.iter().copied())
2032        .collect()
2033}
2034
2035/// The ring and the index of the piece leaving the vertex `v` nearest the
2036/// chart point `at`.
2037fn junction(items: &[Vec<Item>], v: TShapeId, at: Point2) -> OgeomResult<(usize, usize)> {
2038    let mut best: Option<(usize, usize, f64)> = None;
2039    for (r, ring) in items.iter().enumerate() {
2040        for (k, item) in ring.iter().enumerate() {
2041            if item.from != v {
2042                continue;
2043            }
2044            let d = item.polyline[0].distance(at);
2045            if best.is_none_or(|(.., held)| d < held) {
2046                best = Some((r, k, d));
2047            }
2048        }
2049    }
2050    best.map(|(r, k, _)| (r, k)).ok_or_else(|| {
2051        ogeom_err!(
2052            Construction,
2053            "a cut ends at a vertex the face does not have"
2054        )
2055    })
2056}
2057
2058/// A stretch's new edges as directed pieces, and each edge with the edge
2059/// it came from.
2060type Walked = (Vec<Item>, Vec<(Shape, Shape)>);
2061
2062/// The edges along `cutter` over `span`, from `from` to `to`, one per
2063/// segment the span runs through, each with its trace on the surface.
2064fn cut_edges(
2065    model: &mut Model,
2066    cutter: &Cutter,
2067    span: (f64, f64),
2068    from: &Shape,
2069    to: &Shape,
2070    surface: SurfaceId,
2071    tol: Tolerances,
2072) -> OgeomResult<Walked> {
2073    let eps = cutter.resolution(tol);
2074    let mut stops = vec![span.0];
2075    let mut joint = span.0.floor() + 1.0;
2076    while joint < span.1 - eps {
2077        if joint > span.0 + eps {
2078            stops.push(joint);
2079        }
2080        joint += 1.0;
2081    }
2082    stops.push(span.1);
2083    // A vertex at each joint, where the two segments meeting there end.
2084    let mut vertices = vec![from.clone()];
2085    for &t in &stops[1..stops.len() - 1] {
2086        let before = cutter.locate(t - 0.5).0.point(1.0, tol)?;
2087        let after = cutter.point(t, tol)?;
2088        let at = Point::from_vector((before.to_vector() + after.to_vector()) * 0.5);
2089        vertices.push(model.add_vertex(VertexData::new(at)));
2090    }
2091    vertices.push(to.clone());
2092
2093    let mut items = Vec::with_capacity(stops.len() - 1);
2094    let mut edges = Vec::with_capacity(stops.len() - 1);
2095    for (k, w) in stops.windows(2).enumerate() {
2096        let (segment, f0) = cutter.locate(0.5 * (w[0] + w[1]));
2097        let base = 0.5f64.mul_add(-(w[1] - w[0]), f0);
2098        let (f0, f1) = (base, base + (w[1] - w[0]));
2099        let (t0, t1) = (segment.local(f0), segment.local(f1));
2100        let range = (t0.min(t1), t0.max(t1));
2101        let (start, end) = if t0 <= t1 {
2102            (&vertices[k], &vertices[k + 1])
2103        } else {
2104            (&vertices[k + 1], &vertices[k])
2105        };
2106        let curve_id = model.geometry_mut().add_curve(segment.curve.clone());
2107        let mut data = EdgeData::on_curve(curve_id, Location::identity(), range);
2108        let trace = model.geometry_mut().add_pcurve(segment.pcurve.clone());
2109        data.add(EdgeRepr::PCurve {
2110            curve: trace,
2111            surface,
2112            location: Location::identity(),
2113            range,
2114        });
2115        data.assert_same_parameter(true);
2116        // The vertices were placed by the edges they cut or as the joints'
2117        // middles; the new edge's ends reach them within the gap.
2118        let reach = vertex_point(model, start)?
2119            .distance(segment.curve.point_at(range.0, tol)?)
2120            .max(vertex_point(model, end)?.distance(segment.curve.point_at(range.1, tol)?));
2121        let edge = model.add_edge(data, &[start.clone(), end.clone()])?;
2122        model.widen(
2123            &edge,
2124            Tolerance::new(reach.max(segment.tolerance) + tol.confusion())?,
2125        )?;
2126        let walked = if t0 <= t1 {
2127            edge.clone()
2128        } else {
2129            edge.reversed()
2130        };
2131        let polyline: Vec<Point2> = (0..=32)
2132            .map(|i| segment.chart((f1 - f0).mul_add(f64::from(i) / 32.0, f0), tol))
2133            .collect::<OgeomResult<_>>()?;
2134        items.push(Item {
2135            edge: walked,
2136            from: vertices[k].node(),
2137            polyline,
2138        });
2139        edges.push((segment.source.clone(), edge));
2140    }
2141    Ok((items, edges))
2142}