Skip to main content

geop_core_topology/edit/
splice_edge_into_face.rs

1use geop_core_math::{
2    geop_error::{GeopError, GeopResult, WithContext},
3    scalars::Scalar,
4    solvers::bisect::bisect,
5    vector::Vector2,
6};
7
8use crate::{
9    Coedge, CoedgeGeometry, CoedgeId, Curve2, EdgeId, Face, FaceId, Model, Sense, VertexId,
10    boundary::{BoundaryIndex, BoundaryType},
11    contains::face::{PointClassification, face_contains},
12    uv_loop::loop_signed_area,
13};
14
15impl Model {
16    /// Splice an **already-existing** `edge_id` into `face_id`'s boundary
17    /// structure, as a forward/reversed coedge pair whose pcurves it fits onto
18    /// the face's own surface.
19    ///
20    /// The edge already exists because two faces are meant to *share* it
21    /// (the whole point of imprinting an intersection curve), so this
22    /// rewires loops around the edge it's handed rather than making one.
23    ///
24    /// What it does depends on how much of the edge's own topology the face
25    /// already knows about, which is the only thing that determines what a
26    /// correct result even looks like:
27    ///
28    /// | edge's start/end vertex on this face's boundary | result |
29    /// |---|---|
30    /// | neither | a new self-contained ring floating inside the face — a **hole** |
31    /// | exactly one | a spur (out along the edge and back) inserted into that vertex's own loop |
32    /// | both, same hole | that hole is **divided into two holes** |
33    /// | both, two different holes | those holes are **merged into one** |
34    /// | both, a hole and the outer loop | the hole is **absorbed** into the outer loop |
35    /// | both, the outer loop | the face is **split into two faces** |
36    ///
37    /// Splitting the face, and dividing a hole (whose material side becomes
38    /// a face of its own), are the cases that create a face; its id is
39    /// returned, `None` otherwise. The face split is the only one that has to
40    /// reclassify the old face's holes — each now lies inside exactly one of
41    /// the two halves (see [`split_face`]). A spur changes no connectivity at
42    /// all, being "wire" topology rather than a real trim boundary.
43    pub fn splice_edge_into_face(
44        &mut self,
45        edge_id: EdgeId,
46        face_id: FaceId,
47        max_nodes: usize,
48        min_subdivision_size: Scalar,
49    ) -> GeopResult<Option<FaceId>> {
50        let model = self;
51        let ctx = |e: GeopError| {
52            e.with_context(format!(
53                "Model::splice_edge_into_face(edge={edge_id}, face={face_id})"
54            ))
55        };
56
57        let edge = model.get_edge(edge_id).with_context(&ctx)?;
58        let (start_vertex, end_vertex) = (edge.start_vertex, edge.end_vertex);
59        let curve = edge.curve.clone();
60
61        // The coedge each of the edge's endpoints would attach *after*: one
62        // already arriving at that vertex. Where the face already has one,
63        // its pcurve end is the authoritative `(u, v)` there — the new pcurve
64        // is pinned to it so the loop stays exactly continuous.
65        let surface = model.get_face(face_id).with_context(&ctx)?.surface.clone();
66        let fit = |model: &Model, at_start: Option<CoedgeId>, at_end: Option<CoedgeId>| {
67            surface
68                .fit_pcurve(
69                    &curve,
70                    pin(model, &surface, at_start, start_vertex)?,
71                    pin(model, &surface, at_end, end_vertex)?,
72                    max_nodes,
73                    min_subdivision_size,
74                )
75                .with_context(&ctx)
76        };
77        let mut arriving_start =
78            coedges_ending_at(model, face_id, start_vertex).with_context(&ctx)?;
79        let mut arriving_end = coedges_ending_at(model, face_id, end_vertex).with_context(&ctx)?;
80        let first = |arriving: &[CoedgeId]| arriving.first().copied();
81        // A pole is a whole row of the surface at one vertex: no one corner
82        // of the loop is where an edge arrives at it, so the first fit pins
83        // neither end there, and the end's own `(u, v)` says where along the
84        // row it is (see [`split_pole_row`]).
85        let pole = |model: &Model, vertex: VertexId, arriving: &[CoedgeId]| {
86            arriving.iter().any(|&c| {
87                model
88                    .get_coedge(c)
89                    .is_ok_and(|c| c.geometry == CoedgeGeometry::Vertex(vertex))
90            })
91        };
92        let (pole_start, pole_end) = (
93            pole(model, start_vertex, &arriving_start),
94            pole(model, end_vertex, &arriving_end),
95        );
96        let mut pcurve_fwd = fit(
97            model,
98            first(&arriving_start).filter(|_| !pole_start),
99            first(&arriving_end).filter(|_| !pole_end),
100        )?;
101        if pole_start || pole_end {
102            let (t0, t1) = pcurve_fwd.domain();
103            for (is_pole, vertex, t, arriving) in [
104                (pole_start, start_vertex, t0, &mut arriving_start),
105                (pole_end, end_vertex, t1, &mut arriving_end),
106            ] {
107                if !is_pole {
108                    continue;
109                }
110                let uv = pcurve_fwd.evaluate(t).with_context(&ctx)?;
111                if let Some(corner) =
112                    split_pole_row(model, vertex, arriving, uv).with_context(&ctx)?
113                {
114                    *arriving = vec![corner];
115                }
116            }
117            pcurve_fwd = fit(model, first(&arriving_start), first(&arriving_end))?;
118        }
119        // A vertex the loop passes more than once — the root of a spur, or a
120        // point where the face touches itself — has as many corners there,
121        // and the edge belongs in the one it leaves into. Its pcurve says
122        // which, so it is chosen after a first fit and refitted if that
123        // moved the pin.
124        let at_start = choose_corner(
125            model,
126            &arriving_start,
127            Leg {
128                pcurve: &pcurve_fwd,
129                from_end: false,
130            },
131        )
132        .with_context(&ctx)?;
133        let at_end = choose_corner(
134            model,
135            &arriving_end,
136            Leg {
137                pcurve: &pcurve_fwd,
138                from_end: true,
139            },
140        )
141        .with_context(&ctx)?;
142        if at_start != first(&arriving_start) || at_end != first(&arriving_end) {
143            pcurve_fwd = fit(model, at_start, at_end)?;
144        }
145        let pcurve_rev = pcurve_fwd.reverse();
146
147        // A spur dangles into the face: its free end has to lie strictly
148        // inside it. Were it outside, the face's loop would run out of the
149        // face and back — every check of the loop's own structure passes, and
150        // the face is left straddling whatever the edge was meant to divide
151        // (`narrow_groove` surfaced that far downstream, in classification).
152        // On the boundary, it would be a vertex of the loop already.
153        if let (Some(_), None) | (None, Some(_)) = (at_start, at_end) {
154            let (t0, t1) = pcurve_fwd.domain();
155            let (free, t) = if at_start.is_some() {
156                (end_vertex, t1)
157            } else {
158                (start_vertex, t0)
159            };
160            let uv = pcurve_fwd.evaluate(t).with_context(&ctx)?;
161            let class = face_contains(
162                model,
163                face_id,
164                uv[0],
165                uv[1],
166                max_nodes,
167                min_subdivision_size,
168                HOLE_CLASSIFY_SEED,
169            )
170            .with_context(&ctx)?;
171            if class != PointClassification::Inside {
172                let point = model.get_vertex(free).with_context(&ctx)?.point;
173                return Err(ctx(GeopError::new(format!(
174                    "a spur's free end must lie inside the face, but vertex {free} at {point:?} (uv={uv:?}) is {class:?}; the face is bounded by (corners, outer loop first) {:?}",
175                    model.face_corners(face_id).with_context(&ctx)?
176                ))));
177            }
178        }
179
180        let fwd = model.insert_coedge(Coedge {
181            geometry: CoedgeGeometry::Edge(edge_id),
182            sense: Sense::Forward,
183            pcurve: pcurve_fwd,
184            next: CoedgeId(0),
185            prev: CoedgeId(0),
186            face: face_id,
187        });
188        let rev = model.insert_coedge(Coedge {
189            geometry: CoedgeGeometry::Edge(edge_id),
190            sense: Sense::Reversed,
191            pcurve: pcurve_rev,
192            next: CoedgeId(0),
193            prev: CoedgeId(0),
194            face: face_id,
195        });
196
197        let mut new_face = None;
198        match (at_start, at_end) {
199            // Neither endpoint is on this face yet: a self-contained
200            // two-coedge ring, floating inside the face — that is a hole.
201            (None, None) => {
202                link(model, fwd, rev).with_context(&ctx)?;
203                link(model, rev, fwd).with_context(&ctx)?;
204                model
205                    .get_face_mut(face_id)
206                    .with_context(&ctx)?
207                    .holes
208                    .push(BoundaryType::Loop(fwd));
209            }
210            // Only one endpoint is on the face: splice a spur into that loop —
211            // out along the edge and straight back, so the loop still closes.
212            (Some(a), None) => {
213                splice_spur(model, a, fwd, rev).with_context(&ctx)?;
214            }
215            (None, Some(a)) => {
216                splice_spur(model, a, rev, fwd).with_context(&ctx)?;
217            }
218            // Both endpoints are already on the face, so the new edge joins
219            // two points of its boundary structure. *Which* boundaries they
220            // sit on decides what that means topologically, and the four
221            // cases are genuinely different operations.
222            (Some(a), Some(b)) => {
223                let loop_a = model
224                    .find_boundary_containing(face_id, a)
225                    .with_context(&ctx)?;
226                let loop_b = model
227                    .find_boundary_containing(face_id, b)
228                    .with_context(&ctx)?;
229
230                // `fwd` runs start->end, so it leaves the coedge arriving at
231                // `start_vertex` and lands on whatever leaves `end_vertex`;
232                // `rev` closes the complementary path the other way round.
233                let a_next = model.get_coedge(a).with_context(&ctx)?.next;
234                let b_next = model.get_coedge(b).with_context(&ctx)?.next;
235
236                link(model, a, fwd).with_context(&ctx)?;
237                link(model, fwd, b_next).with_context(&ctx)?;
238                link(model, b, rev).with_context(&ctx)?;
239                link(model, rev, a_next).with_context(&ctx)?;
240
241                match (loop_a, loop_b) {
242                    // Two points of the *outer* loop: the edge cuts the face
243                    // itself in two. Both rings bound material, so neither can
244                    // be a hole of the other — this is the only case that
245                    // creates a face.
246                    (BoundaryIndex::Outer, BoundaryIndex::Outer) => {
247                        // Both halves must enclose material. A half of zero
248                        // area means the new edge runs along the stretch of
249                        // boundary between its own endpoints — it duplicates
250                        // a path the face already has, so "splitting" there
251                        // carves off a sliver rather than two faces. Reported
252                        // rather than built: a zero-area face is structurally
253                        // perfect and every other check accepts it, so it
254                        // would surface much later as a face nothing can be
255                        // classified against.
256                        for ring in [fwd, rev] {
257                            let area = loop_signed_area(model, ring).with_context(&ctx)?;
258                            if !area.abs().definitely_greater(Scalar::ZERO) {
259                                let widest = widest_pcurve(model, ring).with_context(&ctx)?;
260                                return Err(ctx(GeopError::new(format!(
261                                    "{DEGENERATE_SPLIT}: splicing edge {edge_id} into face {face_id} would split its outer loop into a ring of signed area {area:?} — the edge runs along the boundary it is being spliced into, so one side encloses nothing (the ring's widest pcurve: {widest})"
262                                ))));
263                            }
264                        }
265                        new_face = Some(
266                            split_face(model, face_id, fwd, rev, max_nodes, min_subdivision_size)
267                                .with_context(&ctx)?,
268                        );
269                    }
270                    // Two points of the *same* hole. The edge runs through
271                    // material, so together with one of the two arcs it just
272                    // cut the hole into, it encloses a patch of material that
273                    // is now bounded on its own — a new face — while the other
274                    // arc still bounds a void and stays a hole.
275                    //
276                    // Which is which is exactly the winding: by this kernel's
277                    // convention an outer loop runs counter-clockwise in
278                    // `(u, v)` and a hole runs clockwise, so the ring with
279                    // positive signed area bounds material and the one with
280                    // negative area bounds a void. Nothing weaker will do —
281                    // both rings pass through the same vertices and contain
282                    // the same edge, so no purely topological test can tell
283                    // them apart.
284                    (BoundaryIndex::Hole(i), BoundaryIndex::Hole(j)) if i == j => {
285                        let area = loop_signed_area(model, fwd).with_context(&ctx)?;
286                        let (material, void) = if area.definitely_greater(Scalar::ZERO) {
287                            (fwd, rev)
288                        } else if area.definitely_less(Scalar::ZERO) {
289                            (rev, fwd)
290                        } else {
291                            return Err(ctx(GeopError::new(format!(
292                                "splice_edge_into_face: splitting hole {i} of face {face_id} with edge {edge_id} produced a ring of signed area {area:?}, which is not decidably clockwise or counter-clockwise — the split would be degenerate"
293                            ))));
294                        };
295                        model
296                            .get_face_mut(face_id)
297                            .with_context(&ctx)?
298                            .set_boundary(BoundaryIndex::Hole(i), BoundaryType::Loop(void));
299                        new_face = Some(
300                            new_face_from_ring(
301                                model,
302                                face_id,
303                                material,
304                                max_nodes,
305                                min_subdivision_size,
306                            )
307                            .with_context(&ctx)?,
308                        );
309                    }
310                    // Two *different* holes: the bridge merges them into one
311                    // hole.
312                    (BoundaryIndex::Hole(i), BoundaryIndex::Hole(j)) => {
313                        let face = model.get_face_mut(face_id).with_context(&ctx)?;
314                        face.set_boundary(BoundaryIndex::Hole(i), BoundaryType::Loop(fwd));
315                        face.holes.remove(j);
316                    }
317                    // A hole bridged to the outer loop: the hole stops being a
318                    // separate boundary and its coedges become part of the one
319                    // ring that now bounds the face.
320                    (BoundaryIndex::Outer, BoundaryIndex::Hole(j))
321                    | (BoundaryIndex::Hole(j), BoundaryIndex::Outer) => {
322                        let face = model.get_face_mut(face_id).with_context(&ctx)?;
323                        face.outer = BoundaryType::Loop(fwd);
324                        face.holes.remove(j);
325                    }
326                }
327            }
328        }
329
330        Ok(new_face)
331    }
332}
333
334/// Where an edge arrives at a pole — `vertex`, at which a face's surface
335/// collapses a whole row — if that is in the middle of the row's coedge: the
336/// coedge among `arriving` that runs along the row (see
337/// [`CoedgeGeometry::Vertex`]) is cut at `uv`, where the edge's pcurve ends,
338/// into two, and the first is returned, which now ends there. The edge is
339/// spliced after it, between the two halves of the row.
340///
341/// `None` where no such coedge holds `uv` strictly inside: the edge arrives
342/// at one of the corners of the row, which the usual choice of corner
343/// finds. A pole has a corner for every end of its row and every meridian
344/// that arrives at it, each at a `(u, v)` of its own, though they are one
345/// vertex — so which of them an edge arrives at is told by its `(u, v)`,
346/// not by the way it leaves the vertex.
347///
348/// Which `u` the edge arrives at, along a row that is all one point, is the
349/// edge's own: taken from where its pcurve was fitted to end — sharp, since
350/// the row is cut there, and the edge is then pinned to that cut.
351fn split_pole_row(
352    model: &mut Model,
353    vertex: VertexId,
354    arriving: &[CoedgeId],
355    uv: Vector2,
356) -> GeopResult<Option<CoedgeId>> {
357    for &row in arriving {
358        let coedge = model.get_coedge(row)?;
359        if coedge.geometry != CoedgeGeometry::Vertex(vertex) {
360            continue;
361        }
362        let (t0, t1) = coedge.pcurve.domain();
363        let (a, b) = (coedge.pcurve.evaluate(t0)?, coedge.pcurve.evaluate(t1)?);
364        let (face, next) = (coedge.face, coedge.next);
365        if !uv[1].could_be_equal(a[1]) || !uv[1].could_be_equal(b[1]) {
366            continue;
367        }
368        let u = uv[0].midpoint().sharpen();
369        let (lo, hi) = (a[0].min(b[0]), a[0].max(b[0]));
370        if !u.definitely_greater(lo) || !u.definitely_less(hi) {
371            continue;
372        }
373        // The row runs straight along `u`, at a speed of its own.
374        let t = t0
375            .add(t1.sub(t0).mul(u.sub(a[0]).div(b[0].sub(a[0]))?))
376            .sharpen();
377        let (before, after) = coedge.pcurve.split(t)?;
378        model.get_coedge_mut(row)?.pcurve = before;
379        let rest = model.insert_coedge(Coedge {
380            geometry: CoedgeGeometry::Vertex(vertex),
381            sense: Sense::Forward,
382            pcurve: after,
383            next,
384            prev: row,
385            face,
386        });
387        link(model, row, rest)?;
388        link(model, rest, next)?;
389        return Ok(Some(row));
390    }
391    Ok(None)
392}
393
394/// The `(u, v)` a coedge's pcurve ends at, if there is such a coedge.
395fn pcurve_end_uv(model: &Model, coedge: Option<CoedgeId>) -> GeopResult<Option<Vector2>> {
396    let Some(coedge) = coedge else {
397        return Ok(None);
398    };
399    let pcurve = &model.get_coedge(coedge)?.pcurve;
400    let (_, t1) = pcurve.domain();
401    Ok(Some(pcurve.evaluate(t1)?))
402}
403
404/// The `(u, v)` to pin a new pcurve's end to at `vertex`, where `coedge`
405/// arrives on `surface`: that coedge's end — what keeps the loop continuous
406/// — intersected with `vertex`'s own projection onto the surface. Both are
407/// enclosures of the one `(u, v)` the vertex is at, so their intersection is
408/// too, and it is no wider than the narrower: an end widened by its own
409/// pcurve's history (a pin of its own, the drift it encloses) does not pass
410/// that width on — a pcurve is widened to hold its pins whole, all along
411/// (see `NurbCurve2D::interpolate_enclosing`), so a wide pin is a wide
412/// pcurve. A projection that fails, or misses the end altogether, leaves
413/// the end as it is.
414fn pin(
415    model: &Model,
416    surface: &geop_core_geometry::nurb_surface::NurbSurface3D,
417    coedge: Option<CoedgeId>,
418    vertex: VertexId,
419) -> GeopResult<Option<Vector2>> {
420    let Some(end) = pcurve_end_uv(model, coedge)? else {
421        return Ok(None);
422    };
423    let point = model.get_vertex(vertex)?.point;
424    let Ok((u, v)) = surface.project(point, end[0].midpoint(), end[1].midpoint(), PIN_ITERATIONS)
425    else {
426        return Ok(Some(end));
427    };
428    let overlaps = end[0].could_be_equal(u) && end[1].could_be_equal(v);
429    Ok(Some(if overlaps {
430        Vector2::from_array([end[0].intersect(u), end[1].intersect(v)])
431    } else {
432        end
433    }))
434}
435
436/// Newton iterations refining a pin (see [`pin`]): from a seed already on
437/// the foot point's doorstep, a handful converge.
438const PIN_ITERATIONS: usize = 8;
439
440/// Every coedge of `face_id` that *arrives* at `vertex`: one per time its
441/// boundary passes through it. A new edge leaving `vertex` has to be spliced
442/// in after one of them — see [`choose_corner`].
443fn coedges_ending_at(
444    model: &Model,
445    face_id: FaceId,
446    vertex: VertexId,
447) -> GeopResult<Vec<CoedgeId>> {
448    let mut arriving = Vec::new();
449    for coedge_id in model.iterate_face_coedges(face_id) {
450        if model.coedge_end_vertex_id(coedge_id)? == vertex {
451            arriving.push(coedge_id);
452        }
453    }
454    Ok(arriving)
455}
456
457/// Of the coedges `arriving` at one vertex, the one whose corner a new edge
458/// leaving the vertex along `leaving` runs into.
459///
460/// Each pass of the boundary through the vertex makes a corner: the face's
461/// material fills the angle swept counter-clockwise from the way the boundary
462/// leaves to the way it arrived from — material lies to the left of every
463/// loop, outer and hole alike. One pass leaves no choice. Several do — a
464/// spur's root, where the boundary passes once on each side of it — and
465/// splicing into the wrong corner puts the edge on the wrong side of the
466/// spur: the face split it makes then carries the spur into the half it does
467/// not lie in. That happened on `three_inscribed_bores_remesh_imprints_every_arc`:
468/// an arc spliced as a spur from a point of a bore's rim, and the rim arc
469/// spliced at that same point a moment later, landed the spur in the outer
470/// piece of the wall.
471///
472/// The curves are ordered around the vertex by where they first leave a
473/// small circle around it, not by their tangents: see [`Leg`].
474fn choose_corner(
475    model: &Model,
476    arriving: &[CoedgeId],
477    leaving: Leg<'_>,
478) -> GeopResult<Option<CoedgeId>> {
479    if arriving.len() <= 1 {
480        return Ok(arriving.first().copied());
481    }
482    let mut corners = Vec::new();
483    for &coedge_id in arriving {
484        let coedge = model.get_coedge(coedge_id)?;
485        let goes_to = Leg {
486            pcurve: &model.get_coedge(coedge.next)?.pcurve,
487            from_end: false,
488        };
489        let came_from = Leg {
490            pcurve: &coedge.pcurve,
491            from_end: true,
492        };
493        corners.push((coedge_id, goes_to, came_from));
494    }
495
496    // One radius for every leg, small enough that no leg meets another
497    // vertex inside it (see `Leg`).
498    let mut radius = leaving.reach()?;
499    for (_, goes_to, came_from) in &corners {
500        for leg in [goes_to, came_from] {
501            let reach = leg.reach()?;
502            if reach.definitely_less(radius) {
503                radius = reach;
504            }
505        }
506    }
507    let radius = radius.div(Scalar::TWO)?.sharpen();
508    if !radius.definitely_greater(Scalar::ZERO) {
509        return Err(GeopError::new(format!(
510            "splice_edge_into_face: a curve at the vertex returns to it at once, so no circle around the vertex orders the corners after coedges {arriving:?}"
511        )));
512    }
513
514    let d = leaving.exit(radius)?;
515    let mut chosen = Vec::new();
516    for (coedge_id, goes_to, came_from) in &corners {
517        let (from, to) = (goes_to.exit(radius)?, came_from.exit(radius)?);
518        match within_corner(from, to, d) {
519            Some(true) => chosen.push(*coedge_id),
520            Some(false) => {}
521            None => {
522                return Err(GeopError::new(format!(
523                    "splice_edge_into_face: cannot tell whether an edge leaving towards {d:?} runs into the corner after coedge {coedge_id}, from {from:?} counter-clockwise to {to:?} (at radius {radius:?})"
524                )));
525            }
526        }
527    }
528    match chosen[..] {
529        [coedge_id] => Ok(Some(coedge_id)),
530        _ => Err(GeopError::new(format!(
531            "splice_edge_into_face: an edge leaving towards {d:?} runs into {} of the corners after coedges {arriving:?}, where it must run into exactly one",
532            chosen.len()
533        ))),
534    }
535}
536
537/// A pcurve run away from the vertex at one of its ends.
538///
539/// Several curves leaving one vertex are ordered around it by where each
540/// first leaves a circle of some radius around the vertex. Within a radius
541/// that no curve meets another vertex inside, their pieces in the disk are
542/// paths from its centre to its rim that do not cross — curves of a face's
543/// boundary, and an edge being spliced into it, meet only at vertices — and
544/// such paths leave the disk in the same cyclic order they leave its centre.
545/// So the order is exact at any such radius, and a larger one only separates
546/// the curves further.
547///
548/// Tangents would answer the same question only where the curves leave in
549/// different directions. Intersection curves of tangent configurations often
550/// leave a vertex tangent to each other, and then only curvature separates
551/// them — which, differentiated twice off a pcurve that encloses a traced
552/// curve, comes out too wide to decide anything.
553struct Leg<'a> {
554    pcurve: &'a Curve2,
555    /// Whether the vertex is at the pcurve's end (and it is run backwards).
556    from_end: bool,
557}
558
559impl Leg<'_> {
560    /// The parameters of the vertex end and of the pcurve's midpoint.
561    fn ends(&self) -> GeopResult<(Scalar, Scalar)> {
562        let (t0, t1) = self.pcurve.domain();
563        let mid = t0.add(t1).div(Scalar::TWO)?;
564        Ok((if self.from_end { t1 } else { t0 }, mid))
565    }
566
567    /// A radius this leg reaches, before meeting any vertex other than the
568    /// one it leaves: its distance to its midpoint, or to its far end where
569    /// that is nearer. A curve returning to the vertex it leaves has no far
570    /// end to meet.
571    fn reach(&self) -> GeopResult<Scalar> {
572        let (t0, t1) = self.pcurve.domain();
573        let (t_vertex, t_mid) = self.ends()?;
574        let t_far = if self.from_end { t0 } else { t1 };
575        let origin = self.pcurve.evaluate(t_vertex)?;
576        let mid = self.pcurve.evaluate(t_mid)?.sub(&origin).norm();
577        let far = self.pcurve.evaluate(t_far)?.sub(&origin).norm();
578        Ok(
579            if far.definitely_greater(Scalar::ZERO) && far.definitely_less(mid) {
580                far
581            } else {
582                mid
583            },
584        )
585    }
586
587    /// Where the leg leaves the circle of `radius` around its vertex,
588    /// relative to the vertex. Located by bisection between the vertex and
589    /// the midpoint, which lies outside a circle of at most half its reach;
590    /// the parameters bisected are the search's own choice, so they are sharp.
591    ///
592    /// Bisection finds *a* crossing of the circle, which is the first exit
593    /// the ordering argument needs as long as the leg does not leave the disk
594    /// and come back before its midpoint — true of the short, smooth trim
595    /// curves at half their reach, and an assumption for anything else.
596    fn exit(&self, radius: Scalar) -> GeopResult<Vector2> {
597        let (t_vertex, t_mid) = self.ends()?;
598        let origin = self.pcurve.evaluate(t_vertex)?;
599        let radius_sq = radius.mul(radius);
600        let mut point = self.pcurve.evaluate(t_mid)?.sub(&origin);
601        bisect(
602            t_vertex,
603            t_mid,
604            EXIT_BISECTIONS,
605            false,
606            |t| -> GeopResult<_> {
607                point = self.pcurve.evaluate(t)?.sub(&origin);
608                // Inside the circle is the vertex's side.
609                Ok(point
610                    .norm_sq()
611                    .sub(radius_sq)
612                    .definite_sign()
613                    .map(|out| !out))
614            },
615        )?;
616        Ok(point)
617    }
618}
619
620/// Bisection steps for [`Leg::exit`]. Bounds effort only: each step halves
621/// the stretch of curve the exit point is known to lie on, and the point
622/// returned is on the curve either way.
623const EXIT_BISECTIONS: usize = 60;
624
625/// Whether `d` lies strictly inside the angle swept counter-clockwise from
626/// `from` to `to`; `None` where the intervals cannot decide it. `from` and
627/// `to` pointing the same way sweep the full turn — the tip of a spur, whose
628/// corner is everything around it.
629fn within_corner(from: Vector2, to: Vector2, d: Vector2) -> Option<bool> {
630    let sign = |x: Scalar| {
631        if x.definitely_greater(Scalar::ZERO) {
632            Some(true)
633        } else if x.definitely_less(Scalar::ZERO) {
634            Some(false)
635        } else {
636            None
637        }
638    };
639    let after_from = sign(from.prod_cross(&d));
640    let before_to = sign(d.prod_cross(&to));
641    let turn = from.prod_cross(&to);
642    if turn.definitely_greater(Scalar::ZERO) {
643        // Less than half a turn: inside means past `from` and short of `to`.
644        match (after_from, before_to) {
645            (Some(false), _) | (_, Some(false)) => Some(false),
646            (Some(true), Some(true)) => Some(true),
647            _ => None,
648        }
649    } else if turn.definitely_less(Scalar::ZERO) {
650        // More than half a turn: everything but the complementary angle.
651        match (after_from, before_to) {
652            (Some(true), _) | (_, Some(true)) => Some(true),
653            (Some(false), Some(false)) => Some(false),
654            _ => None,
655        }
656    } else if from.prod_dot(&to).definitely_greater(Scalar::ZERO) {
657        // The full turn, unless `d` leaves along it.
658        after_from.map(|_| true)
659    } else {
660        None
661    }
662}
663
664/// Make `to` follow `from` in loop order, keeping `prev` consistent.
665fn link(model: &mut Model, from: CoedgeId, to: CoedgeId) -> GeopResult<()> {
666    model.get_coedge_mut(from)?.next = to;
667    model.get_coedge_mut(to)?.prev = from;
668    Ok(())
669}
670
671/// Insert `out`/`back` (an out-and-return pair on the same edge) into the
672/// loop right after `after`, leaving the rest of the loop untouched.
673fn splice_spur(
674    model: &mut Model,
675    after: CoedgeId,
676    out: CoedgeId,
677    back: CoedgeId,
678) -> GeopResult<()> {
679    let after_next = model.get_coedge(after)?.next;
680    link(model, after, out)?;
681    link(model, out, back)?;
682    link(model, back, after_next)?;
683    Ok(())
684}
685
686/// Marks the error raised when a splice would divide a face into a piece of
687/// no area. Callers that are *imprinting* an edge they already know follows
688/// this face's boundary can treat it as "already represented" rather than a
689/// failure — see `booleans::remesh`'s tracing. Matched on the message because
690/// `GeopError` carries no code; that is fragile enough to be worth the
691/// constant rather than a literal at both ends.
692pub const DEGENERATE_SPLIT: &str = "degenerate split";
693
694/// Cut `face_id` in two along the newly inserted edge: `keep` and `moved`
695/// anchor the two rings the old outer loop just split into.
696///
697/// `keep`'s ring stays with `face_id`; `moved`'s ring becomes the outer loop
698/// of a brand-new face, and the holes are re-sorted between the two
699/// ([`new_face_from_ring`]). Both rings bound material — that is what
700/// distinguishes this from every other case in `splice_edge_into_face` — so
701/// neither can become a hole of the other.
702fn split_face(
703    model: &mut Model,
704    face_id: FaceId,
705    keep: CoedgeId,
706    moved: CoedgeId,
707    max_nodes: usize,
708    min_subdivision_size: Scalar,
709) -> GeopResult<FaceId> {
710    model
711        .get_face_mut(face_id)?
712        .set_boundary(BoundaryIndex::Outer, BoundaryType::Loop(keep));
713    new_face_from_ring(model, face_id, moved, max_nodes, min_subdivision_size)
714}
715
716/// Split `ring` off `face_id` as the outer loop of a brand-new face on the
717/// same surface and in the same shell.
718///
719/// `face_id`'s own holes are then re-sorted, since each now lies inside
720/// exactly one of the two faces and the split has no idea which. Each is
721/// classified by taking a point on it and asking `face_contains` — a hole the
722/// new face contains moves there, everything else stays. A hole with no
723/// usable point (a bare `Vertex` boundary, or a loop whose `(u, v)` cannot be
724/// read) stays put rather than being dropped: leaving it on the wrong face is
725/// a recoverable error, losing it silently fills in a hole that should exist.
726fn new_face_from_ring(
727    model: &mut Model,
728    face_id: FaceId,
729    ring: CoedgeId,
730    max_nodes: usize,
731    min_subdivision_size: Scalar,
732) -> GeopResult<FaceId> {
733    let old = model.get_face(face_id)?.clone();
734    let new_face_id = model.insert_face(Face {
735        surface: old.surface.clone(),
736        outer: BoundaryType::Loop(ring),
737        holes: Vec::new(),
738        shell: old.shell,
739    });
740    model.get_shell_mut(old.shell)?.faces.push(new_face_id);
741
742    // Every coedge of the moved ring now belongs to the new face. Walked with
743    // a hard cap: a ring that never returns to its anchor would otherwise spin
744    // here forever, and a corrupted `next` chain is exactly the kind of thing
745    // a restructuring like this can introduce.
746    for c in ring_coedges(model, ring)? {
747        model.get_coedge_mut(c)?.face = new_face_id;
748    }
749
750    let mut stay = Vec::new();
751    let mut move_over = Vec::new();
752    for hole in old.holes {
753        let Some(uv) = boundary_uv(model, hole)? else {
754            stay.push(hole);
755            continue;
756        };
757        let inside_new = matches!(
758            face_contains(
759                model,
760                new_face_id,
761                uv[0],
762                uv[1],
763                max_nodes,
764                min_subdivision_size,
765                HOLE_CLASSIFY_SEED,
766            )?,
767            PointClassification::Inside
768        );
769        if inside_new {
770            move_over.push(hole);
771        } else {
772            stay.push(hole);
773        }
774    }
775    for hole in &move_over {
776        if let BoundaryType::Loop(anchor) = hole {
777            for c in ring_coedges(model, *anchor)? {
778                model.get_coedge_mut(c)?.face = new_face_id;
779            }
780        }
781    }
782    model.get_face_mut(face_id)?.holes = stay;
783    model.get_face_mut(new_face_id)?.holes = move_over;
784    Ok(new_face_id)
785}
786
787/// Which coedge of the ring anchored at `anchor` has the widest pcurve, at
788/// `WIDTH_SAMPLES` points along each, and how wide: what an area too
789/// uncertain to be told from zero comes from — a ring that really encloses
790/// nothing, or a pcurve carrying too much uncertainty to say. Only for the
791/// error message, never for a decision.
792fn widest_pcurve(model: &Model, anchor: CoedgeId) -> GeopResult<String> {
793    const WIDTH_SAMPLES: usize = 8;
794    let width =
795        |uv: &geop_core_math::vector::Vector2| uv[0].width().to_f64().max(uv[1].width().to_f64());
796    let mut widest = (0.0, None);
797    let mut each = Vec::new();
798    for coedge_id in ring_coedges(model, anchor)? {
799        let coedge = model.get_coedge(coedge_id)?;
800        let (t0, t1) = coedge.pcurve.domain();
801        let mut samples = Vec::with_capacity(WIDTH_SAMPLES);
802        for i in 0..WIDTH_SAMPLES {
803            let frac = Scalar::from_ratio(i as i64, (WIDTH_SAMPLES - 1) as i64)?;
804            let uv = coedge.pcurve.evaluate(t0.add(t1.sub(t0).mul(frac)))?;
805            if width(&uv) > widest.0 {
806                widest = (width(&uv), Some((coedge_id, coedge.geometry, uv)));
807            }
808            samples.push(width(&uv));
809        }
810        let mid = samples[samples.len() / 2];
811        each.push(format!(
812            "{:?}: {:.1e} / {mid:.1e} / {:.1e}",
813            coedge.geometry,
814            samples[0],
815            samples[samples.len() - 1]
816        ));
817    }
818    let each = each.join(", ");
819    Ok(match widest.1 {
820        Some((coedge, geometry, uv)) => {
821            // How fast the surface moves there: a slow parametrization
822            // turns a narrow point into a wide `(u, v)`.
823            let face = model.get_coedge(coedge)?.face;
824            let surface_width = model
825                .get_face(face)?
826                .surface
827                .control_points
828                .iter()
829                .flat_map(|cp| (0..4).map(move |k| cp[k].width().to_f64()))
830                .fold(0.0, f64::max);
831            let speed = model
832                .get_face(face)?
833                .surface
834                .derivatives(uv[0].midpoint(), uv[1].midpoint())
835                .map(|(du, dv)| format!("|dS/du| = {:?}, |dS/dv| = {:?}", du.norm(), dv.norm()))
836                .unwrap_or_else(|e| format!("no derivatives: {}", e.root_message()));
837            // Whether its ends land on the surface where its vertices are: a
838            // pcurve pinned to a `(u, v)` off its vertex is widened to span
839            // the gap all along.
840            let coedge_data = model.get_coedge(coedge)?;
841            let surface = &model.get_face(face)?.surface;
842            let (t0, t1) = coedge_data.pcurve.domain();
843            let mut ends = Vec::new();
844            for (t, vertex) in [
845                (t0, model.coedge_start_vertex(coedge)?),
846                (t1, model.coedge_end_vertex(coedge)?),
847            ] {
848                let uv = coedge_data.pcurve.evaluate(t)?;
849                let width = (0..3)
850                    .map(|k| vertex.point[k].width().to_f64())
851                    .fold(0.0, f64::max);
852                ends.push(match surface.evaluate(uv[0].midpoint(), uv[1].midpoint()) {
853                    Ok(p) => format!(
854                        "{:.1e} (vertex {width:.1e} wide)",
855                        p.sub(&vertex.point).norm().to_f64()
856                    ),
857                    Err(_) => {
858                        format!("(outside the surface's domain at {uv:?}; vertex {width:.1e} wide)")
859                    }
860                });
861            }
862            let ends = ends.join(" and ");
863            // And whether it inherits that from its edge's 3-D curve.
864            let curve_width = match geometry {
865                CoedgeGeometry::Edge(edge) => model
866                    .get_edge(edge)?
867                    .curve
868                    .control_points
869                    .iter()
870                    .flat_map(|cp| (0..4).map(move |k| cp[k].width().to_f64()))
871                    .fold(0.0, f64::max),
872                CoedgeGeometry::Vertex(_) => 0.0,
873            };
874            format!(
875                "coedge {coedge} on {geometry:?}, {:e} wide at uv={uv:?}, where {speed} and the surface's control points are up to {surface_width:e} wide; its ends land {ends} away from their vertices; its 3-D curve's control points up to {curve_width:e} wide. Every coedge's width at its start / middle / end: {each}",
876                widest.0
877            )
878        }
879        None => "every sample sharp".into(),
880    })
881}
882
883/// Every coedge of the ring anchored at `anchor`, erroring rather than
884/// spinning if the `next` chain never returns to it.
885fn ring_coedges(model: &Model, anchor: CoedgeId) -> GeopResult<Vec<CoedgeId>> {
886    let cap = model.coedges.len() + 1;
887    let ring: Vec<CoedgeId> = model.iterate_loop_coedges(anchor).take(cap).collect();
888    if ring.len() >= cap {
889        return Err(GeopError::new(format!(
890            "splice_edge_into_face: the ring anchored at coedge {anchor} never returns to its anchor"
891        )));
892    }
893    Ok(ring)
894}
895
896/// Fixed seed for the ray casting behind hole classification — `face_contains`
897/// retries until it finds a ray grazing nothing, so the answer is
898/// seed-independent and a constant keeps a split reproducible run to run.
899const HOLE_CLASSIFY_SEED: u64 = 0x1234_5678_9ABC_DEF0;
900
901/// A `(u, v)` on `boundary`, for classifying which side of a split it falls
902/// on. `None` for a bare `Vertex` boundary, which has no pcurve to read one
903/// from.
904fn boundary_uv(
905    model: &Model,
906    boundary: BoundaryType,
907) -> GeopResult<Option<geop_core_math::vector::Vector2>> {
908    let BoundaryType::Loop(anchor) = boundary else {
909        return Ok(None);
910    };
911    let pcurve = &model.get_coedge(anchor)?.pcurve;
912    let (t0, _) = pcurve.domain();
913    Ok(Some(pcurve.evaluate(t0)?))
914}