Skip to main content

ogeom_algo/
sew.rs

1//! Joining loose topology: ordering a bag of edges into a wire, and sewing free
2//! faces into a shell.
3//!
4//! Both exist because geometry arrives disconnected. An imported file gives a
5//! pile of faces that *touch* but share nothing; a sketch gives edges in
6//! whatever order they were drawn. Topologically these are unrelated pieces,
7//! and every algorithm that walks a boundary treats them that way: a shell of
8//! faces that merely abut has a free edge everywhere two of them meet, encloses
9//! no volume, and cannot be classified against.
10//!
11//! # Sewing is a topological operation, not a geometric one
12//!
13//! It does not move anything. Two edges within tolerance of each other are
14//! decided to be *one* edge, and every face that used either uses that one,
15//! so the shell closes because the topology says so, not because the geometry
16//! was nudged until it did. A version that moved geometry to close gaps would
17//! be a repair, would need to decide which of two positions is right, and would
18//! quietly invalidate every tolerance in the neighbourhood.
19//!
20//! What it will not do is claim a closure it did not achieve. Faces that do not
21//! meet within tolerance stay in separate shells, and the result says how many
22//! there are.
23
24use std::collections::{HashMap, HashSet};
25
26use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
27use ogeom_geom::Curve3d;
28use ogeom_math::Point;
29use ogeom_topo::{
30    EdgeRepr, Model, NodeData, Orientation, Shape, ShapeType, TShapeId, explore_unique,
31};
32
33use crate::bins::Bins;
34use crate::build::{edge_vertices, make_face_on, make_shell, make_wire};
35use crate::history::{Built, History};
36
37/// Roles sewing assigns.
38pub mod roles {
39    use ogeom_core::Role;
40
41    /// An edge that two faces were found to share.
42    pub const SEWN_EDGE: Role = Role::op_defined(40);
43    /// A face rebuilt on shared edges.
44    pub const SEWN_FACE: Role = Role::op_defined(41);
45}
46
47/// Put a bag of edges into an order that walks them end to end.
48///
49/// Reverses an edge where the chain reaches its far end first, so the result is
50/// a path rather than a set. [`make_wire`] then accepts it: it checks that
51/// consecutive edges meet, and a bag in the order it happened to be built in
52/// almost never does.
53///
54/// Follows the chain from one end. Where an end meets more than two edges the
55/// path is genuinely ambiguous (that is a branching network, not a wire), and
56/// this refuses rather than picking one, because picking one silently discards
57/// the branch nobody asked it to drop.
58///
59/// # Errors
60///
61/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the list is
62/// empty, an edge is unbounded, the edges do not form a single connected path,
63/// or a vertex joins three or more of them.
64pub fn order_edges(model: &Model, edges: &[Shape], tol: Tolerances) -> OgeomResult<Vec<Shape>> {
65    if edges.is_empty() {
66        ogeom_bail!(Construction, "there are no edges to order");
67    }
68    if edges.len() == 1 {
69        return Ok(edges.to_vec());
70    }
71
72    let mut ends = Vec::with_capacity(edges.len());
73    for edge in edges {
74        let Some((start, finish)) = edge_vertices(model, edge)? else {
75            ogeom_bail!(
76                Construction,
77                "an unbounded edge cannot be shown to join anything"
78            );
79        };
80        ends.push((placed(model, &start)?, placed(model, &finish)?));
81    }
82
83    // How many edge-ends meet at each position. Three is a branch, and a branch
84    // has no single walk through it.
85    for i in 0..edges.len() {
86        for at in [ends[i].0, ends[i].1] {
87            let meeting = ends
88                .iter()
89                .filter(|(a, b)| a.is_equal(at, tol) || b.is_equal(at, tol))
90                .count();
91            if meeting > 2 {
92                ogeom_bail!(
93                    Construction,
94                    "{meeting} edges meet at {at:?}; that is a branching \
95                     network rather than a wire, and choosing a path through it \
96                     would silently drop the branches not chosen"
97                );
98            }
99        }
100    }
101
102    // Start from a free end if there is one, so an open chain comes out running
103    // the way it reads. A closed loop has none, and any edge will do.
104    let start = (0..edges.len())
105        .find(|&i| {
106            !ends.iter().enumerate().any(|(j, (a, b))| {
107                j != i && (a.is_equal(ends[i].0, tol) || b.is_equal(ends[i].0, tol))
108            })
109        })
110        .unwrap_or(0);
111
112    let mut used = vec![false; edges.len()];
113    let mut out = Vec::with_capacity(edges.len());
114    used[start] = true;
115    out.push(edges[start].clone());
116    let mut reach = ends[start].1;
117
118    while out.len() < edges.len() {
119        let mut stepped = false;
120        for i in 0..edges.len() {
121            if used[i] {
122                continue;
123            }
124            let (a, b) = ends[i];
125            if a.is_equal(reach, tol) {
126                out.push(edges[i].clone());
127                reach = b;
128            } else if b.is_equal(reach, tol) {
129                // The chain arrived at this edge's far end, so it is walked
130                // backwards. Reversing the occurrence is what keeps the wire a
131                // path; leaving it would make `make_wire` report a gap that is
132                // really a direction.
133                out.push(edges[i].reversed());
134                reach = a;
135            } else {
136                continue;
137            }
138            used[i] = true;
139            stepped = true;
140            break;
141        }
142        if !stepped {
143            ogeom_bail!(
144                Construction,
145                "the edges do not form one connected path: {} of {} could not \
146                 be reached from the first",
147                edges.len() - out.len(),
148                edges.len()
149            );
150        }
151    }
152    Ok(out)
153}
154
155/// Build a wire from edges in any order.
156///
157/// [`order_edges`] then [`make_wire`].
158///
159/// # Errors
160///
161/// As [`order_edges`] and [`make_wire`].
162pub fn make_wire_unordered(
163    model: &mut Model,
164    edges: &[Shape],
165    tol: Tolerances,
166) -> OgeomResult<Built> {
167    let ordered = order_edges(model, edges, tol)?;
168    make_wire(model, &ordered, tol)
169}
170
171/// What sewing produced.
172#[derive(Debug, Clone)]
173pub struct Sewn {
174    /// One shell per connected group of faces.
175    ///
176    /// More than one means the faces did not all meet. That is reported rather
177    /// than papered over: a single shell containing disconnected pieces would
178    /// claim a closure that is not there.
179    pub shells: Vec<Shape>,
180    /// How many pairs of edges were found to be the same edge.
181    pub joined: usize,
182    /// Edges still used by exactly one face after sewing.
183    ///
184    /// Zero means every shell is closed. Anything else is the boundary that
185    /// remains, and a caller that needs a solid needs this to be empty.
186    pub free_edges: Vec<Shape>,
187    /// History, as every operation reports.
188    pub history: History,
189}
190
191/// Sew free faces into shells by finding the edges they share.
192///
193/// Two edges are the same edge when their ends coincide within tolerance
194/// (either way round) *and* a point along them does too. The midpoint test is
195/// what stops two different arcs between the same pair of vertices from being
196/// merged into one, which is a real case: the two halves of a circle share both
197/// ends.
198///
199/// Nothing is moved. See the module documentation.
200///
201/// # Errors
202///
203/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `faces` is
204/// empty or holds something that is not a face;
205/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if a handle fails to
206/// resolve.
207pub fn sew(model: &mut Model, faces: &[Shape], tol: Tolerances) -> OgeomResult<Sewn> {
208    if faces.is_empty() {
209        ogeom_bail!(Construction, "there are no faces to sew");
210    }
211    for face in faces {
212        if model.kind_of(face)? != ShapeType::Face {
213            ogeom_bail!(Construction, "sewing joins faces");
214        }
215    }
216    model.begin_operation();
217
218    // Vertices first, and this is not an optimisation; it is what makes the
219    // rest work. Deciding that two edges are one edge leaves the *neighbouring*
220    // edges ending at the vertices they always had, which sit at the same
221    // places as the survivor's but are different nodes. A wire built from that
222    // mixture is reported to have a gap, because it has one: `is_same_position`
223    // asks whether one node appears at two placements, which is the right
224    // question and not this one.
225    let mut vertices = merge_vertices(model, faces, tol)?;
226    // Two edges decided to be one must end on the same vertices, or the
227    // faces that bounded the dropped one keep its neighbours ending where
228    // the dropped one did, and their wires open. Twins whose ends are still
229    // two vertices (each within its own span of the other edge's end, not
230    // of the other vertex) have those vertices joined, and the edges are
231    // rebuilt on them and matched again.
232    let mut rounds = 0;
233    let (rebuilt_edges, merged, joined) = loop {
234        let rebuilt_edges = rebuild_edges(model, faces, &vertices)?;
235
236        // Every distinct edge node used by the faces, with the geometry that
237        // decides whether two of them are the same edge.
238        let mut catalogue: Vec<(TShapeId, Fingerprint)> = Vec::new();
239        let mut catalogued: HashSet<TShapeId> = HashSet::new();
240        for face in faces {
241            for edge in explore_unique(model, face, ShapeType::Edge)? {
242                let id = rebuilt_edges
243                    .get(&edge.node())
244                    .copied()
245                    .unwrap_or(edge.node());
246                if !catalogued.insert(id) {
247                    continue;
248                }
249                if let Some(print) = fingerprint(model, &Shape::of(id), tol)? {
250                    catalogue.push((id, print));
251                }
252            }
253        }
254
255        // Which node each edge is decided to *be*, and whether it runs the other
256        // way from the one it replaced.
257        let mut merged: HashMap<TShapeId, (TShapeId, bool)> = HashMap::new();
258        let mut joined = 0;
259        // The widest reach any pair compares its ends at.
260        let reach = catalogue
261            .iter()
262            .fold(tol.confusion(), |acc, (_, print)| acc.max(print.width));
263        let mut starts = Bins::new(reach);
264        for (index, (_, print)) in catalogue.iter().enumerate() {
265            starts.insert(print.start, index);
266        }
267        for i in 0..catalogue.len() {
268            if merged.contains_key(&catalogue[i].0) {
269                continue;
270            }
271            for j in twin_candidates(&catalogue, &starts, reach, i) {
272                if merged.contains_key(&catalogue[j].0) {
273                    continue;
274                }
275                let Some(flipped) = catalogue[i].1.same_as(&catalogue[j].1, tol)? else {
276                    continue;
277                };
278                // The survivor now answers for both descriptions of the edge,
279                // and its vertices must reach the twin's ends: two fingerprints
280                // that matched within their stated widths may still disagree by
281                // more than a fresh vertex's tolerance, and the disagreement is
282                // recorded where the data model records it.
283                let (kept_fp, dropped_fp) = (catalogue[i].1.clone(), catalogue[j].1.clone());
284                let survivor = Shape::of(catalogue[i].0);
285                let ends = if flipped {
286                    [
287                        (kept_fp.start, dropped_fp.end),
288                        (kept_fp.end, dropped_fp.start),
289                    ]
290                } else {
291                    [
292                        (kept_fp.start, dropped_fp.start),
293                        (kept_fp.end, dropped_fp.end),
294                    ]
295                };
296                let bounds = model.children_of(&survivor)?;
297                for vertex in &bounds {
298                    if let Some(data) = model.node(vertex).and_then(|n| n.data().as_vertex()) {
299                        let at = data.point;
300                        let mut need = data.tolerance.get();
301                        for (a, b) in &ends {
302                            if at.distance(*a) <= need.max(tol.confusion() * 1e2) {
303                                need = need.max(a.distance(*b) + tol.confusion());
304                            }
305                        }
306                        if need > data.tolerance.get()
307                            && let Some(node) = model.node_mut(vertex)
308                            && let NodeData::Vertex(v) = node.data_mut()
309                        {
310                            v.tolerance = v.tolerance.widen_to(need);
311                        }
312                    }
313                }
314                merged.insert(catalogue[j].0, (catalogue[i].0, flipped));
315                joined += 1;
316            }
317        }
318        let apart = twin_ends_apart(model, &merged)?;
319        if apart.is_empty() || rounds == 3 {
320            break (rebuilt_edges, merged, joined);
321        }
322        rounds += 1;
323        for (gone, keep) in apart {
324            join_vertex(model, &mut vertices, gone, keep, tol)?;
325        }
326    };
327
328    // The survivor has to carry the pcurves of the edge it replaced, or the
329    // face that used the replaced one loses its description in parameter space
330    // and stops being triangulable.
331    //
332    // Carrying is not copying. When the merge *flipped* (the two edges run
333    // opposite ways), the dropped edge's pcurve traverses the shared points
334    // backwards relative to the survivor's own curve, and copied unchanged it
335    // makes the survivor's face walk one edge of its boundary the wrong way:
336    // the parameter-space ring zigzags to zero area and the face stops being
337    // triangulable. The boolean found this by sewing faces whose edges were
338    // annotated before sewing decided which twin survives. The pcurve is
339    // consumed by *proportional* same-parameter mapping, so reversing its
340    // traversal exactly is swapping the stored range's ends.
341    //
342    // Nor is it copying when the two edges describe one curve at different
343    // paces: a fitted rim against the exact circle it traces, which the
344    // match admits by asking each middle to lie on the other's stretch. The
345    // dropped edge's pcurve is same-parameter with the *dropped* curve; on
346    // the survivor's parameter it drifts along the edge, and a face walks
347    // its boundary off the vertex it shares. Such a pcurve is refitted at
348    // the survivor's own parameters: the survivor's point at each, found
349    // on the dropped curve, read through the pcurve into the chart.
350    for (dropped, (kept, flipped)) in merged.clone() {
351        let carried: Vec<EdgeRepr> = model
352            .node_by_id(dropped)
353            .and_then(|n| n.data().as_edge())
354            .map(|d| {
355                d.representations
356                    .iter()
357                    .filter(|r| r.is_parametric())
358                    .cloned()
359                    .collect()
360            })
361            .unwrap_or_default();
362        if carried.is_empty() {
363            continue;
364        }
365        let survivor = Shape::of(kept);
366        let paced = repaced_carry(
367            model,
368            &Shape::of(dropped),
369            &survivor,
370            flipped,
371            &carried,
372            tol,
373        )?;
374        let Some(node) = model.node_mut(&survivor) else {
375            ogeom_bail!(Dangling, "an edge is not in this model");
376        };
377        let NodeData::Edge(data) = node.data_mut() else {
378            ogeom_bail!(Construction, "edge node holds no edge data");
379        };
380        for repr in paced {
381            data.add(repr);
382        }
383    }
384
385    // One map from every original edge to what it is now: rebuilt onto merged
386    // vertices, then possibly merged with a coincident twin.
387    let mut substitution: HashMap<TShapeId, (TShapeId, bool)> = HashMap::new();
388    for (original, rebuilt) in &rebuilt_edges {
389        let (final_id, flipped) = merged.get(rebuilt).copied().unwrap_or((*rebuilt, false));
390        substitution.insert(*original, (final_id, flipped));
391    }
392    for (dropped, kept) in &merged {
393        substitution.entry(*dropped).or_insert(*kept);
394    }
395
396    let mut history = History::new();
397    let mut rebuilt = Vec::with_capacity(faces.len());
398    for face in faces {
399        let Some(sewn) = rebuild_face(model, face, &substitution, tol)? else {
400            history.delete(face);
401            continue;
402        };
403        model.set_derived(&sewn, std::slice::from_ref(face), roles::SEWN_FACE)?;
404        history.modify(face, sewn.clone());
405        rebuilt.push(sewn);
406    }
407
408    let groups = connected_groups(model, &rebuilt)?;
409    let mut shells = Vec::with_capacity(groups.len());
410    for group in groups {
411        let shell = make_shell(model, &group)?.shape;
412        for face in &group {
413            history.generate(face, shell.clone());
414        }
415        shells.push(shell);
416    }
417
418    let free_edges = free_edges(model, &rebuilt)?;
419    Ok(Sewn {
420        shells,
421        joined,
422        free_edges,
423        history,
424    })
425}
426
427/// Decide which coincident vertices are the same vertex.
428///
429/// Returns only the ones that were replaced, mapping each to its survivor.
430fn merge_vertices(
431    model: &mut Model,
432    faces: &[Shape],
433    tol: Tolerances,
434) -> OgeomResult<HashMap<TShapeId, TShapeId>> {
435    let tolerance_of = |model: &Model, vertex: &Shape| {
436        model
437            .node(vertex)
438            .and_then(|n| n.data().as_vertex())
439            .map_or(0.0, |d| d.tolerance.get())
440    };
441    // The survivors, binned by position on cells as wide as the loosest
442    // vertex; a vertex is compared with the survivors within the widest
443    // reach any comparison can have, in the order they were kept.
444    let mut loosest = tol.confusion();
445    for face in faces {
446        for vertex in explore_unique(model, face, ShapeType::Vertex)? {
447            loosest = loosest.max(tolerance_of(model, &vertex));
448        }
449    }
450    let mut bins = Bins::new(loosest);
451    let mut seen: Vec<(TShapeId, Point, f64)> = Vec::new();
452    let mut index_of: HashMap<TShapeId, usize> = HashMap::new();
453    let mut widest = tol.confusion();
454    let mut out = HashMap::new();
455    for face in faces {
456        for vertex in explore_unique(model, face, ShapeType::Vertex)? {
457            if index_of.contains_key(&vertex.node()) {
458                continue;
459            }
460            let at = placed(model, &vertex)?;
461            let own = tolerance_of(model, &vertex);
462            // Two vertices are one junction within what their *stated*
463            // tolerances allow, not within a fresh vertex's default: a
464            // vertex that recorded a welded gap reaches that far, and
465            // merging by raw confusion would leave its twin standing a
466            // recorded-but-ignored distance away.
467            let meets = |(_, p, w): &&(TShapeId, Point, f64)| {
468                p.distance(at) <= tol.confusion().max(*w).max(own)
469            };
470            let hit = match bins.near(at, widest.max(own)) {
471                Some(near) => near.into_iter().map(|i| &seen[i]).find(meets),
472                None => seen.iter().find(meets),
473            }
474            .map(|(kept, p, w)| (*kept, *p, *w));
475            match hit {
476                Some((kept, p, w)) => {
477                    // The survivor answers for the absorbed vertex: its
478                    // tolerance widens to reach the absorbed position plus
479                    // whatever that vertex itself was allowed to stray.
480                    let need = p.distance(at) + own + tol.confusion();
481                    if need > w
482                        && let Some(node) = model.node_mut(&Shape::of(kept))
483                        && let NodeData::Vertex(v) = node.data_mut()
484                    {
485                        v.tolerance = v.tolerance.widen_to(need);
486                    }
487                    if let Some(entry) = index_of.get(&kept).map(|&i| &mut seen[i]) {
488                        entry.2 = entry.2.max(need);
489                        widest = widest.max(entry.2);
490                    }
491                    out.insert(vertex.node(), kept);
492                }
493                None => {
494                    bins.insert(at, seen.len());
495                    index_of.insert(vertex.node(), seen.len());
496                    seen.push((vertex.node(), at, own));
497                    widest = widest.max(own);
498                }
499            }
500        }
501    }
502    Ok(out)
503}
504
505/// The vertex pairs twin edges end on that are not yet one vertex: the
506/// dropped edge's end first, the survivor's second.
507fn twin_ends_apart(
508    model: &Model,
509    merged: &HashMap<TShapeId, (TShapeId, bool)>,
510) -> OgeomResult<Vec<(TShapeId, TShapeId)>> {
511    let ends = |id: TShapeId| -> Option<(TShapeId, TShapeId)> {
512        let children = model.node_by_id(id)?.children();
513        Some((children.first()?.node(), children.last()?.node()))
514    };
515    let mut out = Vec::new();
516    let mut pairs: Vec<(&TShapeId, &(TShapeId, bool))> = merged.iter().collect();
517    pairs.sort_by_key(|(dropped, _)| dropped.index());
518    for (dropped, (kept, flipped)) in pairs {
519        let (Some((d0, d1)), Some((k0, k1))) = (ends(*dropped), ends(*kept)) else {
520            continue;
521        };
522        let matched = if *flipped {
523            [(d0, k1), (d1, k0)]
524        } else {
525            [(d0, k0), (d1, k1)]
526        };
527        for (d, k) in matched {
528            if d != k && !out.contains(&(d, k)) {
529                out.push((d, k));
530            }
531        }
532    }
533    Ok(out)
534}
535
536/// Make `gone` one vertex with `keep`: every vertex mapped to either now
537/// maps to `keep`'s survivor, whose tolerance reaches `gone`'s span.
538fn join_vertex(
539    model: &mut Model,
540    vertices: &mut HashMap<TShapeId, TShapeId>,
541    gone: TShapeId,
542    keep: TShapeId,
543    tol: Tolerances,
544) -> OgeomResult<()> {
545    let resolve = |vertices: &HashMap<TShapeId, TShapeId>, mut v: TShapeId| {
546        while let Some(&next) = vertices.get(&v) {
547            if next == v {
548                break;
549            }
550            v = next;
551        }
552        v
553    };
554    let (gone, keep) = (resolve(vertices, gone), resolve(vertices, keep));
555    if gone == keep {
556        return Ok(());
557    }
558    let (Some(g), Some(k)) = (
559        model
560            .node_by_id(gone)
561            .and_then(|n| n.data().as_vertex())
562            .map(|d| (d.point, d.tolerance.get())),
563        model
564            .node_by_id(keep)
565            .and_then(|n| n.data().as_vertex())
566            .map(|d| d.point),
567    ) else {
568        return Ok(());
569    };
570    let need = g.0.distance(k) + g.1 + tol.confusion();
571    if let Some(node) = model.node_mut(&Shape::of(keep))
572        && let NodeData::Vertex(v) = node.data_mut()
573    {
574        v.tolerance = v.tolerance.widen_to(need);
575    }
576    for target in vertices.values_mut() {
577        if *target == gone {
578            *target = keep;
579        }
580    }
581    vertices.insert(gone, keep);
582    Ok(())
583}
584
585/// The dropped edge's parametric representations as the survivor carries
586/// them: reversed when the merge flipped, and refitted at the survivor's
587/// parameters when the two curves pace one stretch differently. Two edges
588/// on one curve object, or on curves whose middle parameters land within
589/// the pair's honesty of each other, are carried as they are.
590fn repaced_carry(
591    model: &mut Model,
592    dropped: &Shape,
593    survivor: &Shape,
594    flipped: bool,
595    carried: &[EdgeRepr],
596    tol: Tolerances,
597) -> OgeomResult<Vec<EdgeRepr>> {
598    let as_is = || -> Vec<EdgeRepr> {
599        carried
600            .iter()
601            .cloned()
602            .map(|r| if flipped { reversed_repr(r) } else { r })
603            .collect()
604    };
605    let (Some(dropped_fp), Some(kept_fp)) = (
606        fingerprint(&*model, dropped, tol)?,
607        fingerprint(&*model, survivor, tol)?,
608    ) else {
609        return Ok(as_is());
610    };
611    let reach = tol.confusion().max(dropped_fp.width).max(kept_fp.width);
612    if dropped_fp.middle.distance(kept_fp.middle) <= reach {
613        if std::env::var_os("OGEOM_DEBUG_SEW").is_some() {
614            eprintln!(
615                "SEW carry as is: flipped {flipped}, middles {:.2e} apart, kept {:?} {:?} dropped {:?} {:?}",
616                dropped_fp.middle.distance(kept_fp.middle),
617                kept_fp.start,
618                kept_fp.end,
619                dropped_fp.start,
620                dropped_fp.end
621            );
622        }
623        return Ok(as_is());
624    }
625    const SAMPLES: usize = 24;
626    let (klo, khi) = (kept_fp.range.0, kept_fp.range.1);
627    let mut out = Vec::with_capacity(carried.len());
628    let mut pending: Vec<(
629        ogeom_topo::SurfaceId,
630        ogeom_topo::Location,
631        ogeom_geom::PlanarCurve,
632        (f64, f64),
633    )> = Vec::new();
634    for repr in carried {
635        let EdgeRepr::PCurve {
636            curve: pc_id,
637            surface,
638            location,
639            range: prange,
640        } = repr
641        else {
642            out.push(if flipped {
643                reversed_repr(repr.clone())
644            } else {
645                repr.clone()
646            });
647            continue;
648        };
649        let Some(pcurve) = model.geometry().pcurve(*pc_id) else {
650            ogeom_bail!(Dangling, "pcurve is not in this model");
651        };
652        let periods = model.geometry().surface(*surface).map(|sg| {
653            use ogeom_geom::Surface as _;
654            let ((ua, ub), (va, vb)) = sg.domain();
655            (
656                if sg.is_periodic_u() { ub - ua } else { 0.0 },
657                if sg.is_periodic_v() { vb - va } else { 0.0 },
658            )
659        });
660        let (dlo, dhi) = (dropped_fp.range.0, dropped_fp.range.1);
661        let mut params = Vec::with_capacity(SAMPLES + 1);
662        let mut image: Vec<ogeom_math::Point2> = Vec::with_capacity(SAMPLES + 1);
663        for k in 0..=SAMPLES {
664            #[allow(clippy::cast_precision_loss)]
665            let t = klo + (khi - klo) * (k as f64) / (SAMPLES as f64);
666            let p = kept_fp.curve.point_at(t, tol)?;
667            let foot = crate::project_on_curve(&dropped_fp.curve, p, 64, tol)?;
668            // The foot's parameter on the dropped curve, then through the
669            // proportional map onto the pcurve's own window. On a curve that
670            // closes on itself the foot may come back a turn away from the
671            // stretch (a rim's last piece, seen from its own points, sits
672            // at the start of the loop as much as at its end) and is
673            // carried across the turn before it is clamped.
674            let (lo, hi) = (dlo.min(dhi), dlo.max(dhi));
675            let (da, db) = dropped_fp.curve.domain();
676            let turn = db - da;
677            let mut s = foot.parameter;
678            if turn > 0.0 {
679                if s < lo - tol.parametric() && s + turn <= hi + tol.parametric() {
680                    s += turn;
681                } else if s > hi + tol.parametric() && s - turn >= lo - tol.parametric() {
682                    s -= turn;
683                }
684            }
685            let s = s.clamp(lo, hi);
686            let pt = if (dhi - dlo).abs() <= f64::MIN_POSITIVE {
687                prange.0
688            } else {
689                prange.0 + (prange.1 - prange.0) * (s - dlo) / (dhi - dlo)
690            };
691            let mut uv = ogeom_geom::Curve2d::point_at(pcurve, pt, tol)?;
692            if let (Some((pu, pv)), Some(prev)) = (periods, image.last()) {
693                for (coord, period, before) in [(&mut uv.x, pu, prev.x), (&mut uv.y, pv, prev.y)] {
694                    if period > 0.0 {
695                        while *coord - before > period / 2.0 {
696                            *coord -= period;
697                        }
698                        while before - *coord > period / 2.0 {
699                            *coord += period;
700                        }
701                    }
702                }
703            }
704            params.push(t);
705            image.push(uv);
706        }
707        let fitted =
708            ogeom_geom::fit::fit_points_2d_at(&params, &image, 3, tol.confusion() * 10.0, tol)?;
709        if std::env::var_os("OGEOM_DEBUG_SEW").is_some() {
710            use ogeom_geom::Surface as _;
711            let mut worst_sample = 0.0_f64;
712            let mut worst_fit = 0.0_f64;
713            if let Some(sg) = model.geometry().surface(*surface) {
714                for (t, uv) in params.iter().zip(&image) {
715                    let p = kept_fp.curve.point_at(*t, tol)?;
716                    worst_sample = worst_sample.max(sg.point_at(uv.x, uv.y, tol)?.distance(p));
717                    let f = ogeom_geom::Curve2d::point_at(&fitted.curve, *t, tol)?;
718                    worst_fit = worst_fit.max(sg.point_at(f.x, f.y, tol)?.distance(p));
719                }
720            }
721            eprintln!(
722                "SEW refit: samples off surface by {worst_sample:.2e}, fit off by {worst_fit:.2e} (fit error {:.2e} met {}) domain {:?} over ({klo:.4}, {khi:.4}); dropped range {:?}",
723                fitted.error,
724                fitted.met,
725                ogeom_geom::Curve2d::domain(&fitted.curve),
726                dropped_fp.range
727            );
728        }
729        if !fitted.met || fitted.error > reach.max(tol.confusion() * 1e3) {
730            // A refit that misses its budget is no description of the edge;
731            // the pcurve is carried as it came, its drift along the edge and
732            // all, rather than replaced by a worse one.
733            out.push(if flipped {
734                reversed_repr(repr.clone())
735            } else {
736                repr.clone()
737            });
738            continue;
739        }
740        pending.push((*surface, location.clone(), fitted.curve.into(), (klo, khi)));
741    }
742    for (surface, location, planar, range) in pending {
743        let curve = model.geometry_mut().add_pcurve(planar);
744        out.push(EdgeRepr::PCurve {
745            curve,
746            surface,
747            location,
748            range,
749        });
750    }
751    Ok(out)
752}
753
754/// A parametric representation running the other way.
755///
756/// The consumers map a 3D-curve parameter onto the stored range
757/// proportionally, so swapping the range's ends reverses the traversal
758/// exactly, with no new geometry. A seam also swaps which pcurve is the
759/// forward one, since "forward" is defined by the traversal that just
760/// reversed.
761fn reversed_repr(repr: EdgeRepr) -> EdgeRepr {
762    match repr {
763        EdgeRepr::PCurve {
764            curve,
765            surface,
766            location,
767            range,
768        } => EdgeRepr::PCurve {
769            curve,
770            surface,
771            location,
772            range: (range.1, range.0),
773        },
774        EdgeRepr::Seam {
775            forward,
776            reversed,
777            surface,
778            location,
779            range,
780        } => EdgeRepr::Seam {
781            forward: reversed,
782            reversed: forward,
783            surface,
784            location,
785            range: (range.1, range.0),
786        },
787        other => other,
788    }
789}
790
791/// Rebuild every edge whose bounding vertices were merged away.
792///
793/// An edge's bounds live in its node, so an edge cannot be pointed at a
794/// different vertex; it has to be built again. Its data comes across whole,
795/// representations included, so the new edge describes itself exactly as the
796/// old one did and only its ends have changed.
797fn rebuild_edges(
798    model: &mut Model,
799    faces: &[Shape],
800    vertices: &HashMap<TShapeId, TShapeId>,
801) -> OgeomResult<HashMap<TShapeId, TShapeId>> {
802    let mut out = HashMap::new();
803    if vertices.is_empty() {
804        return Ok(out);
805    }
806    let mut done: HashSet<TShapeId> = HashSet::new();
807    for face in faces {
808        for edge in explore_unique(model, face, ShapeType::Edge)? {
809            if !done.insert(edge.node()) {
810                continue;
811            }
812
813            let Some(node) = model.node(&edge) else {
814                ogeom_bail!(Dangling, "edge is not in this model");
815            };
816            let bounds: Vec<Shape> = node.children().to_vec();
817            if !bounds.iter().any(|b| vertices.contains_key(&b.node())) {
818                continue;
819            }
820            let NodeData::Edge(data) = node.data().clone() else {
821                continue;
822            };
823            let moved: Vec<Shape> = bounds
824                .iter()
825                .map(|b| match vertices.get(&b.node()) {
826                    Some(kept) => Shape::new(*kept, b.location().clone(), b.orientation()),
827                    None => b.clone(),
828                })
829                .collect();
830            let fresh = model.add_edge(*data, &moved)?;
831            out.insert(edge.node(), fresh.node());
832        }
833    }
834    Ok(out)
835}
836
837/// What decides whether two edges are the same edge.
838#[derive(Debug, Clone)]
839struct Fingerprint {
840    start: Point,
841    middle: Point,
842    end: Point,
843    /// The edge's curve in space and the stretch it covers, for the middle
844    /// of another edge to be asked whether it lies on this one.
845    curve: ogeom_geom::Curve,
846    range: (f64, f64),
847    /// How far this edge's own stated tolerances let it stray: the widest of
848    /// the edge's and its vertices'. An edge whose junction was welded across
849    /// a recorded gap carries that gap here, and the comparison honours it:
850    /// per-entity tolerances are the data model's, not a nicety of import.
851    width: f64,
852}
853
854impl Fingerprint {
855    /// How far `p` sits from this edge's own stretch of its curve.
856    fn off(&self, p: Point, tol: Tolerances) -> OgeomResult<f64> {
857        let foot = crate::project_on_curve(&self.curve, p, 64, tol)?;
858        let (lo, hi) = (
859            self.range.0.min(self.range.1),
860            self.range.0.max(self.range.1),
861        );
862        let mut t = foot.parameter;
863        if self.curve.is_periodic() {
864            let (dlo, dhi) = self.curve.domain();
865            let period = dhi - dlo;
866            if period > 0.0 {
867                t = lo + (t - lo).rem_euclid(period);
868            }
869        }
870        if t >= lo - tol.parametric() && t <= hi + tol.parametric() {
871            return Ok(foot.distance);
872        }
873        Ok(p.distance(self.start).min(p.distance(self.end)))
874    }
875
876    /// Whether two edges coincide, and if so whether the second runs backwards.
877    fn same_as(&self, other: &Self, tol: Tolerances) -> OgeomResult<Option<bool>> {
878        let reach = tol.confusion().max(self.width).max(other.width);
879        let near = |a: Point, b: Point| a.distance(b) <= reach;
880        // Ends first: they cost a distance each, and most candidates that
881        // start near this edge end somewhere else. The middle is asked only
882        // of a pair whose ends already agree.
883        let along = near(self.start, other.start) && near(self.end, other.end);
884        let against = near(self.start, other.end) && near(self.end, other.start);
885        if !along && !against {
886            return Ok(None);
887        }
888        // The midpoint is not a nicety. Two arcs between the same pair of
889        // vertices (the two halves of a circle) agree at both ends and are
890        // not the same edge, and merging them would fuse a shape to itself.
891        // Two descriptions of one curve need not agree on where its middle
892        // *parameter* falls (a fitted rim against the exact circle it
893        // traces paces itself differently), so each middle is asked to lie
894        // on the other's stretch instead, which the far half of a circle
895        // still fails.
896        if !near(self.middle, other.middle)
897            && !(other.off(self.middle, tol)? <= reach && self.off(other.middle, tol)? <= reach)
898        {
899            if std::env::var_os("OGEOM_DEBUG_SEW").is_some() {
900                let foot = crate::project_on_curve(&other.curve, self.middle, 64, tol)?;
901                eprintln!(
902                    "SEW near miss: ends agree within {reach:.2e}, middles off {:.2e} / {:.2e} (widths {:.2e}, {:.2e}) at {:?}; foot on other at {:.6} (range {:?}, domain {:?}, periodic {}) distance {:.2e}",
903                    other.off(self.middle, tol)?,
904                    self.off(other.middle, tol)?,
905                    self.width,
906                    other.width,
907                    self.middle,
908                    foot.parameter,
909                    other.range,
910                    other.curve.domain(),
911                    other.curve.is_periodic(),
912                    foot.distance
913                );
914            }
915            return Ok(None);
916        }
917        Ok(Some(!along))
918    }
919}
920
921/// The edges after `i` in the catalogue that could be the same edge as
922/// edge `i`, in catalogue order.
923///
924/// Two edges are one only when each end of one meets an end of the other,
925/// so an edge's twin starts within reach of one of its ends: the edges
926/// binned by where they start, near either end of edge `i`, are every edge
927/// that could match, and an edge is asked about those alone rather than
928/// about the whole catalogue.
929fn twin_candidates(
930    catalogue: &[(TShapeId, Fingerprint)],
931    starts: &Bins,
932    reach: f64,
933    i: usize,
934) -> Vec<usize> {
935    let print = &catalogue[i].1;
936    let (Some(from_start), Some(from_end)) = (
937        starts.near(print.start, reach),
938        starts.near(print.end, reach),
939    ) else {
940        return ((i + 1)..catalogue.len()).collect();
941    };
942    let mut out: Vec<usize> = from_start
943        .into_iter()
944        .chain(from_end)
945        .filter(|&j| j > i)
946        .collect();
947    out.sort_unstable();
948    out.dedup();
949    out
950}
951
952/// An edge's ends and midpoint, in space.
953fn fingerprint(model: &Model, edge: &Shape, tol: Tolerances) -> OgeomResult<Option<Fingerprint>> {
954    let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
955        return Ok(None);
956    };
957    let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
958        // A degenerate edge has no curve and no length; there is nothing about
959        // it that could match another edge's geometry.
960        return Ok(None);
961    };
962    let Some(geometry) = model.geometry().curve(*curve) else {
963        ogeom_bail!(Dangling, "curve is not in this model");
964    };
965    let placement = edge.transform(model.datums())?;
966    let mut width = data.tolerance.get();
967    for vertex in model.children_of(edge)? {
968        if let Some(v) = model.node(&vertex).and_then(|n| n.data().as_vertex()) {
969            width = width.max(v.tolerance.get());
970        }
971    }
972    use ogeom_geom::Transformable as _;
973    Ok(Some(Fingerprint {
974        start: placement.apply(geometry.point_at(range.0, tol)?),
975        middle: placement.apply(geometry.point_at(f64::midpoint(range.0, range.1), tol)?),
976        end: placement.apply(geometry.point_at(range.1, tol)?),
977        curve: geometry.clone().transformed(&placement, tol)?,
978        range: *range,
979        width,
980    }))
981}
982
983/// A vertex's position in space.
984fn placed(model: &Model, vertex: &Shape) -> OgeomResult<Point> {
985    let Some(data) = model.node(vertex).and_then(|n| n.data().as_vertex()) else {
986        ogeom_bail!(Construction, "expected a vertex");
987    };
988    Ok(vertex.transform(model.datums())?.apply(data.point))
989}
990
991/// Rebuild a face with merged edges in place of the ones they replaced.
992fn rebuild_face(
993    model: &mut Model,
994    face: &Shape,
995    merged: &HashMap<TShapeId, (TShapeId, bool)>,
996    tol: Tolerances,
997) -> OgeomResult<Option<Shape>> {
998    let Some(data) = model.node(face).and_then(|n| n.data().as_face()).cloned() else {
999        ogeom_bail!(Construction, "expected a face");
1000    };
1001    // Nothing to substitute: the face is already built on the shared edges, and
1002    // rebuilding it would only mint a node identical to the one there.
1003    let mut touched = false;
1004
1005    let mut wires = Vec::new();
1006    for wire in model.ordered_children_of(face)? {
1007        let mut ring = Vec::new();
1008        for edge in model.ordered_children_of(&wire)? {
1009            match merged.get(&edge.node()) {
1010                Some((kept, flipped)) => {
1011                    touched = true;
1012                    let mut replacement =
1013                        Shape::new(*kept, edge.location().clone(), edge.orientation());
1014                    if *flipped {
1015                        replacement = replacement.reversed();
1016                    }
1017                    ring.push(replacement);
1018                }
1019                None => ring.push(edge),
1020            }
1021        }
1022        // A ring that merging collapsed onto one edge, walked out and back,
1023        // bounds no area: the sliver between two coincident strands that the
1024        // sew has just found to be one edge. Such a ring is dropped, and a
1025        // face whose outer ring it was with it.
1026        let one_edge = !ring.is_empty() && ring.iter().all(|e| e.node() == ring[0].node());
1027        if one_edge && ring.len() >= 2 {
1028            if wires.is_empty() {
1029                return Ok(None);
1030            }
1031            continue;
1032        }
1033        let wire = match make_wire(model, &ring, tol) {
1034            Ok(w) => w.shape,
1035            Err(e) => {
1036                if std::env::var_os("OGEOM_DEBUG_SEW").is_some() {
1037                    eprintln!("SEW WIRE FAIL: {e}");
1038                    for edge in &ring {
1039                        if let Some((a, b)) = edge_vertices(model, edge)? {
1040                            let (pa, pb) = (placed(model, &a)?, placed(model, &b)?);
1041                            let fp = fingerprint(model, edge, tol)?;
1042                            eprintln!(
1043                                "   edge {:?}{} ({:.5},{:.5},{:.5}) v{} -> ({:.5},{:.5},{:.5}) v{} width {:.2e}",
1044                                edge.node(),
1045                                if edge.orientation() == ogeom_topo::Orientation::Reversed {
1046                                    " rev"
1047                                } else {
1048                                    ""
1049                                },
1050                                pa.x,
1051                                pa.y,
1052                                pa.z,
1053                                a.node().index(),
1054                                pb.x,
1055                                pb.y,
1056                                pb.z,
1057                                b.node().index(),
1058                                fp.map_or(0.0, |f| f.width)
1059                            );
1060                        }
1061                    }
1062                }
1063                return Err(e);
1064            }
1065        };
1066        wires.push(wire);
1067    }
1068    if !touched {
1069        return Ok(Some(face.clone()));
1070    }
1071    if wires.is_empty() {
1072        return Ok(None);
1073    }
1074    let sewn = match make_face_on(model, data.surface, &wires, tol) {
1075        Ok(built) => built.shape,
1076        Err(e) => {
1077            if std::env::var_os("OGEOM_DEBUG_SEW").is_some() {
1078                eprintln!("SEW FACE FAIL: {e}");
1079                for (wi, wire) in wires.iter().enumerate() {
1080                    for edge in model.ordered_children_of(wire)? {
1081                        if let Some((a, b)) = edge_vertices(model, &edge)? {
1082                            let (pa, pb) = (placed(model, &a)?, placed(model, &b)?);
1083                            eprintln!(
1084                                "   wire {wi} edge {:?}{} ({:.5},{:.5},{:.5}) v{} -> ({:.5},{:.5},{:.5}) v{}",
1085                                edge.node(),
1086                                if edge.orientation() == ogeom_topo::Orientation::Reversed {
1087                                    " rev"
1088                                } else {
1089                                    ""
1090                                },
1091                                pa.x,
1092                                pa.y,
1093                                pa.z,
1094                                a.node().index(),
1095                                pb.x,
1096                                pb.y,
1097                                pb.z,
1098                                b.node().index()
1099                            );
1100                        }
1101                    }
1102                }
1103            }
1104            return Err(e);
1105        }
1106    };
1107    Ok(if face.orientation() == Orientation::Reversed {
1108        Some(sewn.reversed())
1109    } else {
1110        Some(sewn)
1111    })
1112}
1113
1114/// Group faces by whether they share an edge, transitively.
1115fn connected_groups(model: &Model, faces: &[Shape]) -> OgeomResult<Vec<Vec<Shape>>> {
1116    let mut group_of: Vec<usize> = (0..faces.len()).collect();
1117    let mut edges_of = Vec::with_capacity(faces.len());
1118    for face in faces {
1119        edges_of.push(
1120            explore_unique(model, face, ShapeType::Edge)?
1121                .into_iter()
1122                .map(|e| e.node())
1123                .collect::<Vec<_>>(),
1124        );
1125    }
1126
1127    // Union-find: each face joins the first face seen with each of its
1128    // edges, which joins it to every face sharing that edge transitively.
1129    let mut first_user: HashMap<TShapeId, usize> = HashMap::new();
1130    for (i, edges) in edges_of.iter().enumerate() {
1131        for edge in edges {
1132            let j = *first_user.entry(*edge).or_insert(i);
1133            let (a, b) = (find(&mut group_of, j), find(&mut group_of, i));
1134            if a != b {
1135                group_of[b] = a;
1136            }
1137        }
1138    }
1139
1140    let mut groups: HashMap<usize, Vec<Shape>> = HashMap::new();
1141    for (i, face) in faces.iter().enumerate() {
1142        groups
1143            .entry(find(&mut group_of, i))
1144            .or_default()
1145            .push(face.clone());
1146    }
1147    let mut out: Vec<Vec<Shape>> = groups.into_values().collect();
1148    // Deterministic: a result whose shells come back in a different order each
1149    // run is one nobody can compare against.
1150    out.sort_by_key(|group| group.first().map(Shape::node));
1151    Ok(out)
1152}
1153
1154/// Follow a union-find chain to its root.
1155fn find(parent: &mut [usize], mut i: usize) -> usize {
1156    // Halving the path on the way up keeps every chain short.
1157    while parent[i] != i {
1158        parent[i] = parent[parent[i]];
1159        i = parent[i];
1160    }
1161    i
1162}
1163
1164/// Edges still used by exactly one face.
1165fn free_edges(model: &Model, faces: &[Shape]) -> OgeomResult<Vec<Shape>> {
1166    let mut uses: HashMap<TShapeId, (usize, Shape)> = HashMap::new();
1167    for face in faces {
1168        for wire in model.children_of(face)? {
1169            for edge in model.children_of(&wire)? {
1170                if model
1171                    .node(&edge)
1172                    .and_then(|n| n.data().as_edge())
1173                    .is_some_and(|d| d.degenerate)
1174                {
1175                    continue;
1176                }
1177                let entry = uses.entry(edge.node()).or_insert((0, edge.clone()));
1178                entry.0 += 1;
1179            }
1180        }
1181    }
1182    let mut out: Vec<Shape> = uses
1183        .into_values()
1184        .filter(|(count, _)| count % 2 == 1)
1185        .map(|(_, edge)| edge)
1186        .collect();
1187    out.sort_by_key(Shape::node);
1188    Ok(out)
1189}
1190
1191#[cfg(test)]
1192#[allow(clippy::unwrap_used, clippy::expect_used)]
1193mod tests {
1194    use super::*;
1195    use crate::{check_tessellation, is_shell_closed, make_box, make_polygon};
1196    use ogeom_geom::PlaneSurface;
1197    use ogeom_math::{Frame, Plane, Vector};
1198    use ogeom_topo::Location;
1199
1200    const T: Tolerances = Tolerances::millimetres();
1201
1202    fn fine() -> ogeom_mesh::Deflection {
1203        ogeom_mesh::Deflection {
1204            chord: 0.02,
1205            ..ogeom_mesh::Deflection::default()
1206        }
1207    }
1208
1209    /// A square face in the z = `at` plane, built from its own fresh edges so
1210    /// it shares nothing with anything else.
1211    fn loose_square(model: &mut Model, corners: [Point; 4]) -> Shape {
1212        let wire = make_polygon(model, &corners, true, T).unwrap().shape;
1213        let normal =
1214            ogeom_math::Direction::from_cross(corners[1] - corners[0], corners[2] - corners[1], T)
1215                .unwrap();
1216        let frame = ogeom_math::Frame::new(
1217            corners[0],
1218            normal,
1219            ogeom_math::Direction::new(corners[1] - corners[0], T).unwrap(),
1220            T,
1221        )
1222        .unwrap();
1223        let surface = model
1224            .geometry_mut()
1225            .add_surface(PlaneSurface::new(Plane::new(frame)).into());
1226        for edge in model.children_of(&wire).unwrap() {
1227            let (a, b) = crate::edge_vertices(model, &edge).unwrap().unwrap();
1228            let (pa, pb) = (placed(model, &a).unwrap(), placed(model, &b).unwrap());
1229            let flat = |p: Point| {
1230                let l = frame.to_local(p);
1231                ogeom_math::Point2::new(l.x, l.y)
1232            };
1233            crate::attach_pcurve(
1234                model,
1235                &edge,
1236                ogeom_geom::Line2d::segment(flat(pa), flat(pb), T)
1237                    .unwrap()
1238                    .into(),
1239                surface,
1240                Location::identity(),
1241                (0.0, pa.distance(pb)),
1242            )
1243            .unwrap();
1244        }
1245        crate::make_face_on(model, surface, std::slice::from_ref(&wire), T)
1246            .unwrap()
1247            .shape
1248    }
1249
1250    #[test]
1251    fn edges_in_any_order_come_back_as_a_path() {
1252        let mut model = Model::new();
1253        let corners = [
1254            Point::new(0.0, 0.0, 0.0),
1255            Point::new(1.0, 0.0, 0.0),
1256            Point::new(1.0, 1.0, 0.0),
1257            Point::new(0.0, 1.0, 0.0),
1258        ];
1259        let wire = make_polygon(&mut model, &corners, true, T).unwrap().shape;
1260        let mut edges = model.children_of(&wire).unwrap();
1261        // Shuffled, and some of them turned round.
1262        edges.swap(0, 2);
1263        edges[1] = edges[1].reversed();
1264        edges[3] = edges[3].reversed();
1265
1266        let ordered = order_edges(&model, &edges, T).unwrap();
1267        assert_eq!(ordered.len(), 4);
1268        // A wire only builds if consecutive edges actually meet, so this is the
1269        // property under test rather than a separate one.
1270        let rebuilt = make_wire(&mut model, &ordered, T).unwrap().shape;
1271        assert!(crate::is_wire_closed(&model, &rebuilt, T).unwrap());
1272    }
1273
1274    #[test]
1275    fn edges_that_do_not_form_one_path_are_refused() {
1276        let mut model = Model::new();
1277        let a = make_polygon(
1278            &mut model,
1279            &[Point::ORIGIN, Point::new(1.0, 0.0, 0.0)],
1280            false,
1281            T,
1282        )
1283        .unwrap()
1284        .shape;
1285        let b = make_polygon(
1286            &mut model,
1287            &[Point::new(5.0, 0.0, 0.0), Point::new(6.0, 0.0, 0.0)],
1288            false,
1289            T,
1290        )
1291        .unwrap()
1292        .shape;
1293        let mut edges = model.children_of(&a).unwrap();
1294        edges.extend(model.children_of(&b).unwrap());
1295
1296        let err = order_edges(&model, &edges, T).unwrap_err();
1297        assert!(
1298            err.to_string().contains("connected path"),
1299            "unexpected message: {err}"
1300        );
1301        assert!(order_edges(&model, &[], T).is_err());
1302    }
1303
1304    #[test]
1305    fn a_branching_network_is_refused_rather_than_arbitrarily_walked() {
1306        // Three edges from one point. Any path through it drops a branch, and
1307        // dropping one silently is worse than saying there is no answer.
1308        let mut model = Model::new();
1309        let hub = Point::ORIGIN;
1310        let mut edges = Vec::new();
1311        for tip in [
1312            Point::new(1.0, 0.0, 0.0),
1313            Point::new(0.0, 1.0, 0.0),
1314            Point::new(0.0, 0.0, 1.0),
1315        ] {
1316            let w = make_polygon(&mut model, &[hub, tip], false, T)
1317                .unwrap()
1318                .shape;
1319            edges.extend(model.children_of(&w).unwrap());
1320        }
1321        let err = order_edges(&model, &edges, T).unwrap_err();
1322        assert!(
1323            err.to_string().contains("branching"),
1324            "unexpected message: {err}"
1325        );
1326    }
1327
1328    #[test]
1329    fn two_faces_that_touch_are_sewn_into_one_shell() {
1330        let mut model = Model::new();
1331        let left = loose_square(
1332            &mut model,
1333            [
1334                Point::new(0.0, 0.0, 0.0),
1335                Point::new(1.0, 0.0, 0.0),
1336                Point::new(1.0, 1.0, 0.0),
1337                Point::new(0.0, 1.0, 0.0),
1338            ],
1339        );
1340        let right = loose_square(
1341            &mut model,
1342            [
1343                Point::new(1.0, 0.0, 0.0),
1344                Point::new(2.0, 0.0, 0.0),
1345                Point::new(2.0, 1.0, 0.0),
1346                Point::new(1.0, 1.0, 0.0),
1347            ],
1348        );
1349
1350        // Before: eight edges, nothing shared.
1351        let before = explore_unique(&model, &left, ShapeType::Edge)
1352            .unwrap()
1353            .len()
1354            + explore_unique(&model, &right, ShapeType::Edge)
1355                .unwrap()
1356                .len();
1357        assert_eq!(before, 8);
1358
1359        let sewn = sew(&mut model, &[left.clone(), right.clone()], T).unwrap();
1360        assert_eq!(sewn.shells.len(), 1, "they touch, so they are one shell");
1361        assert_eq!(sewn.joined, 1, "one shared edge");
1362        assert_eq!(
1363            explore_unique(&model, &sewn.shells[0], ShapeType::Edge)
1364                .unwrap()
1365                .len(),
1366            7,
1367            "the shared edge is one edge now, not two"
1368        );
1369        // A sheet, so it still has a boundary: six free edges round the
1370        // outside, and the shared one is not among them.
1371        assert_eq!(sewn.free_edges.len(), 6);
1372        assert!(!is_shell_closed(&model, &sewn.shells[0]).unwrap());
1373        assert!(sewn.history.is_affected(&left));
1374    }
1375
1376    /// Twin edges whose ends are two vertices apart by more than either
1377    /// vertex's own tolerance, though within the edges': the right square's
1378    /// corner sits a hundredth below the left's, and its shared edge is loose
1379    /// enough to be the left's. Merged, the edges must end on one vertex, or
1380    /// the right square's bottom edge still ends at its own corner and its
1381    /// wire opens.
1382    #[test]
1383    fn twin_edges_join_the_vertices_they_end_on() {
1384        let mut model = Model::new();
1385        let left = loose_square(
1386            &mut model,
1387            [
1388                Point::new(0.0, 0.0, 0.0),
1389                Point::new(1.0, 0.0, 0.0),
1390                Point::new(1.0, 1.0, 0.0),
1391                Point::new(0.0, 1.0, 0.0),
1392            ],
1393        );
1394        let right = loose_square(
1395            &mut model,
1396            [
1397                Point::new(1.0, -0.01, 0.0),
1398                Point::new(2.0, 0.0, 0.0),
1399                Point::new(2.0, 1.0, 0.0),
1400                Point::new(1.0, 1.0, 0.0),
1401            ],
1402        );
1403        for edge in explore_unique(&model, &right, ShapeType::Edge).unwrap() {
1404            let (a, b) = crate::edge_vertices(&model, &edge).unwrap().unwrap();
1405            let (pa, pb) = (placed(&model, &a).unwrap(), placed(&model, &b).unwrap());
1406            if (pa.x - 1.0).abs() < 1e-9
1407                && (pb.x - 1.0).abs() < 1e-9
1408                && let Some(node) = model.node_mut(&edge)
1409                && let NodeData::Edge(data) = node.data_mut()
1410            {
1411                data.tolerance = data.tolerance.widen_to(0.012);
1412            }
1413        }
1414        let sewn = sew(&mut model, &[left, right], T).unwrap();
1415        assert_eq!(sewn.joined, 1, "the loose edge is the left square's");
1416        assert_eq!(sewn.shells.len(), 1);
1417        assert_eq!(
1418            explore_unique(&model, &sewn.shells[0], ShapeType::Vertex)
1419                .unwrap()
1420                .len(),
1421            6,
1422            "the two corners at the bottom of the shared edge are one"
1423        );
1424        assert_eq!(sewn.free_edges.len(), 6);
1425    }
1426
1427    #[test]
1428    fn faces_that_do_not_meet_stay_in_separate_shells() {
1429        // Claiming one shell would claim a closure that is not there.
1430        let mut model = Model::new();
1431        let here = loose_square(
1432            &mut model,
1433            [
1434                Point::new(0.0, 0.0, 0.0),
1435                Point::new(1.0, 0.0, 0.0),
1436                Point::new(1.0, 1.0, 0.0),
1437                Point::new(0.0, 1.0, 0.0),
1438            ],
1439        );
1440        let far = loose_square(
1441            &mut model,
1442            [
1443                Point::new(50.0, 0.0, 0.0),
1444                Point::new(51.0, 0.0, 0.0),
1445                Point::new(51.0, 1.0, 0.0),
1446                Point::new(50.0, 1.0, 0.0),
1447            ],
1448        );
1449        let sewn = sew(&mut model, &[here, far], T).unwrap();
1450        assert_eq!(sewn.shells.len(), 2);
1451        assert_eq!(sewn.joined, 0);
1452        assert_eq!(sewn.free_edges.len(), 8);
1453    }
1454
1455    #[test]
1456    fn a_boxs_faces_taken_apart_and_sewn_back_close_again() {
1457        // The end-to-end case. The faces already share edges here, so what is
1458        // under test is that sewing does not *break* a shell that was closed,
1459        // and that the mesh still agrees with the topology afterwards, which is
1460        // the check a re-built face is most likely to fail.
1461        let mut model = Model::new();
1462        let solid = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T)
1463            .unwrap()
1464            .shape;
1465        let faces = explore_unique(&model, &solid, ShapeType::Face).unwrap();
1466
1467        let sewn = sew(&mut model, &faces, T).unwrap();
1468        assert_eq!(sewn.shells.len(), 1);
1469        assert!(sewn.free_edges.is_empty(), "a box has no free edges");
1470        assert!(is_shell_closed(&model, &sewn.shells[0]).unwrap());
1471        assert!(
1472            check_tessellation(&model, &sewn.shells[0], fine(), T)
1473                .unwrap()
1474                .is_valid()
1475        );
1476    }
1477
1478    #[test]
1479    fn two_arcs_between_the_same_vertices_are_not_the_same_edge() {
1480        // The reason the fingerprint samples the middle. Both halves of a
1481        // circle agree at both ends; merging them would fuse the shape to
1482        // itself and the mistake would look like a successful sew.
1483        let mut model = Model::new();
1484        let circle = ogeom_math::Circle::new(Frame::WORLD, 1.0, T).unwrap();
1485        let upper = crate::make_edge(
1486            &mut model,
1487            ogeom_geom::CircleCurve::new(circle).into(),
1488            (0.0, std::f64::consts::PI),
1489            T,
1490        )
1491        .unwrap()
1492        .shape;
1493        let lower = crate::make_edge(
1494            &mut model,
1495            ogeom_geom::CircleCurve::new(circle).into(),
1496            (std::f64::consts::PI, std::f64::consts::TAU),
1497            T,
1498        )
1499        .unwrap()
1500        .shape;
1501
1502        let a = fingerprint(&model, &upper, T).unwrap().unwrap();
1503        let b = fingerprint(&model, &lower, T).unwrap().unwrap();
1504        assert!(
1505            a.same_as(&b, T).unwrap().is_none(),
1506            "two different arcs were called the same edge"
1507        );
1508        assert!(a.same_as(&a, T).unwrap() == Some(false));
1509    }
1510
1511    #[test]
1512    fn an_edge_found_the_other_way_round_is_reversed_rather_than_dropped() {
1513        let mut model = Model::new();
1514        let up = Point::new(0.0, 0.0, 1.0);
1515        let down = Point::new(0.0, 0.0, 0.0);
1516        let a = crate::make_edge(
1517            &mut model,
1518            ogeom_geom::LineCurve::segment(down, up, T).unwrap().into(),
1519            (0.0, 1.0),
1520            T,
1521        )
1522        .unwrap()
1523        .shape;
1524        let b = crate::make_edge(
1525            &mut model,
1526            ogeom_geom::LineCurve::segment(up, down, T).unwrap().into(),
1527            (0.0, 1.0),
1528            T,
1529        )
1530        .unwrap()
1531        .shape;
1532
1533        let pa = fingerprint(&model, &a, T).unwrap().unwrap();
1534        let pb = fingerprint(&model, &b, T).unwrap().unwrap();
1535        assert_eq!(
1536            pa.same_as(&pb, T).unwrap(),
1537            Some(true),
1538            "the same edge, running the other way"
1539        );
1540    }
1541
1542    #[test]
1543    fn flipped_merges_carry_their_pcurves_the_right_way_round() {
1544        // Six faces of a unit cube, each built loose with its own vertices
1545        // and pre-attached pcurves, wound counter-clockwise around the
1546        // outward normal as a shell is. Sewing merges all twelve edge pairs,
1547        // and every merge is *flipped*: the two faces walk their shared
1548        // edge opposite ways. A carried pcurve copied unchanged then makes
1549        // the losing face walk edges backwards in parameter space; with
1550        // several such edges in one ring the boundary zigzags, and faces
1551        // stop triangulating or triangulate degenerately. Two squares are
1552        // not enough to see it (a single backwards two-point edge self-heals
1553        // in ring assembly), which is why this test is a cube.
1554        //
1555        // The carry must reverse with the merge: consumers map 3D parameters
1556        // onto the pcurve range proportionally, so swapping the stored
1557        // range's ends reverses the traversal exactly. The boolean found
1558        // this by sewing faces annotated before sewing decided which twin
1559        // survives.
1560        let mut model = Model::new();
1561        let c = Point::new;
1562        let faces = [
1563            [c(0., 0., 0.), c(0., 1., 0.), c(1., 1., 0.), c(1., 0., 0.)],
1564            [c(0., 0., 1.), c(1., 0., 1.), c(1., 1., 1.), c(0., 1., 1.)],
1565            [c(0., 0., 0.), c(1., 0., 0.), c(1., 0., 1.), c(0., 0., 1.)],
1566            [c(0., 1., 0.), c(0., 1., 1.), c(1., 1., 1.), c(1., 1., 0.)],
1567            [c(0., 0., 0.), c(0., 0., 1.), c(0., 1., 1.), c(0., 1., 0.)],
1568            [c(1., 0., 0.), c(1., 1., 0.), c(1., 1., 1.), c(1., 0., 1.)],
1569        ];
1570        let built: Vec<Shape> = faces.iter().map(|f| loose_square(&mut model, *f)).collect();
1571        let sewn = sew(&mut model, &built, T).unwrap();
1572        assert_eq!(sewn.joined, 12, "every edge pair merged");
1573        assert!(sewn.free_edges.is_empty());
1574        for face in ogeom_topo::explore(
1575            &model,
1576            &sewn.shells[0],
1577            ogeom_topo::Filter::OfType(ShapeType::Face),
1578        )
1579        .unwrap()
1580        {
1581            let mesh = ogeom_mesh::triangulate(&model, &face, fine(), T).unwrap();
1582            assert_eq!(
1583                mesh.triangles.len(),
1584                2,
1585                "a unit square triangulates into two triangles, whichever twin \
1586                 survived and whichever way it runs"
1587            );
1588        }
1589    }
1590
1591    #[test]
1592    fn sewing_nothing_and_sewing_the_wrong_kind_are_refused() {
1593        let mut model = Model::new();
1594        assert!(sew(&mut model, &[], T).is_err());
1595        let vertex = model.add_point(Point::ORIGIN);
1596        assert!(sew(&mut model, &[vertex], T).is_err());
1597    }
1598
1599    #[test]
1600    fn sewing_does_not_move_anything() {
1601        // A repair that closes gaps by moving geometry has to decide which of
1602        // two positions is right, and would invalidate every tolerance nearby.
1603        // This decides that two edges *are* one edge and leaves the points
1604        // where they were.
1605        let mut model = Model::new();
1606        let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
1607            .unwrap()
1608            .shape;
1609        let faces = explore_unique(&model, &solid, ShapeType::Face).unwrap();
1610        let before: Vec<Point> = explore_unique(&model, &solid, ShapeType::Vertex)
1611            .unwrap()
1612            .iter()
1613            .map(|v| placed(&model, v).unwrap())
1614            .collect();
1615
1616        let sewn = sew(&mut model, &faces, T).unwrap();
1617        let after: Vec<Point> = explore_unique(&model, &sewn.shells[0], ShapeType::Vertex)
1618            .unwrap()
1619            .iter()
1620            .map(|v| placed(&model, v).unwrap())
1621            .collect();
1622        assert_eq!(before.len(), after.len());
1623        for p in &after {
1624            assert!(
1625                before.iter().any(|q| q.is_equal(*p, T)),
1626                "a vertex moved: {p:?}"
1627            );
1628        }
1629        let _ = Vector::ZERO;
1630    }
1631}