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