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//! A feature of several faces each bordering only one of the faces that
52//! must meet again (a step along an edge, a notch cut from a corner, a
53//! pocket open at an edge) is no band, and closes by **extension**: every
54//! two neighbours whose surfaces meet across the wound close the stretch
55//! of that curve it took. Between two neighbours that share no edge there
56//! (a step's top and front) the crease comes back as an edge from the face
57//! it leaves to the face it reaches; where they already share an edge along
58//! it, cut short (a notch's three edges) or split (the edge a pocket opens
59//! on), that edge runs on to where a third neighbour pierces the curve or
60//! to the other half's end.
61//!
62//! Two neighbours the wound left on one surface, facing one way (a slot
63//! across a top leaves the top in two halves on one plane), come back as
64//! one face, its boundary running through both halves' surviving edges.
65//! A curve two neighbours meet along that no other face stops (a rebate
66//! round a drum's rim, its cap and wall meeting again along the rim's
67//! whole circle) is taken whole, closing on itself, cut where a side's
68//! seam reaches it.
69//!
70//! What this does not yet close is refused by name: a wound whose sides
71//! do not meet in a curve and lie on no one surface, a removal that would
72//! leave a face with no boundary and no edge to grow to, and a gap the
73//! recovered edges do not bridge.
74
75use crate::{OgeomResult, Tolerances, ogeom_bail};
76use ogeom_algo::{Built, History, make_edge_between, make_solid, make_vertex, sew};
77use ogeom_core::ogeom_err;
78use ogeom_core::{FastMap, FastSet};
79use ogeom_geom::Curve3d as _;
80use ogeom_geom::Surface as _;
81use ogeom_geom::Transformable as _;
82use ogeom_geom::{Curve, SurfaceGeometry};
83use ogeom_intersect::{
84    CurveSurfaceOptions, IntersectOptions, SurfaceIntersection, intersect_curve_surface,
85    intersect_surfaces,
86};
87use ogeom_math::Point;
88use ogeom_topo::{Filter, Model, NodeData, Shape, ShapeType, TShapeId, explore};
89
90/// Remove `faces` from `solid` and close the openings from the neighbours'
91/// own geometry.
92///
93/// What `solid` shares with other shapes is copied before it is
94/// worked on ([`ogeom_algo::on_own_nodes`]), which leaves them as they are.
95///
96/// # Errors
97///
98/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction), by
99/// name, when the removal is not one this operation closes: no face named,
100/// every face named, a named shape that is not a face of the solid, a wound
101/// whose side surfaces do not meet in a single curve, more than one band, or
102/// geometry whose pcurves have no closed form to rebuild with.
103pub fn remove_faces(
104    model: &mut Model,
105    solid: &Shape,
106    faces: &[Shape],
107    tol: Tolerances,
108) -> OgeomResult<Built> {
109    ogeom_algo::on_own_nodes(model, solid, faces, |model, solid, parts| {
110        remove_faces_own(model, solid, parts, tol)
111    })
112}
113
114/// [`remove_faces`] on a shape every node below which is its own.
115fn remove_faces_own(
116    model: &mut Model,
117    solid: &Shape,
118    faces: &[Shape],
119    tol: Tolerances,
120) -> OgeomResult<Built> {
121    if faces.is_empty() {
122        ogeom_bail!(Construction, "no faces named; there is nothing to remove");
123    }
124    // Separate features remove separately. Two bores named in one call are
125    // two wounds. Classifying their ring edges together declares the two
126    // longest interrupted faces "the sides" across both and recovers a
127    // nonsense edge. Faces group into features by shared edges, and each
128    // feature runs the whole machinery on the previous feature's result,
129    // sequential exactly as a caller would have called it, so one call
130    // means what N calls mean, in the order given.
131    let groups = feature_groups(model, faces)?;
132    if groups.len() > 1 {
133        let mut current = Built::from_nothing(solid.clone());
134        for group in &groups {
135            // A later group's faces survive the earlier surgeries untouched
136            // (different regions), but the solid they belong to is new.
137            let step = remove_faces_own(model, &current.shape, group, tol)?;
138            current = Built {
139                shape: step.shape,
140                history: current.history.then(&step.history),
141            };
142        }
143        return Ok(current);
144    }
145    let all_faces = explore(model, solid, Filter::OfType(ShapeType::Face))?;
146    let removed: FastSet<TShapeId> = faces.iter().map(Shape::node).collect();
147    for face in faces {
148        if !all_faces.iter().any(|f| f.node() == face.node()) {
149            ogeom_bail!(
150                Construction,
151                "a face named for removal is not a face of this solid"
152            );
153        }
154    }
155    let survivors: Vec<Shape> = all_faces
156        .iter()
157        .filter(|f| !removed.contains(&f.node()))
158        .cloned()
159        .collect();
160    if survivors.is_empty() {
161        ogeom_bail!(
162            Construction,
163            "every face was named for removal; nothing remains to close"
164        );
165    }
166
167    // Which edges the removed set shares with the world: an edge is a ring
168    // edge when a removed face and a surviving face both use it.
169    let mut users: FastMap<TShapeId, Vec<Shape>> = FastMap::default();
170    for face in &all_faces {
171        for edge in explore(model, face, Filter::OfType(ShapeType::Edge))? {
172            users.entry(edge.node()).or_default().push(face.clone());
173        }
174    }
175    let is_ring = |edge: &Shape| -> bool {
176        users.get(&edge.node()).is_some_and(|fs| {
177            fs.iter().any(|f| removed.contains(&f.node()))
178                && fs.iter().any(|f| !removed.contains(&f.node()))
179        })
180    };
181
182    // Sort survivors: untouched, whole-ring, and interrupted: a wire with
183    // no rim edge, a wire that is all rim, a wire that is part rim.
184    struct Touched {
185        face: Shape,
186        /// The wires with no rim edge at all, which stand either way.
187        kept: Vec<Shape>,
188        /// Some wire of this face is all rim.
189        whole: bool,
190        /// Some wire of this face is part rim.
191        partial: bool,
192        /// And the outer wire is one of the whole ones.
193        outer: bool,
194    }
195    let mut untouched: Vec<Shape> = Vec::new();
196    let mut touched: Vec<Touched> = Vec::new();
197    for face in &survivors {
198        // The wires as the face stores them: a rebuilt face takes the old
199        // one's sense on top of them.
200        let wires = model.ordered_children_of(&face.oriented(ogeom_topo::Orientation::Forward))?;
201        let mut kept = Vec::new();
202        let (mut whole, mut partial, mut outer) = (false, false, false);
203        for (index, wire) in wires.iter().enumerate() {
204            let edges = model.ordered_children_of(wire)?;
205            let ring_count = edges.iter().filter(|e| is_ring(e)).count();
206            if ring_count == 0 {
207                kept.push(wire.clone());
208            } else if ring_count == edges.len() {
209                whole = true;
210                outer |= index == 0;
211            } else {
212                partial = true;
213            }
214        }
215        if whole || partial {
216            touched.push(Touched {
217                face: face.clone(),
218                kept,
219                whole,
220                partial,
221                outer,
222            });
223        } else {
224            untouched.push(face.clone());
225        }
226    }
227
228    // A neighbour that lost a whole ring is closed one of two ways, and the
229    // neighbours' surfaces say which: where they meet in the wound's own
230    // room the ring is replaced by the edge they meet along, and where they
231    // do not meet at all it is simply dropped and the face grows over what
232    // the feature stood in.
233    let sides: Vec<Shape> = touched.iter().map(|t| t.face.clone()).collect();
234    let recovers = touched.iter().any(|t| t.whole) && wound_recovers(model, faces, &sides, tol)?;
235    let mut rim_surgery: Vec<(Shape, Vec<Shape>)> = Vec::new(); // face, kept wires
236    let mut interrupted: Vec<Shape> = Vec::new();
237    for entry in touched {
238        if entry.partial || recovers {
239            interrupted.push(entry.face);
240        } else {
241            // Dropping the outer boundary would leave a face with nothing
242            // to stand on. Replacing it, where the neighbours meet, is the
243            // branch above.
244            if entry.outer {
245                ogeom_bail!(
246                    Construction,
247                    "removing these faces erases a neighbour's whole outer \
248                     boundary; that face has nothing left to stand on"
249                );
250            }
251            rim_surgery.push((entry.face, entry.kept));
252        }
253    }
254
255    let mut history = History::new();
256    for face in faces {
257        history.delete(face);
258    }
259
260    let mut rebuilt: Vec<Shape> = untouched;
261    for (face, kept_wires) in rim_surgery {
262        let new_face = {
263            let Some(data) = model.node(&face).and_then(|n| match n.data() {
264                NodeData::Face(d) => Some(d.clone()),
265                _ => None,
266            }) else {
267                ogeom_bail!(Construction, "a surviving face holds no face data");
268            };
269            // The kept wires carry their edges, and the edges their pcurves
270            // for this very surface: nothing to recompute.
271            let built = ogeom_algo::make_face_on(model, data.surface, &kept_wires, tol)?.shape;
272            orient_like(&face, built)
273        };
274        history.modify(&face, new_face.clone());
275        rebuilt.push(new_face);
276    }
277
278    if !interrupted.is_empty() {
279        // A band closes from its own two sides. A feature of several faces
280        // each bordering only one of the faces that must meet (a step, a
281        // notch, a pocket open at an edge) closes by extension instead;
282        // where that does not close it either, the band's refusal stands.
283        let band = match close_wound(model, faces, &interrupted, &removed, &users, &is_ring, tol) {
284            Ok(band) => band,
285            // Halves of one face left on either side of the wound (a
286            // slot across a top) close as one face, tried only where
287            // closing them apart does not.
288            Err(refused) => {
289                close_by_extension(model, faces, &interrupted, &users, &is_ring, false, tol)
290                    .or_else(|_| {
291                        close_by_extension(model, faces, &interrupted, &users, &is_ring, true, tol)
292                    })
293                    .map_err(|_| refused)?
294            }
295        };
296        for (old, new) in band {
297            history.modify(&old, new.clone());
298            // Faces closed as one come back as one face, once.
299            if !rebuilt.iter().any(|f| f.is_same(&new)) {
300                rebuilt.push(new);
301            }
302        }
303    }
304
305    let sewn = sew(model, &rebuilt, tol)?;
306    let [shell] = sewn.shells.as_slice() else {
307        ogeom_bail!(
308            Construction,
309            "closing the wound left {} shells where one solid's worth was \
310             expected; the removal disconnected the boundary",
311            sewn.shells.len()
312        );
313    };
314    if !ogeom_algo::is_shell_closed(model, shell)? {
315        ogeom_bail!(
316            Construction,
317            "the boundary does not close after removal; the wound needs a \
318             closure this operation does not construct yet"
319        );
320    }
321    let built = make_solid(model, std::slice::from_ref(shell))?;
322    let mut solid_history = history;
323    solid_history.modify(solid, built.shape.clone());
324    Ok(Built::new(built.shape, solid_history))
325}
326
327/// A face's surface, carried into space by the face's own placement.
328fn placed_surface(model: &Model, face: &Shape, tol: Tolerances) -> OgeomResult<SurfaceGeometry> {
329    let placement = face.transform(model.datums())?;
330    let Some(data) = model.node(face).and_then(|n| n.data().as_face().cloned()) else {
331        ogeom_bail!(Construction, "a band face holds no face data");
332    };
333    let Some(surface) = model.geometry().surface(data.surface) else {
334        ogeom_bail!(Construction, "a band face's surface is not in this model");
335    };
336    surface.clone().transformed(&placement, tol)
337}
338
339/// Whether the wound's neighbours meet each other where it sat.
340///
341/// A whole ring taken out of a neighbour is two different wounds, and only
342/// the neighbours' own surfaces tell them apart. A bore's wall leaves its
343/// two mouths as whole inner wires, and the faces holding them (a block's
344/// top and bottom) never meet: dropping the wires is the closure, and the
345/// block comes back whole. A rim blend leaves a whole ring too (the
346/// annulus a mouth fillet takes out of the top, the circle a boss's seat
347/// takes out of the wall), but there the cap and the wall meet along the
348/// very circle the blend replaced, and dropping would leave the boundary
349/// open where that circle belongs.
350///
351/// Meeting *somewhere* is not enough: two faces of any solid meet if their
352/// surfaces are carried far enough, and a bore through a wedge would
353/// recover the line where the wedge closes. The meeting must stand in the
354/// wound's own room (the removed faces' bounds), which is where the edge
355/// the feature replaced stood.
356fn wound_recovers(
357    model: &Model,
358    removed_faces: &[Shape],
359    candidates: &[Shape],
360    tol: Tolerances,
361) -> OgeomResult<bool> {
362    let mut room = ogeom_math::Aabb::default();
363    for face in removed_faces {
364        room = room.union(&ogeom_algo::shape_bounds(model, face, tol)?);
365    }
366    let room = room.expanded(tol.confusion() * 1e3);
367    let Some(centre) = room.centre() else {
368        return Ok(false);
369    };
370    for (i, first) in candidates.iter().enumerate() {
371        let sa = placed_surface(model, first, tol)?;
372        for second in &candidates[i + 1..] {
373            let sb = placed_surface(model, second, tol)?;
374            let Ok(SurfaceIntersection::Along(sections)) =
375                intersect_surfaces(&sa, &sb, IntersectOptions::default(), tol)
376            else {
377                continue;
378            };
379            for section in sections {
380                let foot = ogeom_algo::project_on_curve(&section.curve, centre, 64, tol)?;
381                if room.contains(foot.point) {
382                    return Ok(true);
383                }
384            }
385        }
386    }
387    Ok(false)
388}
389
390/// One crease the wound recovers: the edge a removed band replaced, from
391/// its two side faces' own surfaces.
392struct Crease {
393    /// The side faces, by node, in a fixed order.
394    sides: [Shape; 2],
395    /// The recovered curve, the branch nearest the removed faces.
396    curve: Curve,
397    /// The removed faces' extent along the curve, unwrapped about the
398    /// anchor on a periodic curve.
399    extent: (f64, f64),
400    /// The parameter nearest the removed faces' centre: what a periodic
401    /// curve's parameters are unwrapped about, so a band straddling the
402    /// curve's seam reads as one run and not its complement.
403    anchor: f64,
404}
405
406/// A periodic curve's parameter brought within half a period of `about`;
407/// any other curve's parameter as it is.
408fn unwrapped(curve: &Curve, t: f64, about: f64) -> f64 {
409    if !curve.is_periodic() {
410        return t;
411    }
412    let (lo, hi) = curve.domain();
413    let period = hi - lo;
414    if period <= 0.0 {
415        return t;
416    }
417    about + (t - about + period / 2.0).rem_euclid(period) - period / 2.0
418}
419
420/// Close a wound: each removed band's two side faces re-intersected into
421/// the crease it replaced, the creases' ends placed where they pierce the
422/// other interrupted faces (or one another's sides, which is where two
423/// bands meeting at a corner share their corner), every dangling edge
424/// extended along its own curve to the corner standing on it, and every
425/// interrupted face rebuilt with the creases it borders.
426///
427/// A band's sides are the two survivors it shares the most ring length
428/// with. A wedge's cap named alongside its band shares the band's sides
429/// and folds into the same crease. A crease's ends are the nearest
430/// piercings just past the removed faces' own extent along it, so a
431/// survivor the curve merely runs through far away is not mistaken for an
432/// end. Corners are one vertex wherever two creases place them within
433/// tolerance of each other.
434#[allow(clippy::too_many_lines, reason = "one wound, one narrative")]
435fn close_wound(
436    model: &mut Model,
437    removed_faces: &[Shape],
438    interrupted: &[Shape],
439    removed: &FastSet<TShapeId>,
440    users: &FastMap<TShapeId, Vec<Shape>>,
441    is_ring: &dyn Fn(&Shape) -> bool,
442    tol: Tolerances,
443) -> OgeomResult<Vec<(Shape, Shape)>> {
444    let surface_of = |model: &Model, face: &Shape| placed_surface(model, face, tol);
445    let vertices_of = |model: &Model, face: &Shape| -> OgeomResult<Vec<Point>> {
446        let mut out = Vec::new();
447        for vertex in explore(model, face, Filter::OfType(ShapeType::Vertex))? {
448            let placement = vertex.transform(model.datums())?;
449            if let Some(d) = model.node(&vertex).and_then(|nd| nd.data().as_vertex()) {
450                out.push(placement.apply(d.point));
451            }
452        }
453        Ok(out)
454    };
455    let interrupted_by_node: FastMap<TShapeId, Shape> =
456        interrupted.iter().map(|f| (f.node(), f.clone())).collect();
457
458    // Each removed face's sides: the two interrupted survivors it shares
459    // the most ring length with. Creases are keyed by the side pair. A
460    // removed face with fewer than two such neighbours (a wedge's cap
461    // standing against another blend's band, bordering one wall and two
462    // removed faces) joins the crease of a removed neighbour it shares an
463    // edge with, once that neighbour has one.
464    let mut creases: Vec<(TShapeId, TShapeId, [Shape; 2], Vec<Point>)> = Vec::new();
465    let mut crease_of: FastMap<TShapeId, usize> = FastMap::default();
466    let mut leftovers: Vec<Shape> = Vec::new();
467    for face in removed_faces {
468        let mut shared: FastMap<TShapeId, f64> = FastMap::default();
469        for edge in explore(model, face, Filter::OfType(ShapeType::Edge))? {
470            if !is_ring(&edge) {
471                continue;
472            }
473            let length = edge_length(model, &edge, tol)?;
474            for user in users.get(&edge.node()).into_iter().flatten() {
475                if !removed.contains(&user.node()) && interrupted_by_node.contains_key(&user.node())
476                {
477                    *shared.entry(user.node()).or_default() += length;
478                }
479            }
480        }
481        let mut ranked: Vec<(TShapeId, f64)> = shared.into_iter().collect();
482        ranked.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.index().cmp(&b.0.index())));
483        let [(a, _), (b, _), ..] = ranked.as_slice() else {
484            leftovers.push(face.clone());
485            continue;
486        };
487        let (lo, hi) = if a.index() <= b.index() {
488            (*a, *b)
489        } else {
490            (*b, *a)
491        };
492        let points = vertices_of(model, face)?;
493        let index = match creases.iter().position(|c| c.0 == lo && c.1 == hi) {
494            Some(i) => {
495                creases[i].3.extend(points);
496                i
497            }
498            None => {
499                creases.push((
500                    lo,
501                    hi,
502                    [
503                        interrupted_by_node[&lo].clone(),
504                        interrupted_by_node[&hi].clone(),
505                    ],
506                    points,
507                ));
508                creases.len() - 1
509            }
510        };
511        crease_of.insert(face.node(), index);
512    }
513    for face in leftovers {
514        let mut joined = None;
515        for edge in explore(model, &face, Filter::OfType(ShapeType::Edge))? {
516            for user in users.get(&edge.node()).into_iter().flatten() {
517                if let Some(&index) = crease_of.get(&user.node()) {
518                    joined = Some(index);
519                }
520            }
521        }
522        let Some(index) = joined else {
523            ogeom_bail!(
524                Construction,
525                "a removed face shares ring edges with fewer than two \
526                 interrupted neighbours and borders no removed face with a \
527                 crease; closing it needs a neighbour to meet itself, which \
528                 is not constructed yet"
529            );
530        };
531        let points = vertices_of(model, &face)?;
532        creases[index].3.extend(points);
533    }
534
535    // The recovered curve of each crease, and the removed faces' extent on it.
536    let mut recovered: Vec<Crease> = Vec::new();
537    for (_, _, sides, points) in creases {
538        let sa = surface_of(model, &sides[0])?;
539        let sb = surface_of(model, &sides[1])?;
540        let meeting = intersect_surfaces(&sa, &sb, IntersectOptions::default(), tol)?;
541        let SurfaceIntersection::Along(sections) = meeting else {
542            ogeom_bail!(
543                Construction,
544                "the band's side surfaces do not meet along a curve; the edge \
545                 the feature replaced cannot be recovered from them"
546            );
547        };
548        let anchor = {
549            let mut sum = ogeom_math::Vector::ZERO;
550            for p in &points {
551                sum += p.to_vector();
552            }
553            #[allow(clippy::cast_precision_loss)]
554            let n = points.len().max(1) as f64;
555            Point::ORIGIN + sum * (1.0 / n)
556        };
557        let section = sections
558            .into_iter()
559            .min_by(|p, q| {
560                nearest_distance(&p.curve, anchor, tol)
561                    .total_cmp(&nearest_distance(&q.curve, anchor, tol))
562            })
563            .ok_or_else(|| ogeom_err!(Construction, "the side surfaces meet along no branch"))?;
564        let curve = section.curve;
565        let anchor = parameter_near(&curve, anchor, tol)?;
566        let mut extent = (f64::INFINITY, f64::NEG_INFINITY);
567        for p in &points {
568            let t = unwrapped(&curve, parameter_near(&curve, *p, tol)?, anchor);
569            extent = (extent.0.min(t), extent.1.max(t));
570        }
571        recovered.push(Crease {
572            sides,
573            curve,
574            extent,
575            anchor,
576        });
577    }
578
579    // Corners: where each crease pierces an interrupted face that is not
580    // one of its sides, the nearest piercing past each end of its extent.
581    // A shared corner is one vertex.
582    let mut corner_vertices: Vec<(Point, Shape)> = Vec::new();
583    let mut vertex_at = |model: &mut Model, p: Point| -> Shape {
584        if let Some((_, v)) = corner_vertices
585            .iter()
586            .find(|(q, _)| q.distance(p) <= tol.confusion() * 1e3)
587        {
588            return v.clone();
589        }
590        let v = make_vertex(model, p).shape;
591        corner_vertices.push((p, v.clone()));
592        v
593    };
594    let mut new_edges: Vec<(Shape, [TShapeId; 2])> = Vec::new(); // edge, its sides
595    let mut corners: Vec<Shape> = Vec::new();
596    for (index, crease) in recovered.iter().enumerate() {
597        let mut piercings: Vec<(f64, Point)> = Vec::new();
598        for face in interrupted {
599            if crease.sides.iter().any(|s| s.node() == face.node()) {
600                continue;
601            }
602            let se = surface_of(model, face)?;
603            let hit =
604                intersect_curve_surface(&crease.curve, &se, CurveSurfaceOptions::default(), tol)?;
605            for c in &hit.crossings {
606                piercings.push((unwrapped(&crease.curve, c.on_curve, crease.anchor), c.point));
607            }
608        }
609        // A tangent junction: two bands of one chain meeting flush (a
610        // stadium's straight run into its semicircular end) share the
611        // cross-section edge where they meet, and their creases touch
612        // there without either piercing the other's side. A wall the
613        // crease merely grazes yields no piercing, and a touch found as a
614        // closest approach sits anywhere in a valley the width of the
615        // slop. The shared edge says exactly where: the cross-section
616        // stands in the plane normal to the rim at the junction, so the
617        // junction is the foot of that edge on either crease: a
618        // transversal projection, exact to the last bit. Taken only where
619        // the two creases are tangent there. Bands meeting at a corner
620        // place theirs by piercing.
621        for face in removed_faces {
622            if crease_of.get(&face.node()) != Some(&index) {
623                continue;
624            }
625            for edge in explore(model, face, Filter::OfType(ShapeType::Edge))? {
626                let Some(other) = users
627                    .get(&edge.node())
628                    .into_iter()
629                    .flatten()
630                    .filter(|user| user.node() != face.node())
631                    .filter_map(|user| crease_of.get(&user.node()).copied())
632                    .find(|&other| other != index)
633                else {
634                    continue;
635                };
636                let samples = sample_edge(model, &edge, tol)?;
637                let Some(&middle) = samples.get(samples.len() / 2) else {
638                    continue;
639                };
640                let here = ogeom_algo::project_on_curve(&crease.curve, middle, 64, tol)?;
641                let there = ogeom_algo::project_on_curve(&recovered[other].curve, middle, 64, tol)?;
642                if here.point.distance(there.point) > tol.confusion() * 1e3 {
643                    continue;
644                }
645                let ta = crease.curve.d1_at(here.parameter, tol)?;
646                let tb = recovered[other].curve.d1_at(there.parameter, tol)?;
647                if ta.cross(tb).magnitude() > ta.magnitude() * tb.magnitude() * 1e-3 {
648                    continue;
649                }
650                piercings.push((
651                    unwrapped(&crease.curve, here.parameter, crease.anchor),
652                    here.point,
653                ));
654            }
655        }
656        let slack = tol.parametric().max(1e-6);
657        let below = piercings
658            .iter()
659            .filter(|(t, _)| *t <= crease.extent.0 + slack)
660            .max_by(|a, b| a.0.total_cmp(&b.0));
661        let above = piercings
662            .iter()
663            .filter(|(t, _)| *t >= crease.extent.1 - slack)
664            .min_by(|a, b| a.0.total_cmp(&b.0));
665        match (below, above) {
666            (None, None) if piercings.is_empty() => {
667                // No ends: the band wraps, and the recovered edge closes on
668                // itself.
669                let (edges, cut) = wrap_round(
670                    model,
671                    &crease.curve,
672                    &crease.sides,
673                    is_ring,
674                    &mut vertex_at,
675                    tol,
676                )?;
677                for edge in edges {
678                    new_edges.push((edge, [crease.sides[0].node(), crease.sides[1].node()]));
679                }
680                corners.extend(cut);
681            }
682            (Some(&(t0, p0)), Some(&(t1, p1))) => {
683                let v0 = vertex_at(model, p0);
684                let v1 = vertex_at(model, p1);
685                // Unwrapped about the anchor, a window can start before a
686                // periodic curve's domain. Slid by whole turns to start
687                // inside it, it is the same run, and may end a turn past
688                // the end as any run across the seam does.
689                let window = if crease.curve.is_periodic() {
690                    let (lo, hi) = crease.curve.domain();
691                    let turns = ((t0 - lo) / (hi - lo)).floor() * (hi - lo);
692                    (t0 - turns, t1 - turns)
693                } else {
694                    (t0, t1)
695                };
696                let edge =
697                    make_edge_between(model, crease.curve.clone(), window, &v0, &v1, tol)?.shape;
698                corners.push(v0);
699                corners.push(v1);
700                new_edges.push((edge, [crease.sides[0].node(), crease.sides[1].node()]));
701            }
702            _ => ogeom_bail!(
703                Construction,
704                "an end face's surface never meets the recovered edge; the \
705                 corner cannot be placed"
706            ),
707        }
708    }
709
710    let mut out = Vec::new();
711    let mut extended: FastMap<TShapeId, Shape> = FastMap::default();
712    for face in interrupted {
713        let rims: Vec<Shape> = explore(model, face, Filter::OfType(ShapeType::Edge))?
714            .into_iter()
715            .filter(|e| is_ring(e))
716            .collect();
717        let borders: Vec<Shape> = new_edges
718            .iter()
719            .filter(|(_, sides)| sides.contains(&face.node()))
720            .map(|(e, _)| e.clone())
721            .collect();
722        let new_face = rebuild_interrupted(
723            model,
724            face,
725            &[],
726            &rims,
727            &borders,
728            &corners,
729            &mut extended,
730            tol,
731        )?;
732        out.push((face.clone(), new_face));
733    }
734    Ok(out)
735}
736
737/// A curve the wound took whole, closed on itself: a periodic curve no
738/// other face stops, cut where a side's dangling boundary reaches it.
739///
740/// It closes, but not always as one edge. A chart's seam reaches the
741/// recovered curve too: a cylinder wall's wire runs up its seam, round the
742/// rim and back down, and a closed edge carrying a vertex of its own leaves
743/// that wire two chains that never meet. So each side's own dangling
744/// boundary names a corner where it reaches the curve, the curve is cut
745/// there, and the dangling edge then extends to it like any other. The
746/// edges are returned with the vertices they were cut at.
747fn wrap_round(
748    model: &mut Model,
749    curve: &Curve,
750    sides: &[Shape; 2],
751    is_ring: &dyn Fn(&Shape) -> bool,
752    vertex_at: &mut dyn FnMut(&mut Model, Point) -> Shape,
753    tol: Tolerances,
754) -> OgeomResult<(Vec<Shape>, Vec<Shape>)> {
755    let (lo, hi) = curve.domain();
756    if !curve.is_periodic() {
757        ogeom_bail!(
758            Construction,
759            "a wrapping band recovered an open curve; the closure is \
760             not constructible from it"
761        );
762    }
763    let period = hi - lo;
764    let mut stops: Vec<f64> = Vec::new();
765    for side in sides {
766        let mut rim_vertices: FastSet<TShapeId> = FastSet::default();
767        for edge in explore(model, side, Filter::OfType(ShapeType::Edge))? {
768            if is_ring(&edge) {
769                for v in model.ordered_children_of(&edge)? {
770                    rim_vertices.insert(v.node());
771                }
772            }
773        }
774        for edge in explore(model, side, Filter::OfType(ShapeType::Edge))? {
775            if is_ring(&edge) {
776                continue;
777            }
778            let free: Vec<Point> = model
779                .ordered_children_of(&edge)?
780                .iter()
781                .filter(|v| rim_vertices.contains(&v.node()))
782                .filter_map(|v| {
783                    model
784                        .node(v)
785                        .and_then(|n| n.data().as_vertex())
786                        .map(|d| d.point)
787                })
788                .collect();
789            if free.is_empty() {
790                continue;
791            }
792            let Some(geometry) = edge_geometry(model, &edge, tol)? else {
793                continue;
794            };
795            for start in free {
796                // The two curves' closest approach, from the
797                // dangling end: each in turn answers where the
798                // other's nearest point is, and an edge that
799                // genuinely reaches the curve settles on it.
800                let mut point = start;
801                for _ in 0..8 {
802                    let on_curve = ogeom_algo::project_on_curve(curve, point, 64, tol)?;
803                    let t = parameter_near(&geometry, on_curve.point, tol)?;
804                    let Ok(on_edge) = geometry.point_at(t, tol) else {
805                        break;
806                    };
807                    if on_edge.distance(on_curve.point) <= tol.confusion() * 10.0 {
808                        stops.push(lo + (on_curve.parameter - lo).rem_euclid(period));
809                        break;
810                    }
811                    point = on_edge;
812                }
813            }
814        }
815    }
816    // Re-anchored at the first corner, so the whole turn stands
817    // inside the curve's own domain: a rim written from a corner
818    // right round to itself would otherwise end a turn past the
819    // end of it. One corner then leaves the rim one closed edge,
820    // which is the shape it had before the feature was cut,
821    // and the shape the exact volume integrator reads as a disc.
822    let (curve, stops) = match (&curve, stops.first().copied()) {
823        (Curve::Circle(circle), Some(first)) => {
824            let at = curve.point_at(first, tol)?;
825            let held = circle.circle();
826            let anchored = ogeom_geom::CircleCurve::new(ogeom_math::Circle::new(
827                ogeom_math::Frame::new(
828                    held.centre(),
829                    held.frame().z(),
830                    ogeom_math::Direction::new(at - held.centre(), tol)?,
831                    tol,
832                )?,
833                held.radius(),
834                tol,
835            )?);
836            let mut anchored = Curve::Circle(anchored);
837            // The same circle, and the same way round it: the
838            // re-anchoring moves where the parameter starts and
839            // must not turn the rim over.
840            if anchored.d1_at(0.0, tol)?.dot(curve.d1_at(first, tol)?) < 0.0 {
841                anchored = ogeom_geom::Reversible::reversed(&anchored);
842            }
843            let shifted = stops
844                .iter()
845                .map(|t| (t - first).rem_euclid(period))
846                .collect::<Vec<f64>>();
847            (anchored, shifted)
848        }
849        // A rim that is not a circle cannot be re-anchored, so
850        // it is cut at the chart's start as well and the turn
851        // stays inside the domain in pieces instead.
852        _ => {
853            let mut kept = stops;
854            kept.push(lo);
855            (curve.clone(), kept)
856        }
857    };
858    let mut cuts = stops;
859    cuts.sort_by(f64::total_cmp);
860    cuts.dedup_by(|a, b| (*a - *b).abs() <= tol.parametric().max(1e-9));
861    if cuts.is_empty() {
862        cuts.push(lo);
863    }
864    let mut cut: Vec<Shape> = Vec::with_capacity(cuts.len());
865    let mut edges: Vec<Shape> = Vec::with_capacity(cuts.len());
866    for t in &cuts {
867        let at = curve.point_at(*t, tol)?;
868        cut.push(vertex_at(model, at));
869    }
870    for (index, t) in cuts.iter().enumerate() {
871        let next = if index + 1 == cuts.len() {
872            cuts[0] + period
873        } else {
874            cuts[index + 1]
875        };
876        let edge = make_edge_between(
877            model,
878            curve.clone(),
879            (*t, next),
880            &cut[index],
881            &cut[(index + 1) % cuts.len()],
882            tol,
883        )?
884        .shape;
885        edges.push(edge);
886    }
887    Ok((edges, cut))
888}
889
890/// Close a wound by extension alone: every pair of interrupted faces whose
891/// surfaces meet along a curve through the wound's room closes the stretch
892/// of that curve the wound took, between the nearest stops either side of
893/// the room. A stop is where another interrupted face's surface pierces
894/// the curve, or where an edge the two faces already share along it
895/// dangles. Between two piercings the stretch is a new edge (a step's lost
896/// crease, from the end face it leaves to the one it reaches). Against a
897/// dangling end the other stop is the corner that edge extends to (a
898/// notch's three edges, each run on to where the three faces meet, or a
899/// pocket open at an edge, its rim's two halves joined across it). A
900/// periodic curve with no stop at all is taken whole, closing on itself
901/// (a rebate round a drum's rim, its cap and wall meeting again along the
902/// rim's circle), cut where a side's seam reaches it.
903///
904/// With `merge`, interrupted faces on one surface, facing one way, are one
905/// face (a slot across a top leaves the top in two halves on one plane):
906/// they close as one, and come back as one face whose boundary runs
907/// through all their surviving edges.
908#[allow(clippy::too_many_lines, reason = "one wound, one narrative")]
909fn close_by_extension(
910    model: &mut Model,
911    removed_faces: &[Shape],
912    interrupted: &[Shape],
913    users: &FastMap<TShapeId, Vec<Shape>>,
914    is_ring: &dyn Fn(&Shape) -> bool,
915    merge: bool,
916    tol: Tolerances,
917) -> OgeomResult<Vec<(Shape, Shape)>> {
918    let mut room = ogeom_math::Aabb::default();
919    for face in removed_faces {
920        room = room.union(&ogeom_algo::shape_bounds(model, face, tol)?);
921    }
922    let room = room.expanded(tol.confusion() * 1e3);
923    let Some(centre) = room.centre() else {
924        ogeom_bail!(Construction, "the removed faces have no extent");
925    };
926    let near = tol.confusion() * 1e3;
927    let point_of = |model: &Model, v: &Shape| -> OgeomResult<Point> {
928        let placement = v.transform(model.datums())?;
929        model
930            .node(v)
931            .and_then(|n| n.data().as_vertex())
932            .map(|d| placement.apply(d.point))
933            .ok_or_else(|| ogeom_err!(Construction, "a vertex holds no point"))
934    };
935
936    // Each interrupted face's representative: itself, or with `merge` the
937    // first interrupted face on the same surface facing the same way.
938    let mut rep: FastMap<TShapeId, Shape> = FastMap::default();
939    for (i, face) in interrupted.iter().enumerate() {
940        let mut found = face.clone();
941        if merge {
942            let surface = placed_surface(model, face, tol)?;
943            let sense = if face.orientation() == ogeom_topo::Orientation::Reversed {
944                -1.0
945            } else {
946                1.0
947            };
948            for earlier in &interrupted[..i] {
949                let other = placed_surface(model, earlier, tol)?;
950                let same = matches!(
951                    intersect_surfaces(&surface, &other, IntersectOptions::default(), tol),
952                    Ok(SurfaceIntersection::Same)
953                );
954                if !same {
955                    continue;
956                }
957                // The two faces' normals compared at one point of their
958                // shared surface: the earlier face's own, read on this
959                // face's chart.
960                let (at, theirs) = ogeom_algo::face_normal(model, earlier, tol)?;
961                let foot = ogeom_algo::project_on_surface(&surface, at, 16, tol)?;
962                let (u, v) = foot.parameters;
963                let ours = surface.normal_at(u, v, tol)?.vector() * sense;
964                if theirs.dot(ours) > 0.0 {
965                    found = rep[&earlier.node()].clone();
966                    break;
967                }
968            }
969        }
970        rep.insert(face.node(), found);
971    }
972    let rep_of = |id: TShapeId| rep.get(&id).map_or(id, Shape::node);
973    let leaders: Vec<Shape> = interrupted
974        .iter()
975        .filter(|f| rep_of(f.node()) == f.node())
976        .cloned()
977        .collect();
978
979    // The ends the removal leaves dangling: each interrupted face's edges
980    // that survive and reach a vertex the rim owned.
981    let mut dangling: Vec<(Point, [TShapeId; 2])> = Vec::new();
982    for face in interrupted {
983        let mut rim_vertices: FastSet<TShapeId> = FastSet::default();
984        for edge in explore(model, face, Filter::OfType(ShapeType::Edge))? {
985            if is_ring(&edge) {
986                for v in model.ordered_children_of(&edge)? {
987                    rim_vertices.insert(v.node());
988                }
989            }
990        }
991        for edge in explore(model, face, Filter::OfType(ShapeType::Edge))? {
992            if is_ring(&edge) {
993                continue;
994            }
995            let faces_of: Vec<TShapeId> = users
996                .get(&edge.node())
997                .into_iter()
998                .flatten()
999                .map(Shape::node)
1000                .collect();
1001            let [a, b] = faces_of.as_slice() else {
1002                continue;
1003            };
1004            for v in model.ordered_children_of(&edge)? {
1005                if rim_vertices.contains(&v.node()) {
1006                    let p = point_of(model, &v)?;
1007                    if !dangling.iter().any(|(q, _)| q.distance(p) <= near) {
1008                        dangling.push((p, [rep_of(*a), rep_of(*b)]));
1009                    }
1010                }
1011            }
1012        }
1013    }
1014
1015    let mut corner_vertices: Vec<(Point, Shape)> = Vec::new();
1016    let mut vertex_at = |model: &mut Model, p: Point| -> Shape {
1017        if let Some((_, v)) = corner_vertices.iter().find(|(q, _)| q.distance(p) <= near) {
1018            return v.clone();
1019        }
1020        let v = make_vertex(model, p).shape;
1021        corner_vertices.push((p, v.clone()));
1022        v
1023    };
1024    let mut new_edges: Vec<(Shape, [TShapeId; 2])> = Vec::new();
1025    let mut corners: Vec<Shape> = Vec::new();
1026    for (i, first) in leaders.iter().enumerate() {
1027        let sa = placed_surface(model, first, tol)?;
1028        for second in &leaders[i + 1..] {
1029            let sb = placed_surface(model, second, tol)?;
1030            let Ok(SurfaceIntersection::Along(sections)) =
1031                intersect_surfaces(&sa, &sb, IntersectOptions::default(), tol)
1032            else {
1033                continue;
1034            };
1035            let pair = [first.node(), second.node()];
1036            for section in sections {
1037                let curve = section.curve;
1038                let foot = ogeom_algo::project_on_curve(&curve, centre, 64, tol)?;
1039                if !room.contains(foot.point) {
1040                    continue;
1041                }
1042                let anchor = foot.parameter;
1043                // The stops along the curve: piercings by the other
1044                // interrupted faces, and this pair's dangling ends on it.
1045                let mut stops: Vec<(f64, Point, bool)> = Vec::new();
1046                for face in &leaders {
1047                    if pair.contains(&face.node()) {
1048                        continue;
1049                    }
1050                    let se = placed_surface(model, face, tol)?;
1051                    let hit =
1052                        intersect_curve_surface(&curve, &se, CurveSurfaceOptions::default(), tol)?;
1053                    for c in &hit.crossings {
1054                        stops.push((unwrapped(&curve, c.on_curve, anchor), c.point, false));
1055                    }
1056                }
1057                for (p, faces_of) in &dangling {
1058                    let shares = faces_of.contains(&pair[0]) && faces_of.contains(&pair[1]);
1059                    if !shares || nearest_distance(&curve, *p, tol) > near {
1060                        continue;
1061                    }
1062                    let t = unwrapped(&curve, parameter_near(&curve, *p, tol)?, anchor);
1063                    stops.push((t, *p, true));
1064                }
1065                let below = stops
1066                    .iter()
1067                    .filter(|s| s.0 < anchor)
1068                    .max_by(|a, b| a.0.total_cmp(&b.0))
1069                    .copied();
1070                let above = stops
1071                    .iter()
1072                    .filter(|s| s.0 > anchor)
1073                    .min_by(|a, b| a.0.total_cmp(&b.0))
1074                    .copied();
1075                if stops.is_empty() {
1076                    let sides = [first.clone(), second.clone()];
1077                    let (edges, cut) =
1078                        wrap_round(model, &curve, &sides, is_ring, &mut vertex_at, tol)?;
1079                    for edge in edges {
1080                        new_edges.push((edge, pair));
1081                    }
1082                    corners.extend(cut);
1083                    continue;
1084                }
1085                let (Some(lo), Some(hi)) = (below, above) else {
1086                    ogeom_bail!(
1087                        Construction,
1088                        "a curve two neighbours meet along leaves the wound \
1089                         with no stop on one side; the corner cannot be placed"
1090                    );
1091                };
1092                match (lo.2, hi.2) {
1093                    // Two piercings: the stretch is an edge of its own.
1094                    (false, false) => {
1095                        let (v0, v1) = (vertex_at(model, lo.1), vertex_at(model, hi.1));
1096                        let window = if curve.is_periodic() {
1097                            let (dlo, dhi) = curve.domain();
1098                            let turns = ((lo.0 - dlo) / (dhi - dlo)).floor() * (dhi - dlo);
1099                            (lo.0 - turns, hi.0 - turns)
1100                        } else {
1101                            (lo.0, hi.0)
1102                        };
1103                        let edge =
1104                            make_edge_between(model, curve.clone(), window, &v0, &v1, tol)?.shape;
1105                        corners.push(v0);
1106                        corners.push(v1);
1107                        new_edges.push((edge, pair));
1108                    }
1109                    // A dangling end runs on to the other stop, which is
1110                    // the corner. Two dangling ends meet at the upper one.
1111                    (true, false) | (true, true) => corners.push(vertex_at(model, hi.1)),
1112                    (false, true) => corners.push(vertex_at(model, lo.1)),
1113                }
1114            }
1115        }
1116    }
1117
1118    let mut out = Vec::new();
1119    let mut extended: FastMap<TShapeId, Shape> = FastMap::default();
1120    let rims_of = |model: &Model, face: &Shape| -> OgeomResult<Vec<Shape>> {
1121        Ok(explore(model, face, Filter::OfType(ShapeType::Edge))?
1122            .into_iter()
1123            .filter(|e| is_ring(e))
1124            .collect())
1125    };
1126    for face in &leaders {
1127        let mut rims = rims_of(model, face)?;
1128        let borders: Vec<Shape> = new_edges
1129            .iter()
1130            .filter(|(_, sides)| sides.contains(&face.node()))
1131            .map(|(e, _)| e.clone())
1132            .collect();
1133        let absorbed: Vec<Shape> = interrupted
1134            .iter()
1135            .filter(|f| f.node() != face.node() && rep_of(f.node()) == face.node())
1136            .cloned()
1137            .collect();
1138        for other in &absorbed {
1139            rims.extend(rims_of(model, other)?);
1140        }
1141        let new_face = rebuild_interrupted(
1142            model,
1143            face,
1144            &absorbed,
1145            &rims,
1146            &borders,
1147            &corners,
1148            &mut extended,
1149            tol,
1150        )?;
1151        for other in &absorbed {
1152            out.push((other.clone(), new_face.clone()));
1153        }
1154        out.push((face.clone(), new_face));
1155    }
1156    Ok(out)
1157}
1158
1159/// The connected components of the removal set: faces joined by shared
1160/// edges belong to one feature and close as one wound.
1161fn feature_groups(model: &Model, faces: &[Shape]) -> OgeomResult<Vec<Vec<Shape>>> {
1162    let mut edge_sets: Vec<FastSet<TShapeId>> = Vec::with_capacity(faces.len());
1163    for face in faces {
1164        edge_sets.push(
1165            explore(model, face, Filter::OfType(ShapeType::Edge))?
1166                .iter()
1167                .map(Shape::node)
1168                .collect(),
1169        );
1170    }
1171    let mut group_of: Vec<usize> = (0..faces.len()).collect();
1172    // Union by scan: small sets, clarity over asymptotics.
1173    fn root(group_of: &mut [usize], mut i: usize) -> usize {
1174        while group_of[i] != i {
1175            group_of[i] = group_of[group_of[i]];
1176            i = group_of[i];
1177        }
1178        i
1179    }
1180    for i in 0..faces.len() {
1181        for j in i + 1..faces.len() {
1182            if edge_sets[i].intersection(&edge_sets[j]).next().is_some() {
1183                let (a, b) = (root(&mut group_of, i), root(&mut group_of, j));
1184                group_of[a.max(b)] = a.min(b);
1185            }
1186        }
1187    }
1188    let mut groups: FastMap<usize, Vec<Shape>> = FastMap::default();
1189    for (i, face) in faces.iter().enumerate() {
1190        groups
1191            .entry(root(&mut group_of, i))
1192            .or_default()
1193            .push(face.clone());
1194    }
1195    let mut out: Vec<Vec<Shape>> = groups.into_values().collect();
1196    // Deterministic order: by each group's smallest node index.
1197    out.sort_by_key(|g| g.iter().map(|f| f.node().index()).min());
1198    Ok(out)
1199}
1200
1201/// Rebuild one interrupted face: drop its ring edges, extend the edges that
1202/// now dangle to the corner vertex standing on their own curve, add the
1203/// recovered edges this face borders, and rechain.
1204///
1205/// `absorbed` are faces on the same surface, facing the same way, that
1206/// come back as part of this one: their wires the wound did not touch are
1207/// kept as this face's, turned to its surface, and the edges that survive
1208/// of the wires it did touch join the recovered ones in bridging this
1209/// face's own wound, which must then be in one wire.
1210#[allow(
1211    clippy::too_many_arguments,
1212    reason = "one face, everything it is rebuilt from"
1213)]
1214fn rebuild_interrupted(
1215    model: &mut Model,
1216    face: &Shape,
1217    absorbed: &[Shape],
1218    rims: &[Shape],
1219    borders: &[Shape],
1220    corners: &[Shape],
1221    extended: &mut FastMap<TShapeId, Shape>,
1222    tol: Tolerances,
1223) -> OgeomResult<Shape> {
1224    let placement = face.transform(model.datums())?;
1225    let Some(data) = model.node(face).and_then(|n| n.data().as_face().cloned()) else {
1226        ogeom_bail!(Construction, "an interrupted face holds no face data");
1227    };
1228    let Some(surface) = model.geometry().surface(data.surface).cloned() else {
1229        ogeom_bail!(
1230            Construction,
1231            "an interrupted face's surface is not in this model"
1232        );
1233    };
1234    let surface = surface.transformed(&placement, tol)?;
1235    let rim_nodes: FastSet<TShapeId> = rims.iter().map(Shape::node).collect();
1236
1237    // The wires as the face stores them: the rebuilt face takes the old
1238    // one's sense on top of them.
1239    let mut wires: Vec<Vec<Shape>> = Vec::new();
1240    // What the absorbed faces bring: their untouched wires, and the edges
1241    // that survive of their touched ones.
1242    let mut carried: Vec<Shape> = Vec::new();
1243    for other in absorbed {
1244        // An absorbed face stored the other way round its surface walks its
1245        // wires the other way round this face's.
1246        let turned = other.orientation() != face.orientation();
1247        for wire in model.ordered_children_of(&other.oriented(ogeom_topo::Orientation::Forward))? {
1248            let mut edges = model.ordered_children_of(&wire)?;
1249            if !edges.iter().any(|e| rim_nodes.contains(&e.node())) {
1250                if turned {
1251                    edges.reverse();
1252                    edges = edges.into_iter().map(|e| e.reversed()).collect();
1253                }
1254                wires.push(edges);
1255                continue;
1256            }
1257            let mut rim_vertices: FastSet<TShapeId> = FastSet::default();
1258            for edge in &edges {
1259                if rim_nodes.contains(&edge.node()) {
1260                    for v in model.ordered_children_of(edge)? {
1261                        rim_vertices.insert(v.node());
1262                    }
1263                }
1264            }
1265            for edge in &edges {
1266                if rim_nodes.contains(&edge.node()) {
1267                    continue;
1268                }
1269                carried.push(survivor(
1270                    model,
1271                    edge,
1272                    &rim_vertices,
1273                    corners,
1274                    extended,
1275                    tol,
1276                )?);
1277            }
1278        }
1279    }
1280    let own_touched = model
1281        .ordered_children_of(&face.oriented(ogeom_topo::Orientation::Forward))?
1282        .iter()
1283        .map(|wire| model.ordered_children_of(wire))
1284        .collect::<OgeomResult<Vec<_>>>()?
1285        .iter()
1286        .filter(|edges| edges.iter().any(|e| rim_nodes.contains(&e.node())))
1287        .count();
1288    if !carried.is_empty() && own_touched != 1 {
1289        ogeom_bail!(
1290            Construction,
1291            "faces on one surface close as one only through a single wire of \
1292             the face they join"
1293        );
1294    }
1295    for wire in model.ordered_children_of(&face.oriented(ogeom_topo::Orientation::Forward))? {
1296        let edges = model.ordered_children_of(&wire)?;
1297        let touched = edges.iter().any(|e| rim_nodes.contains(&e.node()));
1298        if !touched {
1299            wires.push(edges);
1300            continue;
1301        }
1302        // Which vertices the dropped rim owned: an edge that shared one now
1303        // dangles there and must reach a corner instead.
1304        let mut rim_vertices: FastSet<TShapeId> = FastSet::default();
1305        for edge in &edges {
1306            if rim_nodes.contains(&edge.node()) {
1307                for v in model.ordered_children_of(edge)? {
1308                    rim_vertices.insert(v.node());
1309                }
1310            }
1311        }
1312        // Substituted in the wire's own order rather than re-chained from
1313        // a bag of edges. A chart's seam stands in its wire *twice* (up
1314        // one column and down the other), and a bag cannot say so: four
1315        // edge ends meet at each of the seam's vertices, which reads as a
1316        // branching network and not a wire. The order the face already has
1317        // is the answer the bag was being asked to guess.
1318        let mut ring: Vec<(Shape, Option<Shape>)> = Vec::with_capacity(edges.len());
1319        for edge in &edges {
1320            if rim_nodes.contains(&edge.node()) {
1321                ring.push((edge.clone(), None));
1322                continue;
1323            }
1324            ring.push((
1325                edge.clone(),
1326                Some(survivor(
1327                    model,
1328                    edge,
1329                    &rim_vertices,
1330                    corners,
1331                    extended,
1332                    tol,
1333                )?),
1334            ));
1335        }
1336        if std::env::var_os("OGEOM_DEBUG_DEFEATURE").is_some() {
1337            let point = |v: &Shape| {
1338                model
1339                    .node(v)
1340                    .and_then(|n| n.data().as_vertex())
1341                    .map(|d| d.point)
1342            };
1343            for (edge, spliced) in &ring {
1344                let (a, b) = edge_ends(model, spliced.as_ref().unwrap_or(edge))?;
1345                eprintln!(
1346                    "DEFEATURE wire edge {} {} {:?} .. {:?}",
1347                    edge.node().index(),
1348                    if spliced.is_none() { "RIM" } else { "kept" },
1349                    point(&a),
1350                    point(&b)
1351                );
1352            }
1353        }
1354        let mut pool: Vec<Shape> = borders.to_vec();
1355        pool.append(&mut carried);
1356        let mut chained: Vec<Shape> = Vec::new();
1357        if ring.iter().all(|(_, spliced)| spliced.is_none()) {
1358            // The whole wire was the wound's rim: the recovered edges are
1359            // the wire, closing on themselves.
1360            let Some(start) = pool.first().cloned() else {
1361                ogeom_bail!(
1362                    Construction,
1363                    "a neighbour lost a whole ring and no recovered edge \
1364                     borders it; the wound needs a closure this operation \
1365                     does not construct yet"
1366                );
1367            };
1368            let (from, _) = edge_ends(model, &start)?;
1369            chained = bridge_gap(model, &mut pool, &from, &from)?;
1370            let mut was = Vec::new();
1371            for (edge, _) in &ring {
1372                was.extend(sample_edge(model, edge, tol)?);
1373            }
1374            wind_like(model, &mut chained, &was, tol)?;
1375        } else {
1376            // Rotated so the wire begins on an edge that survived, which
1377            // puts every run of rim edges between two of them.
1378            let first = ring
1379                .iter()
1380                .position(|(_, spliced)| spliced.is_some())
1381                .unwrap_or(0);
1382            ring.rotate_left(first);
1383            let mut index = 0;
1384            while index < ring.len() {
1385                if let Some(edge) = ring[index].1.clone() {
1386                    chained.push(edge);
1387                    index += 1;
1388                    continue;
1389                }
1390                let run_end = ring[index..]
1391                    .iter()
1392                    .position(|(_, spliced)| spliced.is_some())
1393                    .map_or(ring.len(), |k| index + k);
1394                let Some(previous) = chained.last() else {
1395                    ogeom_bail!(Construction, "a wound's rim opens a wire that has no start");
1396                };
1397                let (_, from) = edge_ends(model, previous)?;
1398                let to = match ring.get(run_end).and_then(|(_, spliced)| spliced.as_ref()) {
1399                    Some(next) => edge_ends(model, next)?.0,
1400                    // The run closes the ring: it comes back to the start.
1401                    None => edge_ends(model, &chained[0])?.0,
1402                };
1403                let mut bridge = bridge_gap(model, &mut pool, &from, &to)?;
1404                // A gap that leaves and arrives at one vertex could be
1405                // walked either way round. The rim it replaces says which.
1406                if from.node() == to.node() {
1407                    let mut was = Vec::new();
1408                    for (edge, _) in &ring[index..run_end] {
1409                        was.extend(sample_edge(model, edge, tol)?);
1410                    }
1411                    wind_like(model, &mut bridge, &was, tol)?;
1412                }
1413                chained.extend(bridge);
1414                index = run_end;
1415            }
1416        }
1417        if !pool.is_empty() {
1418            ogeom_bail!(
1419                Construction,
1420                "{} recovered edges border this face and its wound's rim has \
1421                 nowhere to put them",
1422                pool.len()
1423            );
1424        }
1425        wires.push(chained);
1426    }
1427    let built = ogeom_algo::make_face_with_pcurves(model, surface, &wires, tol)?.shape;
1428    // The face's own side of its surface, carried over. A rebuilt face is
1429    // born forward, and a bore's wall is not: the mesher reads the wires'
1430    // winding and forgives it, but the exact integrator reads the flag and
1431    // hands back the bore as material.
1432    Ok(orient_like(face, built))
1433}
1434
1435/// An edge that survives a wound, as its wire uses it after: extended along
1436/// its own curve to the corner standing on it where the rim it shared a
1437/// vertex with is gone, and as it was otherwise. A face that has already
1438/// extended this edge decided for everyone, and sewing rejoins on the
1439/// shared node.
1440fn survivor(
1441    model: &mut Model,
1442    edge: &Shape,
1443    rim_vertices: &FastSet<TShapeId>,
1444    corners: &[Shape],
1445    extended: &mut FastMap<TShapeId, Shape>,
1446    tol: Tolerances,
1447) -> OgeomResult<Shape> {
1448    let replaced = match extended.get(&edge.node()) {
1449        Some(found) => Some(found.clone()),
1450        None => {
1451            let dangles = model
1452                .ordered_children_of(edge)?
1453                .iter()
1454                .any(|v| rim_vertices.contains(&v.node()));
1455            match (dangles, corner_on_edge(model, edge, corners, tol)?) {
1456                (true, Some(corner)) => {
1457                    Some(extend_to_corner(model, edge, &corner, extended, tol)?)
1458                }
1459                _ => None,
1460            }
1461        }
1462    };
1463    // A replacement is built forward. The wire's own use decides which way
1464    // it runs here.
1465    Ok(match replaced {
1466        Some(fresh) if edge.orientation() == ogeom_topo::Orientation::Reversed => fresh.reversed(),
1467        Some(fresh) => fresh,
1468        None => edge.clone(),
1469    })
1470}
1471
1472/// A rebuilt face put back on the side of its surface the old one was on.
1473fn orient_like(was: &Shape, built: Shape) -> Shape {
1474    if was.orientation() == ogeom_topo::Orientation::Reversed {
1475        built.reversed()
1476    } else {
1477        built
1478    }
1479}
1480
1481/// Points along an edge, in the direction this use of it runs.
1482fn sample_edge(model: &Model, edge: &Shape, tol: Tolerances) -> OgeomResult<Vec<Point>> {
1483    const STATIONS: usize = 12;
1484    let Some(geometry) = edge_geometry(model, edge, tol)? else {
1485        return Ok(Vec::new());
1486    };
1487    let Some(range) = model
1488        .node(edge)
1489        .and_then(|n| n.data().as_edge())
1490        .and_then(|d| match d.curve3d()? {
1491            ogeom_topo::EdgeRepr::Curve3d { range, .. } => Some(*range),
1492            _ => None,
1493        })
1494    else {
1495        return Ok(Vec::new());
1496    };
1497    let backwards = edge.orientation() == ogeom_topo::Orientation::Reversed;
1498    let mut out = Vec::with_capacity(STATIONS + 1);
1499    for i in 0..=STATIONS {
1500        #[allow(clippy::cast_precision_loss)]
1501        let f = i as f64 / STATIONS as f64;
1502        let f = if backwards { 1.0 - f } else { f };
1503        out.push(geometry.point_at(range.0 + (range.1 - range.0) * f, tol)?);
1504    }
1505    Ok(out)
1506}
1507
1508/// Twice the area a closed run of points sweeps about its own centre, as a
1509/// vector: which way round the run goes, in the only terms two runs of
1510/// different shapes can be compared in.
1511fn swept_area(points: &[Point]) -> ogeom_math::Vector {
1512    if points.len() < 3 {
1513        return ogeom_math::Vector::ZERO;
1514    }
1515    let mut sum = ogeom_math::Vector::ZERO;
1516    for p in points {
1517        sum += p.to_vector();
1518    }
1519    #[allow(clippy::cast_precision_loss)]
1520    let centre = Point::ORIGIN + sum / points.len() as f64;
1521    let mut area = ogeom_math::Vector::ZERO;
1522    for pair in points.windows(2) {
1523        area += (pair[0] - centre).cross(pair[1] - centre);
1524    }
1525    area
1526}
1527
1528/// An edge's curve, carried into space by the edge's own placement.
1529///
1530/// The curve, not the trim: a segment cut short by the feature still
1531/// carries the line the whole edge was cut from, which is what an
1532/// extension runs along.
1533fn edge_geometry(model: &Model, edge: &Shape, tol: Tolerances) -> OgeomResult<Option<Curve>> {
1534    let placement = edge.transform(model.datums())?;
1535    let Some(curve) = model
1536        .node(edge)
1537        .and_then(|n| n.data().as_edge())
1538        .and_then(|d| match d.curve3d()? {
1539            ogeom_topo::EdgeRepr::Curve3d { curve, .. } => Some(*curve),
1540            _ => None,
1541        })
1542        .and_then(|id| model.geometry().curve(id).cloned())
1543    else {
1544        return Ok(None);
1545    };
1546    Ok(Some(curve.transformed(&placement, tol)?))
1547}
1548
1549/// An edge's vertices as this use of it runs: start first.
1550fn edge_ends(model: &Model, edge: &Shape) -> OgeomResult<(Shape, Shape)> {
1551    ogeom_algo::edge_vertices(model, edge)?
1552        .ok_or_else(|| ogeom_err!(Construction, "an edge of a rebuilt wire has no vertices"))
1553}
1554
1555/// Turn a bridging chain to run the way the rim it replaces ran.
1556///
1557/// A chain that leaves and arrives at one vertex closes either way round,
1558/// and the walk that built it took whichever direction its first edge
1559/// happened to be stored in. The rim the wound took out went one way round
1560/// its face, and the recovered one must go the same way or the face is
1561/// inside out along it, which nothing in the topology notices, and the
1562/// mesher finds as a boundary that will not close.
1563fn wind_like(
1564    model: &Model,
1565    chain: &mut [Shape],
1566    was: &[Point],
1567    tol: Tolerances,
1568) -> OgeomResult<()> {
1569    let mut now = Vec::new();
1570    for edge in chain.iter() {
1571        now.extend(sample_edge(model, edge, tol)?);
1572    }
1573    if swept_area(&now).dot(swept_area(was)) >= 0.0 {
1574        return Ok(());
1575    }
1576    chain.reverse();
1577    for edge in chain.iter_mut() {
1578        *edge = edge.clone().reversed();
1579    }
1580    Ok(())
1581}
1582
1583/// Walk `pool` from `from` to `to`, orienting each edge to run the way the
1584/// walk goes and consuming what it uses.
1585///
1586/// The gap a wound's rim leaves in a wire is bridged by the recovered
1587/// edges, and which of them and which way round is decided by their own
1588/// vertices rather than by any ordering they arrive in. Some gaps need no
1589/// edge at all: an end face's rim was the band's cap, and once the two
1590/// edges either side of it reach the corner they meet there themselves. So
1591/// the walk steps only when the pool offers a step, and arriving with
1592/// nothing taken is an answer.
1593fn bridge_gap(
1594    model: &Model,
1595    pool: &mut Vec<Shape>,
1596    from: &Shape,
1597    to: &Shape,
1598) -> OgeomResult<Vec<Shape>> {
1599    let mut chain = Vec::new();
1600    let mut here = from.node();
1601    loop {
1602        let mut found = None;
1603        for (index, edge) in pool.iter().enumerate() {
1604            let (a, b) = edge_ends(model, edge)?;
1605            if a.node() == here {
1606                found = Some((index, false, b));
1607                break;
1608            }
1609            if b.node() == here {
1610                found = Some((index, true, a));
1611                break;
1612            }
1613        }
1614        let Some((index, backwards, next)) = found else {
1615            if here == to.node() {
1616                // Nothing to bridge: the wire's own edges already meet
1617                // where the rim stood.
1618                return Ok(chain);
1619            }
1620            if std::env::var_os("OGEOM_DEBUG_DEFEATURE").is_some() {
1621                let point = |v: &Shape| {
1622                    model
1623                        .node(v)
1624                        .and_then(|n| n.data().as_vertex())
1625                        .map(|d| d.point)
1626                };
1627                eprintln!(
1628                    "DEFEATURE bridge stuck at {:?} heading for {:?}",
1629                    point(from),
1630                    point(to)
1631                );
1632                for edge in pool.iter() {
1633                    let (a, b) = edge_ends(model, edge)?;
1634                    eprintln!("  border {:?} .. {:?}", point(&a), point(&b));
1635                }
1636            }
1637            ogeom_bail!(
1638                Construction,
1639                "no recovered edge bridges the wound's rim; the closure is \
1640                 not constructible from what the neighbours meet along"
1641            );
1642        };
1643        let edge = pool.remove(index);
1644        chain.push(if backwards { edge.reversed() } else { edge });
1645        here = next.node();
1646        if here == to.node() {
1647            return Ok(chain);
1648        }
1649        if pool.is_empty() {
1650            ogeom_bail!(
1651                Construction,
1652                "the recovered edges do not reach across the wound's rim; \
1653                 the closure is not constructible from them"
1654            );
1655        }
1656    }
1657}
1658
1659/// The corner vertex standing on an edge's own curve, nearest the edge,
1660/// when one does.
1661fn corner_on_edge(
1662    model: &Model,
1663    edge: &Shape,
1664    corners: &[Shape],
1665    tol: Tolerances,
1666) -> OgeomResult<Option<Shape>> {
1667    let placement = edge.transform(model.datums())?;
1668    let Some((curve, range)) = model
1669        .node(edge)
1670        .and_then(|n| n.data().as_edge())
1671        .and_then(|d| match d.curve3d()? {
1672            ogeom_topo::EdgeRepr::Curve3d { curve, range, .. } => Some((*curve, *range)),
1673            _ => None,
1674        })
1675    else {
1676        return Ok(None);
1677    };
1678    let Some(geometry) = model.geometry().curve(curve).cloned() else {
1679        return Ok(None);
1680    };
1681    let geometry = geometry.transformed(&placement, tol)?;
1682    let head = geometry.point_at(range.0, tol)?;
1683    let tail = geometry.point_at(range.1, tol)?;
1684    let mut best: Option<(f64, Shape)> = None;
1685    for corner in corners {
1686        let Some(p) = model
1687            .node(corner)
1688            .and_then(|n| n.data().as_vertex())
1689            .map(|d| d.point)
1690        else {
1691            continue;
1692        };
1693        let t = parameter_near(&geometry, p, tol)?;
1694        if geometry.point_at(t, tol)?.distance(p) > tol.confusion() * 1e3 {
1695            continue;
1696        }
1697        let gap = head.distance(p).min(tail.distance(p));
1698        if best.as_ref().is_none_or(|(g, _)| gap < *g) {
1699            best = Some((gap, corner.clone()));
1700        }
1701    }
1702    Ok(best.map(|(_, c)| c))
1703}
1704
1705/// The edge, extended along its own curve so its dangling end reaches the
1706/// corner vertex, shared across the faces that use it, so sewing rejoins
1707/// them on one node.
1708fn extend_to_corner(
1709    model: &mut Model,
1710    edge: &Shape,
1711    corner: &Shape,
1712    extended: &mut FastMap<TShapeId, Shape>,
1713    tol: Tolerances,
1714) -> OgeomResult<Shape> {
1715    if let Some(found) = extended.get(&edge.node()) {
1716        return Ok(found.clone());
1717    }
1718    let placement = edge.transform(model.datums())?;
1719    let Some((curve, range)) = model
1720        .node(edge)
1721        .and_then(|n| n.data().as_edge())
1722        .and_then(|d| match d.curve3d()? {
1723            ogeom_topo::EdgeRepr::Curve3d { curve, range, .. } => Some((*curve, *range)),
1724            _ => None,
1725        })
1726    else {
1727        ogeom_bail!(Construction, "a dangling edge has no curve to extend");
1728    };
1729    let Some(geometry) = model.geometry().curve(curve).cloned() else {
1730        ogeom_bail!(Construction, "a dangling edge's curve is not in this model");
1731    };
1732    let geometry = geometry.transformed(&placement, tol)?;
1733    let corner_point = model
1734        .node(corner)
1735        .and_then(|n| n.data().as_vertex())
1736        .map(|d| d.point)
1737        .ok_or_else(|| ogeom_err!(Construction, "a corner vertex holds no point"))?;
1738
1739    // Which end dangles: the one nearer the corner. The corner's parameter
1740    // on this curve comes from the geometry the curve already has.
1741    let head = geometry.point_at(range.0, tol)?;
1742    let tail = geometry.point_at(range.1, tol)?;
1743    let t_corner = parameter_near(&geometry, corner_point, tol)?;
1744    let (vertices, new_range) = {
1745        // Storage order, deliberately: the range is the stored curve's, and
1746        // `edge_vertices` would swap the pair for a reversed use.
1747        let bounds = model.children_of(edge)?;
1748        let (Some(va), Some(vb)) = (bounds.first().cloned(), bounds.last().cloned()) else {
1749            ogeom_bail!(Construction, "a dangling edge has no vertices");
1750        };
1751        if head.distance(corner_point) <= tail.distance(corner_point) {
1752            ((corner.clone(), vb), (t_corner, range.1))
1753        } else {
1754            ((va, corner.clone()), (range.0, t_corner))
1755        }
1756    };
1757    if new_range.1 <= new_range.0 {
1758        ogeom_bail!(
1759            Construction,
1760            "extending an edge to its corner inverted its range; the corner \
1761             sits on the wrong side of the edge"
1762        );
1763    }
1764    // A segment's stored domain ends at its own vertices. The extension is
1765    // the same line over a wider window.
1766    let geometry = match geometry {
1767        Curve::Line(line) => {
1768            let (lo, hi) = ogeom_geom::Curve3d::domain(&line);
1769            Curve::Line(ogeom_geom::LineCurve::over(
1770                line.axis(),
1771                lo.min(new_range.0),
1772                hi.max(new_range.1),
1773            )?)
1774        }
1775        other => other,
1776    };
1777    let built = make_edge_between(model, geometry, new_range, &vertices.0, &vertices.1, tol)?;
1778    extended.insert(edge.node(), built.shape.clone());
1779    Ok(built.shape)
1780}
1781
1782/// The corner's parameter on a curve, by closed form where one exists and by
1783/// projection where not.
1784fn parameter_near(curve: &Curve, p: Point, tol: Tolerances) -> OgeomResult<f64> {
1785    match curve {
1786        Curve::Line(line) => {
1787            let axis = line.axis();
1788            Ok((p - axis.location).dot(axis.direction.vector()))
1789        }
1790        Curve::Circle(c) => {
1791            let local = c.circle().frame().to_local(p);
1792            Ok(local.y.atan2(local.x).rem_euclid(core::f64::consts::TAU))
1793        }
1794        _ => Ok(ogeom_algo::project_on_curve(curve, p, 64, tol)?.parameter),
1795    }
1796}
1797
1798fn nearest_distance(curve: &Curve, p: Point, tol: Tolerances) -> f64 {
1799    ogeom_algo::project_on_curve(curve, p, 32, tol).map_or(f64::INFINITY, |pr| pr.distance)
1800}
1801
1802fn edge_length(model: &Model, edge: &Shape, tol: Tolerances) -> OgeomResult<f64> {
1803    let Some((curve, range)) = model
1804        .node(edge)
1805        .and_then(|n| n.data().as_edge())
1806        .and_then(|d| match d.curve3d()? {
1807            ogeom_topo::EdgeRepr::Curve3d { curve, range, .. } => Some((*curve, *range)),
1808            _ => None,
1809        })
1810    else {
1811        return Ok(0.0);
1812    };
1813    let Some(geometry) = model.geometry().curve(curve) else {
1814        return Ok(0.0);
1815    };
1816    ogeom_algo::curve_length(geometry, range, tol)
1817}