Skip to main content

ogeom_bool/
defeature.rs

1//! Defeaturing by face removal: delete faces, close the wound from the
2//! neighbours' own surfaces.
3//!
4//! The input is a set of faces; what those faces *mean* is the caller's
5//! business, and the operation works on a solid whose history is gone.
6//! Three wounds exist, and they close differently.
7//!
8//! A feature whose rim is an **inner loop** of a surviving face (a bore in a
9//! lid, a boss on a base, a pocket in the middle of a top) leaves survivors
10//! whose boundary is already right except for that loop. The cure is wire
11//! surgery: the surviving face is rebuilt without the rim wire, edges,
12//! pcurves and all, and nothing is re-intersected because nothing new meets.
13//!
14//! A feature that **interrupts** its neighbours' outer boundaries (a fillet
15//! band or a chamfer along an edge) leaves a gap no surviving boundary
16//! closes. The cure is the neighbours themselves: the two side faces'
17//! surfaces are re-intersected to recover the edge the blend replaced, the
18//! end faces' edges are extended along their own curves to the recovered
19//! corners, and the faces are rebuilt on the result. Extension here is the
20//! surfaces' and curves' own unbounded carriers: no new geometry is
21//! invented, only wider windows of what is already there.
22//!
23//! A feature that takes a **whole ring** out of a neighbour (a rim
24//! blend, round a drum's top, a bore's mouth or a boss's seat) looks
25//! like the first wound and closes like the second. The neighbours' own
26//! surfaces tell the two apart: a bore's two mouths sit in faces that
27//! never meet, so the rings are dropped and the faces grow over them,
28//! while a rim blend's cap and wall meet along the very circle it
29//! replaced, in the wound's own room. There the ring is replaced rather
30//! than dropped, and a neighbour's outer boundary may be the ring: a
31//! drum's cap grows back to its own rim. The wall's chart has a seam, and
32//! the seam reaches the recovered circle: that is where the circle is
33//! cut, and the seam extends to meet it, exactly as a band's end faces
34//! extend to their corners. One corner leaves the rim one closed edge,
35//! re-anchored so the whole turn stands in the curve's own domain: the
36//! shape the rim had before the feature was cut.
37//!
38//! Several bands close together. Each removed band recovers its own
39//! crease; where two creases meet (two blends that met at a corner, or
40//! one blend's flush cap standing against another's band, the cap named
41//! with its band), the corner is where one crease pierces the other's
42//! side, and it is one vertex for both.
43//!
44//! Every rebuilt wire is spliced in the face's own order rather than
45//! re-chained from a bag of edges: a chart's seam stands in its wire
46//! twice, and a bag cannot say so. Where a gap leaves and arrives at one
47//! vertex, the rim it replaces says which way round it goes; nothing in
48//! the topology notices a face inside out along its own rim, and the
49//! mesher finds it as a boundary that will not close.
50//!
51//! What this does not yet close is refused by name: a wound whose sides
52//! do not meet in a curve, a removal that would leave a face with no
53//! boundary and no edge to grow to, and a gap the recovered edges do not
54//! bridge.
55
56use crate::{OgeomResult, Tolerances, ogeom_bail};
57use ogeom_algo::{Built, History, make_edge_between, make_solid, make_vertex, sew};
58use ogeom_core::ogeom_err;
59use ogeom_geom::Curve3d as _;
60use ogeom_geom::Transformable as _;
61use ogeom_geom::{Curve, SurfaceGeometry};
62use ogeom_intersect::{
63    CurveSurfaceOptions, IntersectOptions, SurfaceIntersection, intersect_curve_surface,
64    intersect_surfaces,
65};
66use ogeom_math::Point;
67use ogeom_topo::{Filter, Model, NodeData, Shape, ShapeType, TShapeId, explore};
68use std::collections::{HashMap, HashSet};
69
70/// Remove `faces` from `solid` and close the openings from the neighbours'
71/// own geometry.
72///
73/// # Errors
74///
75/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction), by
76/// name, when the removal is not one this operation closes: no face named,
77/// every face named, a named shape that is not a face of the solid, a wound
78/// whose side surfaces do not meet in a single curve, more than one band, or
79/// geometry whose pcurves have no closed form to rebuild with.
80pub fn remove_faces(
81    model: &mut Model,
82    solid: &Shape,
83    faces: &[Shape],
84    tol: Tolerances,
85) -> OgeomResult<Built> {
86    if faces.is_empty() {
87        ogeom_bail!(Construction, "no faces named; there is nothing to remove");
88    }
89    // Separate features remove separately. Two bores named in one call are
90    // two wounds; classifying their ring edges together declares the two
91    // longest interrupted faces "the sides" across both and recovers a
92    // nonsense edge. Faces group into features by shared edges, and each
93    // feature runs the whole machinery on the previous feature's result,
94    // sequential exactly as a caller would have called it, so one call
95    // means what N calls mean, in the order given.
96    let groups = feature_groups(model, faces)?;
97    if groups.len() > 1 {
98        let mut current = Built::from_nothing(solid.clone());
99        for group in &groups {
100            // A later group's faces survive the earlier surgeries untouched
101            // (different regions), but the solid they belong to is new.
102            let step = remove_faces(model, &current.shape, group, tol)?;
103            current = Built {
104                shape: step.shape,
105                history: current.history.then(&step.history),
106            };
107        }
108        return Ok(current);
109    }
110    let all_faces = explore(model, solid, Filter::OfType(ShapeType::Face))?;
111    let removed: HashSet<TShapeId> = faces.iter().map(Shape::node).collect();
112    for face in faces {
113        if !all_faces.iter().any(|f| f.node() == face.node()) {
114            ogeom_bail!(
115                Construction,
116                "a face named for removal is not a face of this solid"
117            );
118        }
119    }
120    let survivors: Vec<Shape> = all_faces
121        .iter()
122        .filter(|f| !removed.contains(&f.node()))
123        .cloned()
124        .collect();
125    if survivors.is_empty() {
126        ogeom_bail!(
127            Construction,
128            "every face was named for removal; nothing remains to close"
129        );
130    }
131
132    // Which edges the removed set shares with the world: an edge is a ring
133    // edge when a removed face and a surviving face both use it.
134    let mut users: HashMap<TShapeId, Vec<Shape>> = HashMap::new();
135    for face in &all_faces {
136        for edge in explore(model, face, Filter::OfType(ShapeType::Edge))? {
137            users.entry(edge.node()).or_default().push(face.clone());
138        }
139    }
140    let is_ring = |edge: &Shape| -> bool {
141        users.get(&edge.node()).is_some_and(|fs| {
142            fs.iter().any(|f| removed.contains(&f.node()))
143                && fs.iter().any(|f| !removed.contains(&f.node()))
144        })
145    };
146
147    // Sort survivors: untouched, whole-ring, and interrupted: a wire with
148    // no rim edge, a wire that is all rim, a wire that is part rim.
149    struct Touched {
150        face: Shape,
151        /// The wires with no rim edge at all, which stand either way.
152        kept: Vec<Shape>,
153        /// Some wire of this face is all rim.
154        whole: bool,
155        /// Some wire of this face is part rim.
156        partial: bool,
157        /// And the outer wire is one of the whole ones.
158        outer: bool,
159    }
160    let mut untouched: Vec<Shape> = Vec::new();
161    let mut touched: Vec<Touched> = Vec::new();
162    for face in &survivors {
163        let wires = model.ordered_children_of(face)?;
164        let mut kept = Vec::new();
165        let (mut whole, mut partial, mut outer) = (false, false, false);
166        for (index, wire) in wires.iter().enumerate() {
167            let edges = model.ordered_children_of(wire)?;
168            let ring_count = edges.iter().filter(|e| is_ring(e)).count();
169            if ring_count == 0 {
170                kept.push(wire.clone());
171            } else if ring_count == edges.len() {
172                whole = true;
173                outer |= index == 0;
174            } else {
175                partial = true;
176            }
177        }
178        if whole || partial {
179            touched.push(Touched {
180                face: face.clone(),
181                kept,
182                whole,
183                partial,
184                outer,
185            });
186        } else {
187            untouched.push(face.clone());
188        }
189    }
190
191    // A neighbour that lost a whole ring is closed one of two ways, and the
192    // neighbours' surfaces say which: where they meet in the wound's own
193    // room the ring is replaced by the edge they meet along, and where they
194    // do not meet at all it is simply dropped and the face grows over what
195    // the feature stood in.
196    let sides: Vec<Shape> = touched.iter().map(|t| t.face.clone()).collect();
197    let recovers = touched.iter().any(|t| t.whole) && wound_recovers(model, faces, &sides, tol)?;
198    let mut rim_surgery: Vec<(Shape, Vec<Shape>)> = Vec::new(); // face, kept wires
199    let mut interrupted: Vec<Shape> = Vec::new();
200    for entry in touched {
201        if entry.partial || recovers {
202            interrupted.push(entry.face);
203        } else {
204            // Dropping the outer boundary would leave a face with nothing
205            // to stand on; replacing it, where the neighbours meet, is the
206            // branch above.
207            if entry.outer {
208                ogeom_bail!(
209                    Construction,
210                    "removing these faces erases a neighbour's whole outer \
211                     boundary; that face has nothing left to stand on"
212                );
213            }
214            rim_surgery.push((entry.face, entry.kept));
215        }
216    }
217
218    let mut history = History::new();
219    for face in faces {
220        history.delete(face);
221    }
222
223    let mut rebuilt: Vec<Shape> = untouched;
224    for (face, kept_wires) in rim_surgery {
225        let new_face = {
226            let Some(data) = model.node(&face).and_then(|n| match n.data() {
227                NodeData::Face(d) => Some(d.clone()),
228                _ => None,
229            }) else {
230                ogeom_bail!(Construction, "a surviving face holds no face data");
231            };
232            // The kept wires carry their edges, and the edges their pcurves
233            // for this very surface: nothing to recompute.
234            let built = ogeom_algo::make_face_on(model, data.surface, &kept_wires, tol)?.shape;
235            orient_like(&face, built)
236        };
237        history.modify(&face, new_face.clone());
238        rebuilt.push(new_face);
239    }
240
241    if !interrupted.is_empty() {
242        let band = close_wound(model, faces, &interrupted, &removed, &users, &is_ring, tol)?;
243        for (old, new) in band {
244            history.modify(&old, new.clone());
245            rebuilt.push(new);
246        }
247    }
248
249    let sewn = sew(model, &rebuilt, tol)?;
250    let [shell] = sewn.shells.as_slice() else {
251        ogeom_bail!(
252            Construction,
253            "closing the wound left {} shells where one solid's worth was \
254             expected; the removal disconnected the boundary",
255            sewn.shells.len()
256        );
257    };
258    if !ogeom_algo::is_shell_closed(model, shell)? {
259        ogeom_bail!(
260            Construction,
261            "the boundary does not close after removal; the wound needs a \
262             closure this operation does not construct yet"
263        );
264    }
265    let built = make_solid(model, std::slice::from_ref(shell))?;
266    let mut solid_history = history;
267    solid_history.modify(solid, built.shape.clone());
268    Ok(Built::new(built.shape, solid_history))
269}
270
271/// A face's surface, carried into space by the face's own placement.
272fn placed_surface(model: &Model, face: &Shape, tol: Tolerances) -> OgeomResult<SurfaceGeometry> {
273    let placement = face.transform(model.datums())?;
274    let Some(data) = model.node(face).and_then(|n| n.data().as_face().cloned()) else {
275        ogeom_bail!(Construction, "a band face holds no face data");
276    };
277    let Some(surface) = model.geometry().surface(data.surface) else {
278        ogeom_bail!(Construction, "a band face's surface is not in this model");
279    };
280    surface.clone().transformed(&placement, tol)
281}
282
283/// Whether the wound's neighbours meet each other where it sat.
284///
285/// A whole ring taken out of a neighbour is two different wounds, and only
286/// the neighbours' own surfaces tell them apart. A bore's wall leaves its
287/// two mouths as whole inner wires, and the faces holding them (a block's
288/// top and bottom) never meet: dropping the wires is the closure, and the
289/// block comes back whole. A rim blend leaves a whole ring too (the
290/// annulus a mouth fillet takes out of the top, the circle a boss's seat
291/// takes out of the wall), but there the cap and the wall meet along the
292/// very circle the blend replaced, and dropping would leave the boundary
293/// open where that circle belongs.
294///
295/// Meeting *somewhere* is not enough: two faces of any solid meet if their
296/// surfaces are carried far enough, and a bore through a wedge would
297/// recover the line where the wedge closes. The meeting must stand in the
298/// wound's own room (the removed faces' bounds), which is where the edge
299/// the feature replaced stood.
300fn wound_recovers(
301    model: &Model,
302    removed_faces: &[Shape],
303    candidates: &[Shape],
304    tol: Tolerances,
305) -> OgeomResult<bool> {
306    let mut room = ogeom_math::Aabb::default();
307    for face in removed_faces {
308        room = room.union(&ogeom_algo::shape_bounds(model, face, tol)?);
309    }
310    let room = room.expanded(tol.confusion() * 1e3);
311    let Some(centre) = room.centre() else {
312        return Ok(false);
313    };
314    for (i, first) in candidates.iter().enumerate() {
315        let sa = placed_surface(model, first, tol)?;
316        for second in &candidates[i + 1..] {
317            let sb = placed_surface(model, second, tol)?;
318            let Ok(SurfaceIntersection::Along(sections)) =
319                intersect_surfaces(&sa, &sb, IntersectOptions::default(), tol)
320            else {
321                continue;
322            };
323            for section in sections {
324                let foot = ogeom_algo::project_on_curve(&section.curve, centre, 64, tol)?;
325                if room.contains(foot.point) {
326                    return Ok(true);
327                }
328            }
329        }
330    }
331    Ok(false)
332}
333
334/// One crease the wound recovers: the edge a removed band replaced, from
335/// its two side faces' own surfaces.
336struct Crease {
337    /// The side faces, by node, in a fixed order.
338    sides: [Shape; 2],
339    /// The recovered curve, the branch nearest the removed faces.
340    curve: Curve,
341    /// The removed faces' extent along the curve, unwrapped about the
342    /// anchor on a periodic curve.
343    extent: (f64, f64),
344    /// The parameter nearest the removed faces' centre: what a periodic
345    /// curve's parameters are unwrapped about, so a band straddling the
346    /// curve's seam reads as one run and not its complement.
347    anchor: f64,
348}
349
350/// A periodic curve's parameter brought within half a period of `about`;
351/// any other curve's parameter as it is.
352fn unwrapped(curve: &Curve, t: f64, about: f64) -> f64 {
353    if !curve.is_periodic() {
354        return t;
355    }
356    let (lo, hi) = curve.domain();
357    let period = hi - lo;
358    if period <= 0.0 {
359        return t;
360    }
361    about + (t - about + period / 2.0).rem_euclid(period) - period / 2.0
362}
363
364/// Close a wound: each removed band's two side faces re-intersected into
365/// the crease it replaced, the creases' ends placed where they pierce the
366/// other interrupted faces (or one another's sides, which is where two
367/// bands meeting at a corner share their corner), every dangling edge
368/// extended along its own curve to the corner standing on it, and every
369/// interrupted face rebuilt with the creases it borders.
370///
371/// A band's sides are the two survivors it shares the most ring length
372/// with; a wedge's cap named alongside its band shares the band's sides
373/// and folds into the same crease. A crease's ends are the nearest
374/// piercings just past the removed faces' own extent along it, so a
375/// survivor the curve merely runs through far away is not mistaken for an
376/// end. Corners are one vertex wherever two creases place them within
377/// tolerance of each other.
378#[allow(clippy::too_many_lines, reason = "one wound, one narrative")]
379fn close_wound(
380    model: &mut Model,
381    removed_faces: &[Shape],
382    interrupted: &[Shape],
383    removed: &HashSet<TShapeId>,
384    users: &HashMap<TShapeId, Vec<Shape>>,
385    is_ring: &dyn Fn(&Shape) -> bool,
386    tol: Tolerances,
387) -> OgeomResult<Vec<(Shape, Shape)>> {
388    let surface_of = |model: &Model, face: &Shape| placed_surface(model, face, tol);
389    let vertices_of = |model: &Model, face: &Shape| -> OgeomResult<Vec<Point>> {
390        let mut out = Vec::new();
391        for vertex in explore(model, face, Filter::OfType(ShapeType::Vertex))? {
392            let placement = vertex.transform(model.datums())?;
393            if let Some(d) = model.node(&vertex).and_then(|nd| nd.data().as_vertex()) {
394                out.push(placement.apply(d.point));
395            }
396        }
397        Ok(out)
398    };
399    let interrupted_by_node: HashMap<TShapeId, Shape> =
400        interrupted.iter().map(|f| (f.node(), f.clone())).collect();
401
402    // Each removed face's sides: the two interrupted survivors it shares
403    // the most ring length with. Creases are keyed by the side pair. A
404    // removed face with fewer than two such neighbours (a wedge's cap
405    // standing against another blend's band, bordering one wall and two
406    // removed faces) joins the crease of a removed neighbour it shares an
407    // edge with, once that neighbour has one.
408    let mut creases: Vec<(TShapeId, TShapeId, [Shape; 2], Vec<Point>)> = Vec::new();
409    let mut crease_of: HashMap<TShapeId, usize> = HashMap::new();
410    let mut leftovers: Vec<Shape> = Vec::new();
411    for face in removed_faces {
412        let mut shared: HashMap<TShapeId, f64> = HashMap::new();
413        for edge in explore(model, face, Filter::OfType(ShapeType::Edge))? {
414            if !is_ring(&edge) {
415                continue;
416            }
417            let length = edge_length(model, &edge, tol)?;
418            for user in users.get(&edge.node()).into_iter().flatten() {
419                if !removed.contains(&user.node()) && interrupted_by_node.contains_key(&user.node())
420                {
421                    *shared.entry(user.node()).or_default() += length;
422                }
423            }
424        }
425        let mut ranked: Vec<(TShapeId, f64)> = shared.into_iter().collect();
426        ranked.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.index().cmp(&b.0.index())));
427        let [(a, _), (b, _), ..] = ranked.as_slice() else {
428            leftovers.push(face.clone());
429            continue;
430        };
431        let (lo, hi) = if a.index() <= b.index() {
432            (*a, *b)
433        } else {
434            (*b, *a)
435        };
436        let points = vertices_of(model, face)?;
437        let index = match creases.iter().position(|c| c.0 == lo && c.1 == hi) {
438            Some(i) => {
439                creases[i].3.extend(points);
440                i
441            }
442            None => {
443                creases.push((
444                    lo,
445                    hi,
446                    [
447                        interrupted_by_node[&lo].clone(),
448                        interrupted_by_node[&hi].clone(),
449                    ],
450                    points,
451                ));
452                creases.len() - 1
453            }
454        };
455        crease_of.insert(face.node(), index);
456    }
457    for face in leftovers {
458        let mut joined = None;
459        for edge in explore(model, &face, Filter::OfType(ShapeType::Edge))? {
460            for user in users.get(&edge.node()).into_iter().flatten() {
461                if let Some(&index) = crease_of.get(&user.node()) {
462                    joined = Some(index);
463                }
464            }
465        }
466        let Some(index) = joined else {
467            ogeom_bail!(
468                Construction,
469                "a removed face shares ring edges with fewer than two \
470                 interrupted neighbours and borders no removed face with a \
471                 crease; closing it needs a neighbour to meet itself, which \
472                 is not constructed yet"
473            );
474        };
475        let points = vertices_of(model, &face)?;
476        creases[index].3.extend(points);
477    }
478
479    // The recovered curve of each crease, and the removed faces' extent on it.
480    let mut recovered: Vec<Crease> = Vec::new();
481    for (_, _, sides, points) in creases {
482        let sa = surface_of(model, &sides[0])?;
483        let sb = surface_of(model, &sides[1])?;
484        let meeting = intersect_surfaces(&sa, &sb, IntersectOptions::default(), tol)?;
485        let SurfaceIntersection::Along(sections) = meeting else {
486            ogeom_bail!(
487                Construction,
488                "the band's side surfaces do not meet along a curve; the edge \
489                 the feature replaced cannot be recovered from them"
490            );
491        };
492        let anchor = {
493            let mut sum = ogeom_math::Vector::ZERO;
494            for p in &points {
495                sum += p.to_vector();
496            }
497            #[allow(clippy::cast_precision_loss)]
498            let n = points.len().max(1) as f64;
499            Point::ORIGIN + sum * (1.0 / n)
500        };
501        let section = sections
502            .into_iter()
503            .min_by(|p, q| {
504                nearest_distance(&p.curve, anchor, tol)
505                    .total_cmp(&nearest_distance(&q.curve, anchor, tol))
506            })
507            .ok_or_else(|| ogeom_err!(Construction, "the side surfaces meet along no branch"))?;
508        let curve = section.curve;
509        let anchor = parameter_near(&curve, anchor, tol)?;
510        let mut extent = (f64::INFINITY, f64::NEG_INFINITY);
511        for p in &points {
512            let t = unwrapped(&curve, parameter_near(&curve, *p, tol)?, anchor);
513            extent = (extent.0.min(t), extent.1.max(t));
514        }
515        recovered.push(Crease {
516            sides,
517            curve,
518            extent,
519            anchor,
520        });
521    }
522
523    // Corners: where each crease pierces an interrupted face that is not
524    // one of its sides, the nearest piercing past each end of its extent.
525    // A shared corner is one vertex.
526    let mut corner_vertices: Vec<(Point, Shape)> = Vec::new();
527    let mut vertex_at = |model: &mut Model, p: Point| -> Shape {
528        if let Some((_, v)) = corner_vertices
529            .iter()
530            .find(|(q, _)| q.distance(p) <= tol.confusion() * 1e3)
531        {
532            return v.clone();
533        }
534        let v = make_vertex(model, p).shape;
535        corner_vertices.push((p, v.clone()));
536        v
537    };
538    let mut new_edges: Vec<(Shape, [TShapeId; 2])> = Vec::new(); // edge, its sides
539    let mut corners: Vec<Shape> = Vec::new();
540    for (index, crease) in recovered.iter().enumerate() {
541        let mut piercings: Vec<(f64, Point)> = Vec::new();
542        for face in interrupted {
543            if crease.sides.iter().any(|s| s.node() == face.node()) {
544                continue;
545            }
546            let se = surface_of(model, face)?;
547            let hit =
548                intersect_curve_surface(&crease.curve, &se, CurveSurfaceOptions::default(), tol)?;
549            for c in &hit.crossings {
550                piercings.push((unwrapped(&crease.curve, c.on_curve, crease.anchor), c.point));
551            }
552        }
553        // A tangent junction: two bands of one chain meeting flush (a
554        // stadium's straight run into its semicircular end) share the
555        // cross-section edge where they meet, and their creases touch
556        // there without either piercing the other's side; a wall the
557        // crease merely grazes yields no piercing, and a touch found as a
558        // closest approach sits anywhere in a valley the width of the
559        // slop. The shared edge says exactly where: the cross-section
560        // stands in the plane normal to the rim at the junction, so the
561        // junction is the foot of that edge on either crease: a
562        // transversal projection, exact to the last bit. Taken only where
563        // the two creases are tangent there; bands meeting at a corner
564        // place theirs by piercing.
565        for face in removed_faces {
566            if crease_of.get(&face.node()) != Some(&index) {
567                continue;
568            }
569            for edge in explore(model, face, Filter::OfType(ShapeType::Edge))? {
570                let Some(other) = users
571                    .get(&edge.node())
572                    .into_iter()
573                    .flatten()
574                    .filter(|user| user.node() != face.node())
575                    .filter_map(|user| crease_of.get(&user.node()).copied())
576                    .find(|&other| other != index)
577                else {
578                    continue;
579                };
580                let samples = sample_edge(model, &edge, tol)?;
581                let Some(&middle) = samples.get(samples.len() / 2) else {
582                    continue;
583                };
584                let here = ogeom_algo::project_on_curve(&crease.curve, middle, 64, tol)?;
585                let there = ogeom_algo::project_on_curve(&recovered[other].curve, middle, 64, tol)?;
586                if here.point.distance(there.point) > tol.confusion() * 1e3 {
587                    continue;
588                }
589                let ta = crease.curve.d1_at(here.parameter, tol)?;
590                let tb = recovered[other].curve.d1_at(there.parameter, tol)?;
591                if ta.cross(tb).magnitude() > ta.magnitude() * tb.magnitude() * 1e-3 {
592                    continue;
593                }
594                piercings.push((
595                    unwrapped(&crease.curve, here.parameter, crease.anchor),
596                    here.point,
597                ));
598            }
599        }
600        let slack = tol.parametric().max(1e-6);
601        let below = piercings
602            .iter()
603            .filter(|(t, _)| *t <= crease.extent.0 + slack)
604            .max_by(|a, b| a.0.total_cmp(&b.0));
605        let above = piercings
606            .iter()
607            .filter(|(t, _)| *t >= crease.extent.1 - slack)
608            .min_by(|a, b| a.0.total_cmp(&b.0));
609        match (below, above) {
610            (None, None) if piercings.is_empty() => {
611                // No ends: the band wraps, and the recovered edge closes on
612                // itself, but not always as one edge. A chart's seam
613                // reaches the recovered curve too: a cylinder wall's wire
614                // runs up its seam, round the rim and back down, and a
615                // closed edge carrying a vertex of its own leaves that wire
616                // two chains that never meet. So each side's own dangling
617                // boundary names a corner where it reaches the curve, the
618                // curve is cut there, and the dangling edge then extends to
619                // it like any other.
620                let (lo, hi) = crease.curve.domain();
621                if !crease.curve.is_periodic() {
622                    ogeom_bail!(
623                        Construction,
624                        "a wrapping band recovered an open curve; the closure is \
625                         not constructible from it"
626                    );
627                }
628                let period = hi - lo;
629                let mut stops: Vec<f64> = Vec::new();
630                for side in &crease.sides {
631                    let mut rim_vertices: HashSet<TShapeId> = HashSet::new();
632                    for edge in explore(model, side, Filter::OfType(ShapeType::Edge))? {
633                        if is_ring(&edge) {
634                            for v in model.ordered_children_of(&edge)? {
635                                rim_vertices.insert(v.node());
636                            }
637                        }
638                    }
639                    for edge in explore(model, side, Filter::OfType(ShapeType::Edge))? {
640                        if is_ring(&edge) {
641                            continue;
642                        }
643                        let free: Vec<Point> = model
644                            .ordered_children_of(&edge)?
645                            .iter()
646                            .filter(|v| rim_vertices.contains(&v.node()))
647                            .filter_map(|v| {
648                                model
649                                    .node(v)
650                                    .and_then(|n| n.data().as_vertex())
651                                    .map(|d| d.point)
652                            })
653                            .collect();
654                        if free.is_empty() {
655                            continue;
656                        }
657                        let Some(geometry) = edge_geometry(model, &edge, tol)? else {
658                            continue;
659                        };
660                        for start in free {
661                            // The two curves' closest approach, from the
662                            // dangling end: each in turn answers where the
663                            // other's nearest point is, and an edge that
664                            // genuinely reaches the curve settles on it.
665                            let mut point = start;
666                            for _ in 0..8 {
667                                let on_curve =
668                                    ogeom_algo::project_on_curve(&crease.curve, point, 64, tol)?;
669                                let t = parameter_near(&geometry, on_curve.point, tol)?;
670                                let Ok(on_edge) = geometry.point_at(t, tol) else {
671                                    break;
672                                };
673                                if on_edge.distance(on_curve.point) <= tol.confusion() * 10.0 {
674                                    stops.push(lo + (on_curve.parameter - lo).rem_euclid(period));
675                                    break;
676                                }
677                                point = on_edge;
678                            }
679                        }
680                    }
681                }
682                // Re-anchored at the first corner, so the whole turn stands
683                // inside the curve's own domain: a rim written from a corner
684                // right round to itself would otherwise end a turn past the
685                // end of it. One corner then leaves the rim one closed edge,
686                // which is the shape it had before the feature was cut,
687                // and the shape the exact volume integrator reads as a disc.
688                let (curve, stops) = match (&crease.curve, stops.first().copied()) {
689                    (Curve::Circle(circle), Some(first)) => {
690                        let at = crease.curve.point_at(first, tol)?;
691                        let held = circle.circle();
692                        let anchored = ogeom_geom::CircleCurve::new(ogeom_math::Circle::new(
693                            ogeom_math::Frame::new(
694                                held.centre(),
695                                held.frame().z(),
696                                ogeom_math::Direction::new(at - held.centre(), tol)?,
697                                tol,
698                            )?,
699                            held.radius(),
700                            tol,
701                        )?);
702                        let mut anchored = Curve::Circle(anchored);
703                        // The same circle, and the same way round it: the
704                        // re-anchoring moves where the parameter starts and
705                        // must not turn the rim over.
706                        if anchored
707                            .d1_at(0.0, tol)?
708                            .dot(crease.curve.d1_at(first, tol)?)
709                            < 0.0
710                        {
711                            anchored = ogeom_geom::Reversible::reversed(&anchored);
712                        }
713                        let shifted = stops
714                            .iter()
715                            .map(|t| (t - first).rem_euclid(period))
716                            .collect::<Vec<f64>>();
717                        (anchored, shifted)
718                    }
719                    // A rim that is not a circle cannot be re-anchored, so
720                    // it is cut at the chart's start as well and the turn
721                    // stays inside the domain in pieces instead.
722                    _ => {
723                        let mut kept = stops;
724                        kept.push(lo);
725                        (crease.curve.clone(), kept)
726                    }
727                };
728                let mut cuts = stops;
729                cuts.sort_by(f64::total_cmp);
730                cuts.dedup_by(|a, b| (*a - *b).abs() <= tol.parametric().max(1e-9));
731                if cuts.is_empty() {
732                    cuts.push(lo);
733                }
734                let mut cut: Vec<Shape> = Vec::with_capacity(cuts.len());
735                for t in &cuts {
736                    let at = curve.point_at(*t, tol)?;
737                    cut.push(vertex_at(model, at));
738                }
739                for (index, t) in cuts.iter().enumerate() {
740                    let next = if index + 1 == cuts.len() {
741                        cuts[0] + period
742                    } else {
743                        cuts[index + 1]
744                    };
745                    let edge = make_edge_between(
746                        model,
747                        curve.clone(),
748                        (*t, next),
749                        &cut[index],
750                        &cut[(index + 1) % cuts.len()],
751                        tol,
752                    )?
753                    .shape;
754                    new_edges.push((edge, [crease.sides[0].node(), crease.sides[1].node()]));
755                }
756                corners.extend(cut);
757            }
758            (Some(&(t0, p0)), Some(&(t1, p1))) => {
759                let v0 = vertex_at(model, p0);
760                let v1 = vertex_at(model, p1);
761                // Unwrapped about the anchor, a window can start before a
762                // periodic curve's domain; slid by whole turns to start
763                // inside it, it is the same run, and may end a turn past
764                // the end as any run across the seam does.
765                let window = if crease.curve.is_periodic() {
766                    let (lo, hi) = crease.curve.domain();
767                    let turns = ((t0 - lo) / (hi - lo)).floor() * (hi - lo);
768                    (t0 - turns, t1 - turns)
769                } else {
770                    (t0, t1)
771                };
772                let edge =
773                    make_edge_between(model, crease.curve.clone(), window, &v0, &v1, tol)?.shape;
774                corners.push(v0);
775                corners.push(v1);
776                new_edges.push((edge, [crease.sides[0].node(), crease.sides[1].node()]));
777            }
778            _ => ogeom_bail!(
779                Construction,
780                "an end face's surface never meets the recovered edge; the \
781                 corner cannot be placed"
782            ),
783        }
784    }
785
786    let mut out = Vec::new();
787    let mut extended: HashMap<TShapeId, Shape> = HashMap::new();
788    for face in interrupted {
789        let rims: Vec<Shape> = explore(model, face, Filter::OfType(ShapeType::Edge))?
790            .into_iter()
791            .filter(|e| is_ring(e))
792            .collect();
793        let borders: Vec<Shape> = new_edges
794            .iter()
795            .filter(|(_, sides)| sides.contains(&face.node()))
796            .map(|(e, _)| e.clone())
797            .collect();
798        let new_face =
799            rebuild_interrupted(model, face, &rims, &borders, &corners, &mut extended, tol)?;
800        out.push((face.clone(), new_face));
801    }
802    Ok(out)
803}
804
805/// The connected components of the removal set: faces joined by shared
806/// edges belong to one feature and close as one wound.
807fn feature_groups(model: &Model, faces: &[Shape]) -> OgeomResult<Vec<Vec<Shape>>> {
808    let mut edge_sets: Vec<HashSet<TShapeId>> = Vec::with_capacity(faces.len());
809    for face in faces {
810        edge_sets.push(
811            explore(model, face, Filter::OfType(ShapeType::Edge))?
812                .iter()
813                .map(Shape::node)
814                .collect(),
815        );
816    }
817    let mut group_of: Vec<usize> = (0..faces.len()).collect();
818    // Union by scan: small sets, clarity over asymptotics.
819    fn root(group_of: &mut [usize], mut i: usize) -> usize {
820        while group_of[i] != i {
821            group_of[i] = group_of[group_of[i]];
822            i = group_of[i];
823        }
824        i
825    }
826    for i in 0..faces.len() {
827        for j in i + 1..faces.len() {
828            if edge_sets[i].intersection(&edge_sets[j]).next().is_some() {
829                let (a, b) = (root(&mut group_of, i), root(&mut group_of, j));
830                group_of[a.max(b)] = a.min(b);
831            }
832        }
833    }
834    let mut groups: HashMap<usize, Vec<Shape>> = HashMap::new();
835    for (i, face) in faces.iter().enumerate() {
836        groups
837            .entry(root(&mut group_of, i))
838            .or_default()
839            .push(face.clone());
840    }
841    let mut out: Vec<Vec<Shape>> = groups.into_values().collect();
842    // Deterministic order: by each group's smallest node index.
843    out.sort_by_key(|g| g.iter().map(|f| f.node().index()).min());
844    Ok(out)
845}
846
847/// Rebuild one interrupted face: drop its ring edges, extend the edges that
848/// now dangle to the corner vertex standing on their own curve, add the
849/// recovered edges this face borders, and rechain.
850fn rebuild_interrupted(
851    model: &mut Model,
852    face: &Shape,
853    rims: &[Shape],
854    borders: &[Shape],
855    corners: &[Shape],
856    extended: &mut HashMap<TShapeId, Shape>,
857    tol: Tolerances,
858) -> OgeomResult<Shape> {
859    let placement = face.transform(model.datums())?;
860    let Some(data) = model.node(face).and_then(|n| n.data().as_face().cloned()) else {
861        ogeom_bail!(Construction, "an interrupted face holds no face data");
862    };
863    let Some(surface) = model.geometry().surface(data.surface).cloned() else {
864        ogeom_bail!(
865            Construction,
866            "an interrupted face's surface is not in this model"
867        );
868    };
869    let surface = surface.transformed(&placement, tol)?;
870    let rim_nodes: HashSet<TShapeId> = rims.iter().map(Shape::node).collect();
871
872    let mut wires: Vec<Vec<Shape>> = Vec::new();
873    for wire in model.ordered_children_of(face)? {
874        let edges = model.ordered_children_of(&wire)?;
875        let touched = edges.iter().any(|e| rim_nodes.contains(&e.node()));
876        if !touched {
877            wires.push(edges);
878            continue;
879        }
880        // Which vertices the dropped rim owned: an edge that shared one now
881        // dangles there and must reach a corner instead.
882        let mut rim_vertices: HashSet<TShapeId> = HashSet::new();
883        for edge in &edges {
884            if rim_nodes.contains(&edge.node()) {
885                for v in model.ordered_children_of(edge)? {
886                    rim_vertices.insert(v.node());
887                }
888            }
889        }
890        // Substituted in the wire's own order rather than re-chained from
891        // a bag of edges. A chart's seam stands in its wire *twice* (up
892        // one column and down the other), and a bag cannot say so: four
893        // edge ends meet at each of the seam's vertices, which reads as a
894        // branching network and not a wire. The order the face already has
895        // is the answer the bag was being asked to guess.
896        let mut ring: Vec<(Shape, Option<Shape>)> = Vec::with_capacity(edges.len());
897        for edge in &edges {
898            if rim_nodes.contains(&edge.node()) {
899                ring.push((edge.clone(), None));
900                continue;
901            }
902            // A face that has already extended this edge decided for
903            // everyone; sewing rejoins on the shared node.
904            let replaced = match extended.get(&edge.node()) {
905                Some(found) => Some(found.clone()),
906                None => {
907                    let dangles = model
908                        .ordered_children_of(edge)?
909                        .iter()
910                        .any(|v| rim_vertices.contains(&v.node()));
911                    match (dangles, corner_on_edge(model, edge, corners, tol)?) {
912                        (true, Some(corner)) => {
913                            Some(extend_to_corner(model, edge, &corner, extended, tol)?)
914                        }
915                        _ => None,
916                    }
917                }
918            };
919            // A replacement is built forward; the wire's own use decides
920            // which way it runs here.
921            ring.push((
922                edge.clone(),
923                Some(match replaced {
924                    Some(fresh) if edge.orientation() == ogeom_topo::Orientation::Reversed => {
925                        fresh.reversed()
926                    }
927                    Some(fresh) => fresh,
928                    None => edge.clone(),
929                }),
930            ));
931        }
932        if std::env::var_os("OGEOM_DEBUG_DEFEATURE").is_some() {
933            let point = |v: &Shape| {
934                model
935                    .node(v)
936                    .and_then(|n| n.data().as_vertex())
937                    .map(|d| d.point)
938            };
939            for (edge, spliced) in &ring {
940                let (a, b) = edge_ends(model, spliced.as_ref().unwrap_or(edge))?;
941                eprintln!(
942                    "DEFEATURE wire edge {} {} {:?} .. {:?}",
943                    edge.node().index(),
944                    if spliced.is_none() { "RIM" } else { "kept" },
945                    point(&a),
946                    point(&b)
947                );
948            }
949        }
950        let mut pool: Vec<Shape> = borders.to_vec();
951        let mut chained: Vec<Shape> = Vec::new();
952        if ring.iter().all(|(_, spliced)| spliced.is_none()) {
953            // The whole wire was the wound's rim: the recovered edges are
954            // the wire, closing on themselves.
955            let Some(start) = pool.first().cloned() else {
956                ogeom_bail!(
957                    Construction,
958                    "a neighbour lost a whole ring and no recovered edge \
959                     borders it; the wound needs a closure this operation \
960                     does not construct yet"
961                );
962            };
963            let (from, _) = edge_ends(model, &start)?;
964            chained = bridge_gap(model, &mut pool, &from, &from)?;
965            let mut was = Vec::new();
966            for (edge, _) in &ring {
967                was.extend(sample_edge(model, edge, tol)?);
968            }
969            wind_like(model, &mut chained, &was, tol)?;
970        } else {
971            // Rotated so the wire begins on an edge that survived, which
972            // puts every run of rim edges between two of them.
973            let first = ring
974                .iter()
975                .position(|(_, spliced)| spliced.is_some())
976                .unwrap_or(0);
977            ring.rotate_left(first);
978            let mut index = 0;
979            while index < ring.len() {
980                if let Some(edge) = ring[index].1.clone() {
981                    chained.push(edge);
982                    index += 1;
983                    continue;
984                }
985                let run_end = ring[index..]
986                    .iter()
987                    .position(|(_, spliced)| spliced.is_some())
988                    .map_or(ring.len(), |k| index + k);
989                let Some(previous) = chained.last() else {
990                    ogeom_bail!(Construction, "a wound's rim opens a wire that has no start");
991                };
992                let (_, from) = edge_ends(model, previous)?;
993                let to = match ring.get(run_end).and_then(|(_, spliced)| spliced.as_ref()) {
994                    Some(next) => edge_ends(model, next)?.0,
995                    // The run closes the ring: it comes back to the start.
996                    None => edge_ends(model, &chained[0])?.0,
997                };
998                let mut bridge = bridge_gap(model, &mut pool, &from, &to)?;
999                // A gap that leaves and arrives at one vertex could be
1000                // walked either way round; the rim it replaces says which.
1001                if from.node() == to.node() {
1002                    let mut was = Vec::new();
1003                    for (edge, _) in &ring[index..run_end] {
1004                        was.extend(sample_edge(model, edge, tol)?);
1005                    }
1006                    wind_like(model, &mut bridge, &was, tol)?;
1007                }
1008                chained.extend(bridge);
1009                index = run_end;
1010            }
1011        }
1012        if !pool.is_empty() {
1013            ogeom_bail!(
1014                Construction,
1015                "{} recovered edges border this face and its wound's rim has \
1016                 nowhere to put them",
1017                pool.len()
1018            );
1019        }
1020        wires.push(chained);
1021    }
1022    let built = ogeom_algo::make_face_with_pcurves(model, surface, &wires, tol)?.shape;
1023    // The face's own side of its surface, carried over. A rebuilt face is
1024    // born forward, and a bore's wall is not: the mesher reads the wires'
1025    // winding and forgives it, but the exact integrator reads the flag and
1026    // hands back the bore as material.
1027    Ok(orient_like(face, built))
1028}
1029
1030/// A rebuilt face put back on the side of its surface the old one was on.
1031fn orient_like(was: &Shape, built: Shape) -> Shape {
1032    if was.orientation() == ogeom_topo::Orientation::Reversed {
1033        built.reversed()
1034    } else {
1035        built
1036    }
1037}
1038
1039/// Points along an edge, in the direction this use of it runs.
1040fn sample_edge(model: &Model, edge: &Shape, tol: Tolerances) -> OgeomResult<Vec<Point>> {
1041    const STATIONS: usize = 12;
1042    let Some(geometry) = edge_geometry(model, edge, tol)? else {
1043        return Ok(Vec::new());
1044    };
1045    let Some(range) = model
1046        .node(edge)
1047        .and_then(|n| n.data().as_edge())
1048        .and_then(|d| match d.curve3d()? {
1049            ogeom_topo::EdgeRepr::Curve3d { range, .. } => Some(*range),
1050            _ => None,
1051        })
1052    else {
1053        return Ok(Vec::new());
1054    };
1055    let backwards = edge.orientation() == ogeom_topo::Orientation::Reversed;
1056    let mut out = Vec::with_capacity(STATIONS + 1);
1057    for i in 0..=STATIONS {
1058        #[allow(clippy::cast_precision_loss)]
1059        let f = i as f64 / STATIONS as f64;
1060        let f = if backwards { 1.0 - f } else { f };
1061        out.push(geometry.point_at(range.0 + (range.1 - range.0) * f, tol)?);
1062    }
1063    Ok(out)
1064}
1065
1066/// Twice the area a closed run of points sweeps about its own centre, as a
1067/// vector: which way round the run goes, in the only terms two runs of
1068/// different shapes can be compared in.
1069fn swept_area(points: &[Point]) -> ogeom_math::Vector {
1070    if points.len() < 3 {
1071        return ogeom_math::Vector::ZERO;
1072    }
1073    let mut sum = ogeom_math::Vector::ZERO;
1074    for p in points {
1075        sum += p.to_vector();
1076    }
1077    #[allow(clippy::cast_precision_loss)]
1078    let centre = Point::ORIGIN + sum / points.len() as f64;
1079    let mut area = ogeom_math::Vector::ZERO;
1080    for pair in points.windows(2) {
1081        area += (pair[0] - centre).cross(pair[1] - centre);
1082    }
1083    area
1084}
1085
1086/// An edge's curve, carried into space by the edge's own placement.
1087///
1088/// The curve, not the trim: a segment cut short by the feature still
1089/// carries the line the whole edge was cut from, which is what an
1090/// extension runs along.
1091fn edge_geometry(model: &Model, edge: &Shape, tol: Tolerances) -> OgeomResult<Option<Curve>> {
1092    let placement = edge.transform(model.datums())?;
1093    let Some(curve) = model
1094        .node(edge)
1095        .and_then(|n| n.data().as_edge())
1096        .and_then(|d| match d.curve3d()? {
1097            ogeom_topo::EdgeRepr::Curve3d { curve, .. } => Some(*curve),
1098            _ => None,
1099        })
1100        .and_then(|id| model.geometry().curve(id).cloned())
1101    else {
1102        return Ok(None);
1103    };
1104    Ok(Some(curve.transformed(&placement, tol)?))
1105}
1106
1107/// An edge's vertices as this use of it runs: start first.
1108fn edge_ends(model: &Model, edge: &Shape) -> OgeomResult<(Shape, Shape)> {
1109    ogeom_algo::edge_vertices(model, edge)?
1110        .ok_or_else(|| ogeom_err!(Construction, "an edge of a rebuilt wire has no vertices"))
1111}
1112
1113/// Turn a bridging chain to run the way the rim it replaces ran.
1114///
1115/// A chain that leaves and arrives at one vertex closes either way round,
1116/// and the walk that built it took whichever direction its first edge
1117/// happened to be stored in. The rim the wound took out went one way round
1118/// its face, and the recovered one must go the same way or the face is
1119/// inside out along it, which nothing in the topology notices, and the
1120/// mesher finds as a boundary that will not close.
1121fn wind_like(
1122    model: &Model,
1123    chain: &mut [Shape],
1124    was: &[Point],
1125    tol: Tolerances,
1126) -> OgeomResult<()> {
1127    let mut now = Vec::new();
1128    for edge in chain.iter() {
1129        now.extend(sample_edge(model, edge, tol)?);
1130    }
1131    if swept_area(&now).dot(swept_area(was)) >= 0.0 {
1132        return Ok(());
1133    }
1134    chain.reverse();
1135    for edge in chain.iter_mut() {
1136        *edge = edge.clone().reversed();
1137    }
1138    Ok(())
1139}
1140
1141/// Walk `pool` from `from` to `to`, orienting each edge to run the way the
1142/// walk goes and consuming what it uses.
1143///
1144/// The gap a wound's rim leaves in a wire is bridged by the recovered
1145/// edges, and which of them and which way round is decided by their own
1146/// vertices rather than by any ordering they arrive in. Some gaps need no
1147/// edge at all: an end face's rim was the band's cap, and once the two
1148/// edges either side of it reach the corner they meet there themselves. So
1149/// the walk steps only when the pool offers a step, and arriving with
1150/// nothing taken is an answer.
1151fn bridge_gap(
1152    model: &Model,
1153    pool: &mut Vec<Shape>,
1154    from: &Shape,
1155    to: &Shape,
1156) -> OgeomResult<Vec<Shape>> {
1157    let mut chain = Vec::new();
1158    let mut here = from.node();
1159    loop {
1160        let mut found = None;
1161        for (index, edge) in pool.iter().enumerate() {
1162            let (a, b) = edge_ends(model, edge)?;
1163            if a.node() == here {
1164                found = Some((index, false, b));
1165                break;
1166            }
1167            if b.node() == here {
1168                found = Some((index, true, a));
1169                break;
1170            }
1171        }
1172        let Some((index, backwards, next)) = found else {
1173            if here == to.node() {
1174                // Nothing to bridge: the wire's own edges already meet
1175                // where the rim used to run.
1176                return Ok(chain);
1177            }
1178            if std::env::var_os("OGEOM_DEBUG_DEFEATURE").is_some() {
1179                let point = |v: &Shape| {
1180                    model
1181                        .node(v)
1182                        .and_then(|n| n.data().as_vertex())
1183                        .map(|d| d.point)
1184                };
1185                eprintln!(
1186                    "DEFEATURE bridge stuck at {:?} heading for {:?}",
1187                    point(from),
1188                    point(to)
1189                );
1190                for edge in pool.iter() {
1191                    let (a, b) = edge_ends(model, edge)?;
1192                    eprintln!("  border {:?} .. {:?}", point(&a), point(&b));
1193                }
1194            }
1195            ogeom_bail!(
1196                Construction,
1197                "no recovered edge bridges the wound's rim; the closure is \
1198                 not constructible from what the neighbours meet along"
1199            );
1200        };
1201        let edge = pool.remove(index);
1202        chain.push(if backwards { edge.reversed() } else { edge });
1203        here = next.node();
1204        if here == to.node() {
1205            return Ok(chain);
1206        }
1207        if pool.is_empty() {
1208            ogeom_bail!(
1209                Construction,
1210                "the recovered edges do not reach across the wound's rim; \
1211                 the closure is not constructible from them"
1212            );
1213        }
1214    }
1215}
1216
1217/// The corner vertex standing on an edge's own curve, nearest the edge,
1218/// when one does.
1219fn corner_on_edge(
1220    model: &Model,
1221    edge: &Shape,
1222    corners: &[Shape],
1223    tol: Tolerances,
1224) -> OgeomResult<Option<Shape>> {
1225    let placement = edge.transform(model.datums())?;
1226    let Some((curve, range)) = model
1227        .node(edge)
1228        .and_then(|n| n.data().as_edge())
1229        .and_then(|d| match d.curve3d()? {
1230            ogeom_topo::EdgeRepr::Curve3d { curve, range, .. } => Some((*curve, *range)),
1231            _ => None,
1232        })
1233    else {
1234        return Ok(None);
1235    };
1236    let Some(geometry) = model.geometry().curve(curve).cloned() else {
1237        return Ok(None);
1238    };
1239    let geometry = geometry.transformed(&placement, tol)?;
1240    let head = geometry.point_at(range.0, tol)?;
1241    let tail = geometry.point_at(range.1, tol)?;
1242    let mut best: Option<(f64, Shape)> = None;
1243    for corner in corners {
1244        let Some(p) = model
1245            .node(corner)
1246            .and_then(|n| n.data().as_vertex())
1247            .map(|d| d.point)
1248        else {
1249            continue;
1250        };
1251        let t = parameter_near(&geometry, p, tol)?;
1252        if geometry.point_at(t, tol)?.distance(p) > tol.confusion() * 1e3 {
1253            continue;
1254        }
1255        let gap = head.distance(p).min(tail.distance(p));
1256        if best.as_ref().is_none_or(|(g, _)| gap < *g) {
1257            best = Some((gap, corner.clone()));
1258        }
1259    }
1260    Ok(best.map(|(_, c)| c))
1261}
1262
1263/// The edge, extended along its own curve so its dangling end reaches the
1264/// corner vertex, shared across the faces that use it, so sewing rejoins
1265/// them on one node.
1266fn extend_to_corner(
1267    model: &mut Model,
1268    edge: &Shape,
1269    corner: &Shape,
1270    extended: &mut HashMap<TShapeId, Shape>,
1271    tol: Tolerances,
1272) -> OgeomResult<Shape> {
1273    if let Some(found) = extended.get(&edge.node()) {
1274        return Ok(found.clone());
1275    }
1276    let placement = edge.transform(model.datums())?;
1277    let Some((curve, range)) = model
1278        .node(edge)
1279        .and_then(|n| n.data().as_edge())
1280        .and_then(|d| match d.curve3d()? {
1281            ogeom_topo::EdgeRepr::Curve3d { curve, range, .. } => Some((*curve, *range)),
1282            _ => None,
1283        })
1284    else {
1285        ogeom_bail!(Construction, "a dangling edge has no curve to extend");
1286    };
1287    let Some(geometry) = model.geometry().curve(curve).cloned() else {
1288        ogeom_bail!(Construction, "a dangling edge's curve is not in this model");
1289    };
1290    let geometry = geometry.transformed(&placement, tol)?;
1291    let corner_point = model
1292        .node(corner)
1293        .and_then(|n| n.data().as_vertex())
1294        .map(|d| d.point)
1295        .ok_or_else(|| ogeom_err!(Construction, "a corner vertex holds no point"))?;
1296
1297    // Which end dangles: the one nearer the corner. The corner's parameter
1298    // on this curve comes from the geometry the curve already has.
1299    let head = geometry.point_at(range.0, tol)?;
1300    let tail = geometry.point_at(range.1, tol)?;
1301    let t_corner = parameter_near(&geometry, corner_point, tol)?;
1302    let (vertices, new_range, dangle_head) = {
1303        // Storage order, deliberately: the range is the stored curve's, and
1304        // `edge_vertices` would swap the pair for a reversed use.
1305        let bounds = model.children_of(edge)?;
1306        let (Some(va), Some(vb)) = (bounds.first().cloned(), bounds.last().cloned()) else {
1307            ogeom_bail!(Construction, "a dangling edge has no vertices");
1308        };
1309        if head.distance(corner_point) <= tail.distance(corner_point) {
1310            ((corner.clone(), vb), (t_corner, range.1), true)
1311        } else {
1312            ((va, corner.clone()), (range.0, t_corner), false)
1313        }
1314    };
1315    let _ = dangle_head;
1316    if new_range.1 <= new_range.0 {
1317        ogeom_bail!(
1318            Construction,
1319            "extending an edge to its corner inverted its range; the corner \
1320             sits on the wrong side of the edge"
1321        );
1322    }
1323    // A segment's stored domain ends at its own vertices; the extension is
1324    // the same line over a wider window.
1325    let geometry = match geometry {
1326        Curve::Line(line) => {
1327            let (lo, hi) = ogeom_geom::Curve3d::domain(&line);
1328            Curve::Line(ogeom_geom::LineCurve::over(
1329                line.axis(),
1330                lo.min(new_range.0),
1331                hi.max(new_range.1),
1332            )?)
1333        }
1334        other => other,
1335    };
1336    let built = make_edge_between(model, geometry, new_range, &vertices.0, &vertices.1, tol)?;
1337    extended.insert(edge.node(), built.shape.clone());
1338    Ok(built.shape)
1339}
1340
1341/// The corner's parameter on a curve, by closed form where one exists and by
1342/// projection where not.
1343fn parameter_near(curve: &Curve, p: Point, tol: Tolerances) -> OgeomResult<f64> {
1344    match curve {
1345        Curve::Line(line) => {
1346            let axis = line.axis();
1347            Ok((p - axis.location).dot(axis.direction.vector()))
1348        }
1349        Curve::Circle(c) => {
1350            let local = c.circle().frame().to_local(p);
1351            Ok(local.y.atan2(local.x).rem_euclid(core::f64::consts::TAU))
1352        }
1353        _ => Ok(ogeom_algo::project_on_curve(curve, p, 64, tol)?.parameter),
1354    }
1355}
1356
1357fn nearest_distance(curve: &Curve, p: Point, tol: Tolerances) -> f64 {
1358    ogeom_algo::project_on_curve(curve, p, 32, tol).map_or(f64::INFINITY, |pr| pr.distance)
1359}
1360
1361fn edge_length(model: &Model, edge: &Shape, tol: Tolerances) -> OgeomResult<f64> {
1362    let Some((curve, range)) = model
1363        .node(edge)
1364        .and_then(|n| n.data().as_edge())
1365        .and_then(|d| match d.curve3d()? {
1366            ogeom_topo::EdgeRepr::Curve3d { curve, range, .. } => Some((*curve, *range)),
1367            _ => None,
1368        })
1369    else {
1370        return Ok(0.0);
1371    };
1372    let Some(geometry) = model.geometry().curve(curve) else {
1373        return Ok(0.0);
1374    };
1375    ogeom_algo::curve_length(geometry, range, tol)
1376}