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