Skip to main content

ogeom_offset/
shape.rs

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