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