Skip to main content

ogeom_offset/
shape.rs

1//! Offsetting a solid, and the shelling built on it.
2//!
3//! The topology-preserving offset: every face's surface moves along its own
4//! outward normal (a plane translates, a cylinder's radius grows or
5//! shrinks), and the topology is rebuilt one-for-one on the moved surfaces.
6//! Vertices re-solve where their planes now meet, edges re-derive on the
7//! moved supports with their directions and parameterizations preserved, and
8//! band faces rebuild through [`make_revolution_band`] so seams stay seams.
9//! Corners stay sharp: this is the parallel solid of the intersection join,
10//! not the rounded Minkowski body.
11//!
12//! Shelling is the offset pointed inward and the boolean pointed at the
13//! result: the cavity is the inward offset with the *removed* faces left
14//! exactly where they were, so it reaches the boundary at the openings,
15//! and the cut's same-domain resolution melts the flush faces away, which is
16//! what opens the shell.
17//!
18//! The honest limits, refused by name: faces whose surfaces are not among
19//! the five analytics (a spline, revolution, extrusion, trimmed or offset
20//! surface has no same-family parallel to move to, though a face something
21//! *replaces* (a draft's turned wall) rides through on the replacement and
22//! a face moved by nothing keeps its own surface whatever the family),
23//! vertices whose seats leave them under-determined, and offsets that
24//! collapse the solid. Edges between moved supports re-derive exactly where
25//! a line or circle exists; anywhere else the pair's own intersection is
26//! marched and fitted, with its stated slop widening the edge; an edge
27//! that sits unmoved on both supports rebuilds on its own curve.
28
29use crate::wire2d::Join;
30use ogeom_algo::{
31    Built, History, edge_vertices, make_edge, make_edge_between, make_face_with_pcurves,
32    make_revolution_band, make_solid, make_vertex, sew,
33};
34use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
35use ogeom_geom::Curve3d as _;
36use ogeom_geom::{Curve, CylinderSurface, LineCurve, PlaneSurface, SurfaceGeometry};
37use ogeom_math::{Cylinder, Frame, Plane, Point, Vector};
38use ogeom_topo::{
39    EdgeData, EdgeRepr, Filter, Model, NodeData, Orientation, Shape, ShapeType, TShapeId, explore,
40    explore_unique,
41};
42
43use std::collections::HashMap;
44
45/// The displacement constraint one face puts on a point of itself.
46type Displacement<'a> = dyn Fn(&Model, usize, Point) -> OgeomResult<Option<(Vector, f64)>> + 'a;
47
48/// Canonicalize a solid whose topology is *instanced*: the same node placed
49/// twice: a prism's far cap reusing the profile's nodes under the travel.
50///
51/// The rebuild below resolves everything by node, which is one name for two
52/// places on such a solid. Baking restates every occurrence as its own node
53/// in world coordinates, and the caller's face handles ride the bake's
54/// history. A solid whose nodes are each placed once passes through
55/// untouched.
56pub(crate) fn canonical_input(
57    model: &mut Model,
58    solid: &Shape,
59    handles: &[Shape],
60    tol: Tolerances,
61) -> OgeomResult<(Shape, Vec<Shape>, Option<ogeom_algo::History>)> {
62    let probe = Point::new(0.123_456_789, 9.87, -3.21);
63    let mut seen: HashMap<TShapeId, Point> = HashMap::new();
64    let mut instanced = false;
65    'outer: for kind in [ShapeType::Vertex, ShapeType::Edge] {
66        for occurrence in explore(model, solid, Filter::OfType(kind))? {
67            let at = occurrence.transform(model.datums())?.apply(probe);
68            match seen.entry(occurrence.node()) {
69                std::collections::hash_map::Entry::Occupied(held) => {
70                    if held.get().distance(at) > tol.confusion() {
71                        instanced = true;
72                        break 'outer;
73                    }
74                }
75                std::collections::hash_map::Entry::Vacant(slot) => {
76                    slot.insert(at);
77                }
78            }
79        }
80    }
81    if !instanced {
82        return Ok((solid.clone(), handles.to_vec(), None));
83    }
84    let baked = ogeom_algo::baked_shape(model, solid, tol)?;
85    let mapped = handles
86        .iter()
87        .map(|h| match baked.history.trace(h) {
88            [one] => Ok(one.clone()),
89            traced => ogeom_bail!(
90                Construction,
91                "a face handle resolved to {} faces through the canonical \
92                 rebuild; the reference is ambiguous",
93                traced.len()
94            ),
95        })
96        .collect::<OgeomResult<Vec<Shape>>>()?;
97    Ok((baked.shape, mapped, Some(baked.history)))
98}
99
100/// Offset a solid by `offset`: positive grows it, negative shrinks it, and
101/// the topology is preserved one-for-one.
102///
103/// # Errors
104///
105/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a face,
106/// edge or vertex falls outside the analytic vocabulary this rebuild speaks
107/// (see the module documentation), or the offset collapses the solid.
108pub fn offset_shape(
109    model: &mut Model,
110    solid: &Shape,
111    offset: f64,
112    tol: Tolerances,
113) -> OgeomResult<Built> {
114    if !offset.is_finite() || offset.abs() <= tol.confusion() {
115        ogeom_bail!(Construction, "an offset of {offset} moves nothing");
116    }
117    let (canonical, _, prefix) = canonical_input(model, solid, &[], tol)?;
118    if let Some(prefix) = prefix {
119        let mut out = offset_shape(model, &canonical, offset, tol)?;
120        out.history = prefix.then(&out.history);
121        return Ok(out);
122    }
123    rebuilt(model, solid, &|_| offset, &|_| None, tol)
124}
125
126/// Hollow a solid into a shell of the given wall `thickness`, opening it at
127/// the `removed` faces.
128///
129/// Two constructions, chosen by the opening's neighbours. When a removed
130/// face meets every neighbour across a corner, the cavity is the inward
131/// offset of every kept face with the removed faces left in place,
132/// subtracted through the boolean; the flush faces melt away, which is
133/// what opens the shell. When a removed face has a *tangent* neighbour (a
134/// blend melting into the face it rounds), leaving it in place would tear
135/// the shared vertices, so instead the whole solid offsets inward and each
136/// removed face's cavity image extrudes back out through the opening; the
137/// rim a tangent opening leaves is the tapering strip a true
138/// constant-thickness wall has there, which is correct rather than a
139/// defect.
140///
141/// # Errors
142///
143/// As [`offset_shape`], and additionally if `thickness` is not a usable
144/// length, a removed face is not a face of `solid`, or a tangent opening is
145/// not planar.
146pub fn make_thick_solid(
147    model: &mut Model,
148    solid: &Shape,
149    removed: &[Shape],
150    thickness: f64,
151    tol: Tolerances,
152) -> OgeomResult<Built> {
153    make_thick_solid_with(model, solid, removed, thickness, Join::Intersection, tol)
154}
155
156/// [`make_thick_solid`] with a choice of how the walls meet across an edge.
157///
158/// [`Join::Intersection`] extends the walls until they meet: sharp corners,
159/// what [`make_thick_solid`] builds. [`Join::Arc`] rounds them about each
160/// edge that is convex on the side the walls grow toward, a cylinder of the
161/// wall thickness about the edge and a ball about a corner where several
162/// meet: the rolling ball's parallel body. Where the growing side is
163/// concave the two joins agree, and the opening faces stay flush either
164/// way.
165///
166/// # Errors
167///
168/// As [`make_thick_solid`], and for [`Join::Arc`] where an opening meets a
169/// tangent neighbour or the rounding fails.
170pub fn make_thick_solid_with(
171    model: &mut Model,
172    solid: &Shape,
173    removed: &[Shape],
174    thickness: f64,
175    join: Join,
176    tol: Tolerances,
177) -> OgeomResult<Built> {
178    if !thickness.is_finite() || thickness.abs() <= tol.confusion() {
179        ogeom_bail!(Construction, "a wall of {thickness} holds nothing");
180    }
181    let (canonical, mapped, prefix) = canonical_input(model, solid, removed, tol)?;
182    if let Some(prefix) = prefix {
183        let mut out = make_thick_solid_with(model, &canonical, &mapped, thickness, join, tol)?;
184        out.history = prefix.then(&out.history);
185        return Ok(out);
186    }
187    // The sign is the side: positive hollows inward, negative builds the
188    // walls outward around the solid, which becomes the cavity itself.
189    let outward_walls = thickness < 0.0;
190    let reach = thickness.abs();
191    let own: Vec<TShapeId> = explore(model, solid, Filter::OfType(ShapeType::Face))?
192        .iter()
193        .map(Shape::node)
194        .collect();
195    for face in removed {
196        if !own.contains(&face.node()) {
197            ogeom_bail!(Construction, "a removed face is not a face of the solid");
198        }
199    }
200
201    let mut tangent_opening = false;
202    for face in removed {
203        if has_tangent_neighbour(model, solid, face, tol)? {
204            tangent_opening = true;
205            break;
206        }
207    }
208    if !tangent_opening {
209        let skip: Vec<TShapeId> = removed.iter().map(Shape::node).collect();
210        let moved = rebuilt(
211            model,
212            solid,
213            &|face| {
214                if skip.contains(&face.node()) {
215                    0.0
216                } else if outward_walls {
217                    reach
218                } else {
219                    -reach
220                }
221            },
222            &|_| None,
223            tol,
224        )?;
225        // The arc join rounds the moved copy about every edge between two
226        // moved faces that is convex on the growing side: a ball of the
227        // wall's thickness touching both moved walls stands on the old edge.
228        let moved = if join == Join::Arc {
229            let held: Vec<TShapeId> = removed
230                .iter()
231                .flat_map(|f| {
232                    moved
233                        .history
234                        .modified(f)
235                        .iter()
236                        .map(Shape::node)
237                        .collect::<Vec<_>>()
238                })
239                .collect();
240            let edges = growing_edges(model, &moved.shape, &held, outward_walls, tol)?;
241            if edges.is_empty() {
242                moved
243            } else {
244                ogeom_fillet::fillet_edges(model, &moved.shape, &edges, reach, tol)?
245            }
246        } else {
247            moved
248        };
249        // Inward, the moved copy is the cavity carved from the solid;
250        // outward, the solid is the cavity carved from the moved copy. The
251        // held-in-place opening faces coincide either way, and the melt is
252        // what leaves them open.
253        let mut result = if outward_walls {
254            ogeom_bool::cut(model, &moved.shape, solid, tol)?
255        } else {
256            ogeom_bool::cut(model, solid, &moved.shape, tol)?
257        };
258        for face in removed {
259            result.history.delete(face);
260        }
261        return Ok(result);
262    }
263
264    if join == Join::Arc {
265        ogeom_bail!(
266            Construction,
267            "the arc join is built where every opening meets its neighbours \
268             across a corner; an opening with a tangent neighbour is not yet"
269        );
270    }
271    // The tangent construction: everything moves together (which is what
272    // keeps the tangencies intact), and each opening is drilled back out by
273    // extruding its opening image through where the wall now stands.
274    let displaced = if outward_walls { reach } else { -reach };
275    let moved = rebuilt(model, solid, &|_| displaced, &|_| None, tol)?;
276    let opening_normal = |model: &Model, face: &Shape| -> OgeomResult<Vector> {
277        let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
278            ogeom_bail!(Construction, "face node holds no face data");
279        };
280        let Some(SurfaceGeometry::Plane(p)) = model.geometry().surface(data.surface) else {
281            ogeom_bail!(
282                Construction,
283                "a tangent opening must be planar; a curved opening needs \
284                 the general rebuild; see docs/PARITY.md, offset.shell-thicken"
285            );
286        };
287        let mut normal = p.plane().normal().vector();
288        if face.orientation() == Orientation::Reversed {
289            normal = -normal;
290        }
291        Ok(normal)
292    };
293    let mut result = if outward_walls {
294        // The solid itself is the cavity; the openings drill outward from
295        // its own faces through the new walls.
296        let mut tool = solid.clone();
297        for face in removed {
298            let outward = opening_normal(model, face)?;
299            let punch = ogeom_algo::make_prism(model, &face.clone(), outward * (2.0 * reach), tol)?;
300            tool = ogeom_bool::fuse(model, &tool, &punch.shape, tol)?.shape;
301        }
302        ogeom_bool::cut(model, &moved.shape, &tool, tol)?
303    } else {
304        let mut tool = moved.shape.clone();
305        for face in removed {
306            let outward = opening_normal(model, face)?;
307            let [image] = moved.history.modified(face) else {
308                ogeom_bail!(Construction, "a removed face has no single cavity image");
309            };
310            let punch =
311                ogeom_algo::make_prism(model, &image.clone(), outward * (2.0 * reach), tol)?;
312            tool = ogeom_bool::fuse(model, &tool, &punch.shape, tol)?.shape;
313        }
314        ogeom_bool::cut(model, solid, &tool, tol)?
315    };
316    for face in removed {
317        result.history.delete(face);
318    }
319    Ok(result)
320}
321
322/// Whether any neighbour meets `face` tangentially along a shared edge.
323fn has_tangent_neighbour(
324    model: &Model,
325    solid: &Shape,
326    face: &Shape,
327    tol: Tolerances,
328) -> OgeomResult<bool> {
329    use ogeom_geom::Surface as _;
330
331    let own_edges: Vec<TShapeId> = explore(model, face, Filter::OfType(ShapeType::Edge))?
332        .iter()
333        .map(Shape::node)
334        .collect();
335    let normal_at = |model: &Model, face: &Shape, at: Point| -> OgeomResult<Option<Vector>> {
336        let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
337            ogeom_bail!(Construction, "face node holds no face data");
338        };
339        let Some(surface) = model.geometry().surface(data.surface) else {
340            ogeom_bail!(Dangling, "face refers to a surface not in this model");
341        };
342        let projection = ogeom_algo::project_on_surface(surface, at, 32, tol)?;
343        if projection.distance > tol.confusion() * 100.0 {
344            return Ok(None);
345        }
346        let (u, v) = projection.parameters;
347        let (du, dv) = surface.d1_at(u, v, tol)?;
348        let n = du.cross(dv);
349        let m = n.magnitude();
350        if m <= tol.confusion() {
351            return Ok(None);
352        }
353        Ok(Some(n / m))
354    };
355    for other in explore(model, solid, Filter::OfType(ShapeType::Face))? {
356        if other.node() == face.node() {
357            continue;
358        }
359        for edge in explore(model, &other, Filter::OfType(ShapeType::Edge))? {
360            if !own_edges.contains(&edge.node()) {
361                continue;
362            }
363            let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
364                continue;
365            };
366            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
367                continue;
368            };
369            let Some(geometry) = model.geometry().curve(*curve) else {
370                continue;
371            };
372            let mid = geometry.point_at(f64::midpoint(range.0, range.1), tol)?;
373            let (Some(a), Some(b)) = (normal_at(model, face, mid)?, normal_at(model, &other, mid)?)
374            else {
375                continue;
376            };
377            if a.cross(b).magnitude() <= 1e-6 {
378                return Ok(true);
379            }
380        }
381    }
382    Ok(false)
383}
384
385/// A face prepared for the rebuild.
386struct Prepared {
387    shape: Shape,
388    /// The moved surface.
389    surface: SurfaceGeometry,
390    /// The outward normal amount this face moved.
391    amount: f64,
392    /// The sign relating the face's outward side to the surface's own
393    /// normal: `+1` for a Forward face.
394    sign: f64,
395    /// For a full revolution band (seam and two closed rings), the rings.
396    rings: Option<[Shape; 2]>,
397}
398
399/// The rebuild under both entry points: every face offset by its own amount,
400/// the topology re-derived on the moved surfaces.
401///
402/// One rule serves every element. A surface moves along its own normal (a
403/// plane translates, a revolution surface's radius grows), which makes the
404/// *displacement* constraint at any point of it exactly planar: normal
405/// there, offset amount along it. Vertices solve those constraints in the
406/// least-squares sense and then Newton-polish onto the moved surfaces
407/// themselves, edges re-derive from the moved pair (a line from its planes'
408/// constraints, a circle from the pair's analytic intersection re-framed on
409/// its old axes so parameters and orientations carry), and faces rebuild
410/// wire by wire with exact pcurves, or wholesale through
411/// [`make_revolution_band`] where a seam says the face wraps.
412/// Rebuild a solid's topology on moved supports.
413///
414/// `amount_of` says how far each face travels along its own outward normal;
415/// `instead_of` may hand back a surface to use *in place* of that move,
416/// which is how an operation that turns a face rather than translating it
417/// (a draft) rides the same rebuild. The two are exclusive per face: a
418/// surface supplied by `instead_of` is taken as it stands.
419pub(crate) fn rebuilt(
420    model: &mut Model,
421    solid: &Shape,
422    amount_of: &dyn Fn(&Shape) -> f64,
423    instead_of: &dyn Fn(&Shape) -> Option<SurfaceGeometry>,
424    tol: Tolerances,
425) -> OgeomResult<Built> {
426    use ogeom_geom::Surface as _;
427    let faces = explore(model, solid, Filter::OfType(ShapeType::Face))?;
428
429    // Move every surface.
430    let mut prepared: Vec<Prepared> = Vec::with_capacity(faces.len());
431    for face in &faces {
432        let amount = amount_of(face);
433        let Some(node) = model.node(face) else {
434            ogeom_bail!(Dangling, "face is not in this model");
435        };
436        let NodeData::Face(data) = node.data() else {
437            ogeom_bail!(Construction, "face node holds no face data");
438        };
439        let Some(surface) = model.geometry().surface(data.surface) else {
440            ogeom_bail!(Dangling, "face refers to a surface not in this model");
441        };
442        let sign = if face.orientation() == Orientation::Reversed {
443            -1.0
444        } else {
445            1.0
446        };
447        let edges = explore(model, face, Filter::OfType(ShapeType::Edge))?;
448        let mut counts: HashMap<TShapeId, usize> = HashMap::new();
449        for e in &edges {
450            *counts.entry(e.node()).or_insert(0) += 1;
451        }
452        let has_seam = counts.values().any(|c| *c >= 2);
453        let closed_rings: Vec<Shape> = edges
454            .iter()
455            .filter(|e| {
456                edge_vertices(model, e)
457                    .ok()
458                    .flatten()
459                    .is_some_and(|(a, b)| a.node() == b.node())
460            })
461            .cloned()
462            .collect();
463
464        let grow = amount.abs() * 4.0 + 1.0;
465        let replacement = instead_of(face);
466        let moved: SurfaceGeometry = if let Some(given) = replacement {
467            given
468        } else if amount == 0.0 {
469            // A face a draft or a partial offset leaves alone stays on its
470            // own surface, whatever family that is: moving by nothing is
471            // identity, not a construction the family has to support.
472            surface.clone()
473        } else {
474            match surface {
475                SurfaceGeometry::Plane(p) => {
476                    let plane = p.plane();
477                    let ((u0, u1), (v0, v1)) = surface.domain();
478                    let shifted = Plane::new(Frame::new(
479                        plane.origin() + plane.normal().vector() * (sign * amount),
480                        plane.normal(),
481                        plane.frame().x(),
482                        tol,
483                    )?);
484                    PlaneSurface::over(shifted, (u0 - grow, u1 + grow), (v0 - grow, v1 + grow))?
485                        .into()
486                }
487                SurfaceGeometry::Cylinder(c) => {
488                    let cylinder = c.cylinder();
489                    let grown = sign.mul_add(amount, cylinder.radius());
490                    if grown <= tol.confusion() {
491                        ogeom_bail!(Construction, "the offset consumes the cylinder's radius");
492                    }
493                    let (_, (v0, v1)) = surface.domain();
494                    CylinderSurface::new(
495                        Cylinder::new(cylinder.frame(), grown, tol)?,
496                        (v0 - grow, v1 + grow),
497                    )?
498                    .into()
499                }
500                SurfaceGeometry::Sphere(sp) => {
501                    let sphere = sp.sphere();
502                    let grown = sign.mul_add(amount, sphere.radius());
503                    if grown <= tol.confusion() {
504                        ogeom_bail!(Construction, "the offset consumes the sphere's radius");
505                    }
506                    ogeom_geom::SphereSurface::new(ogeom_math::Sphere::centred(
507                        sphere.centre(),
508                        grown,
509                        tol,
510                    )?)
511                    .into()
512                }
513                SurfaceGeometry::Torus(t) => {
514                    let torus = t.torus();
515                    let grown = sign.mul_add(amount, torus.minor_radius());
516                    if grown <= tol.confusion() {
517                        ogeom_bail!(Construction, "the offset consumes the torus's tube");
518                    }
519                    ogeom_geom::TorusSurface::new(ogeom_math::Torus::new(
520                        torus.frame(),
521                        torus.major_radius(),
522                        grown,
523                        tol,
524                    )?)
525                    .into()
526                }
527                SurfaceGeometry::Cone(co) => {
528                    let cone = co.cone();
529                    // The parallel cone: same axis and half-angle, the reference
530                    // radius moved by the offset over the slant's cosine.
531                    let grown = (sign * amount / cone.half_angle().cos())
532                        .mul_add(1.0, cone.reference_radius());
533                    if grown <= tol.confusion() {
534                        ogeom_bail!(Construction, "the offset consumes the cone's throat");
535                    }
536                    let (_, (v0, v1)) = surface.domain();
537                    ogeom_geom::ConeSurface::new(
538                        ogeom_math::Cone::new(cone.frame(), grown, cone.half_angle(), tol)?,
539                        (v0 - grow, v1 + grow),
540                    )?
541                    .into()
542                }
543                _ => ogeom_bail!(
544                    Construction,
545                    "offsetting a face on this surface needs a construction \
546                     the rebuild does not yet speak; see docs/PARITY.md, offset.shell-thicken"
547                ),
548            }
549        };
550        // A band rebuilds wholesale only on a surface of revolution; a
551        // drafted wall on a fitted support is a band the wire path
552        // assembles, seam and all.
553        let fitted_support = matches!(moved, SurfaceGeometry::BSpline(_));
554        prepared.push(Prepared {
555            shape: face.clone(),
556            surface: moved,
557            amount,
558            sign,
559            rings: if has_seam && closed_rings.len() == 2 && !fitted_support {
560                Some([closed_rings[0].clone(), closed_rings[1].clone()])
561            } else {
562                None
563            },
564        });
565    }
566
567    // Which faces meet each edge, seams excluded by their double use.
568    let mut edge_faces: HashMap<TShapeId, Vec<usize>> = HashMap::new();
569    for (fi, face) in faces.iter().enumerate() {
570        for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
571            let entry = edge_faces.entry(e.node()).or_default();
572            if !entry.contains(&fi) {
573                entry.push(fi);
574            }
575        }
576    }
577
578    // The displacement constraint each face puts on a point of itself: the
579    // surface normal there, moved its amount along it. Exact, because a
580    // normal offset moves every point of a surface along its own normal.
581    let constraint = |model: &Model, fi: usize, at: Point| -> OgeomResult<Option<(Vector, f64)>> {
582        let face = &faces[fi];
583        let Some(node) = model.node(face) else {
584            ogeom_bail!(Dangling, "face is not in this model");
585        };
586        let NodeData::Face(data) = node.data() else {
587            ogeom_bail!(Construction, "face node holds no face data");
588        };
589        let Some(surface) = model.geometry().surface(data.surface) else {
590            ogeom_bail!(Dangling, "face refers to a surface not in this model");
591        };
592        let projection = ogeom_algo::project_on_surface(surface, at, 32, tol)?;
593        if projection.distance > tol.confusion() * 100.0 {
594            return Ok(None);
595        }
596        let (u, v) = projection.parameters;
597        let (du, dv) = surface.d1_at(u, v, tol)?;
598        let n = du.cross(dv);
599        let m = n.magnitude();
600        if m <= tol.confusion() {
601            return Ok(None);
602        }
603        let outward = n / m * prepared[fi].sign;
604        Ok(Some((outward, prepared[fi].amount)))
605    };
606
607    // New vertices: the linear constraint solve seeds a Newton polish onto
608    // the moved surfaces themselves; the tangent-plane answer is exact for
609    // planes and off by the surfaces' own curvature otherwise.
610    let mut new_vertices: HashMap<TShapeId, (Shape, Point)> = HashMap::new();
611    for vertex in explore_unique(model, solid, ShapeType::Vertex)? {
612        let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) else {
613            continue;
614        };
615        let at = vertex.transform(model.datums())?.apply(data.point);
616        let mut seats: Vec<usize> = Vec::new();
617        for (fi, face) in faces.iter().enumerate() {
618            for v in explore(model, face, Filter::OfType(ShapeType::Vertex))? {
619                if v.node() == vertex.node() && !seats.contains(&fi) {
620                    seats.push(fi);
621                }
622            }
623        }
624        if seats.is_empty() {
625            continue;
626        }
627        // Independent constraints only: tangent faces share their normal and
628        // must agree on the displacement, or the vertex tears.
629        let mut normals: Vec<Vector> = Vec::new();
630        let mut amounts: Vec<f64> = Vec::new();
631        let mut kept: Vec<usize> = Vec::new();
632        for fi in &seats {
633            let Some((n, w)) = constraint(model, *fi, at)? else {
634                continue;
635            };
636            if let Some(k) = normals
637                .iter()
638                .position(|m| m.cross(n).magnitude() <= tol.angular().max(1e-6))
639            {
640                if (amounts[k] - w).abs() > tol.confusion() {
641                    ogeom_bail!(
642                        Construction,
643                        "two tangent faces move a shared vertex by different \
644                         amounts; the offset tears it"
645                    );
646                }
647                continue;
648            }
649            normals.push(n);
650            amounts.push(w);
651            kept.push(*fi);
652        }
653        if normals.is_empty() {
654            // A cone's apex has no normal to offer (the projection there is
655            // degenerate), but the parallel cone knows exactly where its own
656            // apex went.
657            let mut apex: Option<Point> = None;
658            for fi in &seats {
659                let Some(node) = model.node(&faces[*fi]) else {
660                    continue;
661                };
662                let NodeData::Face(data) = node.data() else {
663                    continue;
664                };
665                let Some(SurfaceGeometry::Cone(old)) = model.geometry().surface(data.surface)
666                else {
667                    continue;
668                };
669                if old.cone().apex().distance(at) > tol.confusion() * 100.0 {
670                    continue;
671                }
672                if let SurfaceGeometry::Cone(moved_cone) = &prepared[*fi].surface {
673                    apex = Some(moved_cone.cone().apex());
674                    break;
675                }
676            }
677            let Some(moved) = apex else {
678                ogeom_bail!(
679                    Construction,
680                    "a vertex with no seat the rebuild can read cannot be \
681                     re-solved"
682                );
683            };
684            new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
685            continue;
686        }
687        if normals.len() == 1 {
688            // Every seat is tangent to the rest, and the dedup above made
689            // them agree on the amount. A normal offset moves each point of a
690            // surface along its own normal, so the shared normal is the exact
691            // answer: no corner to solve, nothing to polish.
692            let moved = at + normals[0] * amounts[0];
693            new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
694            continue;
695        }
696        // A vertex where a seam ends has two seats, not three, and the
697        // third constraint is the seam itself: the vertex is where the moved
698        // support's seam column meets the other seat. Solved as that
699        // crossing where the seam has an iso-curve to offer; the nearest
700        // point two seats agree on is somewhere along their whole edge.
701        if kept.len() == 2
702            && let Some(moved) = seam_end(model, &faces, &prepared, &vertex, &kept, at, tol)?
703        {
704            new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
705            continue;
706        }
707        let mut moved = at + solve_corner(&normals, &amounts, tol)?;
708        // Newton onto the moved surfaces: residuals are the signed
709        // distances, gradients the normals, and the same least-squares
710        // machinery takes the step.
711        for _ in 0..8 {
712            let mut ns: Vec<Vector> = Vec::new();
713            let mut rs: Vec<f64> = Vec::new();
714            for fi in &kept {
715                let projection =
716                    ogeom_algo::project_on_surface(&prepared[*fi].surface, moved, 32, tol)?;
717                let (u, v) = projection.parameters;
718                let (du, dv) = prepared[*fi].surface.d1_at(u, v, tol)?;
719                let n = du.cross(dv);
720                let m = n.magnitude();
721                if m <= tol.confusion() {
722                    continue;
723                }
724                let n = n / m;
725                let foot = prepared[*fi].surface.point_at(u, v, tol)?;
726                ns.push(n);
727                rs.push((moved - foot).dot(n));
728            }
729            if ns.len() < 2 {
730                break;
731            }
732            let worst = rs.iter().fold(0.0_f64, |a, r| a.max(r.abs()));
733            if worst <= tol.confusion() * 0.1 {
734                break;
735            }
736            let step: Vec<f64> = rs.iter().map(|r| -r).collect();
737            moved += solve_corner(&ns, &step, tol)?;
738        }
739        new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
740    }
741
742    // How many times each edge occurs across all faces; a seam is one face
743    // using an edge twice, which face-deduplicated sides cannot see.
744    let mut edge_uses: HashMap<TShapeId, usize> = HashMap::new();
745    for face in &faces {
746        for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
747            *edge_uses.entry(e.node()).or_insert(0) += 1;
748        }
749    }
750
751    // New edges on the moved supports.
752    let mut new_edges: HashMap<TShapeId, Shape> = HashMap::new();
753    let mut history = History::new();
754    for edge in explore_unique(model, solid, ShapeType::Edge)? {
755        let sides = edge_faces.get(&edge.node()).cloned().unwrap_or_default();
756        if sides.len() != 2 {
757            if edge_uses.get(&edge.node()).copied().unwrap_or(0) >= 2 {
758                // A seam. A band face rebuilds its own; a face assembled wire
759                // by wire (a band a boolean split into arc rings) needs the
760                // moved seam here: the same iso-column on the moved surface,
761                // which chart preservation makes exact.
762                if let [fi] = sides.as_slice()
763                    && let Some(built) =
764                        rebuilt_seam_edge(model, &edge, &prepared[*fi], &new_vertices, tol)?
765                {
766                    history.modify(&edge, built.clone());
767                    new_edges.insert(edge.node(), built);
768                }
769                continue;
770            }
771            // A genuinely single-sided edge: the ring a boolean left
772            // coincident with a neighbour's twin, or a cone's apex.
773            let Some(built) =
774                rebuilt_lone_edge(model, &edge, &sides, &constraint, &new_vertices, tol)?
775            else {
776                ogeom_bail!(
777                    Construction,
778                    "an edge with one face is neither a ring nor an apex; \
779                     the offset cannot re-derive it"
780                );
781            };
782            history.modify(&edge, built.clone());
783            new_edges.insert(edge.node(), built);
784            continue;
785        }
786        let (curve, range) = {
787            let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
788                ogeom_bail!(Construction, "edge node holds no edge data");
789            };
790            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
791                ogeom_bail!(Construction, "an edge has no curve to offset");
792            };
793            let Some(geometry) = model.geometry().curve(*curve) else {
794                ogeom_bail!(Dangling, "curve is not in this model");
795            };
796            (geometry.clone(), *range)
797        };
798        let forward = if edge.orientation() == Orientation::Reversed {
799            edge.reversed()
800        } else {
801            edge.clone()
802        };
803        let built = match &curve {
804            Curve::Line(_) => {
805                // A straight edge is the line through its own re-solved
806                // ends. That is true whether the supports were translated
807                // or turned (an offset leaves the direction alone and this
808                // reproduces it, a draft does not and this follows it),
809                // whereas a line anchored where the old one sat misses its
810                // own vertices the moment either end moves sideways.
811                let Some((sv, ev)) = edge_vertices(model, &forward)? else {
812                    ogeom_bail!(Construction, "a straight edge has no vertices");
813                };
814                let (Some((v_from, p_from)), Some((v_to, p_to))) = (
815                    new_vertices.get(&sv.node()).cloned(),
816                    new_vertices.get(&ev.node()).cloned(),
817                ) else {
818                    ogeom_bail!(Construction, "an edge end has no re-solved vertex");
819                };
820                if p_to.distance(p_from) <= tol.parametric() {
821                    ogeom_bail!(Construction, "the offset collapses an edge");
822                }
823                let segment = LineCurve::segment(p_from, p_to, tol)?;
824                let (t0, t1) = segment.domain();
825                let moved: Curve = segment.into();
826                make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
827            }
828            Curve::Circle(c)
829                if !matches!(prepared[sides[0]].surface, SurfaceGeometry::BSpline(_))
830                    && !matches!(prepared[sides[1]].surface, SurfaceGeometry::BSpline(_)) =>
831            {
832                // The moved pair's own analytic intersection, taken in the
833                // circle's old frame so parameters and orientations carry.
834                // Between analytic supports a circle stays a circle; against
835                // a fitted support it is whatever the march finds, below.
836                let circle = c.circle();
837                let found = ogeom_intersect::intersect_surfaces(
838                    &prepared[sides[0]].surface,
839                    &prepared[sides[1]].surface,
840                    ogeom_intersect::IntersectOptions::default(),
841                    tol,
842                )?;
843                let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
844                    ogeom_bail!(
845                        Construction,
846                        "the moved faces no longer meet along the edge they \
847                         shared; the offset collapses it"
848                    );
849                };
850                let mut best: Option<(ogeom_math::Circle, f64)> = None;
851                for section in &candidates {
852                    let Curve::Circle(cc) = &section.curve else {
853                        continue;
854                    };
855                    let candidate = cc.circle();
856                    let score = candidate.centre().distance(circle.centre())
857                        + (candidate.radius() - circle.radius()).abs();
858                    if best.as_ref().is_none_or(|(_, held)| score < *held) {
859                        best = Some((candidate, score));
860                    }
861                }
862                let Some((candidate, _)) = best else {
863                    ogeom_bail!(
864                        Construction,
865                        "the moved faces meet along nothing circular where a \
866                         circle was; the offset needs the general rebuild"
867                    );
868                };
869                let reframed = ogeom_math::Circle::new(
870                    Frame::new(
871                        candidate.centre(),
872                        circle.frame().z(),
873                        circle.frame().x(),
874                        tol,
875                    )?,
876                    candidate.radius(),
877                    tol,
878                )?;
879                let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
880                let closed = {
881                    let Some((sv, ev)) = edge_vertices(model, &forward)? else {
882                        ogeom_bail!(Construction, "a ring has no vertex");
883                    };
884                    sv.node() == ev.node()
885                };
886                if closed {
887                    make_edge(model, moved, range, tol)?.shape
888                } else {
889                    let Some((sv, ev)) = edge_vertices(model, &forward)? else {
890                        ogeom_bail!(Construction, "an arc has no vertices");
891                    };
892                    let (Some((v_from, p_from)), Some((v_to, p_to))) = (
893                        new_vertices.get(&sv.node()).cloned(),
894                        new_vertices.get(&ev.node()).cloned(),
895                    ) else {
896                        ogeom_bail!(Construction, "an arc end has no re-solved vertex");
897                    };
898                    let angle_of = |p: Point| {
899                        let l = reframed.frame().to_local(p);
900                        l.y.atan2(l.x)
901                    };
902                    let tau = core::f64::consts::TAU;
903                    let mut t0 = angle_of(p_from);
904                    let mut t1 = angle_of(p_to);
905                    // Keep the new range in the old one's winding and span.
906                    while t0 < range.0 - core::f64::consts::PI {
907                        t0 += tau;
908                    }
909                    while t0 > range.0 + core::f64::consts::PI {
910                        t0 -= tau;
911                    }
912                    while t1 <= t0 + tol.parametric() {
913                        t1 += tau;
914                    }
915                    if (t1 - t0) - (range.1 - range.0) > core::f64::consts::PI {
916                        t1 -= tau;
917                    }
918                    if t1 <= t0 + tol.parametric() {
919                        ogeom_bail!(Construction, "the offset collapses an arc");
920                    }
921                    make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
922                }
923            }
924            _ => {
925                // The general edge. First the still question: a hinge edge
926                // (a draft's neutral crossing) sits on both moved supports
927                // exactly where it always was, and an edge that did not move
928                // rebuilds on its own curve rather than on a march of it.
929                let unmoved = {
930                    let mut worst = 0.0_f64;
931                    'probe: for i in 0..9 {
932                        #[allow(clippy::cast_precision_loss)]
933                        let t = range.0 + (range.1 - range.0) * (i as f64) / 8.0;
934                        let p = curve.point_at(t, tol)?;
935                        for side in [sides[0], sides[1]] {
936                            let Ok(near) =
937                                ogeom_algo::project_on_surface(&prepared[side].surface, p, 17, tol)
938                            else {
939                                worst = f64::INFINITY;
940                                break 'probe;
941                            };
942                            worst = worst.max(near.distance);
943                        }
944                    }
945                    // Within the moved supports' own stated accuracy: a
946                    // fitted support holds its points only to the fit
947                    // target, and the hinge is exactly on it by less.
948                    (worst <= (tol.confusion() * 1e3).max(1e-4)).then_some(worst)
949                };
950                if let Some(worst) = unmoved {
951                    let Some((sv, ev)) = edge_vertices(model, &forward)? else {
952                        ogeom_bail!(Construction, "an edge has no vertices");
953                    };
954                    let closed = sv.node() == ev.node();
955                    let built = if closed {
956                        // On the vertex the rest of the rebuild uses (a
957                        // seam starts from it), not one of the curve's own.
958                        match new_vertices.get(&sv.node()).cloned() {
959                            Some((v_at, p_at)) => {
960                                let gap = curve.point_at(range.0, tol)?.distance(p_at);
961                                if gap > tol.confusion() {
962                                    model.widen(&v_at, ogeom_core::Tolerance::new(gap * 2.0)?)?;
963                                }
964                                make_edge_between(model, curve.clone(), range, &v_at, &v_at, tol)?
965                                    .shape
966                            }
967                            None => make_edge(model, curve.clone(), range, tol)?.shape,
968                        }
969                    } else {
970                        let (Some((v_from, p_from)), Some((v_to, p_to))) = (
971                            new_vertices.get(&sv.node()).cloned(),
972                            new_vertices.get(&ev.node()).cloned(),
973                        ) else {
974                            ogeom_bail!(Construction, "an edge end has no re-solved vertex");
975                        };
976                        // The ends re-solved against a fitted support land a
977                        // fit's breadth from the curve that did not move; the
978                        // vertices own that breadth.
979                        let gap = curve
980                            .point_at(range.0, tol)?
981                            .distance(p_from)
982                            .min(curve.point_at(range.0, tol)?.distance(p_to))
983                            .max(
984                                curve
985                                    .point_at(range.1, tol)?
986                                    .distance(p_to)
987                                    .min(curve.point_at(range.1, tol)?.distance(p_from)),
988                            );
989                        if gap > tol.confusion() {
990                            for v in [&v_from, &v_to] {
991                                model.widen(v, ogeom_core::Tolerance::new(gap * 2.0)?)?;
992                            }
993                        }
994                        make_edge_between(model, curve.clone(), range, &v_from, &v_to, tol)?.shape
995                    };
996                    if worst > tol.confusion()
997                        && let Some(node) = model.node_mut(&built)
998                        && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
999                    {
1000                        data.tolerance = data.tolerance.widen_to(worst);
1001                    }
1002                    history.modify(&edge, built.clone());
1003                    new_edges.insert(edge.node(), built);
1004                    continue;
1005                }
1006                // Otherwise the moved pair's own intersection, marched where
1007                // no closed form exists (a drafted spline wall re-meeting
1008                // its cap plane), with the candidate nearest the old edge
1009                // kept and trimmed between the re-solved ends. The section's
1010                // stated slop widens the edge; nothing pretends the fit is
1011                // exact.
1012                let mid = curve.point_at(f64::midpoint(range.0, range.1), tol)?;
1013                let found = ogeom_intersect::intersect_surfaces(
1014                    &prepared[sides[0]].surface,
1015                    &prepared[sides[1]].surface,
1016                    ogeom_intersect::IntersectOptions::default(),
1017                    tol,
1018                )?;
1019                let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
1020                    ogeom_bail!(
1021                        Construction,
1022                        "the moved faces no longer meet along the edge they \
1023                         shared; the offset collapses it"
1024                    );
1025                };
1026                let mut best: Option<(Curve, f64, f64)> = None;
1027                for section in candidates {
1028                    let Ok(projected) = ogeom_algo::project_on_curve(&section.curve, mid, 64, tol)
1029                    else {
1030                        continue;
1031                    };
1032                    if best
1033                        .as_ref()
1034                        .is_none_or(|(_, _, held)| projected.distance < *held)
1035                    {
1036                        best = Some((section.curve, section.tolerance, projected.distance));
1037                    }
1038                }
1039                let Some((moved, slop, _)) = best else {
1040                    ogeom_bail!(
1041                        Construction,
1042                        "the moved faces meet along nothing where the edge \
1043                         was; the offset collapses it"
1044                    );
1045                };
1046                let closed = {
1047                    let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1048                        ogeom_bail!(Construction, "an edge has no vertices");
1049                    };
1050                    sv.node() == ev.node()
1051                };
1052                let built = if closed {
1053                    // A ring's one vertex is a corner the neighbours' seams
1054                    // start from, re-solved like any other: the marched
1055                    // section is re-seamed to begin there, so the ring and
1056                    // the seam meet at one vertex rather than at two a
1057                    // section's start apart.
1058                    let Some((sv, _)) = edge_vertices(model, &forward)? else {
1059                        ogeom_bail!(Construction, "a ring has no vertex");
1060                    };
1061                    match (new_vertices.get(&sv.node()).cloned(), &moved) {
1062                        (Some((v_at, p_at)), Curve::BSpline(spline)) => {
1063                            let t = ogeom_algo::project_on_curve(&moved, p_at, 64, tol)?;
1064                            let (lo, hi) = moved.domain();
1065                            let seamed: Curve = if t.parameter > lo + tol.parametric()
1066                                && t.parameter < hi - tol.parametric()
1067                            {
1068                                Curve::BSpline(spline.reseamed_at(t.parameter, tol)?)
1069                            } else {
1070                                moved.clone()
1071                            };
1072                            // Run the way the old ring ran: the wire uses the
1073                            // rebuilt edge with the old orientation, and a
1074                            // march has no opinion about direction.
1075                            let seamed = {
1076                                use ogeom_geom::Reversible as _;
1077                                let (a, _) = seamed.domain();
1078                                let old = curve.d1_at(range.0, tol)?;
1079                                if seamed.d1_at(a, tol)?.dot(old) < 0.0 {
1080                                    seamed.reversed()
1081                                } else {
1082                                    seamed
1083                                }
1084                            };
1085                            let miss = t.distance.max(slop);
1086                            if miss > tol.confusion() {
1087                                model.widen(&v_at, ogeom_core::Tolerance::new(miss * 2.0)?)?;
1088                            }
1089                            let window = seamed.domain();
1090                            make_edge_between(model, seamed, window, &v_at, &v_at, tol)?.shape
1091                        }
1092                        _ => {
1093                            let window = moved.domain();
1094                            make_edge(model, moved, window, tol)?.shape
1095                        }
1096                    }
1097                } else {
1098                    let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1099                        ogeom_bail!(Construction, "an edge has no vertices");
1100                    };
1101                    let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1102                        new_vertices.get(&sv.node()).cloned(),
1103                        new_vertices.get(&ev.node()).cloned(),
1104                    ) else {
1105                        ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1106                    };
1107                    // The fitted section lands within its stated slop of the
1108                    // re-solved ends; the vertices own that slop.
1109                    if slop > tol.confusion() {
1110                        for v in [&v_from, &v_to] {
1111                            model.widen(v, ogeom_core::Tolerance::new(slop * 2.0)?)?;
1112                        }
1113                    }
1114                    let ta = ogeom_algo::project_on_curve(&moved, p_from, 64, tol)?.parameter;
1115                    let tb = ogeom_algo::project_on_curve(&moved, p_to, 64, tol)?.parameter;
1116                    if (tb - ta).abs() <= tol.parametric() {
1117                        ogeom_bail!(Construction, "the offset collapses an edge");
1118                    }
1119                    // The ends run with the curve or against it; a run
1120                    // against builds on the reversed parameterization so
1121                    // the edge still leaves `v_from` first.
1122                    let (moved, ta, tb) = if ta <= tb {
1123                        (moved, ta, tb)
1124                    } else {
1125                        use ogeom_geom::Reversible as _;
1126                        let (lo, hi) = moved.domain();
1127                        (moved.reversed(), lo + hi - ta, lo + hi - tb)
1128                    };
1129                    make_edge_between(model, moved, (ta, tb), &v_from, &v_to, tol)?.shape
1130                };
1131                if slop > tol.confusion()
1132                    && let Some(node) = model.node_mut(&built)
1133                    && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
1134                {
1135                    data.tolerance = data.tolerance.widen_to(slop);
1136                }
1137                built
1138            }
1139        };
1140        history.modify(&edge, built.clone());
1141        new_edges.insert(edge.node(), built);
1142    }
1143
1144    // Faces: bands wholesale, everything else wire by wire with exact
1145    // pcurves on the moved surface.
1146    let mut rebuilt_faces: Vec<Shape> = Vec::with_capacity(prepared.len());
1147    for prep in &prepared {
1148        let built = if let Some(rings) = &prep.rings {
1149            let (Some(lo), Some(hi)) = (
1150                new_edges.get(&rings[0].node()),
1151                new_edges.get(&rings[1].node()),
1152            ) else {
1153                ogeom_bail!(Construction, "a band's ring was not rebuilt");
1154            };
1155            let band = make_revolution_band(model, &prep.surface, lo, hi, tol)?;
1156            if prep.shape.orientation() == Orientation::Reversed {
1157                band.reversed()
1158            } else {
1159                band
1160            }
1161        } else {
1162            let mut wires: Vec<Vec<Shape>> = Vec::new();
1163            let mut face_uses: HashMap<TShapeId, usize> = HashMap::new();
1164            for wire in explore(model, &prep.shape, Filter::OfType(ShapeType::Wire))? {
1165                let mut edges: Vec<Shape> = Vec::new();
1166                // The wire's own order, not the walker's: a rebuilt wire is
1167                // re-chained edge to edge, and the walk order is not a chain.
1168                for used in model.ordered_children_of(&wire)? {
1169                    *face_uses.entry(used.node()).or_insert(0) += 1;
1170                    let Some(fresh) = new_edges.get(&used.node()) else {
1171                        ogeom_bail!(Construction, "a face edge was not rebuilt");
1172                    };
1173                    edges.push(if used.orientation() == Orientation::Reversed {
1174                        fresh.reversed()
1175                    } else {
1176                        fresh.clone()
1177                    });
1178                }
1179                wires.push(edges);
1180            }
1181            let face = if face_uses.values().any(|c| *c >= 2) {
1182                // A seam in a wire-assembled face: a band a boolean split
1183                // into arc rings. Every ordinary pcurve is recomputed on the
1184                // moved surface; the seam's columns carry over, which the
1185                // seam rebuild already validated against the re-solved ends.
1186                assembled_with_seam(model, prep, &wires, &new_edges, tol)?
1187            } else {
1188                make_face_with_pcurves(model, prep.surface.clone(), &wires, tol)?.shape
1189            };
1190            if prep.shape.orientation() == Orientation::Reversed {
1191                face.reversed()
1192            } else {
1193                face
1194            }
1195        };
1196        history.modify(&prep.shape, built.clone());
1197        rebuilt_faces.push(built);
1198    }
1199
1200    let sewn = sew(model, &rebuilt_faces, tol)?;
1201    if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
1202        ogeom_bail!(Construction, "the offset solid did not close");
1203    }
1204    // The faces carried their use-orientations through; the *shell* has one
1205    // too, and a solid whose outer shell was used reversed reads inside out
1206    // if the rebuilt shell forgets it.
1207    let outer = {
1208        let old_reversed = model
1209            .children_of(solid)?
1210            .first()
1211            .is_some_and(|s| s.orientation() == Orientation::Reversed);
1212        if old_reversed {
1213            sewn.shells[0].reversed()
1214        } else {
1215            sewn.shells[0].clone()
1216        }
1217    };
1218    let built = make_solid(model, std::slice::from_ref(&outer))?;
1219
1220    // The one global guard the local checks cannot give: an offset that
1221    // moved faces past each other builds a shell that is closed and inside
1222    // out. Its measured volume is the tell.
1223    // The guard meshes at the default deflection, and a thin tangential
1224    // cusp (a small blend meeting its face) can defeat that resolution
1225    // without anything being wrong. One finer retry separates a mesh that
1226    // cannot see the cusp from a solid that is genuinely inside out.
1227    // The finer retry is a fraction of the part's own size, not a fixed
1228    // length: a metre-sized part meshed at a tenth of a micron is millions
1229    // of triangles for a sign.
1230    let size = ogeom_algo::shape_bounds(model, &built.shape, tol)?.diagonal();
1231    let fine = (size * 1e-5).clamp(
1232        tol.confusion() * 1e2,
1233        ogeom_mesh::Deflection::default().chord,
1234    );
1235    let mut mass = None;
1236    let mut first_error = None;
1237    for chord in [ogeom_mesh::Deflection::default().chord, fine] {
1238        let deflection = ogeom_mesh::Deflection {
1239            chord,
1240            ..ogeom_mesh::Deflection::default()
1241        };
1242        match ogeom_algo::volume_properties(model, &built.shape, deflection, tol) {
1243            Ok(props) => {
1244                mass = Some(props.mass);
1245                break;
1246            }
1247            Err(e @ (ogeom_core::OgeomError::Cancelled | ogeom_core::OgeomError::Dangling(_))) => {
1248                return Err(e);
1249            }
1250            Err(e) => {
1251                first_error.get_or_insert(e);
1252            }
1253        }
1254    }
1255    let Some(mass) = mass else {
1256        ogeom_bail!(
1257            Construction,
1258            "the offset solid's mesh does not close at any tried resolution{}",
1259            first_error.map_or_else(String::new, |e| format!(": {e}"))
1260        );
1261    };
1262    if !mass.is_finite() || mass <= tol.confusion() {
1263        ogeom_bail!(Construction, "the offset collapses the solid");
1264    }
1265
1266    history.modify(solid, built.shape.clone());
1267    Ok(Built::new(built.shape, history))
1268}
1269
1270/// The displacement that puts a point back on every moved plane: solve
1271/// `x · nᵢ = wᵢ` for the corner's normals, exactly for three, in the least
1272/// squares sense beyond.
1273/// Rebuild a seam for a face assembled wire by wire: the same iso-column on
1274/// the moved surface, over the same rows.
1275///
1276/// A same-family move preserves the chart (every point travels along its
1277/// own normal without changing its parameters), so the moved seam sits at
1278/// the column the old one's own pcurves state, between the re-solved end
1279/// vertices. `None` when the old edge carries no seam representation on this
1280/// face's surface.
1281fn rebuilt_seam_edge(
1282    model: &mut Model,
1283    edge: &Shape,
1284    prep: &Prepared,
1285    new_vertices: &HashMap<TShapeId, (Shape, Point)>,
1286    tol: Tolerances,
1287) -> OgeomResult<Option<Shape>> {
1288    use ogeom_geom::Curve2d as _;
1289
1290    let old_surface = {
1291        let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1292        else {
1293            ogeom_bail!(Construction, "face node holds no face data");
1294        };
1295        data.surface
1296    };
1297    let found = {
1298        let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1299            ogeom_bail!(Construction, "edge node holds no edge data");
1300        };
1301        let mut found = None;
1302        for repr in &data.representations {
1303            if let EdgeRepr::Seam {
1304                forward,
1305                surface,
1306                range,
1307                ..
1308            } = repr
1309                && *surface == old_surface
1310            {
1311                let Some(pcurve) = model.geometry().pcurve(*forward) else {
1312                    ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1313                };
1314                // The pcurve states the column the seam sits at. The rows
1315                // cannot come from it: the seam's ends are corner vertices,
1316                // moved by the corner solve rather than by this face alone.
1317                found = Some(pcurve.point_at(range.0, tol)?.x);
1318                break;
1319            }
1320        }
1321        found
1322    };
1323    let Some(column) = found else {
1324        return Ok(None);
1325    };
1326    let Some((sv, ev)) = edge_vertices(model, edge)? else {
1327        ogeom_bail!(Construction, "a seam has no vertices");
1328    };
1329    let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1330        new_vertices.get(&sv.node()).cloned(),
1331        new_vertices.get(&ev.node()).cloned(),
1332    ) else {
1333        ogeom_bail!(Construction, "a seam end has no re-solved vertex");
1334    };
1335    let Some(curve) = ogeom_algo::surface_iso_u_curve(&prep.surface, column, tol) else {
1336        ogeom_bail!(
1337            Construction,
1338            "the moved surface's iso-curve has no closed form; no seam can \
1339             be rebuilt"
1340        );
1341    };
1342    // The parameters the re-solved ends land at, by the iso-curve's own
1343    // closed form; the ends were Newton-polished onto this very surface, so
1344    // they lie on the curve exactly.
1345    let along = |p: Point| -> OgeomResult<f64> {
1346        match &curve {
1347            Curve::Line(l) => Ok((p - l.axis().location).dot(l.axis().direction.vector())),
1348            Curve::Circle(c) => {
1349                let local = c.circle().frame().to_local(p);
1350                let mut angle = local.y.atan2(local.x);
1351                if angle < 0.0 {
1352                    angle += core::f64::consts::TAU;
1353                }
1354                Ok(angle)
1355            }
1356            // A fitted support's iso-curve is a B-spline: the parameter is
1357            // found by projection, and the check below says whether the end
1358            // lies on it.
1359            _ => Ok(ogeom_algo::project_on_curve(&curve, p, 64, tol)?.parameter),
1360        }
1361    };
1362    let (t_start, t_end) = (along(p_from)?, along(p_to)?);
1363    // Self-validation instead of trusting the move: a turned support only
1364    // keeps its column when the turn was built to; the re-solved ends say
1365    // whether it was.
1366    // A fitted support holds its column only to the fit's target, and the
1367    // ends were polished onto the surface, not the column: the slack is the
1368    // fit's, on a fitted support, and a hundred confusions elsewhere.
1369    // A drafted support is two fits, the hinge's and its rulings', and at
1370    // the far end of a ruling their errors add; a few targets' worth is
1371    // the support's own honesty, not a wrong column.
1372    let slack = if matches!(prep.surface, SurfaceGeometry::BSpline(_)) {
1373        (tol.confusion() * 1e3).max(1e-4) * 4.0
1374    } else {
1375        tol.confusion() * 100.0
1376    };
1377    for (t, p, v) in [(t_start, p_from, &v_from), (t_end, p_to, &v_to)] {
1378        let off = curve.point_at(t, tol)?.distance(p);
1379        if off > slack {
1380            return Ok(None);
1381        }
1382        // The end sits on the column to the fit's breadth, and the vertex
1383        // owns that breadth.
1384        if off > tol.confusion() {
1385            model.widen(v, ogeom_core::Tolerance::new(off * 2.0)?)?;
1386        }
1387    }
1388    Ok(Some(if t_start <= t_end {
1389        make_edge_between(model, curve, (t_start, t_end), &v_from, &v_to, tol)?.shape
1390    } else {
1391        // The old seam ran against the iso-curve's own direction: build it
1392        // the way the curve runs, then hand back the reversed occurrence so
1393        // the wire's stored orientations still compose.
1394        make_edge_between(model, curve, (t_end, t_start), &v_to, &v_from, tol)?
1395            .shape
1396            .reversed()
1397    }))
1398}
1399
1400/// Assemble a moved face whose wires contain a seam.
1401///
1402/// Every ordinary edge gets its exact pcurve recomputed on the moved
1403/// surface. The seam is the one edge no closed-form projection can answer
1404/// (it needs a column per side), so its columns carry over from the old
1405/// face's own seam representation (a same-family move leaves the columns
1406/// where they were), rebuilt over the rows the moved seam actually spans.
1407fn assembled_with_seam(
1408    model: &mut Model,
1409    prep: &Prepared,
1410    wires: &[Vec<Shape>],
1411    new_edges: &HashMap<TShapeId, Shape>,
1412    tol: Tolerances,
1413) -> OgeomResult<Shape> {
1414    let mut rings: Vec<Shape> = Vec::with_capacity(wires.len());
1415    for edges in wires {
1416        rings.push(ogeom_algo::make_wire(model, edges, tol)?.shape);
1417    }
1418    let face = ogeom_algo::make_face(model, prep.surface.clone(), &rings, tol)?.shape;
1419    let new_surface = {
1420        let Some(NodeData::Face(data)) = model.node(&face).map(ogeom_topo::TShape::data) else {
1421            ogeom_bail!(Construction, "the face just built holds no face data");
1422        };
1423        data.surface
1424    };
1425    let old_surface = {
1426        let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1427        else {
1428            ogeom_bail!(Construction, "face node holds no face data");
1429        };
1430        data.surface
1431    };
1432
1433    let mut done: Vec<TShapeId> = Vec::new();
1434    for used in explore(model, &prep.shape, Filter::OfType(ShapeType::Edge))? {
1435        if done.contains(&used.node()) {
1436            continue;
1437        }
1438        done.push(used.node());
1439        let Some(fresh) = new_edges.get(&used.node()).cloned() else {
1440            ogeom_bail!(Construction, "a face edge was not rebuilt");
1441        };
1442        let (fresh_curve, fresh_range) = {
1443            let Some(data) = model.node(&fresh).and_then(|n| n.data().as_edge()) else {
1444                ogeom_bail!(Construction, "a rebuilt edge holds no edge data");
1445            };
1446            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1447                ogeom_bail!(Construction, "a rebuilt edge has no curve");
1448            };
1449            let Some(geometry) = model.geometry().curve(*curve) else {
1450                ogeom_bail!(Dangling, "curve is not in this model");
1451            };
1452            (geometry.clone(), *range)
1453        };
1454        let columns = {
1455            let Some(data) = model.node(&used).and_then(|n| n.data().as_edge()) else {
1456                ogeom_bail!(Construction, "edge node holds no edge data");
1457            };
1458            let mut columns = None;
1459            for repr in &data.representations {
1460                if let EdgeRepr::Seam {
1461                    forward,
1462                    reversed,
1463                    surface,
1464                    range,
1465                    ..
1466                } = repr
1467                    && *surface == old_surface
1468                {
1469                    use ogeom_geom::Curve2d as _;
1470                    let (Some(f), Some(r)) = (
1471                        model.geometry().pcurve(*forward),
1472                        model.geometry().pcurve(*reversed),
1473                    ) else {
1474                        ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1475                    };
1476                    columns = Some((f.point_at(range.0, tol)?.x, r.point_at(range.0, tol)?.x));
1477                    break;
1478                }
1479            }
1480            columns
1481        };
1482        if let Some((forward_col, reversed_col)) = columns {
1483            // The rows the moved seam spans, from its own rebuilt range,
1484            // identical to the curve range except a cone's slant rescale.
1485            let rows = match &prep.surface {
1486                SurfaceGeometry::Cone(c) => {
1487                    let cos = c.cone().half_angle().cos();
1488                    (fresh_range.0 * cos, fresh_range.1 * cos)
1489                }
1490                _ => fresh_range,
1491            };
1492            let column = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1493                Ok(ogeom_geom::Line2d::over(
1494                    ogeom_math::Axis2::new(
1495                        ogeom_math::Point2::new(u, 0.0),
1496                        ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, 1.0), tol)?,
1497                    ),
1498                    rows.0 - 1.0,
1499                    rows.1 + 1.0,
1500                )?
1501                .into())
1502            };
1503            ogeom_algo::attach_seam(
1504                model,
1505                &fresh,
1506                column(forward_col)?,
1507                column(reversed_col)?,
1508                new_surface,
1509                ogeom_topo::Location::identity(),
1510                rows,
1511            )?;
1512        } else {
1513            // A closed form where one exists; on a fitted support, the
1514            // projected fit the face builder trusts, the measured offset
1515            // widening the edge.
1516            let pcurve = match ogeom_intersect::exact_pcurve_of(&fresh_curve, &prep.surface, tol) {
1517                Some(exact) => exact,
1518                None => {
1519                    let (fitted, _, _, worst_off, _) =
1520                        ogeom_algo::pcurve_fit::fit_projected_pcurve(
1521                            &fresh_curve,
1522                            fresh_range,
1523                            &prep.surface,
1524                            tol,
1525                        )?;
1526                    if worst_off > tol.confusion() {
1527                        // The edge owns the offset, and so must the vertices
1528                        // that bound it: a bound no looser than what it
1529                        // bounds is the containment rule.
1530                        let widened = ogeom_core::Tolerance::new(worst_off + tol.confusion())?;
1531                        model.widen(&fresh, widened)?;
1532                        if let Some((a, b)) = edge_vertices(model, &fresh)? {
1533                            model.widen(&a, widened)?;
1534                            model.widen(&b, widened)?;
1535                        }
1536                    }
1537                    fitted
1538                }
1539            };
1540            ogeom_algo::attach_pcurve(
1541                model,
1542                &fresh,
1543                pcurve,
1544                new_surface,
1545                ogeom_topo::Location::identity(),
1546                fresh_range,
1547            )?;
1548        }
1549    }
1550    Ok(face)
1551}
1552
1553/// Where a seam ending at `vertex` meets the other seat, on the moved
1554/// supports: the seam's column as an iso-curve on its face's moved surface,
1555/// pierced through the other face's; `None` where no seam ends here or the
1556/// column has no curve.
1557fn seam_end(
1558    model: &Model,
1559    faces: &[Shape],
1560    prepared: &[Prepared],
1561    vertex: &Shape,
1562    kept: &[usize],
1563    at: Point,
1564    tol: Tolerances,
1565) -> OgeomResult<Option<Point>> {
1566    use ogeom_geom::Curve2d as _;
1567    for (slot, &fi) in kept.iter().enumerate() {
1568        let other = kept[1 - slot];
1569        let face = &faces[fi];
1570        let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
1571            continue;
1572        };
1573        let old_surface = data.surface;
1574        let mut uses: HashMap<TShapeId, usize> = HashMap::new();
1575        for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
1576            *uses.entry(e.node()).or_insert(0) += 1;
1577        }
1578        for e in explore_unique(model, face, ShapeType::Edge)? {
1579            if uses.get(&e.node()).copied().unwrap_or(0) < 2 {
1580                continue;
1581            }
1582            let Some((a, b)) = edge_vertices(model, &e)? else {
1583                continue;
1584            };
1585            if a.node() != vertex.node() && b.node() != vertex.node() {
1586                continue;
1587            }
1588            let Some(edata) = model.node(&e).and_then(|n| n.data().as_edge()) else {
1589                continue;
1590            };
1591            let mut column = None;
1592            for repr in &edata.representations {
1593                if let EdgeRepr::Seam {
1594                    forward,
1595                    surface,
1596                    range,
1597                    ..
1598                } = repr
1599                    && *surface == old_surface
1600                    && let Some(pcurve) = model.geometry().pcurve(*forward)
1601                {
1602                    column = Some(pcurve.point_at(range.0, tol)?.x);
1603                    break;
1604                }
1605            }
1606            let Some(column) = column else {
1607                continue;
1608            };
1609            let Some(iso) = ogeom_algo::surface_iso_u_curve(&prepared[fi].surface, column, tol)
1610            else {
1611                continue;
1612            };
1613            let found = ogeom_intersect::intersect_curve_surface(
1614                &iso,
1615                &prepared[other].surface,
1616                ogeom_intersect::CurveSurfaceOptions::default(),
1617                tol,
1618            )?;
1619            let nearest = found
1620                .crossings
1621                .iter()
1622                .map(|hit| hit.point)
1623                .min_by(|p, q| p.distance(at).total_cmp(&q.distance(at)));
1624            if let Some(p) = nearest {
1625                return Ok(Some(p));
1626            }
1627        }
1628    }
1629    Ok(None)
1630}
1631
1632/// Rebuild an edge only one face owns: the ring a boolean left coincident
1633/// with a neighbour's twin, or a cone's apex.
1634///
1635/// A normal offset moves every point of a face along the face's own normal,
1636/// so three displaced samples of a ring pin the moved ring exactly; no
1637/// second face required. `None` when the edge is neither shape.
1638fn rebuilt_lone_edge(
1639    model: &mut Model,
1640    edge: &Shape,
1641    sides: &[usize],
1642    constraint: &Displacement<'_>,
1643    new_vertices: &HashMap<TShapeId, (Shape, Point)>,
1644    tol: Tolerances,
1645) -> OgeomResult<Option<Shape>> {
1646    use ogeom_geom::Curve3d as _;
1647
1648    let (degenerate, curve) = {
1649        let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1650            ogeom_bail!(Construction, "edge node holds no edge data");
1651        };
1652        let curve = data.curve3d().and_then(|repr| {
1653            let EdgeRepr::Curve3d { curve, range, .. } = repr else {
1654                return None;
1655            };
1656            model.geometry().curve(*curve).cloned().map(|c| (c, *range))
1657        });
1658        (data.degenerate, curve)
1659    };
1660    let Some((start, end)) = edge_vertices(model, edge)? else {
1661        ogeom_bail!(Construction, "a lone edge has no vertices");
1662    };
1663    if degenerate {
1664        // An apex: a rim of no length at the re-solved vertex.
1665        let Some((vertex, _)) = new_vertices.get(&start.node()) else {
1666            ogeom_bail!(Construction, "an apex has no re-solved vertex");
1667        };
1668        let mut data = EdgeData::new();
1669        data.degenerate = true;
1670        return Ok(Some(
1671            model.add_edge(data, &[vertex.clone(), vertex.clone()])?,
1672        ));
1673    }
1674    let (Some((Curve::Circle(c), range)), true, &[fi]) = (curve, start.node() == end.node(), sides)
1675    else {
1676        return Ok(None);
1677    };
1678    let circle = c.circle();
1679    let mut moved_points = Vec::with_capacity(3);
1680    for k in 0..3 {
1681        #[allow(clippy::cast_precision_loss, reason = "k is 0..3")]
1682        let t = (range.1 - range.0).mul_add(k as f64 / 3.0, range.0);
1683        let p = Curve::Circle(c).point_at(t, tol)?;
1684        let Some((n, w)) = constraint(model, fi, p)? else {
1685            return Ok(None);
1686        };
1687        moved_points.push(p + n * w);
1688    }
1689    // Equally spaced samples average to the centre; the displacement is
1690    // rotationally symmetric about the ring's own axis, so the moved ring is
1691    // concentric on it.
1692    let centre = Point::from_vector(
1693        moved_points
1694            .iter()
1695            .fold(Vector::new(0.0, 0.0, 0.0), |a, p| a + p.to_vector())
1696            / 3.0,
1697    );
1698    let radius = centre.distance(moved_points[0]);
1699    let reframed = ogeom_math::Circle::new(
1700        Frame::new(centre, circle.frame().z(), circle.frame().x(), tol)?,
1701        radius,
1702        tol,
1703    )?;
1704    let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
1705    Ok(Some(make_edge(model, moved, range, tol)?.shape))
1706}
1707
1708fn solve_corner(normals: &[Vector], amounts: &[f64], tol: Tolerances) -> OgeomResult<Vector> {
1709    // Normal equations: (NᵀN) x = Nᵀw, 3×3 whatever the seat count.
1710    let mut a = [[0.0_f64; 3]; 3];
1711    let mut b = [0.0_f64; 3];
1712    for (n, w) in normals.iter().zip(amounts) {
1713        let row = [n.x, n.y, n.z];
1714        for i in 0..3 {
1715            for j in 0..3 {
1716                a[i][j] += row[i] * row[j];
1717            }
1718            b[i] += row[i] * w;
1719        }
1720    }
1721    // For an edge between two planes the system is rank two; regularize
1722    // along the null direction (the edge itself), where the displacement is
1723    // rightly zero.
1724    if normals.len() == 2 {
1725        let along = normals[0].cross(normals[1]);
1726        let m = along.magnitude();
1727        if m <= tol.angular() {
1728            ogeom_bail!(Construction, "an edge between parallel faces has no corner");
1729        }
1730        let d = along / m;
1731        let row = [d.x, d.y, d.z];
1732        for i in 0..3 {
1733            for j in 0..3 {
1734                a[i][j] += row[i] * row[j];
1735            }
1736        }
1737    }
1738    let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
1739        - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
1740        + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
1741    if det.abs() <= tol.angular() * tol.angular() {
1742        ogeom_bail!(
1743            Construction,
1744            "a corner's faces are too nearly parallel to re-solve"
1745        );
1746    }
1747    let inv = |r: usize, c: usize| -> f64 {
1748        let (r1, r2) = ((r + 1) % 3, (r + 2) % 3);
1749        let (c1, c2) = ((c + 1) % 3, (c + 2) % 3);
1750        (a[c1][r1] * a[c2][r2] - a[c1][r2] * a[c2][r1]) / det
1751    };
1752    let mut x = [0.0_f64; 3];
1753    for (i, xi) in x.iter_mut().enumerate() {
1754        for (j, bj) in b.iter().enumerate() {
1755            *xi += inv(i, j) * bj;
1756        }
1757    }
1758    Ok(Vector::new(x[0], x[1], x[2]))
1759}
1760
1761/// The edges of `body` between two faces neither of which is `held`, that
1762/// are convex (`convex` true) or concave: where the walls, grown toward
1763/// that side, round about the old edge. An edge is convex where the face
1764/// across it falls away below its neighbour's outward normal.
1765fn growing_edges(
1766    model: &Model,
1767    body: &Shape,
1768    held: &[TShapeId],
1769    convex: bool,
1770    tol: Tolerances,
1771) -> OgeomResult<Vec<Shape>> {
1772    use ogeom_geom::{Curve3d as _, Surface as _};
1773    // Which faces hold each edge, gathered in one walk over the faces, in
1774    // the faces' order: asking per edge walked every face each time.
1775    let mut holders: std::collections::HashMap<TShapeId, Vec<(Shape, Shape)>> =
1776        std::collections::HashMap::new();
1777    for face in ogeom_topo::explore(model, body, ogeom_topo::Filter::OfType(ShapeType::Face))? {
1778        for e in ogeom_topo::explore(model, &face, ogeom_topo::Filter::OfType(ShapeType::Edge))? {
1779            holders.entry(e.node()).or_default().push((e, face.clone()));
1780        }
1781    }
1782    let mut out = Vec::new();
1783    for edge in ogeom_topo::explore_unique(model, body, ShapeType::Edge)? {
1784        let mut faces: Vec<Shape> = Vec::new();
1785        for (e, face) in holders.get(&edge.node()).map_or(&[][..], Vec::as_slice) {
1786            if e.is_same(&edge) && !faces.iter().any(|f| f.is_same(face)) {
1787                faces.push(face.clone());
1788            }
1789        }
1790        let mut distinct: Vec<Shape> = Vec::new();
1791        for f in faces {
1792            if !distinct.iter().any(|d| d.node() == f.node()) {
1793                distinct.push(f);
1794            }
1795        }
1796        let [f1, f2] = distinct.as_slice() else {
1797            continue;
1798        };
1799        if held.contains(&f1.node()) || held.contains(&f2.node()) {
1800            continue;
1801        }
1802        let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
1803            continue;
1804        };
1805        let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1806            continue;
1807        };
1808        let Some(geometry) = model.geometry().curve(*curve) else {
1809            continue;
1810        };
1811        let p = edge
1812            .transform(model.datums())?
1813            .apply(geometry.point_at(f64::midpoint(range.0, range.1), tol)?);
1814        // The first face's outward normal at the edge.
1815        let Some(NodeData::Face(face_data)) = model.node(f1).map(ogeom_topo::TShape::data) else {
1816            continue;
1817        };
1818        let Some(surface) = model.geometry().surface(face_data.surface) else {
1819            continue;
1820        };
1821        use ogeom_geom::Transformable as _;
1822        let placed = surface
1823            .clone()
1824            .transformed(&f1.transform(model.datums())?, tol)?;
1825        let (u, v) = ogeom_algo::project_on_surface(&placed, p, 16, tol)?.parameters;
1826        let mut n1 = placed.normal_at(u, v, tol)?.vector();
1827        if f1.orientation() == Orientation::Reversed {
1828            n1 = -n1;
1829        }
1830        // Which side of the first face's plane the other face lies: its
1831        // mesh point standing furthest off that plane says.
1832        let mesh = ogeom_mesh::triangulate_face(model, f2, ogeom_mesh::Deflection::default(), tol)?;
1833        let below = mesh
1834            .positions
1835            .iter()
1836            .map(|q| (*q - p).dot(n1))
1837            .max_by(|a, b| a.abs().total_cmp(&b.abs()))
1838            .unwrap_or(0.0);
1839        if (convex && below < -tol.confusion()) || (!convex && below > tol.confusion()) {
1840            out.push(edge);
1841        }
1842    }
1843    Ok(out)
1844}