Skip to main content

ogeom_heal/
upgrade.rs

1//! The upgrade family: same-domain face unification, collinear edge
2//! merging, and tolerance reduction, undoing the splits an operation left
3//! behind without changing the shape they describe.
4
5use ogeom_core::{FastMap, FastSet};
6
7use ogeom_algo::{Built, History};
8use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
9use ogeom_geom::{Curve2d as _, Curve3d as _, PlanarCurve, SurfaceGeometry};
10use ogeom_math::{Direction2, Plane, Point, Point2, Transform};
11use ogeom_topo::{
12    EdgeRepr, Location, Model, NodeData, Orientation, PCurveId, Shape, ShapeType, SurfaceId,
13    TShapeId, explore_unique,
14};
15
16use crate::reshape::Reshape;
17
18mod curved;
19
20/// Merge adjacent faces lying on one carrier into single faces.
21///
22/// Two faces qualify when they share an edge and lie on one carrier facing
23/// one way, whatever frame each stored it in and whichever way round: the
24/// split a boolean or an exchange leaves behind. A carrier is a plane; a
25/// cylinder, cone, sphere or torus of one size about one axis; or an
26/// extrusion of one basis curve along one line, the curve's parameter run
27/// either way and shifted. The merged face keeps the first face's surface,
28/// its window grown to hold the others'; the others' pcurves are carried
29/// into its chart by the exact map between the two charts (a turn and a
30/// shift on a plane; on a curved carrier each parameter run either way and
31/// shifted, by a period where it repeats); the shared edges dissolve, or
32/// stay as a seam where the merged face closes round its carrier; the
33/// faces' own seams stay seams; the remaining boundary re-chains into an
34/// outer ring and one ring per hole. Faces whose boundary does not
35/// re-chain so (two rings through one vertex, an edge a third face also
36/// bounds, a band closing round its axis with no seam) are left split, the
37/// rest merged. Faces on any other surface pass through untouched.
38///
39/// # Errors
40///
41/// As the model's own builders, where the shape does not resolve in
42/// `model`.
43pub fn unify_same_domain(
44    model: &mut Model,
45    shape: &Shape,
46    tol: Tolerances,
47) -> OgeomResult<(Built, usize)> {
48    unify(model, shape, None, tol)
49}
50
51/// [`unify_same_domain`] for the faces `around` alone: each is merged with
52/// the faces on its carrier it reaches across shared edges, and every other
53/// face of `shape` is left as it is, split or not.
54///
55/// What an application refining after a feature wants: the faces the
56/// feature made unified with their neighbours, and the splits the part
57/// already held (kept on purpose, or not its business) untouched. A face
58/// of `around` that is not in `shape` names nothing and is passed over.
59///
60/// # Errors
61///
62/// As [`unify_same_domain`].
63pub fn unify_same_domain_around(
64    model: &mut Model,
65    shape: &Shape,
66    around: &[Shape],
67    tol: Tolerances,
68) -> OgeomResult<(Built, usize)> {
69    unify(model, shape, Some(around), tol)
70}
71
72/// Whether faces `a` and `b` lie on one carrier facing one way: the pair
73/// [`unify_same_domain`] merges where they share an edge. An edge between
74/// two such faces is a seam, no edge of the shape's form: the surface runs
75/// on across it unbroken, and the material stays on the one side.
76///
77/// # Errors
78///
79/// As the model's own readers, where a face does not resolve in `model`.
80pub fn same_domain(model: &Model, a: &Shape, b: &Shape, tol: Tolerances) -> OgeomResult<bool> {
81    Ok(
82        match (carrier_of(model, a, tol)?, carrier_of(model, b, tol)?) {
83            (Some(a), Some(b)) => same_carrier(&a, &b, tol),
84            _ => false,
85        },
86    )
87}
88
89/// The merged faces describe the edges they keep on their surfaces, so
90/// what `shape` shares with other shapes is copied first and `around`
91/// carried to the copies.
92fn unify(
93    model: &mut Model,
94    shape: &Shape,
95    around: Option<&[Shape]>,
96    tol: Tolerances,
97) -> OgeomResult<(Built, usize)> {
98    ogeom_algo::on_own_nodes_with(
99        model,
100        shape,
101        around.unwrap_or_default(),
102        |model, shape, parts| unify_own(model, shape, around.map(|_| parts), tol),
103        |(built, _)| &mut built.history,
104    )
105}
106
107fn unify_own(
108    model: &mut Model,
109    shape: &Shape,
110    around: Option<&[Shape]>,
111    tol: Tolerances,
112) -> OgeomResult<(Built, usize)> {
113    let faces = explore_unique(model, shape, ShapeType::Face)?;
114
115    // The faces in play: every face, or those `around` names and every
116    // face their carriers reach across shared edges, found as they are
117    // reached.
118    let in_play: Vec<bool> = match around {
119        None => vec![true; faces.len()],
120        Some(seeds) => {
121            let mut edge_users: FastMap<TShapeId, Vec<usize>> = FastMap::default();
122            for (i, face) in faces.iter().enumerate() {
123                for edge in explore_unique(model, face, ShapeType::Edge)? {
124                    edge_users.entry(edge.node()).or_default().push(i);
125                }
126            }
127            let mut reached = vec![false; faces.len()];
128            let mut carriers: FastMap<usize, Option<Carrier>> = FastMap::default();
129            let mut queue: Vec<usize> = Vec::new();
130            for (i, face) in faces.iter().enumerate() {
131                if seeds.iter().any(|s| s.node() == face.node()) {
132                    reached[i] = true;
133                    queue.push(i);
134                }
135            }
136            while let Some(i) = queue.pop() {
137                if let ogeom_core::collections::hash_map::Entry::Vacant(slot) = carriers.entry(i) {
138                    slot.insert(carrier_of(model, &faces[i], tol)?);
139                }
140                for edge in explore_unique(model, &faces[i], ShapeType::Edge)? {
141                    for &j in edge_users.get(&edge.node()).map_or(&[][..], Vec::as_slice) {
142                        if reached[j] {
143                            continue;
144                        }
145                        if let ogeom_core::collections::hash_map::Entry::Vacant(slot) =
146                            carriers.entry(j)
147                        {
148                            slot.insert(carrier_of(model, &faces[j], tol)?);
149                        }
150                        if let (Some(Some(ci)), Some(Some(cj))) =
151                            (carriers.get(&i), carriers.get(&j))
152                            && same_carrier(ci, cj, tol)
153                        {
154                            reached[j] = true;
155                            queue.push(j);
156                        }
157                    }
158                }
159            }
160            reached
161        }
162    };
163
164    // The carrier of each face in play: where it lies in the world, which
165    // decides the grouping, and the chart it was stored in, which is where
166    // any carried or synthesized pcurve has to land.
167    let mut carriers: Vec<Option<Carrier>> = Vec::with_capacity(faces.len());
168    for (face, &playing) in faces.iter().zip(&in_play) {
169        carriers.push(if playing {
170            carrier_of(model, face, tol)?
171        } else {
172            None
173        });
174    }
175
176    // Union-find over faces sharing an edge on one carrier.
177    let mut group: Vec<usize> = (0..faces.len()).collect();
178    fn root(group: &mut [usize], mut i: usize) -> usize {
179        while group[i] != i {
180            group[i] = group[group[i]];
181            i = group[i];
182        }
183        i
184    }
185    let mut edge_users: FastMap<TShapeId, Vec<usize>> = FastMap::default();
186    for (i, face) in faces.iter().enumerate() {
187        for edge in explore_unique(model, face, ShapeType::Edge)? {
188            edge_users.entry(edge.node()).or_default().push(i);
189        }
190    }
191    for users in edge_users.values() {
192        for pair in users.windows(2) {
193            let (a, b) = (pair[0], pair[1]);
194            // One carrier means one *surface*, not one frame: two halves of
195            // a wall carry their own origins on the same flat.
196            if let (Some(ca), Some(cb)) = (carriers[a].as_ref(), carriers[b].as_ref())
197                && same_carrier(ca, cb, tol)
198            {
199                let (ra, rb) = (root(&mut group, a), root(&mut group, b));
200                group[ra] = rb;
201            }
202        }
203    }
204
205    // Clusters in the order of their first face, so the merged faces enter
206    // the arena in the same order on every run.
207    let mut cluster_of_root: FastMap<usize, usize> = FastMap::default();
208    let mut clusters: Vec<Vec<usize>> = Vec::new();
209    for i in 0..faces.len() {
210        let r = root(&mut group, i);
211        let c = *cluster_of_root.entry(r).or_insert_with(|| {
212            clusters.push(Vec::new());
213            clusters.len() - 1
214        });
215        clusters[c].push(i);
216    }
217
218    let mut reshape = Reshape::new();
219    for members in &clusters {
220        if members.len() < 2 {
221            continue;
222        }
223        // A cluster whose boundary does not close into rings the merged
224        // face can keep is left split, its faces untouched. Where it holds
225        // faces on seams or on extrusions related by more than a lift, the
226        // clusters its plain pairs make are tried in its place.
227        let mut tries = vec![members.clone()];
228        let mut k = 0;
229        while k < tries.len() {
230            let attempt = &tries[k];
231            k += 1;
232            match merge_cluster(model, &faces, attempt, &carriers, &edge_users, tol) {
233                Ok(Some(merged)) => {
234                    reshape.replace(&faces[attempt[0]], merged);
235                    for &other in &attempt[1..] {
236                        reshape.remove(&faces[other]);
237                    }
238                }
239                // A curved merge edits nothing before its face builds, and
240                // a flat cluster's plain clusters are itself.
241                Ok(None) | Err(_) if k == 1 => {
242                    let plain = plain_clusters(model, &faces, members, &carriers, tol)?;
243                    if plain.as_slice() != [members.clone()] {
244                        tries.extend(plain);
245                    }
246                }
247                _ => {}
248            }
249        }
250    }
251    if reshape.is_empty() {
252        let mut built = Built::from_nothing(shape.clone());
253        built.history.modify(shape, shape.clone());
254        return Ok((built, 0));
255    }
256    let unified = reshape.len();
257    Ok((reshape.apply(model, shape)?, unified))
258}
259
260/// The clusters `members` falls into when only plain pairs join: faces
261/// sharing an edge whose carriers are one by [`same_plain_carrier`], in the
262/// order of their first member, each of two faces or more.
263fn plain_clusters(
264    model: &Model,
265    faces: &[Shape],
266    members: &[usize],
267    carriers: &[Option<Carrier>],
268    tol: Tolerances,
269) -> OgeomResult<Vec<Vec<usize>>> {
270    let mut group: Vec<usize> = (0..members.len()).collect();
271    fn root(group: &mut [usize], mut i: usize) -> usize {
272        while group[i] != i {
273            group[i] = group[group[i]];
274            i = group[i];
275        }
276        i
277    }
278    let mut edge_users: FastMap<TShapeId, Vec<usize>> = FastMap::default();
279    for (k, &i) in members.iter().enumerate() {
280        for edge in explore_unique(model, &faces[i], ShapeType::Edge)? {
281            edge_users.entry(edge.node()).or_default().push(k);
282        }
283    }
284    let mut pairs: Vec<(usize, usize)> = edge_users
285        .values()
286        .flat_map(|users| users.windows(2).map(|w| (w[0], w[1])))
287        .collect();
288    pairs.sort_unstable();
289    for (a, b) in pairs {
290        if let (Some(ca), Some(cb)) = (&carriers[members[a]], &carriers[members[b]])
291            && same_plain_carrier(ca, cb, tol)
292        {
293            let (ra, rb) = (root(&mut group, a), root(&mut group, b));
294            group[ra] = rb;
295        }
296    }
297    let mut cluster_of_root: FastMap<usize, usize> = FastMap::default();
298    let mut clusters: Vec<Vec<usize>> = Vec::new();
299    for (k, &i) in members.iter().enumerate() {
300        let r = root(&mut group, k);
301        let c = *cluster_of_root.entry(r).or_insert_with(|| {
302            clusters.push(Vec::new());
303            clusters.len() - 1
304        });
305        clusters[c].push(i);
306    }
307    clusters.retain(|c| c.len() > 1);
308    Ok(clusters)
309}
310
311/// The face `members` make together: one face on the first member's
312/// surface, bounded by the rings their outer edges walk. `None` where they
313/// make no one face bounding the same material.
314///
315/// Every edge is walked as the members walk it, seen from the side they
316/// all face, so the rings come out wound as the material lies: the outer
317/// one counter-clockwise about that side, the holes clockwise. An edge two
318/// members walk once each, opposite ways, dissolves; one a face outside
319/// the cluster also bounds, or one walked against the rule, keeps the
320/// cluster as it is, as does a ring that does not close or a vertex two
321/// rings pass through, where no one walk is the boundary.
322fn merge_cluster(
323    model: &mut Model,
324    faces: &[Shape],
325    members: &[usize],
326    carriers: &[Option<Carrier>],
327    edge_users: &FastMap<TShapeId, Vec<usize>>,
328    tol: Tolerances,
329) -> OgeomResult<Option<Shape>> {
330    let keeper = members[0];
331    let Some(carrier) = carriers[keeper].clone() else {
332        return Ok(None);
333    };
334    let Some(NodeData::Face(keeper_data)) = model.node(&faces[keeper]).map(|n| n.data().clone())
335    else {
336        return Ok(None);
337    };
338
339    // Each member's edges as it walks them, seen from the side it faces.
340    let mut walks: Vec<(usize, Shape)> = Vec::new();
341    let mut runs: Vec<core::ops::Range<usize>> = Vec::new();
342    let mut uses: FastMap<TShapeId, Vec<usize>> = FastMap::default();
343    for &i in members {
344        let wires = model.children_of(&faces[i])?;
345        if wires.is_empty() {
346            return Ok(None);
347        }
348        for wire in wires {
349            let start = walks.len();
350            for edge in model.ordered_children_of(&wire)? {
351                uses.entry(edge.node()).or_default().push(walks.len());
352                walks.push((i, edge));
353            }
354            runs.push(start..walks.len());
355        }
356    }
357    let mut kept = vec![false; walks.len()];
358    for (node, at) in &uses {
359        let across = match at.as_slice() {
360            [_] => false,
361            [a, b] if walks[*a].0 == walks[*b].0 => false,
362            [a, b] => {
363                if walks[*a].1.orientation() == walks[*b].1.orientation()
364                    || edge_users.get(node).map_or(0, Vec::len) != 2
365                {
366                    return Ok(None);
367                }
368                true
369            }
370            _ => return Ok(None),
371        };
372        if !across {
373            for &k in at {
374                kept[k] = true;
375            }
376        }
377    }
378    let reversed = faces[keeper].orientation() == Orientation::Reversed;
379    let (rings, edits) = match &carrier {
380        Carrier::Flat(flat) => {
381            let Some(rings) =
382                flat_rings(model, faces, members, carriers, &walks, &kept, flat, tol)?
383            else {
384                return Ok(None);
385            };
386            (rings, None)
387        }
388        Carrier::Curved(_) => {
389            let mut on: Vec<&curved::Curved> = Vec::with_capacity(members.len());
390            for &i in members {
391                let Some(Carrier::Curved(c)) = &carriers[i] else {
392                    return Ok(None);
393                };
394                on.push(c);
395            }
396            let cluster = curved::Members {
397                members,
398                carriers: &on,
399                wires: &runs,
400            };
401            let Some(merged) = curved::merge(model, &cluster, &walks, &kept, reversed, tol)? else {
402                return Ok(None);
403            };
404            (merged.rings, Some(merged.edits))
405        }
406    };
407
408    // The rings as the kept face's own node walks them: a member used
409    // reversed in the solid walks its node's rings back.
410    let mut wires = Vec::with_capacity(rings.len());
411    for ring in &rings {
412        let ring = ring.iter().map(|&w| walks[w].1.clone());
413        let ring: Vec<Shape> = if reversed {
414            ring.rev().map(|e| e.reversed()).collect()
415        } else {
416            ring.collect()
417        };
418        wires.push(ogeom_algo::make_wire(model, &ring, tol)?.shape);
419    }
420    // The kept face's mesh covers the kept face alone.
421    let mut data = *keeper_data;
422    data.triangulation = None;
423    let face = model.add_face(data, &wires)?;
424    // The curved carrier's edits wait for the face, so a ring that does not
425    // build leaves the members' edges and surface as they were.
426    if let Some(edits) = edits {
427        edits.apply(model)?;
428    }
429    Ok(Some(face))
430}
431
432/// The rings a cluster on one plane makes, outer first, as indices into
433/// `walks`: the kept walks chained head to tail through their vertices, the
434/// outer ring the one turning about the side the faces face. Every outer
435/// edge is given a pcurve on the keeper's plane, and its window grown to
436/// hold the members'. `None` where a vertex starts two walks, a ring does
437/// not close, or the rings do not turn as one outer ring round its holes.
438#[allow(clippy::too_many_arguments, reason = "the cluster's parts, read once")]
439fn flat_rings(
440    model: &mut Model,
441    faces: &[Shape],
442    members: &[usize],
443    carriers: &[Option<Carrier>],
444    walks: &[(usize, Shape)],
445    kept: &[bool],
446    flat: &Flat,
447    tol: Tolerances,
448) -> OgeomResult<Option<Vec<Vec<usize>>>> {
449    let Some(NodeData::Face(keeper_data)) =
450        model.node(&faces[members[0]]).map(|n| n.data().clone())
451    else {
452        return Ok(None);
453    };
454    let surface_id = keeper_data.surface;
455    let boundary: Vec<usize> = (0..walks.len()).filter(|&w| kept[w]).collect();
456    if boundary.is_empty() {
457        return Ok(None);
458    }
459    let mut seen: FastSet<TShapeId> = FastSet::default();
460    let outer_edges: Vec<Shape> = boundary
461        .iter()
462        .map(|&w| &walks[w].1)
463        .filter(|e| seen.insert(e.node()))
464        .cloned()
465        .collect();
466
467    // Chain the walks into rings, head to tail.
468    let mut ends: Vec<(Shape, Shape)> = Vec::with_capacity(boundary.len());
469    for &w in &boundary {
470        let Some(pair) = ogeom_algo::edge_vertices(model, &walks[w].1)? else {
471            return Ok(None);
472        };
473        ends.push(pair);
474    }
475    let mut leaving: FastMap<TShapeId, usize> = FastMap::default();
476    for (k, (start, _)) in ends.iter().enumerate() {
477        if leaving.insert(start.node(), k).is_some() {
478            return Ok(None);
479        }
480    }
481    let mut placed = vec![false; boundary.len()];
482    let mut rings: Vec<Vec<usize>> = Vec::new();
483    for first in 0..boundary.len() {
484        if placed[first] {
485            continue;
486        }
487        let mut ring = Vec::new();
488        let mut k = first;
489        loop {
490            placed[k] = true;
491            ring.push(boundary[k]);
492            let Some(&next) = leaving.get(&ends[k].1.node()) else {
493                return Ok(None);
494            };
495            if next == first {
496                break;
497            }
498            if placed[next] {
499                return Ok(None);
500            }
501            k = next;
502        }
503        rings.push(ring);
504    }
505
506    // The outer ring is the one turning about the side the faces face.
507    let mut turns = Vec::with_capacity(rings.len());
508    for ring in &rings {
509        let edges: Vec<Shape> = ring.iter().map(|&w| walks[w].1.clone()).collect();
510        turns.push(ogeom_algo::edges_turn(model, &edges, flat.outward, tol)?);
511    }
512    let area = tol.confusion() * tol.confusion();
513    let mut outer = turns.iter().enumerate().filter(|(_, t)| **t > area);
514    let (Some((outer, _)), None) = (outer.next(), outer.next()) else {
515        return Ok(None);
516    };
517    if turns.iter().filter(|t| **t < -area).count() != rings.len() - 1 {
518        return Ok(None);
519    }
520    rings.swap(0, outer);
521
522    // Every outer edge needs a pcurve for the kept surface: the one it
523    // carries on another member's chart, moved across, or else a
524    // projection.
525    let mut charts: Vec<(SurfaceId, Flat)> = Vec::with_capacity(members.len());
526    for &i in &members[1..] {
527        if let (Some(NodeData::Face(data)), Some(Carrier::Flat(c))) =
528            (model.node(&faces[i]).map(|n| n.data()), &carriers[i])
529        {
530            charts.push((data.surface, c.clone()));
531        }
532    }
533    for edge in &outer_edges {
534        ensure_planar_pcurve(model, edge, surface_id, flat, &charts, tol)?;
535    }
536    widen_to_members(model, surface_id, flat, &charts, tol)?;
537    Ok(Some(rings))
538}
539
540/// Grow the window of the plane `surface_id` stores, where it is bounded,
541/// to hold every window of `charts` moved into its chart: the merged face
542/// reaches over all of them, and a point of a face past its surface's
543/// window reads as off the face.
544fn widen_to_members(
545    model: &mut Model,
546    surface_id: SurfaceId,
547    carrier: &Flat,
548    charts: &[(SurfaceId, Flat)],
549    tol: Tolerances,
550) -> OgeomResult<()> {
551    use ogeom_geom::Surface as _;
552    let Some(SurfaceGeometry::Plane(kept)) = model.geometry().surface(surface_id).cloned() else {
553        return Ok(());
554    };
555    let ((mut ua, mut ub), (mut va, mut vb)) = kept.domain();
556    let was = ((ua, ub), (va, vb));
557    for (id, chart) in charts {
558        let Some(SurfaceGeometry::Plane(other)) = model.geometry().surface(*id) else {
559            continue;
560        };
561        let Some(map) = chart_to_chart(chart, carrier, tol)? else {
562            ogeom_bail!(
563                Construction,
564                "two faces on one plane do not share its chart"
565            );
566        };
567        let ((a, b), (c, d)) = other.domain();
568        for (u, v) in [(a, c), (a, d), (b, c), (b, d)] {
569            let at = map.apply(Point2::new(u, v));
570            (ua, ub) = (ua.min(at.x), ub.max(at.x));
571            (va, vb) = (va.min(at.y), vb.max(at.y));
572        }
573    }
574    if ((ua, ub), (va, vb)) != was
575        && let Some(held) = model.geometry_mut().surface_mut(surface_id)
576    {
577        *held = ogeom_geom::PlaneSurface::over(kept.plane(), (ua, ub), (va, vb))?.into();
578    }
579    Ok(())
580}
581
582/// Where a face lies and which chart it was stored in, for a face on a
583/// carrier unification knows.
584#[derive(Debug, Clone)]
585enum Carrier {
586    Flat(Flat),
587    Curved(curved::Curved),
588}
589
590/// Where a planar face lies and which chart it was stored in.
591#[derive(Debug, Clone)]
592struct Flat {
593    /// The plane in world space: what "same carrier" is decided on.
594    world: Plane,
595    /// The side the face faces in the world: its plane's normal, turned
596    /// back where the face is used reversed. Two faces on one plane are
597    /// one carrier only when they face the same way, whichever way each
598    /// stored its plane.
599    outward: ogeom_math::Vector,
600    /// The plane as the surface stores it: the chart pcurves speak in.
601    stored: Plane,
602    /// What takes the stored chart to the world.
603    placement: Transform,
604    /// A point of the plane amid the face, and how far from it the face
605    /// reaches: a plane tilted off this one by an angle whose sine is `s`
606    /// stands off it, across the face, by up to `s` times the reach more
607    /// than it does at the point.
608    centre: Point,
609    reach: f64,
610}
611
612/// Whether two carriers are one surface facing one way, whatever their
613/// frames and whichever way each stored its surface.
614fn same_carrier(a: &Carrier, b: &Carrier, tol: Tolerances) -> bool {
615    match (a, b) {
616        (Carrier::Flat(a), Carrier::Flat(b)) => {
617            let tilt = a
618                .world
619                .normal()
620                .vector()
621                .cross(b.world.normal().vector())
622                .magnitude();
623            a.outward.dot(b.outward) > 0.0
624                && a.world.signed_distance_to(b.centre).abs() + tilt * b.reach <= tol.confusion()
625                && b.world.signed_distance_to(a.centre).abs() + tilt * a.reach <= tol.confusion()
626        }
627        (Carrier::Curved(a), Carrier::Curved(b)) => curved::same_carrier(a, b, tol),
628        _ => false,
629    }
630}
631
632/// [`same_carrier`] for the pairs any cluster can fall back to: two planes,
633/// or two curved faces neither on a seam and related by a plain map.
634fn same_plain_carrier(a: &Carrier, b: &Carrier, tol: Tolerances) -> bool {
635    match (a, b) {
636        (Carrier::Curved(a), Carrier::Curved(b)) => curved::same_plain_carrier(a, b, tol),
637        _ => same_carrier(a, b, tol),
638    }
639}
640
641/// The carrier of a face, if it lies on one.
642fn carrier_of(model: &Model, face: &Shape, tol: Tolerances) -> OgeomResult<Option<Carrier>> {
643    let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data().clone()) else {
644        return Ok(None);
645    };
646    let Some(SurfaceGeometry::Plane(p)) = model.geometry().surface(data.surface) else {
647        return Ok(curved::carrier_of(model, face, tol)?.map(Carrier::Curved));
648    };
649    let stored = p.plane();
650    let placement = face.transform(model.datums())?;
651    if (placement.scale_factor().abs() - 1.0).abs() > 1e-9 {
652        return Ok(None);
653    }
654    let world = stored.transformed(&placement, tol)?;
655    let normal = world.normal().vector();
656    let bounds = ogeom_algo::face_bounds(model, face)?;
657    let (Some(low), Some(high)) = (bounds.low(), bounds.high()) else {
658        return Ok(None);
659    };
660    let mid = Point::new(
661        f64::midpoint(low.x, high.x),
662        f64::midpoint(low.y, high.y),
663        f64::midpoint(low.z, high.z),
664    );
665    let centre = mid - normal * world.signed_distance_to(mid);
666    let reach = [low.x, high.x]
667        .into_iter()
668        .flat_map(|x| [low.y, high.y].map(|y| (x, y)))
669        .flat_map(|(x, y)| [low.z, high.z].map(|z| Point::new(x, y, z)))
670        .map(|corner| corner.distance(centre))
671        .fold(0.0_f64, f64::max);
672    Ok(Some(Carrier::Flat(Flat {
673        outward: if face.orientation() == Orientation::Reversed {
674            -normal
675        } else {
676            normal
677        },
678        world,
679        stored,
680        placement,
681        centre,
682        reach,
683    })))
684}
685
686/// Attach a pcurve for `surface_id` to `edge` if it does not carry one.
687///
688/// A pcurve the edge carries on one of `charts` (another face on the same
689/// plane) is moved into the stored plane's frame by the rigid map between
690/// the two charts, which keeps its kind and its parameter: an arc stays
691/// the arc it was. With none, a line is projected directly, exact, and any
692/// other curve is fitted by projection.
693///
694/// The chart is the *stored* plane's, so the world point is carried back
695/// through the face's placement before it is flattened; a pcurve read under
696/// that placement then lands where the edge is.
697fn ensure_planar_pcurve(
698    model: &mut Model,
699    edge: &Shape,
700    surface_id: SurfaceId,
701    carrier: &Flat,
702    charts: &[(SurfaceId, Flat)],
703    tol: Tolerances,
704) -> OgeomResult<()> {
705    let (curve, range, has, sibling) = {
706        let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
707            ogeom_bail!(Construction, "edge holds no edge data");
708        };
709        let has = data.pcurve_for(surface_id, edge.location()).is_some();
710        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
711            ogeom_bail!(Construction, "an edge with no curve cannot be unified over");
712        };
713        let sibling = charts.iter().find_map(|(surface, chart)| {
714            match data.pcurve_for(*surface, edge.location()) {
715                Some(EdgeRepr::PCurve { curve, range, .. }) => Some((*curve, *range, chart)),
716                _ => None,
717            }
718        });
719        (*curve, *range, has, sibling)
720    };
721    if has {
722        return Ok(());
723    }
724    if let Some((pcurve, prange, chart)) = sibling
725        && let Some(moved) = model.geometry().pcurve(pcurve).cloned()
726        && let Some(map) = chart_to_chart(chart, carrier, tol)?
727    {
728        return ogeom_algo::attach_pcurve(
729            model,
730            edge,
731            moved.transformed(&map, tol)?,
732            surface_id,
733            edge.location().clone(),
734            prange,
735        );
736    }
737    let Some(geometry) = model.geometry().curve(curve).cloned() else {
738        ogeom_bail!(Construction, "edge refers to a curve not in this model");
739    };
740    let back = carrier.placement.inverse()?;
741    let pcurve = if matches!(geometry, ogeom_geom::Curve::Line(_)) {
742        let flat = |p: ogeom_math::Point| {
743            let local = carrier.stored.frame().to_local(back.apply(p));
744            Point2::new(local.x, local.y)
745        };
746        let a = flat(geometry.point_at(range.0, tol)?);
747        let b = flat(geometry.point_at(range.1, tol)?);
748        chart_line(a, b, range, tol)?
749    } else {
750        let local = ogeom_geom::Transformable::transformed(&geometry, &back, tol)?;
751        let plane = SurfaceGeometry::Plane(ogeom_geom::PlaneSurface::new(carrier.stored));
752        ogeom_algo::pcurve_fit::fit_projected_pcurve(&local, range, &plane, tol)?.0
753    };
754    ogeom_algo::attach_pcurve(
755        model,
756        edge,
757        pcurve,
758        surface_id,
759        edge.location().clone(),
760        range,
761    )
762}
763
764/// The rigid map taking `from`'s stored chart onto `to`'s, two charts of
765/// one plane: a turn and a shift, with a mirror where their frames run
766/// opposite ways round. `None` where the charts are not one plane.
767fn chart_to_chart(
768    from: &Flat,
769    to: &Flat,
770    tol: Tolerances,
771) -> OgeomResult<Option<ogeom_math::Transform2>> {
772    use ogeom_math::{Point, Transform2, Vector2};
773    let into = to.placement.inverse()?;
774    let lift = |u: f64, v: f64| {
775        let world = from
776            .placement
777            .apply(from.stored.frame().to_world(Point::new(u, v, 0.0)));
778        to.stored.frame().to_local(into.apply(world))
779    };
780    let (o, x, y) = (lift(0.0, 0.0), lift(1.0, 0.0), lift(0.0, 1.0));
781    if [o, x, y].iter().any(|p| p.z.abs() > tol.confusion()) {
782        return Ok(None);
783    }
784    let ex = Vector2::new(x.x - o.x, x.y - o.y);
785    let ey = Vector2::new(y.x - o.x, y.y - o.y);
786    let turn = Transform2::rotation(Point2::ORIGIN, ex.y.atan2(ex.x));
787    let shift = Transform2::translation(Vector2::new(o.x, o.y));
788    let map = if ex.x.mul_add(ey.y, -(ex.y * ey.x)) > 0.0 {
789        shift * turn
790    } else {
791        let flip = Transform2::line_mirror(
792            Point2::ORIGIN,
793            Direction2::new(Vector2::new(0.0, 1.0), tol)?,
794        );
795        shift * turn * flip
796    };
797    // The charts are rigid images of each other, so the map built from the
798    // first axis must carry the second one where it lands too.
799    let lands = map.apply(Point2::new(0.0, 1.0));
800    if lands.distance(Point2::new(y.x, y.y)) > tol.confusion() {
801        return Ok(None);
802    }
803    Ok(Some(map))
804}
805
806/// The chart line running from `a` at `range.0` to `b` at `range.1`.
807///
808/// A [`Line2d`](ogeom_geom::Line2d) reads its parameter from the axis
809/// origin outward, so the origin is where the parameter would have been
810/// zero, not where the range starts.
811fn chart_line(
812    a: Point2,
813    b: Point2,
814    range: (f64, f64),
815    tol: Tolerances,
816) -> OgeomResult<PlanarCurve> {
817    let span = range.1 - range.0;
818    let direction = Direction2::new((b - a) / span, tol)?;
819    let origin = a - direction.vector() * range.0;
820    Ok(
821        ogeom_geom::Line2d::over(ogeom_math::Axis2::new(origin, direction), range.0, range.1)?
822            .into(),
823    )
824}
825
826/// Merge chains of edges lying on one curve into single edges.
827///
828/// Within each wire, consecutive edges continuing one curve (the same
829/// stored curve over contiguous ranges, or two collinear lines meeting end
830/// to end) become one edge over the joined range, and the vertex between
831/// them dissolves. Every pcurve the pair carried is joined with it and then
832/// *measured* against the joined curve: a pair whose parameterizations do
833/// not join cleanly is left split rather than merged into a face that could
834/// no longer be triangulated.
835///
836/// # Errors
837///
838/// As the model's own builders.
839pub fn merge_edges(
840    model: &mut Model,
841    shape: &Shape,
842    tol: Tolerances,
843) -> OgeomResult<(Built, usize)> {
844    // One pass merges disjoint pairs; a chain of three needs the next pass
845    // to see the pair the first one made. Eight is well past any real chain.
846    let mut current = shape.clone();
847    let mut steps: Vec<History> = Vec::new();
848    let mut merged = 0;
849    for _ in 0..8 {
850        let Some((step, joined)) = merge_pass(model, &current, tol)? else {
851            break;
852        };
853        current = step.shape;
854        steps.push(step.history);
855        merged += joined;
856    }
857    if steps.is_empty() {
858        let mut built = Built::from_nothing(shape.clone());
859        built.history.modify(shape, shape.clone());
860        return Ok((built, 0));
861    }
862    Ok((Built::new(current, History::chain(&steps)), merged))
863}
864
865/// One merge pass: every disjoint joinable pair, in one rebuild. `None`
866/// when nothing joins.
867fn merge_pass(
868    model: &mut Model,
869    shape: &Shape,
870    tol: Tolerances,
871) -> OgeomResult<Option<(Built, usize)>> {
872    // Which parameterizations each edge is actually *read* on: the faces
873    // bounding it, not whatever reprs it accumulated along the way. An
874    // earlier unification leaves pcurves on surfaces nothing carries any
875    // more, and a join that tried to honour those would refuse work it can
876    // do.
877    let mut needed: FastMap<TShapeId, Vec<(SurfaceId, Location)>> = FastMap::default();
878    for face in explore_unique(model, shape, ShapeType::Face)? {
879        let Some(NodeData::Face(data)) = model.node(&face).map(|n| n.data().clone()) else {
880            continue;
881        };
882        for edge in explore_unique(model, &face, ShapeType::Edge)? {
883            let slot = (data.surface, edge.location().clone());
884            let slots = needed.entry(edge.node()).or_default();
885            if !slots.contains(&slot) {
886                slots.push(slot);
887            }
888        }
889    }
890
891    let wires = explore_unique(model, shape, ShapeType::Wire)?;
892    let mut reshape = Reshape::new();
893    let mut merged_nodes: FastSet<TShapeId> = FastSet::default();
894    for wire in &wires {
895        let edges = model.ordered_children_of(wire)?;
896        if edges.len() < 2 {
897            continue;
898        }
899        // A closed wire has no last edge: its end is the start again, and
900        // the split sitting across that join is a split like any other.
901        let closed = is_closed_wire(model, &edges, tol);
902        let pairs = if closed { edges.len() } else { edges.len() - 1 };
903        let mut i = 0;
904        while i < pairs {
905            let (a, b) = (edges[i].clone(), edges[(i + 1) % edges.len()].clone());
906            if a.node() == b.node()
907                || merged_nodes.contains(&a.node())
908                || merged_nodes.contains(&b.node())
909            {
910                i += 1;
911                continue;
912            }
913            let mut slots: Vec<(SurfaceId, Location)> =
914                needed.get(&a.node()).cloned().unwrap_or_default();
915            for slot in needed.get(&b.node()).into_iter().flatten() {
916                if !slots.contains(slot) {
917                    slots.push(slot.clone());
918                }
919            }
920            let Some(join) = joinable(model, &a, &b, &slots, tol)? else {
921                i += 1;
922                continue;
923            };
924            // The joined edge runs between the pair's outer vertices, so the
925            // edges either side still meet it; a pair whose curve does not
926            // reach them is left split.
927            let Some((from, to)) = join.ends.clone() else {
928                i += 1;
929                continue;
930            };
931            let Ok(joined) =
932                ogeom_algo::make_edge_between(model, join.curve, join.range, &from, &to, tol)
933            else {
934                i += 1;
935                continue;
936            };
937            let joined = joined.shape;
938            for pcurve in join.pcurves {
939                ogeom_algo::attach_pcurve(
940                    model,
941                    &joined,
942                    pcurve.curve,
943                    pcurve.surface,
944                    pcurve.location,
945                    pcurve.range,
946                )?;
947            }
948            merged_nodes.insert(a.node());
949            merged_nodes.insert(b.node());
950            reshape.replace(&a, joined);
951            reshape.remove(&b);
952            i += 2;
953        }
954    }
955    if reshape.is_empty() {
956        return Ok(None);
957    }
958    let joined = reshape.len();
959    Ok(Some((reshape.apply(model, shape)?, joined)))
960}
961
962/// Whether an ordered edge list closes back on its own start.
963fn is_closed_wire(model: &Model, edges: &[Shape], tol: Tolerances) -> bool {
964    let ends = |e: &Shape| -> Option<(ogeom_math::Point, ogeom_math::Point)> {
965        let vertices = explore_unique(model, e, ShapeType::Vertex).ok()?;
966        let first = model.node(vertices.first()?)?.data().as_vertex()?.point;
967        let last = model.node(vertices.last()?)?.data().as_vertex()?.point;
968        Some((first, last))
969    };
970    let (Some(first), Some(last)) = (ends(&edges[0]), ends(&edges[edges.len() - 1])) else {
971        return false;
972    };
973    [first.0, first.1]
974        .iter()
975        .any(|p| p.distance(last.0) <= tol.confusion() || p.distance(last.1) <= tol.confusion())
976}
977
978/// One joined edge, ready to build.
979struct Join {
980    /// The curve the joined edge runs on.
981    curve: ogeom_geom::Curve,
982    /// The range it runs over.
983    range: (f64, f64),
984    /// The joined pcurve for every surface the pair was parameterized on.
985    pcurves: Vec<PcurveJoin>,
986    /// The vertex the first edge starts at and the one the second ends at,
987    /// unplaced: the joined edge's own ends.
988    ends: Option<(Shape, Shape)>,
989}
990
991/// A joined pcurve, in the slot it goes back into.
992struct PcurveJoin {
993    surface: SurfaceId,
994    location: Location,
995    curve: PlanarCurve,
996    range: (f64, f64),
997}
998
999/// The three-dimensional half of a join: the curve, the range it runs
1000/// over, and whether `a` runs first along it.
1001type CurveJoin = (ogeom_geom::Curve, (f64, f64), bool);
1002
1003/// The three-dimensional half of a join: the curve, its range, and whether
1004/// `a` runs first along it.
1005fn joinable_curve(
1006    model: &Model,
1007    a: &Shape,
1008    b: &Shape,
1009    tol: Tolerances,
1010) -> OgeomResult<Option<CurveJoin>> {
1011    let read = |e: &Shape| -> Option<(ogeom_topo::CurveId, (f64, f64))> {
1012        let data = model.node(e)?.data().as_edge()?;
1013        let EdgeRepr::Curve3d { curve, range, .. } = data.curve3d()? else {
1014            return None;
1015        };
1016        Some((*curve, *range))
1017    };
1018    let (Some((ca, ra)), Some((cb, rb))) = (read(a), read(b)) else {
1019        return Ok(None);
1020    };
1021    if ca == cb {
1022        let Some(geometry) = model.geometry().curve(ca).cloned() else {
1023            return Ok(None);
1024        };
1025        if (ra.1 - rb.0).abs() <= tol.parametric() {
1026            return Ok(Some((geometry, (ra.0, rb.1), true)));
1027        }
1028        if (rb.1 - ra.0).abs() <= tol.parametric() {
1029            return Ok(Some((geometry, (rb.0, ra.1), false)));
1030        }
1031        return Ok(None);
1032    }
1033    // Distinct curves: collinear lines meeting end to end still join.
1034    let (Some(ga), Some(gb)) = (
1035        model.geometry().curve(ca).cloned(),
1036        model.geometry().curve(cb).cloned(),
1037    ) else {
1038        return Ok(None);
1039    };
1040    let (ogeom_geom::Curve::Line(la), ogeom_geom::Curve::Line(lb)) = (&ga, &gb) else {
1041        return Ok(None);
1042    };
1043    if !la.axis().is_collinear(lb.axis(), tol) {
1044        return Ok(None);
1045    }
1046    let (a0, a1) = (ga.point_at(ra.0, tol)?, ga.point_at(ra.1, tol)?);
1047    let (b0, b1) = (gb.point_at(rb.0, tol)?, gb.point_at(rb.1, tol)?);
1048    let fresh = |from: ogeom_math::Point,
1049                 to: ogeom_math::Point,
1050                 a_first: bool|
1051     -> OgeomResult<Option<CurveJoin>> {
1052        let segment = ogeom_geom::LineCurve::segment(from, to, tol)?;
1053        let range = segment.domain();
1054        Ok(Some((segment.into(), range, a_first)))
1055    };
1056    if a1.distance(b0) <= tol.confusion() {
1057        return fresh(a0, b1, true);
1058    }
1059    if b1.distance(a0) <= tol.confusion() {
1060        return fresh(b0, a1, false);
1061    }
1062    Ok(None)
1063}
1064
1065/// Whether two consecutive edges continue one curve, pcurves and all.
1066///
1067/// `slots` is what the joined edge will be read on: every one of them has
1068/// to come out of the join, or the merge would leave a face that cannot be
1069/// triangulated, worse than the split it came to fix.
1070fn joinable(
1071    model: &Model,
1072    a: &Shape,
1073    b: &Shape,
1074    slots: &[(SurfaceId, Location)],
1075    tol: Tolerances,
1076) -> OgeomResult<Option<Join>> {
1077    let Some((curve, range, a_first)) = joinable_curve(model, a, b, tol)? else {
1078        return Ok(None);
1079    };
1080    let (first, second) = if a_first { (a, b) } else { (b, a) };
1081    let reprs = |e: &Shape| -> Vec<EdgeRepr> {
1082        model
1083            .node(e)
1084            .and_then(|n| n.data().as_edge())
1085            .map(|d| d.representations.to_vec())
1086            .unwrap_or_default()
1087    };
1088    let (first_reprs, second_reprs) = (reprs(first), reprs(second));
1089    // A seam is a face's own doubling, not a split; leave those pairs be.
1090    if first_reprs
1091        .iter()
1092        .chain(&second_reprs)
1093        .any(|r| matches!(r, EdgeRepr::Seam { .. }))
1094    {
1095        return Ok(None);
1096    }
1097    let parametric = |reprs: &[EdgeRepr]| -> Vec<(SurfaceId, Location, PCurveId, (f64, f64))> {
1098        reprs
1099            .iter()
1100            .filter_map(|r| match r {
1101                EdgeRepr::PCurve {
1102                    curve,
1103                    range,
1104                    surface,
1105                    location,
1106                } => Some((*surface, location.clone(), *curve, *range)),
1107                _ => None,
1108            })
1109            .collect()
1110    };
1111    let (ones, twos) = (parametric(&first_reprs), parametric(&second_reprs));
1112    let mut pcurves = Vec::with_capacity(slots.len());
1113    for (surface, location) in slots {
1114        let (surface, location) = (*surface, location.clone());
1115        let find = |from: &[(SurfaceId, Location, PCurveId, (f64, f64))]| {
1116            from.iter()
1117                .find(|(s, l, _, _)| *s == surface && *l == location)
1118                .cloned()
1119        };
1120        let (Some((_, _, id_one, range_one)), Some((_, _, id_two, range_two))) =
1121            (find(&ones), find(&twos))
1122        else {
1123            return Ok(None);
1124        };
1125        let (Some(one), Some(two)) = (
1126            model.geometry().pcurve(id_one).cloned(),
1127            model.geometry().pcurve(id_two).cloned(),
1128        ) else {
1129            return Ok(None);
1130        };
1131        let joined = if id_one == id_two && (range_one.1 - range_two.0).abs() <= tol.parametric() {
1132            (one, (range_one.0, range_two.1))
1133        } else {
1134            // Two chart lines meeting end to end join over the 3D range the
1135            // joined edge will be read at.
1136            let (PlanarCurve::Line(_), PlanarCurve::Line(_)) = (&one, &two) else {
1137                return Ok(None);
1138            };
1139            let start = one.point_at(range_one.0, tol)?;
1140            let end = two.point_at(range_two.1, tol)?;
1141            if one
1142                .point_at(range_one.1, tol)?
1143                .distance(two.point_at(range_two.0, tol)?)
1144                > tol.confusion()
1145            {
1146                return Ok(None);
1147            }
1148            (chart_line(start, end, range, tol)?, range)
1149        };
1150        pcurves.push(PcurveJoin {
1151            surface,
1152            location,
1153            curve: joined.0,
1154            range: joined.1,
1155        });
1156    }
1157    // The plan is only a plan until it measures up against the joined curve.
1158    for pcurve in &pcurves {
1159        if !agrees(model, &curve, range, pcurve, tol)? {
1160            return Ok(None);
1161        }
1162    }
1163    let bounds = |e: &Shape| {
1164        model.children_of(&Shape::new(
1165            e.node(),
1166            Location::identity(),
1167            Orientation::Forward,
1168        ))
1169    };
1170    let (starts, stops) = (bounds(first)?, bounds(second)?);
1171    let ends = starts.first().cloned().zip(stops.last().cloned());
1172    Ok(Some(Join {
1173        curve,
1174        range,
1175        pcurves,
1176        ends,
1177    }))
1178}
1179
1180/// Whether a joined pcurve, read the way a face reads it (the same
1181/// fraction along both ranges) lands on the joined curve.
1182fn agrees(
1183    model: &Model,
1184    curve: &ogeom_geom::Curve,
1185    range: (f64, f64),
1186    pcurve: &PcurveJoin,
1187    tol: Tolerances,
1188) -> OgeomResult<bool> {
1189    use ogeom_geom::Surface as _;
1190    let Some(surface) = model.geometry().surface(pcurve.surface) else {
1191        return Ok(false);
1192    };
1193    let placement = pcurve.location.composed(model.datums())?;
1194    for k in 0..=6 {
1195        let f = f64::from(k) / 6.0;
1196        let t = (range.1 - range.0).mul_add(f, range.0);
1197        let pt = (pcurve.range.1 - pcurve.range.0).mul_add(f, pcurve.range.0);
1198        let (Ok(on_curve), Ok(chart)) = (curve.point_at(t, tol), pcurve.curve.point_at(pt, tol))
1199        else {
1200            return Ok(false);
1201        };
1202        let Ok(lifted) = surface.point_at(chart.x, chart.y, tol) else {
1203            return Ok(false);
1204        };
1205        if placement.apply(lifted).distance(on_curve) > tol.confusion() {
1206            return Ok(false);
1207        }
1208    }
1209    Ok(true)
1210}
1211
1212/// Shrink every edge and vertex tolerance to what the geometry measures,
1213/// floored at the confusion tolerance: the inverse of the widenings repair
1214/// operations apply, safe because it is measured the same way.
1215///
1216/// Returns how many claims shrank.
1217///
1218/// Nodes below `shape` that other shapes hold as well are copied first
1219/// ([`Model::unshare`]), so no other shape's claims shrink.
1220///
1221/// # Errors
1222///
1223/// As evaluation.
1224pub fn reduce_tolerances(model: &mut Model, shape: &Shape, tol: Tolerances) -> OgeomResult<usize> {
1225    model.unshare(shape)?;
1226    let edges = explore_unique(model, shape, ShapeType::Edge)?;
1227    // Each edge is measured on its own and the model is only read, so the
1228    // measures run side by side.
1229    let measured = {
1230        let model = &*model;
1231        ogeom_core::parallel::map_ordered(&edges, |_, edge| measure_edge(model, edge, false, tol))
1232    };
1233    let mut shrunk = 0;
1234    for (edge, measured) in edges.iter().zip(measured) {
1235        let Some(worst) = measured else { continue };
1236        let target = ogeom_core::Tolerance::new(worst + tol.confusion())?;
1237        if let Some(node) = model.node_mut(edge)
1238            && let NodeData::Edge(data) = node.data_mut()
1239            && target.get() < data.tolerance.get()
1240        {
1241            data.tolerance = target;
1242            shrunk += 1;
1243        }
1244    }
1245    Ok(shrunk)
1246}
1247
1248/// How far an edge's pcurves stand from its curve, or `None` where it has
1249/// no curve or no pcurve to measure. Each lifted point is held to the
1250/// curve's point at the same parameter where `paced` or where the edge
1251/// claims same parameter, and otherwise to the nearest point of the curve.
1252pub(crate) fn measure_edge(
1253    model: &Model,
1254    edge: &Shape,
1255    paced: bool,
1256    tol: Tolerances,
1257) -> Option<f64> {
1258    let data = model.node(edge).and_then(|n| n.data().as_edge())?;
1259    let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1260        return None;
1261    };
1262    let geometry = model.geometry().curve(*curve)?;
1263    // The claim an edge tolerance covers: every pcurve, both columns
1264    // of a seam, lifted against its curve, measured as the readers
1265    // and repairs that widen it measure.
1266    let mut worst = 0.0f64;
1267    let mut measurable = false;
1268    for representation in &data.representations {
1269        let (sides, prange, surface, location) = match representation {
1270            EdgeRepr::PCurve {
1271                curve,
1272                range,
1273                surface,
1274                location,
1275            } => ([Some(*curve), None], *range, *surface, location),
1276            EdgeRepr::Seam {
1277                forward,
1278                reversed,
1279                range,
1280                surface,
1281                location,
1282            } => (
1283                [Some(*forward), Some(*reversed)],
1284                *range,
1285                *surface,
1286                location,
1287            ),
1288            _ => continue,
1289        };
1290        let Some(surface_geometry) = model.geometry().surface(surface) else {
1291            continue;
1292        };
1293        let placed;
1294        let surface_geometry = if location.is_identity() {
1295            surface_geometry
1296        } else {
1297            use ogeom_geom::Transformable as _;
1298            let Ok(placement) = location.composed(model.datums()) else {
1299                continue;
1300            };
1301            let Ok(moved) = surface_geometry.transformed(&placement, tol) else {
1302                continue;
1303            };
1304            placed = moved;
1305            &placed
1306        };
1307        for pc in sides.into_iter().flatten() {
1308            let Some(pcurve) = model.geometry().pcurve(pc) else {
1309                continue;
1310            };
1311            let Ok(gap) = ogeom_algo::pcurve_fit::lifted_gap(
1312                (geometry, *range),
1313                (pcurve, prange),
1314                surface_geometry,
1315                paced || data.same_parameter(),
1316                tol,
1317            ) else {
1318                continue;
1319            };
1320            worst = worst.max(gap);
1321            measurable = true;
1322        }
1323    }
1324    measurable.then_some(worst)
1325}