Skip to main content

ogeom_offset/
shape.rs

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