Skip to main content

ogeom_offset/
shape.rs

1//! Offsetting a solid, and the shelling built on it.
2//!
3//! The topology-preserving offset: every face's surface moves along its own
4//! outward normal (a plane translates, a cylinder's radius grows or
5//! shrinks), and the topology is rebuilt one-for-one on the moved surfaces.
6//! Vertices re-solve where their planes now meet, edges re-derive on the
7//! moved supports with their directions and parameterizations preserved, and
8//! band faces rebuild through [`make_revolution_band`] so seams stay seams.
9//! Corners stay sharp: this is the parallel solid of the intersection join,
10//! not the rounded Minkowski body.
11//!
12//! Shelling is the offset pointed inward and the boolean pointed at the
13//! result: the cavity is the inward offset with the *removed* faces left
14//! exactly where they were, so it reaches the boundary at the openings,
15//! and the cut's same-domain resolution melts the flush faces away, which is
16//! what opens the shell.
17//!
18//! The honest limits, refused by name: faces whose surfaces are not among
19//! the five analytics (a spline, revolution, extrusion, trimmed or offset
20//! surface has no same-family parallel to move to, though a face something
21//! *replaces* (a draft's turned wall) rides through on the replacement and
22//! a face moved by nothing keeps its own surface whatever the family),
23//! vertices whose seats leave them under-determined, and offsets that
24//! collapse the solid. Edges between moved supports re-derive exactly where
25//! a line or circle exists; anywhere else the pair's own intersection is
26//! marched and fitted, with its stated slop widening the edge; an edge
27//! that sits unmoved on both supports rebuilds on its own curve.
28
29use crate::wire2d::Join;
30use ogeom_algo::{
31    Built, History, edge_vertices, make_edge, make_edge_between, make_face_with_pcurves,
32    make_revolution_band, make_solid, make_vertex, sew,
33};
34use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
35use ogeom_geom::Curve3d as _;
36use ogeom_geom::{Curve, CylinderSurface, LineCurve, PlaneSurface, SurfaceGeometry};
37use ogeom_math::{Cylinder, Frame, Plane, Point, Vector};
38use ogeom_topo::{
39    EdgeData, EdgeRepr, Filter, Model, NodeData, Orientation, Shape, ShapeType, TShapeId, explore,
40    explore_unique,
41};
42
43use std::collections::HashMap;
44
45/// The displacement constraint one face puts on a point of itself.
46type Displacement<'a> = dyn Fn(&Model, usize, Point) -> OgeomResult<Option<(Vector, f64)>> + 'a;
47
48/// Canonicalize a solid whose topology is *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: HashMap<TShapeId, Point> = HashMap::new();
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                std::collections::hash_map::Entry::Occupied(held) => {
77                    if held.get().distance(at) > tol.confusion() {
78                        instanced = true;
79                        break 'outer;
80                    }
81                }
82                std::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: HashMap<TShapeId, SurfaceGeometry> = HashMap::new();
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: &std::collections::HashSet<TShapeId>,
272    built: Built,
273    tol: Tolerances,
274) -> OgeomResult<Built> {
275    let faces = explore_unique(model, solid, ShapeType::Face)?;
276    let mut corners: std::collections::HashSet<TShapeId> = std::collections::HashSet::new();
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<std::collections::HashSet<TShapeId>> {
315    if faces.is_empty() {
316        ogeom_bail!(Construction, "no face was named to move");
317    }
318    let own: std::collections::HashSet<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: HashMap<TShapeId, usize> = HashMap::new();
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                    ogeom_geom::SphereSurface::new(ogeom_math::Sphere::centred(
748                        sphere.centre(),
749                        grown,
750                        tol,
751                    )?)
752                    .into()
753                }
754                SurfaceGeometry::Torus(t) => {
755                    let torus = t.torus();
756                    let grown = sign.mul_add(amount, torus.minor_radius());
757                    if grown <= tol.confusion() {
758                        ogeom_bail!(Construction, "the offset consumes the torus's tube");
759                    }
760                    ogeom_geom::TorusSurface::new(ogeom_math::Torus::new(
761                        torus.frame(),
762                        torus.major_radius(),
763                        grown,
764                        tol,
765                    )?)
766                    .into()
767                }
768                SurfaceGeometry::Cone(co) => {
769                    let cone = co.cone();
770                    // The parallel cone: same axis and half-angle, the reference
771                    // radius moved by the offset over the slant's cosine.
772                    let grown = (sign * amount / cone.half_angle().cos())
773                        .mul_add(1.0, cone.reference_radius());
774                    if grown <= tol.confusion() {
775                        ogeom_bail!(Construction, "the offset consumes the cone's throat");
776                    }
777                    let (_, (v0, v1)) = surface.domain();
778                    ogeom_geom::ConeSurface::new(
779                        ogeom_math::Cone::new(cone.frame(), grown, cone.half_angle(), tol)?,
780                        (v0 - grow, v1 + grow),
781                    )?
782                    .into()
783                }
784                _ => ogeom_bail!(
785                    Construction,
786                    "offsetting a face on this surface needs a construction \
787                     the rebuild does not yet speak; see docs/PARITY.md, offset.shell-thicken"
788                ),
789            }
790        };
791        // A band rebuilds wholesale only on a surface of revolution; a
792        // drafted wall on a fitted support is a band the wire path
793        // assembles, seam and all.
794        let fitted_support = matches!(moved, SurfaceGeometry::BSpline(_));
795        prepared.push(Prepared {
796            shape: face.clone(),
797            surface: moved,
798            amount,
799            sign,
800            // A band needs a ring with an angle to anchor its chart; a whole
801            // sphere, bounded by its two poles alone, is assembled wire by
802            // wire like any other seamed face.
803            rings: if has_seam && closed_rings.len() == 2 && !fitted_support && !both_poles {
804                Some([closed_rings[0].clone(), closed_rings[1].clone()])
805            } else {
806                None
807            },
808        });
809    }
810
811    // Which faces meet each edge, seams excluded by their double use.
812    let mut edge_faces: HashMap<TShapeId, Vec<usize>> = HashMap::new();
813    for (fi, face) in faces.iter().enumerate() {
814        for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
815            let entry = edge_faces.entry(e.node()).or_default();
816            if !entry.contains(&fi) {
817                entry.push(fi);
818            }
819        }
820    }
821
822    // The displacement constraint each face puts on a point of itself: the
823    // surface normal there, moved its amount along it. Exact, because a
824    // normal offset moves every point of a surface along its own normal.
825    let constraint = |model: &Model, fi: usize, at: Point| -> OgeomResult<Option<(Vector, f64)>> {
826        let face = &faces[fi];
827        let Some(node) = model.node(face) else {
828            ogeom_bail!(Dangling, "face is not in this model");
829        };
830        let NodeData::Face(data) = node.data() else {
831            ogeom_bail!(Construction, "face node holds no face data");
832        };
833        let Some(surface) = model.geometry().surface(data.surface) else {
834            ogeom_bail!(Dangling, "face refers to a surface not in this model");
835        };
836        let projection = ogeom_algo::project_on_surface(surface, at, 32, tol)?;
837        if projection.distance > tol.confusion() * 100.0 {
838            return Ok(None);
839        }
840        let (u, v) = projection.parameters;
841        let (du, dv) = surface.d1_at(u, v, tol)?;
842        let n = du.cross(dv);
843        let m = n.magnitude();
844        if m <= tol.confusion() {
845            return Ok(None);
846        }
847        let outward = n / m * prepared[fi].sign;
848        Ok(Some((outward, prepared[fi].amount)))
849    };
850
851    // New vertices: the linear constraint solve seeds a Newton polish onto
852    // the moved surfaces themselves; the tangent-plane answer is exact for
853    // planes and off by the surfaces' own curvature otherwise.
854    let mut new_vertices: HashMap<TShapeId, (Shape, Point)> = HashMap::new();
855    for vertex in explore_unique(model, solid, ShapeType::Vertex)? {
856        let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) else {
857            continue;
858        };
859        let at = vertex.transform(model.datums())?.apply(data.point);
860        let mut seats: Vec<usize> = Vec::new();
861        for (fi, face) in faces.iter().enumerate() {
862            for v in explore(model, face, Filter::OfType(ShapeType::Vertex))? {
863                if v.node() == vertex.node() && !seats.contains(&fi) {
864                    seats.push(fi);
865                }
866            }
867        }
868        if seats.is_empty() {
869            continue;
870        }
871        // Independent constraints only: tangent faces share their normal and
872        // must agree on the displacement, or the vertex tears.
873        let mut normals: Vec<Vector> = Vec::new();
874        let mut amounts: Vec<f64> = Vec::new();
875        let mut kept: Vec<usize> = Vec::new();
876        for fi in &seats {
877            let Some((n, w)) = constraint(model, *fi, at)? else {
878                continue;
879            };
880            if let Some(k) = normals
881                .iter()
882                .position(|m| m.cross(n).magnitude() <= tol.angular().max(1e-6))
883            {
884                if (amounts[k] - w).abs() > tol.confusion() {
885                    ogeom_bail!(
886                        Construction,
887                        "two tangent faces move a shared vertex by different \
888                         amounts; the offset tears it"
889                    );
890                }
891                continue;
892            }
893            normals.push(n);
894            amounts.push(w);
895            kept.push(*fi);
896        }
897        if normals.is_empty() {
898            // A cone's apex has no normal to offer (the projection there is
899            // degenerate), but the parallel cone knows exactly where its own
900            // apex went.
901            let mut apex: Option<Point> = None;
902            for fi in &seats {
903                let Some(node) = model.node(&faces[*fi]) else {
904                    continue;
905                };
906                let NodeData::Face(data) = node.data() else {
907                    continue;
908                };
909                let Some(SurfaceGeometry::Cone(old)) = model.geometry().surface(data.surface)
910                else {
911                    continue;
912                };
913                if old.cone().apex().distance(at) > tol.confusion() * 100.0 {
914                    continue;
915                }
916                if let SurfaceGeometry::Cone(moved_cone) = &prepared[*fi].surface {
917                    apex = Some(moved_cone.cone().apex());
918                    break;
919                }
920            }
921            // A sphere's pole has no normal to offer either, and a moved
922            // sphere keeps its chart (concentric under an offset, carried
923            // along under a rigid move): the pole is where the moved surface
924            // stands at the old one's parameters there.
925            if apex.is_none() {
926                for fi in &seats {
927                    let Some(NodeData::Face(data)) = model.node(&faces[*fi]).map(|n| n.data())
928                    else {
929                        continue;
930                    };
931                    let Some(old @ SurfaceGeometry::Sphere(_)) =
932                        model.geometry().surface(data.surface)
933                    else {
934                        continue;
935                    };
936                    if !matches!(prepared[*fi].surface, SurfaceGeometry::Sphere(_)) {
937                        continue;
938                    }
939                    let local = faces[*fi].transform(model.datums())?;
940                    let found =
941                        ogeom_algo::project_on_surface(old, local.inverse()?.apply(at), 32, tol)?;
942                    if found.distance > tol.confusion() * 100.0 {
943                        continue;
944                    }
945                    let (u, v) = found.parameters;
946                    apex = Some(local.apply(prepared[*fi].surface.point_at(u, v, tol)?));
947                    break;
948                }
949            }
950            let Some(moved) = apex else {
951                ogeom_bail!(
952                    Construction,
953                    "a vertex with no seat the rebuild can read cannot be \
954                     re-solved"
955                );
956            };
957            new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
958            continue;
959        }
960        if normals.len() == 1 {
961            // Every seat is tangent to the rest, and the dedup above made
962            // them agree on the amount. A normal offset moves each point of a
963            // surface along its own normal, so the shared normal is the exact
964            // answer: no corner to solve, nothing to polish.
965            let moved = at + normals[0] * amounts[0];
966            corner_met(&prepared, &kept, moved, tol)?;
967            new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
968            continue;
969        }
970        // A vertex where a seam ends has two seats, not three, and the
971        // third constraint is the seam itself: the vertex is where the moved
972        // support's seam column meets the other seat. Solved as that
973        // crossing where the seam has an iso-curve to offer; the nearest
974        // point two seats agree on is somewhere along their whole edge.
975        if kept.len() == 2
976            && let Some(moved) = seam_end(model, &faces, &prepared, &vertex, &kept, at, tol)?
977        {
978            new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
979            continue;
980        }
981        let mut moved = at + solve_corner(&normals, &amounts, tol)?;
982        // Newton onto the moved surfaces: residuals are the signed
983        // distances, gradients the normals, and the same least-squares
984        // machinery takes the step.
985        for _ in 0..8 {
986            let mut ns: Vec<Vector> = Vec::new();
987            let mut rs: Vec<f64> = Vec::new();
988            for fi in &kept {
989                let projection =
990                    ogeom_algo::project_on_surface(&prepared[*fi].surface, moved, 32, tol)?;
991                let (u, v) = projection.parameters;
992                let (du, dv) = prepared[*fi].surface.d1_at(u, v, tol)?;
993                let n = du.cross(dv);
994                let m = n.magnitude();
995                if m <= tol.confusion() {
996                    continue;
997                }
998                let n = n / m;
999                let foot = prepared[*fi].surface.point_at(u, v, tol)?;
1000                ns.push(n);
1001                rs.push((moved - foot).dot(n));
1002            }
1003            if ns.len() < 2 {
1004                break;
1005            }
1006            let worst = rs.iter().fold(0.0_f64, |a, r| a.max(r.abs()));
1007            if worst <= tol.confusion() * 0.1 {
1008                break;
1009            }
1010            let step: Vec<f64> = rs.iter().map(|r| -r).collect();
1011            moved += solve_corner(&ns, &step, tol)?;
1012        }
1013        corner_met(&prepared, &kept, moved, tol)?;
1014        new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
1015    }
1016
1017    // How many times each edge occurs across all faces; a seam is one face
1018    // using an edge twice, which face-deduplicated sides cannot see.
1019    let mut edge_uses: HashMap<TShapeId, usize> = HashMap::new();
1020    for face in &faces {
1021        for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
1022            *edge_uses.entry(e.node()).or_insert(0) += 1;
1023        }
1024    }
1025
1026    // New edges on the moved supports.
1027    let mut new_edges: HashMap<TShapeId, Shape> = HashMap::new();
1028    let mut history = History::new();
1029    for edge in explore_unique(model, solid, ShapeType::Edge)? {
1030        let sides = edge_faces.get(&edge.node()).cloned().unwrap_or_default();
1031        if sides.len() != 2 {
1032            if edge_uses.get(&edge.node()).copied().unwrap_or(0) >= 2 {
1033                // A seam. A band face rebuilds its own; a face assembled wire
1034                // by wire (a band a boolean split into arc rings) needs the
1035                // moved seam here: the same iso-column on the moved surface,
1036                // which chart preservation makes exact.
1037                if let [fi] = sides.as_slice()
1038                    && let Some(built) =
1039                        rebuilt_seam_edge(model, &edge, &prepared[*fi], &new_vertices, tol)?
1040                {
1041                    history.modify(&edge, built.clone());
1042                    new_edges.insert(edge.node(), built);
1043                }
1044                continue;
1045            }
1046            // A genuinely single-sided edge: the ring a boolean left
1047            // coincident with a neighbour's twin, or a cone's apex.
1048            let Some(built) =
1049                rebuilt_lone_edge(model, &edge, &sides, &constraint, &new_vertices, tol)?
1050            else {
1051                ogeom_bail!(
1052                    Construction,
1053                    "an edge with one face is neither a ring nor an apex; \
1054                     the offset cannot re-derive it"
1055                );
1056            };
1057            history.modify(&edge, built.clone());
1058            new_edges.insert(edge.node(), built);
1059            continue;
1060        }
1061        let (curve, range) = {
1062            let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
1063                ogeom_bail!(Construction, "edge node holds no edge data");
1064            };
1065            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1066                ogeom_bail!(Construction, "an edge has no curve to offset");
1067            };
1068            let Some(geometry) = model.geometry().curve(*curve) else {
1069                ogeom_bail!(Dangling, "curve is not in this model");
1070            };
1071            (geometry.clone(), *range)
1072        };
1073        let forward = if edge.orientation() == Orientation::Reversed {
1074            edge.reversed()
1075        } else {
1076            edge.clone()
1077        };
1078        let built = match &curve {
1079            Curve::Line(_) => {
1080                // A straight edge is the line through its own re-solved
1081                // ends. That is true whether the supports were translated
1082                // or turned (an offset leaves the direction alone and this
1083                // reproduces it, a draft does not and this follows it),
1084                // whereas a line anchored where the old one sat misses its
1085                // own vertices the moment either end moves sideways.
1086                let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1087                    ogeom_bail!(Construction, "a straight edge has no vertices");
1088                };
1089                let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1090                    new_vertices.get(&sv.node()).cloned(),
1091                    new_vertices.get(&ev.node()).cloned(),
1092                ) else {
1093                    ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1094                };
1095                if p_to.distance(p_from) <= tol.parametric() {
1096                    ogeom_bail!(Construction, "the offset collapses an edge");
1097                }
1098                let segment = LineCurve::segment(p_from, p_to, tol)?;
1099                let (t0, t1) = segment.domain();
1100                let moved: Curve = segment.into();
1101                make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
1102            }
1103            Curve::Circle(c)
1104                if !matches!(prepared[sides[0]].surface, SurfaceGeometry::BSpline(_))
1105                    && !matches!(prepared[sides[1]].surface, SurfaceGeometry::BSpline(_)) =>
1106            {
1107                // The moved pair's own analytic intersection, taken in the
1108                // circle's old frame so parameters and orientations carry.
1109                // Between analytic supports a circle stays a circle; against
1110                // a fitted support it is whatever the march finds, below.
1111                let circle = c.circle();
1112                let found = ogeom_intersect::intersect_surfaces(
1113                    &prepared[sides[0]].surface,
1114                    &prepared[sides[1]].surface,
1115                    ogeom_intersect::IntersectOptions::default(),
1116                    tol,
1117                )?;
1118                let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
1119                    ogeom_bail!(
1120                        Construction,
1121                        "the moved faces no longer meet along the edge they \
1122                         shared; the offset collapses it"
1123                    );
1124                };
1125                let mut best: Option<(ogeom_math::Circle, f64)> = None;
1126                for section in &candidates {
1127                    let Curve::Circle(cc) = &section.curve else {
1128                        continue;
1129                    };
1130                    let candidate = cc.circle();
1131                    let score = candidate.centre().distance(circle.centre())
1132                        + (candidate.radius() - circle.radius()).abs();
1133                    if best.as_ref().is_none_or(|(_, held)| score < *held) {
1134                        best = Some((candidate, score));
1135                    }
1136                }
1137                let Some((candidate, _)) = best else {
1138                    ogeom_bail!(
1139                        Construction,
1140                        "the moved faces meet along nothing circular where a \
1141                         circle was; the offset needs the general rebuild"
1142                    );
1143                };
1144                let reframed = ogeom_math::Circle::new(
1145                    Frame::new(
1146                        candidate.centre(),
1147                        circle.frame().z(),
1148                        circle.frame().x(),
1149                        tol,
1150                    )?,
1151                    candidate.radius(),
1152                    tol,
1153                )?;
1154                let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
1155                let closed = {
1156                    let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1157                        ogeom_bail!(Construction, "a ring has no vertex");
1158                    };
1159                    sv.node() == ev.node()
1160                };
1161                if closed {
1162                    make_edge(model, moved, range, tol)?.shape
1163                } else {
1164                    let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1165                        ogeom_bail!(Construction, "an arc has no vertices");
1166                    };
1167                    let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1168                        new_vertices.get(&sv.node()).cloned(),
1169                        new_vertices.get(&ev.node()).cloned(),
1170                    ) else {
1171                        ogeom_bail!(Construction, "an arc end has no re-solved vertex");
1172                    };
1173                    let angle_of = |p: Point| {
1174                        let l = reframed.frame().to_local(p);
1175                        l.y.atan2(l.x)
1176                    };
1177                    let tau = core::f64::consts::TAU;
1178                    let mut t0 = angle_of(p_from);
1179                    let mut t1 = angle_of(p_to);
1180                    // Keep the new range in the old one's winding and span.
1181                    while t0 < range.0 - core::f64::consts::PI {
1182                        t0 += tau;
1183                    }
1184                    while t0 > range.0 + core::f64::consts::PI {
1185                        t0 -= tau;
1186                    }
1187                    while t1 <= t0 + tol.parametric() {
1188                        t1 += tau;
1189                    }
1190                    if (t1 - t0) - (range.1 - range.0) > core::f64::consts::PI {
1191                        t1 -= tau;
1192                    }
1193                    if t1 <= t0 + tol.parametric() {
1194                        ogeom_bail!(Construction, "the offset collapses an arc");
1195                    }
1196                    make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
1197                }
1198            }
1199            _ => {
1200                // The general edge. First the still question: a hinge edge
1201                // (a draft's neutral crossing) sits on both moved supports
1202                // exactly where it always was, and an edge that did not move
1203                // rebuilds on its own curve rather than on a march of it.
1204                let unmoved = {
1205                    let mut worst = 0.0_f64;
1206                    'probe: for i in 0..9 {
1207                        #[allow(clippy::cast_precision_loss)]
1208                        let t = range.0 + (range.1 - range.0) * (i as f64) / 8.0;
1209                        let p = curve.point_at(t, tol)?;
1210                        for side in [sides[0], sides[1]] {
1211                            let Ok(near) =
1212                                ogeom_algo::project_on_surface(&prepared[side].surface, p, 17, tol)
1213                            else {
1214                                worst = f64::INFINITY;
1215                                break 'probe;
1216                            };
1217                            worst = worst.max(near.distance);
1218                        }
1219                    }
1220                    // Within the moved supports' own stated accuracy: a
1221                    // fitted support holds its points only to the fit
1222                    // target, and the hinge is exactly on it by less.
1223                    (worst <= (tol.confusion() * 1e3).max(1e-4)).then_some(worst)
1224                };
1225                if let Some(worst) = unmoved {
1226                    let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1227                        ogeom_bail!(Construction, "an edge has no vertices");
1228                    };
1229                    let closed = sv.node() == ev.node();
1230                    let built = if closed {
1231                        // On the vertex the rest of the rebuild uses (a
1232                        // seam starts from it), not one of the curve's own.
1233                        match new_vertices.get(&sv.node()).cloned() {
1234                            Some((v_at, p_at)) => {
1235                                let gap = curve.point_at(range.0, tol)?.distance(p_at);
1236                                if gap > tol.confusion() {
1237                                    model.widen(&v_at, ogeom_core::Tolerance::new(gap * 2.0)?)?;
1238                                }
1239                                make_edge_between(model, curve.clone(), range, &v_at, &v_at, tol)?
1240                                    .shape
1241                            }
1242                            None => make_edge(model, curve.clone(), range, tol)?.shape,
1243                        }
1244                    } else {
1245                        let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1246                            new_vertices.get(&sv.node()).cloned(),
1247                            new_vertices.get(&ev.node()).cloned(),
1248                        ) else {
1249                            ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1250                        };
1251                        // The ends re-solved against a fitted support land a
1252                        // fit's breadth from the curve that did not move; the
1253                        // vertices own that breadth.
1254                        let gap = curve
1255                            .point_at(range.0, tol)?
1256                            .distance(p_from)
1257                            .min(curve.point_at(range.0, tol)?.distance(p_to))
1258                            .max(
1259                                curve
1260                                    .point_at(range.1, tol)?
1261                                    .distance(p_to)
1262                                    .min(curve.point_at(range.1, tol)?.distance(p_from)),
1263                            );
1264                        if gap > tol.confusion() {
1265                            for v in [&v_from, &v_to] {
1266                                model.widen(v, ogeom_core::Tolerance::new(gap * 2.0)?)?;
1267                            }
1268                        }
1269                        make_edge_between(model, curve.clone(), range, &v_from, &v_to, tol)?.shape
1270                    };
1271                    if worst > tol.confusion() {
1272                        model.widen(&built, ogeom_core::Tolerance::new(worst)?)?;
1273                    }
1274                    history.modify(&edge, built.clone());
1275                    new_edges.insert(edge.node(), built);
1276                    continue;
1277                }
1278                // Otherwise the moved pair's own intersection, marched where
1279                // no closed form exists (a drafted spline wall re-meeting
1280                // its cap plane), with the candidate nearest the old edge
1281                // kept and trimmed between the re-solved ends. The section's
1282                // stated slop widens the edge; nothing pretends the fit is
1283                // exact.
1284                let mid = curve.point_at(f64::midpoint(range.0, range.1), tol)?;
1285                let found = ogeom_intersect::intersect_surfaces(
1286                    &prepared[sides[0]].surface,
1287                    &prepared[sides[1]].surface,
1288                    ogeom_intersect::IntersectOptions::default(),
1289                    tol,
1290                )?;
1291                let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
1292                    ogeom_bail!(
1293                        Construction,
1294                        "the moved faces no longer meet along the edge they \
1295                         shared; the offset collapses it"
1296                    );
1297                };
1298                let mut best: Option<(Curve, f64, f64)> = None;
1299                for section in candidates {
1300                    let Ok(projected) = ogeom_algo::project_on_curve(&section.curve, mid, 64, tol)
1301                    else {
1302                        continue;
1303                    };
1304                    if best
1305                        .as_ref()
1306                        .is_none_or(|(_, _, held)| projected.distance < *held)
1307                    {
1308                        best = Some((section.curve, section.tolerance, projected.distance));
1309                    }
1310                }
1311                let Some((moved, slop, _)) = best else {
1312                    ogeom_bail!(
1313                        Construction,
1314                        "the moved faces meet along nothing where the edge \
1315                         was; the offset collapses it"
1316                    );
1317                };
1318                let closed = {
1319                    let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1320                        ogeom_bail!(Construction, "an edge has no vertices");
1321                    };
1322                    sv.node() == ev.node()
1323                };
1324                let built = if closed {
1325                    // A ring's one vertex is a corner the neighbours' seams
1326                    // start from, re-solved like any other: the marched
1327                    // section is re-seamed to begin there, so the ring and
1328                    // the seam meet at one vertex rather than at two a
1329                    // section's start apart.
1330                    let Some((sv, _)) = edge_vertices(model, &forward)? else {
1331                        ogeom_bail!(Construction, "a ring has no vertex");
1332                    };
1333                    match (new_vertices.get(&sv.node()).cloned(), &moved) {
1334                        (Some((v_at, p_at)), Curve::BSpline(spline)) => {
1335                            let t = ogeom_algo::project_on_curve(&moved, p_at, 64, tol)?;
1336                            let (lo, hi) = moved.domain();
1337                            let seamed: Curve = if t.parameter > lo + tol.parametric()
1338                                && t.parameter < hi - tol.parametric()
1339                            {
1340                                Curve::BSpline(spline.reseamed_at(t.parameter, tol)?)
1341                            } else {
1342                                moved.clone()
1343                            };
1344                            // Run the way the old ring ran: the wire uses the
1345                            // rebuilt edge with the old orientation, and a
1346                            // march has no opinion about direction.
1347                            let seamed = {
1348                                use ogeom_geom::Reversible as _;
1349                                let (a, _) = seamed.domain();
1350                                let old = curve.d1_at(range.0, tol)?;
1351                                if seamed.d1_at(a, tol)?.dot(old) < 0.0 {
1352                                    seamed.reversed()
1353                                } else {
1354                                    seamed
1355                                }
1356                            };
1357                            let miss = t.distance.max(slop);
1358                            if miss > tol.confusion() {
1359                                model.widen(&v_at, ogeom_core::Tolerance::new(miss * 2.0)?)?;
1360                            }
1361                            let window = seamed.domain();
1362                            make_edge_between(model, seamed, window, &v_at, &v_at, tol)?.shape
1363                        }
1364                        _ => {
1365                            let window = moved.domain();
1366                            make_edge(model, moved, window, tol)?.shape
1367                        }
1368                    }
1369                } else {
1370                    let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1371                        ogeom_bail!(Construction, "an edge has no vertices");
1372                    };
1373                    let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1374                        new_vertices.get(&sv.node()).cloned(),
1375                        new_vertices.get(&ev.node()).cloned(),
1376                    ) else {
1377                        ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1378                    };
1379                    // The fitted section lands within its stated slop of the
1380                    // re-solved ends; the vertices own that slop.
1381                    if slop > tol.confusion() {
1382                        for v in [&v_from, &v_to] {
1383                            model.widen(v, ogeom_core::Tolerance::new(slop * 2.0)?)?;
1384                        }
1385                    }
1386                    let ta = ogeom_algo::project_on_curve(&moved, p_from, 64, tol)?.parameter;
1387                    let tb = ogeom_algo::project_on_curve(&moved, p_to, 64, tol)?.parameter;
1388                    if (tb - ta).abs() <= tol.parametric() {
1389                        ogeom_bail!(Construction, "the offset collapses an edge");
1390                    }
1391                    // The ends run with the curve or against it; a run
1392                    // against builds on the reversed parameterization so
1393                    // the edge still leaves `v_from` first.
1394                    let (moved, ta, tb) = if ta <= tb {
1395                        (moved, ta, tb)
1396                    } else {
1397                        use ogeom_geom::Reversible as _;
1398                        let (lo, hi) = moved.domain();
1399                        (moved.reversed(), lo + hi - ta, lo + hi - tb)
1400                    };
1401                    make_edge_between(model, moved, (ta, tb), &v_from, &v_to, tol)?.shape
1402                };
1403                if slop > tol.confusion() {
1404                    model.widen(&built, ogeom_core::Tolerance::new(slop)?)?;
1405                }
1406                built
1407            }
1408        };
1409        history.modify(&edge, built.clone());
1410        new_edges.insert(edge.node(), built);
1411    }
1412
1413    // Faces: bands wholesale, everything else wire by wire with exact
1414    // pcurves on the moved surface.
1415    let mut rebuilt_faces: Vec<Shape> = Vec::with_capacity(prepared.len());
1416    for prep in &prepared {
1417        let built = if let Some(rings) = &prep.rings {
1418            let (Some(lo), Some(hi)) = (
1419                new_edges.get(&rings[0].node()),
1420                new_edges.get(&rings[1].node()),
1421            ) else {
1422                ogeom_bail!(Construction, "a band's ring was not rebuilt");
1423            };
1424            let band = make_revolution_band(model, &prep.surface, lo, hi, tol)?;
1425            if prep.shape.orientation() == Orientation::Reversed {
1426                band.reversed()
1427            } else {
1428                band
1429            }
1430        } else {
1431            let mut wires: Vec<Vec<Shape>> = Vec::new();
1432            let mut face_uses: HashMap<TShapeId, usize> = HashMap::new();
1433            // The wires as the face stores them: the rebuilt face takes the
1434            // old one's sense below.
1435            let stored = prep.shape.oriented(Orientation::Forward);
1436            for wire in explore(model, &stored, Filter::OfType(ShapeType::Wire))? {
1437                let mut edges: Vec<Shape> = Vec::new();
1438                // The wire's own order, not the walker's: a rebuilt wire is
1439                // re-chained edge to edge, and the walk order is not a chain.
1440                for used in model.ordered_children_of(&wire)? {
1441                    *face_uses.entry(used.node()).or_insert(0) += 1;
1442                    let Some(fresh) = new_edges.get(&used.node()) else {
1443                        ogeom_bail!(Construction, "a face edge was not rebuilt");
1444                    };
1445                    edges.push(if used.orientation() == Orientation::Reversed {
1446                        fresh.reversed()
1447                    } else {
1448                        fresh.clone()
1449                    });
1450                }
1451                wires.push(edges);
1452            }
1453            let face = if face_uses.values().any(|c| *c >= 2) {
1454                // A seam in a wire-assembled face: a band a boolean split
1455                // into arc rings. Every ordinary pcurve is recomputed on the
1456                // moved surface; the seam's columns carry over, which the
1457                // seam rebuild already validated against the re-solved ends.
1458                assembled_with_seam(model, prep, &wires, &new_edges, tol)?
1459            } else {
1460                make_face_with_pcurves(model, prep.surface.clone(), &wires, tol)?.shape
1461            };
1462            if prep.shape.orientation() == Orientation::Reversed {
1463                face.reversed()
1464            } else {
1465                face
1466            }
1467        };
1468        history.modify(&prep.shape, built.clone());
1469        rebuilt_faces.push(built);
1470    }
1471
1472    let sewn = sew(model, &rebuilt_faces, tol)?;
1473    if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
1474        ogeom_bail!(Construction, "the offset solid did not close");
1475    }
1476    // The faces carried their use-orientations through; the *shell* has one
1477    // too, and a solid whose outer shell was used reversed reads inside out
1478    // if the rebuilt shell forgets it.
1479    let outer = {
1480        let old_reversed = model
1481            .children_of(solid)?
1482            .first()
1483            .is_some_and(|s| s.orientation() == Orientation::Reversed);
1484        if old_reversed {
1485            sewn.shells[0].reversed()
1486        } else {
1487            sewn.shells[0].clone()
1488        }
1489    };
1490    let built = make_solid(model, std::slice::from_ref(&outer))?;
1491
1492    // The one global guard the local checks cannot give: an offset that
1493    // moved faces past each other builds a shell that is closed and inside
1494    // out. Its measured volume is the tell.
1495    // The guard meshes at the default deflection, and a thin tangential
1496    // cusp (a small blend meeting its face) can defeat that resolution
1497    // without anything being wrong. One finer retry separates a mesh that
1498    // cannot see the cusp from a solid that is genuinely inside out.
1499    // The finer retry is a fraction of the part's own size, not a fixed
1500    // length: a metre-sized part meshed at a tenth of a micron is millions
1501    // of triangles for a sign.
1502    let size = ogeom_algo::shape_bounds(model, &built.shape, tol)?.diagonal();
1503    let fine = (size * 1e-5).clamp(
1504        tol.confusion() * 1e2,
1505        ogeom_mesh::Deflection::default().chord,
1506    );
1507    let mut mass = None;
1508    let mut first_error = None;
1509    for chord in [ogeom_mesh::Deflection::default().chord, fine] {
1510        let deflection = ogeom_mesh::Deflection {
1511            chord,
1512            ..ogeom_mesh::Deflection::default()
1513        };
1514        match ogeom_algo::volume_properties(model, &built.shape, deflection, tol) {
1515            Ok(props) => {
1516                mass = Some(props.mass);
1517                break;
1518            }
1519            Err(e @ (ogeom_core::OgeomError::Cancelled | ogeom_core::OgeomError::Dangling(_))) => {
1520                return Err(e);
1521            }
1522            Err(e) => {
1523                first_error.get_or_insert(e);
1524            }
1525        }
1526    }
1527    let Some(mass) = mass else {
1528        ogeom_bail!(
1529            Construction,
1530            "the offset solid's mesh does not close at any tried resolution{}",
1531            first_error.map_or_else(String::new, |e| format!(": {e}"))
1532        );
1533    };
1534    if !mass.is_finite() || mass <= tol.confusion() {
1535        ogeom_bail!(Construction, "the offset collapses the solid");
1536    }
1537
1538    history.modify(solid, built.shape.clone());
1539    Ok(Built::new(built.shape, history))
1540}
1541
1542/// Rebuild a seam for a face assembled wire by wire: the same iso-column on
1543/// the moved surface, over the same rows.
1544///
1545/// A same-family move preserves the chart (every point travels along its
1546/// own normal without changing its parameters), so the moved seam sits at
1547/// the column the old one's own pcurves state, between the re-solved end
1548/// vertices. `None` when the old edge carries no seam representation on this
1549/// face's surface.
1550fn rebuilt_seam_edge(
1551    model: &mut Model,
1552    edge: &Shape,
1553    prep: &Prepared,
1554    new_vertices: &HashMap<TShapeId, (Shape, Point)>,
1555    tol: Tolerances,
1556) -> OgeomResult<Option<Shape>> {
1557    use ogeom_geom::Curve2d as _;
1558
1559    let old_surface = {
1560        let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1561        else {
1562            ogeom_bail!(Construction, "face node holds no face data");
1563        };
1564        data.surface
1565    };
1566    let found = {
1567        let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1568            ogeom_bail!(Construction, "edge node holds no edge data");
1569        };
1570        let mut found = None;
1571        for repr in &data.representations {
1572            if let EdgeRepr::Seam {
1573                forward,
1574                surface,
1575                range,
1576                ..
1577            } = repr
1578                && *surface == old_surface
1579            {
1580                let Some(pcurve) = model.geometry().pcurve(*forward) else {
1581                    ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1582                };
1583                // The pcurve states the column the seam sits at. The rows
1584                // cannot come from it: the seam's ends are corner vertices,
1585                // moved by the corner solve rather than by this face alone.
1586                found = Some(pcurve.point_at(range.0, tol)?.x);
1587                break;
1588            }
1589        }
1590        found
1591    };
1592    let Some(column) = found else {
1593        return Ok(None);
1594    };
1595    // Rebuilt edges are kept in their node's own direction, each wire
1596    // turning its use of them as it did the old one's, so the ends are read
1597    // off the node's forward occurrence, however the seam was reached.
1598    let forward = if edge.orientation() == Orientation::Reversed {
1599        edge.reversed()
1600    } else {
1601        edge.clone()
1602    };
1603    let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1604        ogeom_bail!(Construction, "a seam has no vertices");
1605    };
1606    let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1607        new_vertices.get(&sv.node()).cloned(),
1608        new_vertices.get(&ev.node()).cloned(),
1609    ) else {
1610        ogeom_bail!(Construction, "a seam end has no re-solved vertex");
1611    };
1612    let Some(curve) = ogeom_algo::surface_iso_u_curve(&prep.surface, column, tol) else {
1613        ogeom_bail!(
1614            Construction,
1615            "the moved surface's iso-curve has no closed form; no seam can \
1616             be rebuilt"
1617        );
1618    };
1619    // The parameters the re-solved ends land at, by the iso-curve's own
1620    // closed form; the ends were Newton-polished onto this very surface, so
1621    // they lie on the curve exactly.
1622    let along = |p: Point| -> OgeomResult<f64> {
1623        match &curve {
1624            Curve::Line(l) => Ok((p - l.axis().location).dot(l.axis().direction.vector())),
1625            Curve::Circle(c) => {
1626                let local = c.circle().frame().to_local(p);
1627                let mut angle = local.y.atan2(local.x);
1628                if angle < 0.0 {
1629                    angle += core::f64::consts::TAU;
1630                }
1631                Ok(angle)
1632            }
1633            // A fitted support's iso-curve is a B-spline: the parameter is
1634            // found by projection, and the check below says whether the end
1635            // lies on it.
1636            _ => Ok(ogeom_algo::project_on_curve(&curve, p, 64, tol)?.parameter),
1637        }
1638    };
1639    let (t_start, t_end) = (along(p_from)?, along(p_to)?);
1640    // Self-validation instead of trusting the move: a turned support only
1641    // keeps its column when the turn was built to; the re-solved ends say
1642    // whether it was.
1643    // A fitted support holds its column only to the fit's target, and the
1644    // ends were polished onto the surface, not the column: the slack is the
1645    // fit's, on a fitted support, and a hundred confusions elsewhere.
1646    // A drafted support is two fits, the hinge's and its rulings', and at
1647    // the far end of a ruling their errors add; a few targets' worth is
1648    // the support's own honesty, not a wrong column.
1649    let slack = if matches!(prep.surface, SurfaceGeometry::BSpline(_)) {
1650        (tol.confusion() * 1e3).max(1e-4) * 4.0
1651    } else {
1652        tol.confusion() * 100.0
1653    };
1654    for (t, p, v) in [(t_start, p_from, &v_from), (t_end, p_to, &v_to)] {
1655        let off = curve.point_at(t, tol)?.distance(p);
1656        if off > slack {
1657            return Ok(None);
1658        }
1659        // The end sits on the column to the fit's breadth, and the vertex
1660        // owns that breadth.
1661        if off > tol.confusion() {
1662            model.widen(v, ogeom_core::Tolerance::new(off * 2.0)?)?;
1663        }
1664    }
1665    Ok(Some(if t_start <= t_end {
1666        make_edge_between(model, curve, (t_start, t_end), &v_from, &v_to, tol)?.shape
1667    } else {
1668        // The old seam ran against the iso-curve's own direction: build it
1669        // the way the curve runs, then hand back the reversed occurrence so
1670        // the wire's stored orientations still compose.
1671        make_edge_between(model, curve, (t_end, t_start), &v_to, &v_from, tol)?
1672            .shape
1673            .reversed()
1674    }))
1675}
1676
1677/// Assemble a moved face whose wires contain a seam.
1678///
1679/// Every ordinary edge gets its exact pcurve recomputed on the moved
1680/// surface. The seam is the one edge no closed-form projection can answer
1681/// (it needs a column per side), so its columns carry over from the old
1682/// face's own seam representation (a same-family move leaves the columns
1683/// where they were), rebuilt over the rows the moved seam actually spans.
1684fn assembled_with_seam(
1685    model: &mut Model,
1686    prep: &Prepared,
1687    wires: &[Vec<Shape>],
1688    new_edges: &HashMap<TShapeId, Shape>,
1689    tol: Tolerances,
1690) -> OgeomResult<Shape> {
1691    let mut rings: Vec<Shape> = Vec::with_capacity(wires.len());
1692    for edges in wires {
1693        rings.push(ogeom_algo::make_wire(model, edges, tol)?.shape);
1694    }
1695    let face = ogeom_algo::make_face(model, prep.surface.clone(), &rings, tol)?.shape;
1696    let new_surface = {
1697        let Some(NodeData::Face(data)) = model.node(&face).map(ogeom_topo::TShape::data) else {
1698            ogeom_bail!(Construction, "the face just built holds no face data");
1699        };
1700        data.surface
1701    };
1702    let old_surface = {
1703        let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1704        else {
1705            ogeom_bail!(Construction, "face node holds no face data");
1706        };
1707        data.surface
1708    };
1709
1710    let mut done: Vec<TShapeId> = Vec::new();
1711    for used in explore(model, &prep.shape, Filter::OfType(ShapeType::Edge))? {
1712        if done.contains(&used.node()) {
1713            continue;
1714        }
1715        done.push(used.node());
1716        let Some(fresh) = new_edges.get(&used.node()).cloned() else {
1717            ogeom_bail!(Construction, "a face edge was not rebuilt");
1718        };
1719        // A pole has no curve, only its row, and the moved surface keeps
1720        // its chart: the old row carries over as it is.
1721        if model
1722            .node(&fresh)
1723            .and_then(|n| n.data().as_edge())
1724            .is_some_and(|d| d.degenerate)
1725        {
1726            let Some(EdgeRepr::PCurve { curve, range, .. }) = model
1727                .node(&used)
1728                .and_then(|n| n.data().as_edge())
1729                .and_then(|d| d.pcurve_for(old_surface, used.location()))
1730                .cloned()
1731            else {
1732                ogeom_bail!(Construction, "a pole has no row on its face");
1733            };
1734            let Some(row) = model.geometry().pcurve(curve).cloned() else {
1735                ogeom_bail!(Dangling, "a pole's row is not in this model");
1736            };
1737            ogeom_algo::attach_pcurve(
1738                model,
1739                &fresh,
1740                row,
1741                new_surface,
1742                ogeom_topo::Location::identity(),
1743                range,
1744            )?;
1745            continue;
1746        }
1747        let (fresh_curve, fresh_range) = {
1748            let Some(data) = model.node(&fresh).and_then(|n| n.data().as_edge()) else {
1749                ogeom_bail!(Construction, "a rebuilt edge holds no edge data");
1750            };
1751            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1752                ogeom_bail!(Construction, "a rebuilt edge has no curve");
1753            };
1754            let Some(geometry) = model.geometry().curve(*curve) else {
1755                ogeom_bail!(Dangling, "curve is not in this model");
1756            };
1757            (geometry.clone(), *range)
1758        };
1759        let columns = {
1760            let Some(data) = model.node(&used).and_then(|n| n.data().as_edge()) else {
1761                ogeom_bail!(Construction, "edge node holds no edge data");
1762            };
1763            let mut columns = None;
1764            for repr in &data.representations {
1765                if let EdgeRepr::Seam {
1766                    forward,
1767                    reversed,
1768                    surface,
1769                    range,
1770                    ..
1771                } = repr
1772                    && *surface == old_surface
1773                {
1774                    use ogeom_geom::Curve2d as _;
1775                    let (Some(f), Some(r)) = (
1776                        model.geometry().pcurve(*forward),
1777                        model.geometry().pcurve(*reversed),
1778                    ) else {
1779                        ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1780                    };
1781                    columns = Some((f.point_at(range.0, tol)?.x, r.point_at(range.0, tol)?.x));
1782                    break;
1783                }
1784            }
1785            columns
1786        };
1787        if let Some((forward_col, reversed_col)) = columns {
1788            // The rows the moved seam spans, from its own rebuilt range,
1789            // identical to the curve range except a cone's slant rescale.
1790            let rows = match &prep.surface {
1791                SurfaceGeometry::Cone(c) => {
1792                    let cos = c.cone().half_angle().cos();
1793                    (fresh_range.0 * cos, fresh_range.1 * cos)
1794                }
1795                _ => fresh_range,
1796            };
1797            let column = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1798                Ok(ogeom_geom::Line2d::over(
1799                    ogeom_math::Axis2::new(
1800                        ogeom_math::Point2::new(u, 0.0),
1801                        ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, 1.0), tol)?,
1802                    ),
1803                    rows.0 - 1.0,
1804                    rows.1 + 1.0,
1805                )?
1806                .into())
1807            };
1808            ogeom_algo::attach_seam(
1809                model,
1810                &fresh,
1811                column(forward_col)?,
1812                column(reversed_col)?,
1813                new_surface,
1814                ogeom_topo::Location::identity(),
1815                rows,
1816            )?;
1817        } else {
1818            // A closed form where one exists; on a fitted support, the
1819            // projected fit the face builder trusts, the measured offset
1820            // widening the edge.
1821            let pcurve = match ogeom_intersect::exact_pcurve_over(
1822                &fresh_curve,
1823                fresh_range,
1824                &prep.surface,
1825                tol,
1826            ) {
1827                Some(exact) => exact,
1828                None => {
1829                    let (fitted, _, _, worst_off, _) =
1830                        ogeom_algo::pcurve_fit::fit_projected_pcurve(
1831                            &fresh_curve,
1832                            fresh_range,
1833                            &prep.surface,
1834                            tol,
1835                        )?;
1836                    if worst_off > tol.confusion() {
1837                        // The edge owns the offset, and so must the vertices
1838                        // that bound it: a bound no looser than what it
1839                        // bounds is the containment rule.
1840                        let widened = ogeom_core::Tolerance::new(worst_off + tol.confusion())?;
1841                        model.widen(&fresh, widened)?;
1842                        if let Some((a, b)) = edge_vertices(model, &fresh)? {
1843                            model.widen(&a, widened)?;
1844                            model.widen(&b, widened)?;
1845                        }
1846                    }
1847                    fitted
1848                }
1849            };
1850            ogeom_algo::attach_pcurve(
1851                model,
1852                &fresh,
1853                pcurve,
1854                new_surface,
1855                ogeom_topo::Location::identity(),
1856                fresh_range,
1857            )?;
1858        }
1859    }
1860    Ok(face)
1861}
1862
1863/// Where a seam ending at `vertex` meets the other seat, on the moved
1864/// supports: the seam's column as an iso-curve on its face's moved surface,
1865/// pierced through the other face's; `None` where no seam ends here or the
1866/// column has no curve.
1867fn seam_end(
1868    model: &Model,
1869    faces: &[Shape],
1870    prepared: &[Prepared],
1871    vertex: &Shape,
1872    kept: &[usize],
1873    at: Point,
1874    tol: Tolerances,
1875) -> OgeomResult<Option<Point>> {
1876    use ogeom_geom::Curve2d as _;
1877    for (slot, &fi) in kept.iter().enumerate() {
1878        let other = kept[1 - slot];
1879        let face = &faces[fi];
1880        let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
1881            continue;
1882        };
1883        let old_surface = data.surface;
1884        let mut uses: HashMap<TShapeId, usize> = HashMap::new();
1885        for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
1886            *uses.entry(e.node()).or_insert(0) += 1;
1887        }
1888        for e in explore_unique(model, face, ShapeType::Edge)? {
1889            if uses.get(&e.node()).copied().unwrap_or(0) < 2 {
1890                continue;
1891            }
1892            let Some((a, b)) = edge_vertices(model, &e)? else {
1893                continue;
1894            };
1895            if a.node() != vertex.node() && b.node() != vertex.node() {
1896                continue;
1897            }
1898            let Some(edata) = model.node(&e).and_then(|n| n.data().as_edge()) else {
1899                continue;
1900            };
1901            let mut column = None;
1902            for repr in &edata.representations {
1903                if let EdgeRepr::Seam {
1904                    forward,
1905                    surface,
1906                    range,
1907                    ..
1908                } = repr
1909                    && *surface == old_surface
1910                    && let Some(pcurve) = model.geometry().pcurve(*forward)
1911                {
1912                    column = Some(pcurve.point_at(range.0, tol)?.x);
1913                    break;
1914                }
1915            }
1916            let Some(column) = column else {
1917                continue;
1918            };
1919            let Some(iso) = ogeom_algo::surface_iso_u_curve(&prepared[fi].surface, column, tol)
1920            else {
1921                continue;
1922            };
1923            let found = ogeom_intersect::intersect_curve_surface(
1924                &iso,
1925                &prepared[other].surface,
1926                ogeom_intersect::CurveSurfaceOptions::default(),
1927                tol,
1928            )?;
1929            let nearest = found
1930                .crossings
1931                .iter()
1932                .map(|hit| hit.point)
1933                .min_by(|p, q| p.distance(at).total_cmp(&q.distance(at)));
1934            if let Some(p) = nearest {
1935                return Ok(Some(p));
1936            }
1937        }
1938    }
1939    Ok(None)
1940}
1941
1942/// Rebuild an edge only one face owns: the ring a boolean left coincident
1943/// with a neighbour's twin, or a cone's apex.
1944///
1945/// A normal offset moves every point of a face along the face's own normal,
1946/// so three displaced samples of a ring pin the moved ring exactly; no
1947/// second face required. `None` when the edge is neither shape.
1948fn rebuilt_lone_edge(
1949    model: &mut Model,
1950    edge: &Shape,
1951    sides: &[usize],
1952    constraint: &Displacement<'_>,
1953    new_vertices: &HashMap<TShapeId, (Shape, Point)>,
1954    tol: Tolerances,
1955) -> OgeomResult<Option<Shape>> {
1956    use ogeom_geom::Curve3d as _;
1957
1958    let (degenerate, curve) = {
1959        let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1960            ogeom_bail!(Construction, "edge node holds no edge data");
1961        };
1962        let curve = data.curve3d().and_then(|repr| {
1963            let EdgeRepr::Curve3d { curve, range, .. } = repr else {
1964                return None;
1965            };
1966            model.geometry().curve(*curve).cloned().map(|c| (c, *range))
1967        });
1968        (data.degenerate, curve)
1969    };
1970    let Some((start, end)) = edge_vertices(model, edge)? else {
1971        ogeom_bail!(Construction, "a lone edge has no vertices");
1972    };
1973    if degenerate {
1974        // An apex: a rim of no length at the re-solved vertex.
1975        let Some((vertex, _)) = new_vertices.get(&start.node()) else {
1976            ogeom_bail!(Construction, "an apex has no re-solved vertex");
1977        };
1978        let mut data = EdgeData::new();
1979        data.degenerate = true;
1980        return Ok(Some(
1981            model.add_edge(data, &[vertex.clone(), vertex.clone()])?,
1982        ));
1983    }
1984    let (Some((Curve::Circle(c), range)), true, &[fi]) = (curve, start.node() == end.node(), sides)
1985    else {
1986        return Ok(None);
1987    };
1988    let circle = c.circle();
1989    let mut moved_points = Vec::with_capacity(3);
1990    for k in 0..3 {
1991        #[allow(clippy::cast_precision_loss, reason = "k is 0..3")]
1992        let t = (range.1 - range.0).mul_add(k as f64 / 3.0, range.0);
1993        let p = Curve::Circle(c).point_at(t, tol)?;
1994        let Some((n, w)) = constraint(model, fi, p)? else {
1995            return Ok(None);
1996        };
1997        moved_points.push(p + n * w);
1998    }
1999    // Equally spaced samples average to the centre; the displacement is
2000    // rotationally symmetric about the ring's own axis, so the moved ring is
2001    // concentric on it.
2002    let centre = Point::from_vector(
2003        moved_points
2004            .iter()
2005            .fold(Vector::new(0.0, 0.0, 0.0), |a, p| a + p.to_vector())
2006            / 3.0,
2007    );
2008    let radius = centre.distance(moved_points[0]);
2009    let reframed = ogeom_math::Circle::new(
2010        Frame::new(centre, circle.frame().z(), circle.frame().x(), tol)?,
2011        radius,
2012        tol,
2013    )?;
2014    let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
2015    Ok(Some(make_edge(model, moved, range, tol)?.shape))
2016}
2017
2018/// The displacement that puts a point back on every moved plane: solve
2019/// `x · nᵢ = wᵢ` for the corner's normals, exactly for three, in the least
2020/// squares sense beyond.
2021/// Refuse a re-solved corner that does not lie on every moved support it
2022/// sits on: a plane moved clear of the drum it was tangent to meets it
2023/// nowhere, and the solve stops on neither. Within the supports' own
2024/// stated accuracy, as the hinge test allows a fitted support.
2025fn corner_met(
2026    prepared: &[Prepared],
2027    seats: &[usize],
2028    at: Point,
2029    tol: Tolerances,
2030) -> OgeomResult<()> {
2031    for fi in seats {
2032        // An analytic support's distance is its own; a projection is
2033        // clamped to the surface's parameter window and measures the
2034        // window's edge where the point lies past it.
2035        let off = match &prepared[*fi].surface {
2036            SurfaceGeometry::Plane(s) => s.plane().distance_to(at),
2037            SurfaceGeometry::Cylinder(s) => s.cylinder().distance_to(at),
2038            SurfaceGeometry::Cone(s) => s.cone().distance_to(at),
2039            SurfaceGeometry::Sphere(s) => s.sphere().distance_to(at),
2040            SurfaceGeometry::Torus(s) => s.torus().distance_to(at),
2041            other => ogeom_algo::project_on_surface(other, at, 32, tol)?.distance,
2042        };
2043        if off > (tol.confusion() * 1e3).max(1e-4) {
2044            ogeom_bail!(
2045                Construction,
2046                "the moved faces no longer meet at a corner they shared; the \
2047                 edit pulls them apart there"
2048            );
2049        }
2050    }
2051    Ok(())
2052}
2053
2054fn solve_corner(normals: &[Vector], amounts: &[f64], tol: Tolerances) -> OgeomResult<Vector> {
2055    // Normal equations: (NᵀN) x = Nᵀw, 3×3 whatever the seat count.
2056    let mut a = [[0.0_f64; 3]; 3];
2057    let mut b = [0.0_f64; 3];
2058    for (n, w) in normals.iter().zip(amounts) {
2059        let row = [n.x, n.y, n.z];
2060        for i in 0..3 {
2061            for j in 0..3 {
2062                a[i][j] += row[i] * row[j];
2063            }
2064            b[i] += row[i] * w;
2065        }
2066    }
2067    // For an edge between two planes the system is rank two; regularize
2068    // along the null direction (the edge itself), where the displacement is
2069    // rightly zero.
2070    if normals.len() == 2 {
2071        let along = normals[0].cross(normals[1]);
2072        let m = along.magnitude();
2073        if m <= tol.angular() {
2074            ogeom_bail!(Construction, "an edge between parallel faces has no corner");
2075        }
2076        let d = along / m;
2077        let row = [d.x, d.y, d.z];
2078        for i in 0..3 {
2079            for j in 0..3 {
2080                a[i][j] += row[i] * row[j];
2081            }
2082        }
2083    }
2084    let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
2085        - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
2086        + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
2087    if det.abs() <= tol.angular() * tol.angular() {
2088        ogeom_bail!(
2089            Construction,
2090            "a corner's faces are too nearly parallel to re-solve"
2091        );
2092    }
2093    let inv = |r: usize, c: usize| -> f64 {
2094        let (r1, r2) = ((r + 1) % 3, (r + 2) % 3);
2095        let (c1, c2) = ((c + 1) % 3, (c + 2) % 3);
2096        (a[c1][r1] * a[c2][r2] - a[c1][r2] * a[c2][r1]) / det
2097    };
2098    let mut x = [0.0_f64; 3];
2099    for (i, xi) in x.iter_mut().enumerate() {
2100        for (j, bj) in b.iter().enumerate() {
2101            *xi += inv(i, j) * bj;
2102        }
2103    }
2104    Ok(Vector::new(x[0], x[1], x[2]))
2105}
2106
2107/// The edges of `body` between two faces neither of which is `held`, that
2108/// are convex (`convex` true) or concave: where the walls, grown toward
2109/// that side, round about the old edge. An edge is convex where the face
2110/// across it falls away below its neighbour's outward normal.
2111fn growing_edges(
2112    model: &Model,
2113    body: &Shape,
2114    held: &[TShapeId],
2115    convex: bool,
2116    tol: Tolerances,
2117) -> OgeomResult<Vec<Shape>> {
2118    use ogeom_geom::{Curve3d as _, Surface as _};
2119    // Which faces hold each edge, gathered in one walk over the faces, in
2120    // the faces' order: asking per edge walked every face each time.
2121    let mut holders: std::collections::HashMap<TShapeId, Vec<(Shape, Shape)>> =
2122        std::collections::HashMap::new();
2123    for face in ogeom_topo::explore(model, body, ogeom_topo::Filter::OfType(ShapeType::Face))? {
2124        for e in ogeom_topo::explore(model, &face, ogeom_topo::Filter::OfType(ShapeType::Edge))? {
2125            holders.entry(e.node()).or_default().push((e, face.clone()));
2126        }
2127    }
2128    let mut out = Vec::new();
2129    for edge in ogeom_topo::explore_unique(model, body, ShapeType::Edge)? {
2130        let mut faces: Vec<Shape> = Vec::new();
2131        for (e, face) in holders.get(&edge.node()).map_or(&[][..], Vec::as_slice) {
2132            if e.is_same(&edge) && !faces.iter().any(|f| f.is_same(face)) {
2133                faces.push(face.clone());
2134            }
2135        }
2136        let mut distinct: Vec<Shape> = Vec::new();
2137        for f in faces {
2138            if !distinct.iter().any(|d| d.node() == f.node()) {
2139                distinct.push(f);
2140            }
2141        }
2142        let [f1, f2] = distinct.as_slice() else {
2143            continue;
2144        };
2145        if held.contains(&f1.node()) || held.contains(&f2.node()) {
2146            continue;
2147        }
2148        let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
2149            continue;
2150        };
2151        let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
2152            continue;
2153        };
2154        let Some(geometry) = model.geometry().curve(*curve) else {
2155            continue;
2156        };
2157        let p = edge
2158            .transform(model.datums())?
2159            .apply(geometry.point_at(f64::midpoint(range.0, range.1), tol)?);
2160        // The first face's outward normal at the edge.
2161        let Some(NodeData::Face(face_data)) = model.node(f1).map(ogeom_topo::TShape::data) else {
2162            continue;
2163        };
2164        let Some(surface) = model.geometry().surface(face_data.surface) else {
2165            continue;
2166        };
2167        use ogeom_geom::Transformable as _;
2168        let placed = surface
2169            .clone()
2170            .transformed(&f1.transform(model.datums())?, tol)?;
2171        let (u, v) = ogeom_algo::project_on_surface(&placed, p, 16, tol)?.parameters;
2172        let mut n1 = placed.normal_at(u, v, tol)?.vector();
2173        if f1.orientation() == Orientation::Reversed {
2174            n1 = -n1;
2175        }
2176        // Which side of the first face's plane the other face lies: its
2177        // mesh point standing furthest off that plane says.
2178        let mesh = ogeom_mesh::triangulate_face(model, f2, ogeom_mesh::Deflection::default(), tol)?;
2179        let below = mesh
2180            .positions
2181            .iter()
2182            .map(|q| (*q - p).dot(n1))
2183            .max_by(|a, b| a.abs().total_cmp(&b.abs()))
2184            .unwrap_or(0.0);
2185        if (convex && below < -tol.confusion()) || (!convex && below > tol.confusion()) {
2186            out.push(edge);
2187        }
2188    }
2189    Ok(out)
2190}