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 std::collections::{HashMap, HashSet};
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, Point2, Transform};
11use ogeom_topo::{
12    EdgeRepr, Location, Model, NodeData, PCurveId, Shape, ShapeType, SurfaceId, TShapeId,
13    explore_unique,
14};
15
16use crate::reshape::Reshape;
17
18/// Merge adjacent faces lying on one carrier into single faces.
19///
20/// Two faces qualify when they share an edge and their surfaces are the
21/// same plane, in the same parameterization: the split a boolean or an
22/// exchange leaves behind. The merged face keeps the first face's surface;
23/// the shared edges dissolve; the remaining boundary re-chains into wires.
24/// Faces on other carriers pass through untouched; a curved unification
25/// wants parameterization transport this deliberately does not guess at.
26///
27/// # Errors
28///
29/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a
30/// merged boundary fails to chain into closed wires.
31pub fn unify_same_domain(
32    model: &mut Model,
33    shape: &Shape,
34    tol: Tolerances,
35) -> OgeomResult<(Built, usize)> {
36    let faces = explore_unique(model, shape, ShapeType::Face)?;
37
38    // The carrier of each planar face: where it lies in the world, which
39    // decides the grouping, and the chart it was stored in, which is where
40    // any synthesized pcurve has to land.
41    let mut carriers: Vec<Option<Carrier>> = Vec::with_capacity(faces.len());
42    for face in &faces {
43        carriers.push(carrier_of(model, face, tol)?);
44    }
45
46    // Union-find over faces sharing an edge on one carrier.
47    let mut group: Vec<usize> = (0..faces.len()).collect();
48    fn root(group: &mut [usize], mut i: usize) -> usize {
49        while group[i] != i {
50            group[i] = group[group[i]];
51            i = group[i];
52        }
53        i
54    }
55    let mut edge_users: HashMap<TShapeId, Vec<usize>> = HashMap::new();
56    for (i, face) in faces.iter().enumerate() {
57        for edge in explore_unique(model, face, ShapeType::Edge)? {
58            edge_users.entry(edge.node()).or_default().push(i);
59        }
60    }
61    for users in edge_users.values() {
62        for pair in users.windows(2) {
63            let (a, b) = (pair[0], pair[1]);
64            // One carrier means one *plane*, not one frame: two halves of a
65            // wall carry their own origins on the same flat.
66            if let (Some(ca), Some(cb)) = (carriers[a].as_ref(), carriers[b].as_ref())
67                && ca.world.normal().dot(cb.world.normal()) > 1.0 - tol.angular()
68                && ca.world.signed_distance_to(cb.world.frame().origin()).abs() <= tol.confusion()
69            {
70                let (ra, rb) = (root(&mut group, a), root(&mut group, b));
71                group[ra] = rb;
72            }
73        }
74    }
75
76    let mut clusters: HashMap<usize, Vec<usize>> = HashMap::new();
77    for i in 0..faces.len() {
78        let r = root(&mut group, i);
79        clusters.entry(r).or_default().push(i);
80    }
81
82    let mut reshape = Reshape::new();
83    for members in clusters.values() {
84        if members.len() < 2 {
85            continue;
86        }
87        // Interior edges (used by two members) dissolve; the rest chain.
88        let mut counts: HashMap<TShapeId, (usize, Shape)> = HashMap::new();
89        for &i in members {
90            for edge in explore_unique(model, &faces[i], ShapeType::Edge)? {
91                counts
92                    .entry(edge.node())
93                    .and_modify(|(n, _)| *n += 1)
94                    .or_insert((1, edge));
95            }
96        }
97        let boundary: Vec<Shape> = {
98            let mut edges: Vec<(TShapeId, Shape)> = counts
99                .into_iter()
100                .filter(|(_, (n, _))| *n == 1)
101                .map(|(id, (_, e))| (id, e))
102                .collect();
103            edges.sort_by_key(|(id, _)| *id);
104            edges.into_iter().map(|(_, e)| e).collect()
105        };
106        if boundary.len() < 3 {
107            continue;
108        }
109        let keeper = members[0];
110        let surface_id = {
111            let Some(NodeData::Face(data)) = model.node(&faces[keeper]).map(|n| n.data().clone())
112            else {
113                continue;
114            };
115            data.surface
116        };
117        // Every boundary edge needs a pcurve for the kept surface; a plane's
118        // is direct projection.
119        let Some(carrier) = carriers[keeper].clone() else {
120            continue;
121        };
122        for edge in &boundary {
123            ensure_planar_pcurve(model, edge, surface_id, &carrier, tol)?;
124        }
125        let wire = ogeom_algo::make_wire_unordered(model, &boundary, tol)?.shape;
126        let merged = {
127            let Some(NodeData::Face(data)) = model.node(&faces[keeper]).map(|n| n.data().clone())
128            else {
129                continue;
130            };
131            model.add_face(*data, std::slice::from_ref(&wire))?
132        };
133        reshape.replace(&faces[keeper], merged);
134        for &other in &members[1..] {
135            reshape.remove(&faces[other]);
136        }
137    }
138    if reshape.is_empty() {
139        let mut built = Built::from_nothing(shape.clone());
140        built.history.modify(shape, shape.clone());
141        return Ok((built, 0));
142    }
143    let unified = reshape.len();
144    Ok((reshape.apply(model, shape)?, unified))
145}
146
147/// Where a planar face lies and which chart it was stored in.
148#[derive(Debug, Clone)]
149struct Carrier {
150    /// The plane in world space: what "same carrier" is decided on.
151    world: Plane,
152    /// The plane as the surface stores it: the chart pcurves speak in.
153    stored: Plane,
154    /// What takes the stored chart to the world.
155    placement: Transform,
156}
157
158/// The carrier of a planar face, if it is one.
159fn carrier_of(model: &Model, face: &Shape, tol: Tolerances) -> OgeomResult<Option<Carrier>> {
160    let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data().clone()) else {
161        return Ok(None);
162    };
163    let Some(SurfaceGeometry::Plane(p)) = model.geometry().surface(data.surface) else {
164        return Ok(None);
165    };
166    let stored = p.plane();
167    let placement = face.transform(model.datums())?;
168    if (placement.scale_factor().abs() - 1.0).abs() > 1e-9 {
169        return Ok(None);
170    }
171    Ok(Some(Carrier {
172        world: stored.transformed(&placement, tol)?,
173        stored,
174        placement,
175    }))
176}
177
178/// Attach a pcurve for `surface_id` to `edge` if it does not carry one:
179/// direct projection into the stored plane's own frame, exact.
180///
181/// The chart is the *stored* plane's, so the world point is carried back
182/// through the face's placement before it is flattened; a pcurve read under
183/// that placement then lands where the edge is.
184fn ensure_planar_pcurve(
185    model: &mut Model,
186    edge: &Shape,
187    surface_id: SurfaceId,
188    carrier: &Carrier,
189    tol: Tolerances,
190) -> OgeomResult<()> {
191    let (curve, range, has) = {
192        let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
193            ogeom_bail!(Construction, "edge holds no edge data");
194        };
195        let has = data.pcurve_for(surface_id, edge.location()).is_some();
196        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
197            ogeom_bail!(Construction, "an edge with no curve cannot be unified over");
198        };
199        (*curve, *range, has)
200    };
201    if has {
202        return Ok(());
203    }
204    let Some(geometry) = model.geometry().curve(curve).cloned() else {
205        ogeom_bail!(Construction, "edge refers to a curve not in this model");
206    };
207    let back = carrier.placement.inverse()?;
208    let flat = |p: ogeom_math::Point| {
209        let local = carrier.stored.frame().to_local(back.apply(p));
210        Point2::new(local.x, local.y)
211    };
212    let a = flat(geometry.point_at(range.0, tol)?);
213    let b = flat(geometry.point_at(range.1, tol)?);
214    ogeom_algo::attach_pcurve(
215        model,
216        edge,
217        chart_line(a, b, range, tol)?,
218        surface_id,
219        edge.location().clone(),
220        range,
221    )
222}
223
224/// The chart line running from `a` at `range.0` to `b` at `range.1`.
225///
226/// A [`Line2d`](ogeom_geom::Line2d) reads its parameter from the axis
227/// origin outward, so the origin is where the parameter would have been
228/// zero, not where the range starts.
229fn chart_line(
230    a: Point2,
231    b: Point2,
232    range: (f64, f64),
233    tol: Tolerances,
234) -> OgeomResult<PlanarCurve> {
235    let span = range.1 - range.0;
236    let direction = Direction2::new((b - a) / span, tol)?;
237    let origin = a - direction.vector() * range.0;
238    Ok(
239        ogeom_geom::Line2d::over(ogeom_math::Axis2::new(origin, direction), range.0, range.1)?
240            .into(),
241    )
242}
243
244/// Merge chains of edges lying on one curve into single edges.
245///
246/// Within each wire, consecutive edges continuing one curve (the same
247/// stored curve over contiguous ranges, or two collinear lines meeting end
248/// to end) become one edge over the joined range, and the vertex between
249/// them dissolves. Every pcurve the pair carried is joined with it and then
250/// *measured* against the joined curve: a pair whose parameterizations do
251/// not join cleanly is left split rather than merged into a face that could
252/// no longer be triangulated.
253///
254/// # Errors
255///
256/// As the model's own builders.
257pub fn merge_edges(
258    model: &mut Model,
259    shape: &Shape,
260    tol: Tolerances,
261) -> OgeomResult<(Built, usize)> {
262    // One pass merges disjoint pairs; a chain of three needs the next pass
263    // to see the pair the first one made. Eight is well past any real chain.
264    let mut current = shape.clone();
265    let mut steps: Vec<History> = Vec::new();
266    let mut merged = 0;
267    for _ in 0..8 {
268        let Some((step, joined)) = merge_pass(model, &current, tol)? else {
269            break;
270        };
271        current = step.shape;
272        steps.push(step.history);
273        merged += joined;
274    }
275    if steps.is_empty() {
276        let mut built = Built::from_nothing(shape.clone());
277        built.history.modify(shape, shape.clone());
278        return Ok((built, 0));
279    }
280    Ok((Built::new(current, History::chain(&steps)), merged))
281}
282
283/// One merge pass: every disjoint joinable pair, in one rebuild. `None`
284/// when nothing joins.
285fn merge_pass(
286    model: &mut Model,
287    shape: &Shape,
288    tol: Tolerances,
289) -> OgeomResult<Option<(Built, usize)>> {
290    // Which parameterizations each edge is actually *read* on: the faces
291    // bounding it, not whatever reprs it accumulated along the way. An
292    // earlier unification leaves pcurves on surfaces nothing carries any
293    // more, and a join that tried to honour those would refuse work it can
294    // do.
295    let mut needed: HashMap<TShapeId, Vec<(SurfaceId, Location)>> = HashMap::new();
296    for face in explore_unique(model, shape, ShapeType::Face)? {
297        let Some(NodeData::Face(data)) = model.node(&face).map(|n| n.data().clone()) else {
298            continue;
299        };
300        for edge in explore_unique(model, &face, ShapeType::Edge)? {
301            let slot = (data.surface, edge.location().clone());
302            let slots = needed.entry(edge.node()).or_default();
303            if !slots.contains(&slot) {
304                slots.push(slot);
305            }
306        }
307    }
308
309    let wires = explore_unique(model, shape, ShapeType::Wire)?;
310    let mut reshape = Reshape::new();
311    let mut merged_nodes: HashSet<TShapeId> = HashSet::new();
312    for wire in &wires {
313        let edges = model.ordered_children_of(wire)?;
314        if edges.len() < 2 {
315            continue;
316        }
317        // A closed wire has no last edge: its end is the start again, and
318        // the split sitting across that join is a split like any other.
319        let closed = is_closed_wire(model, &edges, tol);
320        let pairs = if closed { edges.len() } else { edges.len() - 1 };
321        let mut i = 0;
322        while i < pairs {
323            let (a, b) = (edges[i].clone(), edges[(i + 1) % edges.len()].clone());
324            if a.node() == b.node()
325                || merged_nodes.contains(&a.node())
326                || merged_nodes.contains(&b.node())
327            {
328                i += 1;
329                continue;
330            }
331            let mut slots: Vec<(SurfaceId, Location)> =
332                needed.get(&a.node()).cloned().unwrap_or_default();
333            for slot in needed.get(&b.node()).into_iter().flatten() {
334                if !slots.contains(slot) {
335                    slots.push(slot.clone());
336                }
337            }
338            let Some(join) = joinable(model, &a, &b, &slots, tol)? else {
339                i += 1;
340                continue;
341            };
342            let joined = ogeom_algo::make_edge(model, join.curve, join.range, tol)?.shape;
343            for pcurve in join.pcurves {
344                ogeom_algo::attach_pcurve(
345                    model,
346                    &joined,
347                    pcurve.curve,
348                    pcurve.surface,
349                    pcurve.location,
350                    pcurve.range,
351                )?;
352            }
353            merged_nodes.insert(a.node());
354            merged_nodes.insert(b.node());
355            reshape.replace(&a, joined);
356            reshape.remove(&b);
357            i += 2;
358        }
359    }
360    if reshape.is_empty() {
361        return Ok(None);
362    }
363    let joined = reshape.len();
364    Ok(Some((reshape.apply(model, shape)?, joined)))
365}
366
367/// Whether an ordered edge list closes back on its own start.
368fn is_closed_wire(model: &Model, edges: &[Shape], tol: Tolerances) -> bool {
369    let ends = |e: &Shape| -> Option<(ogeom_math::Point, ogeom_math::Point)> {
370        let vertices = explore_unique(model, e, ShapeType::Vertex).ok()?;
371        let first = model.node(vertices.first()?)?.data().as_vertex()?.point;
372        let last = model.node(vertices.last()?)?.data().as_vertex()?.point;
373        Some((first, last))
374    };
375    let (Some(first), Some(last)) = (ends(&edges[0]), ends(&edges[edges.len() - 1])) else {
376        return false;
377    };
378    [first.0, first.1]
379        .iter()
380        .any(|p| p.distance(last.0) <= tol.confusion() || p.distance(last.1) <= tol.confusion())
381}
382
383/// One joined edge, ready to build.
384struct Join {
385    /// The curve the joined edge runs on.
386    curve: ogeom_geom::Curve,
387    /// The range it runs over.
388    range: (f64, f64),
389    /// The joined pcurve for every surface the pair was parameterized on.
390    pcurves: Vec<PcurveJoin>,
391}
392
393/// A joined pcurve, in the slot it goes back into.
394struct PcurveJoin {
395    surface: SurfaceId,
396    location: Location,
397    curve: PlanarCurve,
398    range: (f64, f64),
399}
400
401/// The three-dimensional half of a join: the curve, the range it runs
402/// over, and whether `a` runs first along it.
403type CurveJoin = (ogeom_geom::Curve, (f64, f64), bool);
404
405/// The three-dimensional half of a join: the curve, its range, and whether
406/// `a` runs first along it.
407fn joinable_curve(
408    model: &Model,
409    a: &Shape,
410    b: &Shape,
411    tol: Tolerances,
412) -> OgeomResult<Option<CurveJoin>> {
413    let read = |e: &Shape| -> Option<(ogeom_topo::CurveId, (f64, f64))> {
414        let data = model.node(e)?.data().as_edge()?;
415        let EdgeRepr::Curve3d { curve, range, .. } = data.curve3d()? else {
416            return None;
417        };
418        Some((*curve, *range))
419    };
420    let (Some((ca, ra)), Some((cb, rb))) = (read(a), read(b)) else {
421        return Ok(None);
422    };
423    if ca == cb {
424        let Some(geometry) = model.geometry().curve(ca).cloned() else {
425            return Ok(None);
426        };
427        if (ra.1 - rb.0).abs() <= tol.parametric() {
428            return Ok(Some((geometry, (ra.0, rb.1), true)));
429        }
430        if (rb.1 - ra.0).abs() <= tol.parametric() {
431            return Ok(Some((geometry, (rb.0, ra.1), false)));
432        }
433        return Ok(None);
434    }
435    // Distinct curves: collinear lines meeting end to end still join.
436    let (Some(ga), Some(gb)) = (
437        model.geometry().curve(ca).cloned(),
438        model.geometry().curve(cb).cloned(),
439    ) else {
440        return Ok(None);
441    };
442    let (ogeom_geom::Curve::Line(la), ogeom_geom::Curve::Line(lb)) = (&ga, &gb) else {
443        return Ok(None);
444    };
445    if !la.axis().is_collinear(lb.axis(), tol) {
446        return Ok(None);
447    }
448    let (a0, a1) = (ga.point_at(ra.0, tol)?, ga.point_at(ra.1, tol)?);
449    let (b0, b1) = (gb.point_at(rb.0, tol)?, gb.point_at(rb.1, tol)?);
450    let fresh = |from: ogeom_math::Point,
451                 to: ogeom_math::Point,
452                 a_first: bool|
453     -> OgeomResult<Option<CurveJoin>> {
454        let segment = ogeom_geom::LineCurve::segment(from, to, tol)?;
455        let range = segment.domain();
456        Ok(Some((segment.into(), range, a_first)))
457    };
458    if a1.distance(b0) <= tol.confusion() {
459        return fresh(a0, b1, true);
460    }
461    if b1.distance(a0) <= tol.confusion() {
462        return fresh(b0, a1, false);
463    }
464    Ok(None)
465}
466
467/// Whether two consecutive edges continue one curve, pcurves and all.
468///
469/// `slots` is what the joined edge will be read on: every one of them has
470/// to come out of the join, or the merge would leave a face that cannot be
471/// triangulated, worse than the split it came to fix.
472fn joinable(
473    model: &Model,
474    a: &Shape,
475    b: &Shape,
476    slots: &[(SurfaceId, Location)],
477    tol: Tolerances,
478) -> OgeomResult<Option<Join>> {
479    let Some((curve, range, a_first)) = joinable_curve(model, a, b, tol)? else {
480        return Ok(None);
481    };
482    let (first, second) = if a_first { (a, b) } else { (b, a) };
483    let reprs = |e: &Shape| -> Vec<EdgeRepr> {
484        model
485            .node(e)
486            .and_then(|n| n.data().as_edge())
487            .map(|d| d.representations.to_vec())
488            .unwrap_or_default()
489    };
490    let (first_reprs, second_reprs) = (reprs(first), reprs(second));
491    // A seam is a face's own doubling, not a split; leave those pairs be.
492    if first_reprs
493        .iter()
494        .chain(&second_reprs)
495        .any(|r| matches!(r, EdgeRepr::Seam { .. }))
496    {
497        return Ok(None);
498    }
499    let parametric = |reprs: &[EdgeRepr]| -> Vec<(SurfaceId, Location, PCurveId, (f64, f64))> {
500        reprs
501            .iter()
502            .filter_map(|r| match r {
503                EdgeRepr::PCurve {
504                    curve,
505                    range,
506                    surface,
507                    location,
508                } => Some((*surface, location.clone(), *curve, *range)),
509                _ => None,
510            })
511            .collect()
512    };
513    let (ones, twos) = (parametric(&first_reprs), parametric(&second_reprs));
514    let mut pcurves = Vec::with_capacity(slots.len());
515    for (surface, location) in slots {
516        let (surface, location) = (*surface, location.clone());
517        let find = |from: &[(SurfaceId, Location, PCurveId, (f64, f64))]| {
518            from.iter()
519                .find(|(s, l, _, _)| *s == surface && *l == location)
520                .cloned()
521        };
522        let (Some((_, _, id_one, range_one)), Some((_, _, id_two, range_two))) =
523            (find(&ones), find(&twos))
524        else {
525            return Ok(None);
526        };
527        let (Some(one), Some(two)) = (
528            model.geometry().pcurve(id_one).cloned(),
529            model.geometry().pcurve(id_two).cloned(),
530        ) else {
531            return Ok(None);
532        };
533        let joined = if id_one == id_two && (range_one.1 - range_two.0).abs() <= tol.parametric() {
534            (one, (range_one.0, range_two.1))
535        } else {
536            // Two chart lines meeting end to end join over the 3D range the
537            // joined edge will be read at.
538            let (PlanarCurve::Line(_), PlanarCurve::Line(_)) = (&one, &two) else {
539                return Ok(None);
540            };
541            let start = one.point_at(range_one.0, tol)?;
542            let end = two.point_at(range_two.1, tol)?;
543            if one
544                .point_at(range_one.1, tol)?
545                .distance(two.point_at(range_two.0, tol)?)
546                > tol.confusion()
547            {
548                return Ok(None);
549            }
550            (chart_line(start, end, range, tol)?, range)
551        };
552        pcurves.push(PcurveJoin {
553            surface,
554            location,
555            curve: joined.0,
556            range: joined.1,
557        });
558    }
559    // The plan is only a plan until it measures up against the joined curve.
560    for pcurve in &pcurves {
561        if !agrees(model, &curve, range, pcurve, tol)? {
562            return Ok(None);
563        }
564    }
565    Ok(Some(Join {
566        curve,
567        range,
568        pcurves,
569    }))
570}
571
572/// Whether a joined pcurve, read the way a face reads it (the same
573/// fraction along both ranges) lands on the joined curve.
574fn agrees(
575    model: &Model,
576    curve: &ogeom_geom::Curve,
577    range: (f64, f64),
578    pcurve: &PcurveJoin,
579    tol: Tolerances,
580) -> OgeomResult<bool> {
581    use ogeom_geom::Surface as _;
582    let Some(surface) = model.geometry().surface(pcurve.surface) else {
583        return Ok(false);
584    };
585    let placement = pcurve.location.composed(model.datums())?;
586    for k in 0..=6 {
587        let f = f64::from(k) / 6.0;
588        let t = (range.1 - range.0).mul_add(f, range.0);
589        let pt = (pcurve.range.1 - pcurve.range.0).mul_add(f, pcurve.range.0);
590        let (Ok(on_curve), Ok(chart)) = (curve.point_at(t, tol), pcurve.curve.point_at(pt, tol))
591        else {
592            return Ok(false);
593        };
594        let Ok(lifted) = surface.point_at(chart.x, chart.y, tol) else {
595            return Ok(false);
596        };
597        if placement.apply(lifted).distance(on_curve) > tol.confusion() {
598            return Ok(false);
599        }
600    }
601    Ok(true)
602}
603
604/// Shrink every edge and vertex tolerance to what the geometry measures,
605/// floored at the confusion tolerance: the inverse of the widenings repair
606/// operations apply, safe because it is measured the same way.
607///
608/// Returns how many claims shrank.
609///
610/// # Errors
611///
612/// As evaluation.
613pub fn reduce_tolerances(model: &mut Model, shape: &Shape, tol: Tolerances) -> OgeomResult<usize> {
614    let mut shrunk = 0;
615    for edge in explore_unique(model, shape, ShapeType::Edge)? {
616        let measured = {
617            let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
618                continue;
619            };
620            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
621                continue;
622            };
623            let Some(geometry) = model.geometry().curve(*curve) else {
624                continue;
625            };
626            // The claim an edge tolerance covers: its pcurves against its
627            // curve at matched parameters.
628            let mut worst = 0.0f64;
629            let mut measurable = false;
630            for representation in &data.representations {
631                let EdgeRepr::PCurve {
632                    curve: pc,
633                    range: prange,
634                    surface,
635                    location,
636                } = representation
637                else {
638                    continue;
639                };
640                let Some(pcurve) = model.geometry().pcurve(*pc) else {
641                    continue;
642                };
643                let Some(surface_geometry) = model.geometry().surface(*surface) else {
644                    continue;
645                };
646                use ogeom_geom::Surface as _;
647                for k in 0..=8 {
648                    let t = range.0 + (range.1 - range.0) * f64::from(k) / 8.0;
649                    let pt = prange.0 + (prange.1 - prange.0) * f64::from(k) / 8.0;
650                    let (Ok(on_curve), Ok(chart)) =
651                        (geometry.point_at(t, tol), pcurve.point_at(pt, tol))
652                    else {
653                        continue;
654                    };
655                    let Ok(lifted) = surface_geometry.point_at(chart.x, chart.y, tol) else {
656                        continue;
657                    };
658                    let Ok(placement) = location.composed(model.datums()) else {
659                        continue;
660                    };
661                    worst = worst.max(placement.apply(lifted).distance(on_curve));
662                    measurable = true;
663                }
664            }
665            measurable.then_some(worst)
666        };
667        let Some(worst) = measured else { continue };
668        let target = ogeom_core::Tolerance::new(worst + tol.confusion())?;
669        if let Some(node) = model.node_mut(&edge)
670            && let NodeData::Edge(data) = node.data_mut()
671            && target.get() < data.tolerance.get()
672        {
673            data.tolerance = target;
674            shrunk += 1;
675        }
676    }
677    Ok(shrunk)
678}