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                        && let Some(node) = model.node_mut(&built)
1273                        && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
1274                    {
1275                        data.tolerance = data.tolerance.widen_to(worst);
1276                    }
1277                    history.modify(&edge, built.clone());
1278                    new_edges.insert(edge.node(), built);
1279                    continue;
1280                }
1281                // Otherwise the moved pair's own intersection, marched where
1282                // no closed form exists (a drafted spline wall re-meeting
1283                // its cap plane), with the candidate nearest the old edge
1284                // kept and trimmed between the re-solved ends. The section's
1285                // stated slop widens the edge; nothing pretends the fit is
1286                // exact.
1287                let mid = curve.point_at(f64::midpoint(range.0, range.1), tol)?;
1288                let found = ogeom_intersect::intersect_surfaces(
1289                    &prepared[sides[0]].surface,
1290                    &prepared[sides[1]].surface,
1291                    ogeom_intersect::IntersectOptions::default(),
1292                    tol,
1293                )?;
1294                let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
1295                    ogeom_bail!(
1296                        Construction,
1297                        "the moved faces no longer meet along the edge they \
1298                         shared; the offset collapses it"
1299                    );
1300                };
1301                let mut best: Option<(Curve, f64, f64)> = None;
1302                for section in candidates {
1303                    let Ok(projected) = ogeom_algo::project_on_curve(&section.curve, mid, 64, tol)
1304                    else {
1305                        continue;
1306                    };
1307                    if best
1308                        .as_ref()
1309                        .is_none_or(|(_, _, held)| projected.distance < *held)
1310                    {
1311                        best = Some((section.curve, section.tolerance, projected.distance));
1312                    }
1313                }
1314                let Some((moved, slop, _)) = best else {
1315                    ogeom_bail!(
1316                        Construction,
1317                        "the moved faces meet along nothing where the edge \
1318                         was; the offset collapses it"
1319                    );
1320                };
1321                let closed = {
1322                    let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1323                        ogeom_bail!(Construction, "an edge has no vertices");
1324                    };
1325                    sv.node() == ev.node()
1326                };
1327                let built = if closed {
1328                    // A ring's one vertex is a corner the neighbours' seams
1329                    // start from, re-solved like any other: the marched
1330                    // section is re-seamed to begin there, so the ring and
1331                    // the seam meet at one vertex rather than at two a
1332                    // section's start apart.
1333                    let Some((sv, _)) = edge_vertices(model, &forward)? else {
1334                        ogeom_bail!(Construction, "a ring has no vertex");
1335                    };
1336                    match (new_vertices.get(&sv.node()).cloned(), &moved) {
1337                        (Some((v_at, p_at)), Curve::BSpline(spline)) => {
1338                            let t = ogeom_algo::project_on_curve(&moved, p_at, 64, tol)?;
1339                            let (lo, hi) = moved.domain();
1340                            let seamed: Curve = if t.parameter > lo + tol.parametric()
1341                                && t.parameter < hi - tol.parametric()
1342                            {
1343                                Curve::BSpline(spline.reseamed_at(t.parameter, tol)?)
1344                            } else {
1345                                moved.clone()
1346                            };
1347                            // Run the way the old ring ran: the wire uses the
1348                            // rebuilt edge with the old orientation, and a
1349                            // march has no opinion about direction.
1350                            let seamed = {
1351                                use ogeom_geom::Reversible as _;
1352                                let (a, _) = seamed.domain();
1353                                let old = curve.d1_at(range.0, tol)?;
1354                                if seamed.d1_at(a, tol)?.dot(old) < 0.0 {
1355                                    seamed.reversed()
1356                                } else {
1357                                    seamed
1358                                }
1359                            };
1360                            let miss = t.distance.max(slop);
1361                            if miss > tol.confusion() {
1362                                model.widen(&v_at, ogeom_core::Tolerance::new(miss * 2.0)?)?;
1363                            }
1364                            let window = seamed.domain();
1365                            make_edge_between(model, seamed, window, &v_at, &v_at, tol)?.shape
1366                        }
1367                        _ => {
1368                            let window = moved.domain();
1369                            make_edge(model, moved, window, tol)?.shape
1370                        }
1371                    }
1372                } else {
1373                    let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1374                        ogeom_bail!(Construction, "an edge has no vertices");
1375                    };
1376                    let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1377                        new_vertices.get(&sv.node()).cloned(),
1378                        new_vertices.get(&ev.node()).cloned(),
1379                    ) else {
1380                        ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1381                    };
1382                    // The fitted section lands within its stated slop of the
1383                    // re-solved ends; the vertices own that slop.
1384                    if slop > tol.confusion() {
1385                        for v in [&v_from, &v_to] {
1386                            model.widen(v, ogeom_core::Tolerance::new(slop * 2.0)?)?;
1387                        }
1388                    }
1389                    let ta = ogeom_algo::project_on_curve(&moved, p_from, 64, tol)?.parameter;
1390                    let tb = ogeom_algo::project_on_curve(&moved, p_to, 64, tol)?.parameter;
1391                    if (tb - ta).abs() <= tol.parametric() {
1392                        ogeom_bail!(Construction, "the offset collapses an edge");
1393                    }
1394                    // The ends run with the curve or against it; a run
1395                    // against builds on the reversed parameterization so
1396                    // the edge still leaves `v_from` first.
1397                    let (moved, ta, tb) = if ta <= tb {
1398                        (moved, ta, tb)
1399                    } else {
1400                        use ogeom_geom::Reversible as _;
1401                        let (lo, hi) = moved.domain();
1402                        (moved.reversed(), lo + hi - ta, lo + hi - tb)
1403                    };
1404                    make_edge_between(model, moved, (ta, tb), &v_from, &v_to, tol)?.shape
1405                };
1406                if slop > tol.confusion()
1407                    && let Some(node) = model.node_mut(&built)
1408                    && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
1409                {
1410                    data.tolerance = data.tolerance.widen_to(slop);
1411                }
1412                built
1413            }
1414        };
1415        history.modify(&edge, built.clone());
1416        new_edges.insert(edge.node(), built);
1417    }
1418
1419    // Faces: bands wholesale, everything else wire by wire with exact
1420    // pcurves on the moved surface.
1421    let mut rebuilt_faces: Vec<Shape> = Vec::with_capacity(prepared.len());
1422    for prep in &prepared {
1423        let built = if let Some(rings) = &prep.rings {
1424            let (Some(lo), Some(hi)) = (
1425                new_edges.get(&rings[0].node()),
1426                new_edges.get(&rings[1].node()),
1427            ) else {
1428                ogeom_bail!(Construction, "a band's ring was not rebuilt");
1429            };
1430            let band = make_revolution_band(model, &prep.surface, lo, hi, tol)?;
1431            if prep.shape.orientation() == Orientation::Reversed {
1432                band.reversed()
1433            } else {
1434                band
1435            }
1436        } else {
1437            let mut wires: Vec<Vec<Shape>> = Vec::new();
1438            let mut face_uses: HashMap<TShapeId, usize> = HashMap::new();
1439            for wire in explore(model, &prep.shape, Filter::OfType(ShapeType::Wire))? {
1440                let mut edges: Vec<Shape> = Vec::new();
1441                // The wire's own order, not the walker's: a rebuilt wire is
1442                // re-chained edge to edge, and the walk order is not a chain.
1443                for used in model.ordered_children_of(&wire)? {
1444                    *face_uses.entry(used.node()).or_insert(0) += 1;
1445                    let Some(fresh) = new_edges.get(&used.node()) else {
1446                        ogeom_bail!(Construction, "a face edge was not rebuilt");
1447                    };
1448                    edges.push(if used.orientation() == Orientation::Reversed {
1449                        fresh.reversed()
1450                    } else {
1451                        fresh.clone()
1452                    });
1453                }
1454                wires.push(edges);
1455            }
1456            let face = if face_uses.values().any(|c| *c >= 2) {
1457                // A seam in a wire-assembled face: a band a boolean split
1458                // into arc rings. Every ordinary pcurve is recomputed on the
1459                // moved surface; the seam's columns carry over, which the
1460                // seam rebuild already validated against the re-solved ends.
1461                assembled_with_seam(model, prep, &wires, &new_edges, tol)?
1462            } else {
1463                make_face_with_pcurves(model, prep.surface.clone(), &wires, tol)?.shape
1464            };
1465            if prep.shape.orientation() == Orientation::Reversed {
1466                face.reversed()
1467            } else {
1468                face
1469            }
1470        };
1471        history.modify(&prep.shape, built.clone());
1472        rebuilt_faces.push(built);
1473    }
1474
1475    let sewn = sew(model, &rebuilt_faces, tol)?;
1476    if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
1477        ogeom_bail!(Construction, "the offset solid did not close");
1478    }
1479    // The faces carried their use-orientations through; the *shell* has one
1480    // too, and a solid whose outer shell was used reversed reads inside out
1481    // if the rebuilt shell forgets it.
1482    let outer = {
1483        let old_reversed = model
1484            .children_of(solid)?
1485            .first()
1486            .is_some_and(|s| s.orientation() == Orientation::Reversed);
1487        if old_reversed {
1488            sewn.shells[0].reversed()
1489        } else {
1490            sewn.shells[0].clone()
1491        }
1492    };
1493    let built = make_solid(model, std::slice::from_ref(&outer))?;
1494
1495    // The one global guard the local checks cannot give: an offset that
1496    // moved faces past each other builds a shell that is closed and inside
1497    // out. Its measured volume is the tell.
1498    // The guard meshes at the default deflection, and a thin tangential
1499    // cusp (a small blend meeting its face) can defeat that resolution
1500    // without anything being wrong. One finer retry separates a mesh that
1501    // cannot see the cusp from a solid that is genuinely inside out.
1502    // The finer retry is a fraction of the part's own size, not a fixed
1503    // length: a metre-sized part meshed at a tenth of a micron is millions
1504    // of triangles for a sign.
1505    let size = ogeom_algo::shape_bounds(model, &built.shape, tol)?.diagonal();
1506    let fine = (size * 1e-5).clamp(
1507        tol.confusion() * 1e2,
1508        ogeom_mesh::Deflection::default().chord,
1509    );
1510    let mut mass = None;
1511    let mut first_error = None;
1512    for chord in [ogeom_mesh::Deflection::default().chord, fine] {
1513        let deflection = ogeom_mesh::Deflection {
1514            chord,
1515            ..ogeom_mesh::Deflection::default()
1516        };
1517        match ogeom_algo::volume_properties(model, &built.shape, deflection, tol) {
1518            Ok(props) => {
1519                mass = Some(props.mass);
1520                break;
1521            }
1522            Err(e @ (ogeom_core::OgeomError::Cancelled | ogeom_core::OgeomError::Dangling(_))) => {
1523                return Err(e);
1524            }
1525            Err(e) => {
1526                first_error.get_or_insert(e);
1527            }
1528        }
1529    }
1530    let Some(mass) = mass else {
1531        ogeom_bail!(
1532            Construction,
1533            "the offset solid's mesh does not close at any tried resolution{}",
1534            first_error.map_or_else(String::new, |e| format!(": {e}"))
1535        );
1536    };
1537    if !mass.is_finite() || mass <= tol.confusion() {
1538        ogeom_bail!(Construction, "the offset collapses the solid");
1539    }
1540
1541    history.modify(solid, built.shape.clone());
1542    Ok(Built::new(built.shape, history))
1543}
1544
1545/// Rebuild a seam for a face assembled wire by wire: the same iso-column on
1546/// the moved surface, over the same rows.
1547///
1548/// A same-family move preserves the chart (every point travels along its
1549/// own normal without changing its parameters), so the moved seam sits at
1550/// the column the old one's own pcurves state, between the re-solved end
1551/// vertices. `None` when the old edge carries no seam representation on this
1552/// face's surface.
1553fn rebuilt_seam_edge(
1554    model: &mut Model,
1555    edge: &Shape,
1556    prep: &Prepared,
1557    new_vertices: &HashMap<TShapeId, (Shape, Point)>,
1558    tol: Tolerances,
1559) -> OgeomResult<Option<Shape>> {
1560    use ogeom_geom::Curve2d as _;
1561
1562    let old_surface = {
1563        let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1564        else {
1565            ogeom_bail!(Construction, "face node holds no face data");
1566        };
1567        data.surface
1568    };
1569    let found = {
1570        let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1571            ogeom_bail!(Construction, "edge node holds no edge data");
1572        };
1573        let mut found = None;
1574        for repr in &data.representations {
1575            if let EdgeRepr::Seam {
1576                forward,
1577                surface,
1578                range,
1579                ..
1580            } = repr
1581                && *surface == old_surface
1582            {
1583                let Some(pcurve) = model.geometry().pcurve(*forward) else {
1584                    ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1585                };
1586                // The pcurve states the column the seam sits at. The rows
1587                // cannot come from it: the seam's ends are corner vertices,
1588                // moved by the corner solve rather than by this face alone.
1589                found = Some(pcurve.point_at(range.0, tol)?.x);
1590                break;
1591            }
1592        }
1593        found
1594    };
1595    let Some(column) = found else {
1596        return Ok(None);
1597    };
1598    // Rebuilt edges are kept in their node's own direction, each wire
1599    // turning its use of them as it did the old one's, so the ends are read
1600    // off the node's forward occurrence, however the seam was reached.
1601    let forward = if edge.orientation() == Orientation::Reversed {
1602        edge.reversed()
1603    } else {
1604        edge.clone()
1605    };
1606    let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1607        ogeom_bail!(Construction, "a seam has no vertices");
1608    };
1609    let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1610        new_vertices.get(&sv.node()).cloned(),
1611        new_vertices.get(&ev.node()).cloned(),
1612    ) else {
1613        ogeom_bail!(Construction, "a seam end has no re-solved vertex");
1614    };
1615    let Some(curve) = ogeom_algo::surface_iso_u_curve(&prep.surface, column, tol) else {
1616        ogeom_bail!(
1617            Construction,
1618            "the moved surface's iso-curve has no closed form; no seam can \
1619             be rebuilt"
1620        );
1621    };
1622    // The parameters the re-solved ends land at, by the iso-curve's own
1623    // closed form; the ends were Newton-polished onto this very surface, so
1624    // they lie on the curve exactly.
1625    let along = |p: Point| -> OgeomResult<f64> {
1626        match &curve {
1627            Curve::Line(l) => Ok((p - l.axis().location).dot(l.axis().direction.vector())),
1628            Curve::Circle(c) => {
1629                let local = c.circle().frame().to_local(p);
1630                let mut angle = local.y.atan2(local.x);
1631                if angle < 0.0 {
1632                    angle += core::f64::consts::TAU;
1633                }
1634                Ok(angle)
1635            }
1636            // A fitted support's iso-curve is a B-spline: the parameter is
1637            // found by projection, and the check below says whether the end
1638            // lies on it.
1639            _ => Ok(ogeom_algo::project_on_curve(&curve, p, 64, tol)?.parameter),
1640        }
1641    };
1642    let (t_start, t_end) = (along(p_from)?, along(p_to)?);
1643    // Self-validation instead of trusting the move: a turned support only
1644    // keeps its column when the turn was built to; the re-solved ends say
1645    // whether it was.
1646    // A fitted support holds its column only to the fit's target, and the
1647    // ends were polished onto the surface, not the column: the slack is the
1648    // fit's, on a fitted support, and a hundred confusions elsewhere.
1649    // A drafted support is two fits, the hinge's and its rulings', and at
1650    // the far end of a ruling their errors add; a few targets' worth is
1651    // the support's own honesty, not a wrong column.
1652    let slack = if matches!(prep.surface, SurfaceGeometry::BSpline(_)) {
1653        (tol.confusion() * 1e3).max(1e-4) * 4.0
1654    } else {
1655        tol.confusion() * 100.0
1656    };
1657    for (t, p, v) in [(t_start, p_from, &v_from), (t_end, p_to, &v_to)] {
1658        let off = curve.point_at(t, tol)?.distance(p);
1659        if off > slack {
1660            return Ok(None);
1661        }
1662        // The end sits on the column to the fit's breadth, and the vertex
1663        // owns that breadth.
1664        if off > tol.confusion() {
1665            model.widen(v, ogeom_core::Tolerance::new(off * 2.0)?)?;
1666        }
1667    }
1668    Ok(Some(if t_start <= t_end {
1669        make_edge_between(model, curve, (t_start, t_end), &v_from, &v_to, tol)?.shape
1670    } else {
1671        // The old seam ran against the iso-curve's own direction: build it
1672        // the way the curve runs, then hand back the reversed occurrence so
1673        // the wire's stored orientations still compose.
1674        make_edge_between(model, curve, (t_end, t_start), &v_to, &v_from, tol)?
1675            .shape
1676            .reversed()
1677    }))
1678}
1679
1680/// Assemble a moved face whose wires contain a seam.
1681///
1682/// Every ordinary edge gets its exact pcurve recomputed on the moved
1683/// surface. The seam is the one edge no closed-form projection can answer
1684/// (it needs a column per side), so its columns carry over from the old
1685/// face's own seam representation (a same-family move leaves the columns
1686/// where they were), rebuilt over the rows the moved seam actually spans.
1687fn assembled_with_seam(
1688    model: &mut Model,
1689    prep: &Prepared,
1690    wires: &[Vec<Shape>],
1691    new_edges: &HashMap<TShapeId, Shape>,
1692    tol: Tolerances,
1693) -> OgeomResult<Shape> {
1694    let mut rings: Vec<Shape> = Vec::with_capacity(wires.len());
1695    for edges in wires {
1696        rings.push(ogeom_algo::make_wire(model, edges, tol)?.shape);
1697    }
1698    let face = ogeom_algo::make_face(model, prep.surface.clone(), &rings, tol)?.shape;
1699    let new_surface = {
1700        let Some(NodeData::Face(data)) = model.node(&face).map(ogeom_topo::TShape::data) else {
1701            ogeom_bail!(Construction, "the face just built holds no face data");
1702        };
1703        data.surface
1704    };
1705    let old_surface = {
1706        let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1707        else {
1708            ogeom_bail!(Construction, "face node holds no face data");
1709        };
1710        data.surface
1711    };
1712
1713    let mut done: Vec<TShapeId> = Vec::new();
1714    for used in explore(model, &prep.shape, Filter::OfType(ShapeType::Edge))? {
1715        if done.contains(&used.node()) {
1716            continue;
1717        }
1718        done.push(used.node());
1719        let Some(fresh) = new_edges.get(&used.node()).cloned() else {
1720            ogeom_bail!(Construction, "a face edge was not rebuilt");
1721        };
1722        // A pole has no curve, only its row, and the moved surface keeps
1723        // its chart: the old row carries over as it is.
1724        if model
1725            .node(&fresh)
1726            .and_then(|n| n.data().as_edge())
1727            .is_some_and(|d| d.degenerate)
1728        {
1729            let Some(EdgeRepr::PCurve { curve, range, .. }) = model
1730                .node(&used)
1731                .and_then(|n| n.data().as_edge())
1732                .and_then(|d| d.pcurve_for(old_surface, used.location()))
1733                .cloned()
1734            else {
1735                ogeom_bail!(Construction, "a pole has no row on its face");
1736            };
1737            let Some(row) = model.geometry().pcurve(curve).cloned() else {
1738                ogeom_bail!(Dangling, "a pole's row is not in this model");
1739            };
1740            ogeom_algo::attach_pcurve(
1741                model,
1742                &fresh,
1743                row,
1744                new_surface,
1745                ogeom_topo::Location::identity(),
1746                range,
1747            )?;
1748            continue;
1749        }
1750        let (fresh_curve, fresh_range) = {
1751            let Some(data) = model.node(&fresh).and_then(|n| n.data().as_edge()) else {
1752                ogeom_bail!(Construction, "a rebuilt edge holds no edge data");
1753            };
1754            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1755                ogeom_bail!(Construction, "a rebuilt edge has no curve");
1756            };
1757            let Some(geometry) = model.geometry().curve(*curve) else {
1758                ogeom_bail!(Dangling, "curve is not in this model");
1759            };
1760            (geometry.clone(), *range)
1761        };
1762        let columns = {
1763            let Some(data) = model.node(&used).and_then(|n| n.data().as_edge()) else {
1764                ogeom_bail!(Construction, "edge node holds no edge data");
1765            };
1766            let mut columns = None;
1767            for repr in &data.representations {
1768                if let EdgeRepr::Seam {
1769                    forward,
1770                    reversed,
1771                    surface,
1772                    range,
1773                    ..
1774                } = repr
1775                    && *surface == old_surface
1776                {
1777                    use ogeom_geom::Curve2d as _;
1778                    let (Some(f), Some(r)) = (
1779                        model.geometry().pcurve(*forward),
1780                        model.geometry().pcurve(*reversed),
1781                    ) else {
1782                        ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1783                    };
1784                    columns = Some((f.point_at(range.0, tol)?.x, r.point_at(range.0, tol)?.x));
1785                    break;
1786                }
1787            }
1788            columns
1789        };
1790        if let Some((forward_col, reversed_col)) = columns {
1791            // The rows the moved seam spans, from its own rebuilt range,
1792            // identical to the curve range except a cone's slant rescale.
1793            let rows = match &prep.surface {
1794                SurfaceGeometry::Cone(c) => {
1795                    let cos = c.cone().half_angle().cos();
1796                    (fresh_range.0 * cos, fresh_range.1 * cos)
1797                }
1798                _ => fresh_range,
1799            };
1800            let column = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1801                Ok(ogeom_geom::Line2d::over(
1802                    ogeom_math::Axis2::new(
1803                        ogeom_math::Point2::new(u, 0.0),
1804                        ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, 1.0), tol)?,
1805                    ),
1806                    rows.0 - 1.0,
1807                    rows.1 + 1.0,
1808                )?
1809                .into())
1810            };
1811            ogeom_algo::attach_seam(
1812                model,
1813                &fresh,
1814                column(forward_col)?,
1815                column(reversed_col)?,
1816                new_surface,
1817                ogeom_topo::Location::identity(),
1818                rows,
1819            )?;
1820        } else {
1821            // A closed form where one exists; on a fitted support, the
1822            // projected fit the face builder trusts, the measured offset
1823            // widening the edge.
1824            let pcurve = match ogeom_intersect::exact_pcurve_of(&fresh_curve, &prep.surface, tol) {
1825                Some(exact) => exact,
1826                None => {
1827                    let (fitted, _, _, worst_off, _) =
1828                        ogeom_algo::pcurve_fit::fit_projected_pcurve(
1829                            &fresh_curve,
1830                            fresh_range,
1831                            &prep.surface,
1832                            tol,
1833                        )?;
1834                    if worst_off > tol.confusion() {
1835                        // The edge owns the offset, and so must the vertices
1836                        // that bound it: a bound no looser than what it
1837                        // bounds is the containment rule.
1838                        let widened = ogeom_core::Tolerance::new(worst_off + tol.confusion())?;
1839                        model.widen(&fresh, widened)?;
1840                        if let Some((a, b)) = edge_vertices(model, &fresh)? {
1841                            model.widen(&a, widened)?;
1842                            model.widen(&b, widened)?;
1843                        }
1844                    }
1845                    fitted
1846                }
1847            };
1848            ogeom_algo::attach_pcurve(
1849                model,
1850                &fresh,
1851                pcurve,
1852                new_surface,
1853                ogeom_topo::Location::identity(),
1854                fresh_range,
1855            )?;
1856        }
1857    }
1858    Ok(face)
1859}
1860
1861/// Where a seam ending at `vertex` meets the other seat, on the moved
1862/// supports: the seam's column as an iso-curve on its face's moved surface,
1863/// pierced through the other face's; `None` where no seam ends here or the
1864/// column has no curve.
1865fn seam_end(
1866    model: &Model,
1867    faces: &[Shape],
1868    prepared: &[Prepared],
1869    vertex: &Shape,
1870    kept: &[usize],
1871    at: Point,
1872    tol: Tolerances,
1873) -> OgeomResult<Option<Point>> {
1874    use ogeom_geom::Curve2d as _;
1875    for (slot, &fi) in kept.iter().enumerate() {
1876        let other = kept[1 - slot];
1877        let face = &faces[fi];
1878        let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
1879            continue;
1880        };
1881        let old_surface = data.surface;
1882        let mut uses: HashMap<TShapeId, usize> = HashMap::new();
1883        for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
1884            *uses.entry(e.node()).or_insert(0) += 1;
1885        }
1886        for e in explore_unique(model, face, ShapeType::Edge)? {
1887            if uses.get(&e.node()).copied().unwrap_or(0) < 2 {
1888                continue;
1889            }
1890            let Some((a, b)) = edge_vertices(model, &e)? else {
1891                continue;
1892            };
1893            if a.node() != vertex.node() && b.node() != vertex.node() {
1894                continue;
1895            }
1896            let Some(edata) = model.node(&e).and_then(|n| n.data().as_edge()) else {
1897                continue;
1898            };
1899            let mut column = None;
1900            for repr in &edata.representations {
1901                if let EdgeRepr::Seam {
1902                    forward,
1903                    surface,
1904                    range,
1905                    ..
1906                } = repr
1907                    && *surface == old_surface
1908                    && let Some(pcurve) = model.geometry().pcurve(*forward)
1909                {
1910                    column = Some(pcurve.point_at(range.0, tol)?.x);
1911                    break;
1912                }
1913            }
1914            let Some(column) = column else {
1915                continue;
1916            };
1917            let Some(iso) = ogeom_algo::surface_iso_u_curve(&prepared[fi].surface, column, tol)
1918            else {
1919                continue;
1920            };
1921            let found = ogeom_intersect::intersect_curve_surface(
1922                &iso,
1923                &prepared[other].surface,
1924                ogeom_intersect::CurveSurfaceOptions::default(),
1925                tol,
1926            )?;
1927            let nearest = found
1928                .crossings
1929                .iter()
1930                .map(|hit| hit.point)
1931                .min_by(|p, q| p.distance(at).total_cmp(&q.distance(at)));
1932            if let Some(p) = nearest {
1933                return Ok(Some(p));
1934            }
1935        }
1936    }
1937    Ok(None)
1938}
1939
1940/// Rebuild an edge only one face owns: the ring a boolean left coincident
1941/// with a neighbour's twin, or a cone's apex.
1942///
1943/// A normal offset moves every point of a face along the face's own normal,
1944/// so three displaced samples of a ring pin the moved ring exactly; no
1945/// second face required. `None` when the edge is neither shape.
1946fn rebuilt_lone_edge(
1947    model: &mut Model,
1948    edge: &Shape,
1949    sides: &[usize],
1950    constraint: &Displacement<'_>,
1951    new_vertices: &HashMap<TShapeId, (Shape, Point)>,
1952    tol: Tolerances,
1953) -> OgeomResult<Option<Shape>> {
1954    use ogeom_geom::Curve3d as _;
1955
1956    let (degenerate, curve) = {
1957        let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1958            ogeom_bail!(Construction, "edge node holds no edge data");
1959        };
1960        let curve = data.curve3d().and_then(|repr| {
1961            let EdgeRepr::Curve3d { curve, range, .. } = repr else {
1962                return None;
1963            };
1964            model.geometry().curve(*curve).cloned().map(|c| (c, *range))
1965        });
1966        (data.degenerate, curve)
1967    };
1968    let Some((start, end)) = edge_vertices(model, edge)? else {
1969        ogeom_bail!(Construction, "a lone edge has no vertices");
1970    };
1971    if degenerate {
1972        // An apex: a rim of no length at the re-solved vertex.
1973        let Some((vertex, _)) = new_vertices.get(&start.node()) else {
1974            ogeom_bail!(Construction, "an apex has no re-solved vertex");
1975        };
1976        let mut data = EdgeData::new();
1977        data.degenerate = true;
1978        return Ok(Some(
1979            model.add_edge(data, &[vertex.clone(), vertex.clone()])?,
1980        ));
1981    }
1982    let (Some((Curve::Circle(c), range)), true, &[fi]) = (curve, start.node() == end.node(), sides)
1983    else {
1984        return Ok(None);
1985    };
1986    let circle = c.circle();
1987    let mut moved_points = Vec::with_capacity(3);
1988    for k in 0..3 {
1989        #[allow(clippy::cast_precision_loss, reason = "k is 0..3")]
1990        let t = (range.1 - range.0).mul_add(k as f64 / 3.0, range.0);
1991        let p = Curve::Circle(c).point_at(t, tol)?;
1992        let Some((n, w)) = constraint(model, fi, p)? else {
1993            return Ok(None);
1994        };
1995        moved_points.push(p + n * w);
1996    }
1997    // Equally spaced samples average to the centre; the displacement is
1998    // rotationally symmetric about the ring's own axis, so the moved ring is
1999    // concentric on it.
2000    let centre = Point::from_vector(
2001        moved_points
2002            .iter()
2003            .fold(Vector::new(0.0, 0.0, 0.0), |a, p| a + p.to_vector())
2004            / 3.0,
2005    );
2006    let radius = centre.distance(moved_points[0]);
2007    let reframed = ogeom_math::Circle::new(
2008        Frame::new(centre, circle.frame().z(), circle.frame().x(), tol)?,
2009        radius,
2010        tol,
2011    )?;
2012    let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
2013    Ok(Some(make_edge(model, moved, range, tol)?.shape))
2014}
2015
2016/// The displacement that puts a point back on every moved plane: solve
2017/// `x · nᵢ = wᵢ` for the corner's normals, exactly for three, in the least
2018/// squares sense beyond.
2019/// Refuse a re-solved corner that does not lie on every moved support it
2020/// sits on: a plane moved clear of the drum it was tangent to meets it
2021/// nowhere, and the solve stops on neither. Within the supports' own
2022/// stated accuracy, as the hinge test allows a fitted support.
2023fn corner_met(
2024    prepared: &[Prepared],
2025    seats: &[usize],
2026    at: Point,
2027    tol: Tolerances,
2028) -> OgeomResult<()> {
2029    for fi in seats {
2030        // An analytic support's distance is its own; a projection is
2031        // clamped to the surface's parameter window and measures the
2032        // window's edge where the point lies past it.
2033        let off = match &prepared[*fi].surface {
2034            SurfaceGeometry::Plane(s) => s.plane().distance_to(at),
2035            SurfaceGeometry::Cylinder(s) => s.cylinder().distance_to(at),
2036            SurfaceGeometry::Cone(s) => s.cone().distance_to(at),
2037            SurfaceGeometry::Sphere(s) => s.sphere().distance_to(at),
2038            SurfaceGeometry::Torus(s) => s.torus().distance_to(at),
2039            other => ogeom_algo::project_on_surface(other, at, 32, tol)?.distance,
2040        };
2041        if off > (tol.confusion() * 1e3).max(1e-4) {
2042            ogeom_bail!(
2043                Construction,
2044                "the moved faces no longer meet at a corner they shared; the \
2045                 edit pulls them apart there"
2046            );
2047        }
2048    }
2049    Ok(())
2050}
2051
2052fn solve_corner(normals: &[Vector], amounts: &[f64], tol: Tolerances) -> OgeomResult<Vector> {
2053    // Normal equations: (NᵀN) x = Nᵀw, 3×3 whatever the seat count.
2054    let mut a = [[0.0_f64; 3]; 3];
2055    let mut b = [0.0_f64; 3];
2056    for (n, w) in normals.iter().zip(amounts) {
2057        let row = [n.x, n.y, n.z];
2058        for i in 0..3 {
2059            for j in 0..3 {
2060                a[i][j] += row[i] * row[j];
2061            }
2062            b[i] += row[i] * w;
2063        }
2064    }
2065    // For an edge between two planes the system is rank two; regularize
2066    // along the null direction (the edge itself), where the displacement is
2067    // rightly zero.
2068    if normals.len() == 2 {
2069        let along = normals[0].cross(normals[1]);
2070        let m = along.magnitude();
2071        if m <= tol.angular() {
2072            ogeom_bail!(Construction, "an edge between parallel faces has no corner");
2073        }
2074        let d = along / m;
2075        let row = [d.x, d.y, d.z];
2076        for i in 0..3 {
2077            for j in 0..3 {
2078                a[i][j] += row[i] * row[j];
2079            }
2080        }
2081    }
2082    let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
2083        - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
2084        + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
2085    if det.abs() <= tol.angular() * tol.angular() {
2086        ogeom_bail!(
2087            Construction,
2088            "a corner's faces are too nearly parallel to re-solve"
2089        );
2090    }
2091    let inv = |r: usize, c: usize| -> f64 {
2092        let (r1, r2) = ((r + 1) % 3, (r + 2) % 3);
2093        let (c1, c2) = ((c + 1) % 3, (c + 2) % 3);
2094        (a[c1][r1] * a[c2][r2] - a[c1][r2] * a[c2][r1]) / det
2095    };
2096    let mut x = [0.0_f64; 3];
2097    for (i, xi) in x.iter_mut().enumerate() {
2098        for (j, bj) in b.iter().enumerate() {
2099            *xi += inv(i, j) * bj;
2100        }
2101    }
2102    Ok(Vector::new(x[0], x[1], x[2]))
2103}
2104
2105/// The edges of `body` between two faces neither of which is `held`, that
2106/// are convex (`convex` true) or concave: where the walls, grown toward
2107/// that side, round about the old edge. An edge is convex where the face
2108/// across it falls away below its neighbour's outward normal.
2109fn growing_edges(
2110    model: &Model,
2111    body: &Shape,
2112    held: &[TShapeId],
2113    convex: bool,
2114    tol: Tolerances,
2115) -> OgeomResult<Vec<Shape>> {
2116    use ogeom_geom::{Curve3d as _, Surface as _};
2117    // Which faces hold each edge, gathered in one walk over the faces, in
2118    // the faces' order: asking per edge walked every face each time.
2119    let mut holders: std::collections::HashMap<TShapeId, Vec<(Shape, Shape)>> =
2120        std::collections::HashMap::new();
2121    for face in ogeom_topo::explore(model, body, ogeom_topo::Filter::OfType(ShapeType::Face))? {
2122        for e in ogeom_topo::explore(model, &face, ogeom_topo::Filter::OfType(ShapeType::Edge))? {
2123            holders.entry(e.node()).or_default().push((e, face.clone()));
2124        }
2125    }
2126    let mut out = Vec::new();
2127    for edge in ogeom_topo::explore_unique(model, body, ShapeType::Edge)? {
2128        let mut faces: Vec<Shape> = Vec::new();
2129        for (e, face) in holders.get(&edge.node()).map_or(&[][..], Vec::as_slice) {
2130            if e.is_same(&edge) && !faces.iter().any(|f| f.is_same(face)) {
2131                faces.push(face.clone());
2132            }
2133        }
2134        let mut distinct: Vec<Shape> = Vec::new();
2135        for f in faces {
2136            if !distinct.iter().any(|d| d.node() == f.node()) {
2137                distinct.push(f);
2138            }
2139        }
2140        let [f1, f2] = distinct.as_slice() else {
2141            continue;
2142        };
2143        if held.contains(&f1.node()) || held.contains(&f2.node()) {
2144            continue;
2145        }
2146        let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
2147            continue;
2148        };
2149        let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
2150            continue;
2151        };
2152        let Some(geometry) = model.geometry().curve(*curve) else {
2153            continue;
2154        };
2155        let p = edge
2156            .transform(model.datums())?
2157            .apply(geometry.point_at(f64::midpoint(range.0, range.1), tol)?);
2158        // The first face's outward normal at the edge.
2159        let Some(NodeData::Face(face_data)) = model.node(f1).map(ogeom_topo::TShape::data) else {
2160            continue;
2161        };
2162        let Some(surface) = model.geometry().surface(face_data.surface) else {
2163            continue;
2164        };
2165        use ogeom_geom::Transformable as _;
2166        let placed = surface
2167            .clone()
2168            .transformed(&f1.transform(model.datums())?, tol)?;
2169        let (u, v) = ogeom_algo::project_on_surface(&placed, p, 16, tol)?.parameters;
2170        let mut n1 = placed.normal_at(u, v, tol)?.vector();
2171        if f1.orientation() == Orientation::Reversed {
2172            n1 = -n1;
2173        }
2174        // Which side of the first face's plane the other face lies: its
2175        // mesh point standing furthest off that plane says.
2176        let mesh = ogeom_mesh::triangulate_face(model, f2, ogeom_mesh::Deflection::default(), tol)?;
2177        let below = mesh
2178            .positions
2179            .iter()
2180            .map(|q| (*q - p).dot(n1))
2181            .max_by(|a, b| a.abs().total_cmp(&b.abs()))
2182            .unwrap_or(0.0);
2183        if (convex && below < -tol.confusion()) || (!convex && below > tol.confusion()) {
2184            out.push(edge);
2185        }
2186    }
2187    Ok(out)
2188}