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 — 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 — docs/PARITY.md, offset.shell-thicken"
490                ),
491            }
492        };
493        prepared.push(Prepared {
494            shape: face.clone(),
495            surface: moved,
496            amount,
497            sign,
498            rings: if has_seam && closed_rings.len() == 2 {
499                Some([closed_rings[0].clone(), closed_rings[1].clone()])
500            } else {
501                None
502            },
503        });
504    }
505
506    // Which faces meet each edge, seams excluded by their double use.
507    let mut edge_faces: HashMap<TShapeId, Vec<usize>> = HashMap::new();
508    for (fi, face) in faces.iter().enumerate() {
509        for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
510            let entry = edge_faces.entry(e.node()).or_default();
511            if !entry.contains(&fi) {
512                entry.push(fi);
513            }
514        }
515    }
516
517    // The displacement constraint each face puts on a point of itself: the
518    // surface normal there, moved its amount along it. Exact, because a
519    // normal offset moves every point of a surface along its own normal.
520    let constraint = |model: &Model, fi: usize, at: Point| -> OgeomResult<Option<(Vector, f64)>> {
521        let face = &faces[fi];
522        let Some(node) = model.node(face) else {
523            ogeom_bail!(Dangling, "face is not in this model");
524        };
525        let NodeData::Face(data) = node.data() else {
526            ogeom_bail!(Construction, "face node holds no face data");
527        };
528        let Some(surface) = model.geometry().surface(data.surface) else {
529            ogeom_bail!(Dangling, "face refers to a surface not in this model");
530        };
531        let projection = ogeom_algo::project_on_surface(surface, at, 32, tol)?;
532        if projection.distance > tol.confusion() * 100.0 {
533            return Ok(None);
534        }
535        let (u, v) = projection.parameters;
536        let (du, dv) = surface.d1_at(u, v, tol)?;
537        let n = du.cross(dv);
538        let m = n.magnitude();
539        if m <= tol.confusion() {
540            return Ok(None);
541        }
542        let outward = n / m * prepared[fi].sign;
543        Ok(Some((outward, prepared[fi].amount)))
544    };
545
546    // New vertices: the linear constraint solve seeds a Newton polish onto
547    // the moved surfaces themselves — the tangent-plane answer is exact for
548    // planes and off by the surfaces' own curvature otherwise.
549    let mut new_vertices: HashMap<TShapeId, (Shape, Point)> = HashMap::new();
550    for vertex in explore_unique(model, solid, ShapeType::Vertex)? {
551        let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) else {
552            continue;
553        };
554        let at = vertex.transform(model.datums())?.apply(data.point);
555        let mut seats: Vec<usize> = Vec::new();
556        for (fi, face) in faces.iter().enumerate() {
557            for v in explore(model, face, Filter::OfType(ShapeType::Vertex))? {
558                if v.node() == vertex.node() && !seats.contains(&fi) {
559                    seats.push(fi);
560                }
561            }
562        }
563        if seats.is_empty() {
564            continue;
565        }
566        // Independent constraints only: tangent faces share their normal and
567        // must agree on the displacement, or the vertex tears.
568        let mut normals: Vec<Vector> = Vec::new();
569        let mut amounts: Vec<f64> = Vec::new();
570        let mut kept: Vec<usize> = Vec::new();
571        for fi in &seats {
572            let Some((n, w)) = constraint(model, *fi, at)? else {
573                continue;
574            };
575            if let Some(k) = normals
576                .iter()
577                .position(|m| m.cross(n).magnitude() <= tol.angular().max(1e-6))
578            {
579                if (amounts[k] - w).abs() > tol.confusion() {
580                    ogeom_bail!(
581                        Construction,
582                        "two tangent faces move a shared vertex by different \
583                         amounts; the offset tears it"
584                    );
585                }
586                continue;
587            }
588            normals.push(n);
589            amounts.push(w);
590            kept.push(*fi);
591        }
592        if normals.is_empty() {
593            // A cone's apex has no normal to offer — the projection there is
594            // degenerate — but the parallel cone knows exactly where its own
595            // apex went.
596            let mut apex: Option<Point> = None;
597            for fi in &seats {
598                let Some(node) = model.node(&faces[*fi]) else {
599                    continue;
600                };
601                let NodeData::Face(data) = node.data() else {
602                    continue;
603                };
604                let Some(SurfaceGeometry::Cone(old)) = model.geometry().surface(data.surface)
605                else {
606                    continue;
607                };
608                if old.cone().apex().distance(at) > tol.confusion() * 100.0 {
609                    continue;
610                }
611                if let SurfaceGeometry::Cone(moved_cone) = &prepared[*fi].surface {
612                    apex = Some(moved_cone.cone().apex());
613                    break;
614                }
615            }
616            let Some(moved) = apex else {
617                ogeom_bail!(
618                    Construction,
619                    "a vertex with no seat the rebuild can read cannot be \
620                     re-solved"
621                );
622            };
623            new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
624            continue;
625        }
626        if normals.len() == 1 {
627            // Every seat is tangent to the rest, and the dedup above made
628            // them agree on the amount. A normal offset moves each point of a
629            // surface along its own normal, so the shared normal is the exact
630            // answer — no corner to solve, nothing to polish.
631            let moved = at + normals[0] * amounts[0];
632            new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
633            continue;
634        }
635        let mut moved = at + solve_corner(&normals, &amounts, tol)?;
636        // Newton onto the moved surfaces: residuals are the signed
637        // distances, gradients the normals, and the same least-squares
638        // machinery takes the step.
639        for _ in 0..8 {
640            let mut ns: Vec<Vector> = Vec::new();
641            let mut rs: Vec<f64> = Vec::new();
642            for fi in &kept {
643                let projection =
644                    ogeom_algo::project_on_surface(&prepared[*fi].surface, moved, 32, tol)?;
645                let (u, v) = projection.parameters;
646                let (du, dv) = prepared[*fi].surface.d1_at(u, v, tol)?;
647                let n = du.cross(dv);
648                let m = n.magnitude();
649                if m <= tol.confusion() {
650                    continue;
651                }
652                let n = n / m;
653                let foot = prepared[*fi].surface.point_at(u, v, tol)?;
654                ns.push(n);
655                rs.push((moved - foot).dot(n));
656            }
657            if ns.len() < 2 {
658                break;
659            }
660            let worst = rs.iter().fold(0.0_f64, |a, r| a.max(r.abs()));
661            if worst <= tol.confusion() * 0.1 {
662                break;
663            }
664            let step: Vec<f64> = rs.iter().map(|r| -r).collect();
665            moved += solve_corner(&ns, &step, tol)?;
666        }
667        new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
668    }
669
670    // How many times each edge occurs across all faces — a seam is one face
671    // using an edge twice, which face-deduplicated sides cannot see.
672    let mut edge_uses: HashMap<TShapeId, usize> = HashMap::new();
673    for face in &faces {
674        for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
675            *edge_uses.entry(e.node()).or_insert(0) += 1;
676        }
677    }
678
679    // New edges on the moved supports.
680    let mut new_edges: HashMap<TShapeId, Shape> = HashMap::new();
681    let mut history = History::new();
682    for edge in explore_unique(model, solid, ShapeType::Edge)? {
683        let sides = edge_faces.get(&edge.node()).cloned().unwrap_or_default();
684        if sides.len() != 2 {
685            if edge_uses.get(&edge.node()).copied().unwrap_or(0) >= 2 {
686                // A seam. A band face rebuilds its own; a face assembled wire
687                // by wire — a band a boolean split into arc rings — needs the
688                // moved seam here: the same iso-column on the moved surface,
689                // which chart preservation makes exact.
690                if let [fi] = sides.as_slice()
691                    && let Some(built) =
692                        rebuilt_seam_edge(model, &edge, &prepared[*fi], &new_vertices, tol)?
693                {
694                    history.modify(&edge, built.clone());
695                    new_edges.insert(edge.node(), built);
696                }
697                continue;
698            }
699            // A genuinely single-sided edge: the ring a boolean left
700            // coincident with a neighbour's twin, or a cone's apex.
701            let Some(built) =
702                rebuilt_lone_edge(model, &edge, &sides, &constraint, &new_vertices, tol)?
703            else {
704                ogeom_bail!(
705                    Construction,
706                    "an edge with one face is neither a ring nor an apex; \
707                     the offset cannot re-derive it"
708                );
709            };
710            history.modify(&edge, built.clone());
711            new_edges.insert(edge.node(), built);
712            continue;
713        }
714        let (curve, range) = {
715            let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
716                ogeom_bail!(Construction, "edge node holds no edge data");
717            };
718            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
719                ogeom_bail!(Construction, "an edge has no curve to offset");
720            };
721            let Some(geometry) = model.geometry().curve(*curve) else {
722                ogeom_bail!(Dangling, "curve is not in this model");
723            };
724            (geometry.clone(), *range)
725        };
726        let forward = if edge.orientation() == Orientation::Reversed {
727            edge.reversed()
728        } else {
729            edge.clone()
730        };
731        let built = match &curve {
732            Curve::Line(_) => {
733                // A straight edge is the line through its own re-solved
734                // ends. That is true whether the supports were translated
735                // or turned — an offset leaves the direction alone and this
736                // reproduces it, a draft does not and this follows it —
737                // whereas a line anchored where the old one sat misses its
738                // own vertices the moment either end moves sideways.
739                let Some((sv, ev)) = edge_vertices(model, &forward)? else {
740                    ogeom_bail!(Construction, "a straight edge has no vertices");
741                };
742                let (Some((v_from, p_from)), Some((v_to, p_to))) = (
743                    new_vertices.get(&sv.node()).cloned(),
744                    new_vertices.get(&ev.node()).cloned(),
745                ) else {
746                    ogeom_bail!(Construction, "an edge end has no re-solved vertex");
747                };
748                if p_to.distance(p_from) <= tol.parametric() {
749                    ogeom_bail!(Construction, "the offset collapses an edge");
750                }
751                let segment = LineCurve::segment(p_from, p_to, tol)?;
752                let (t0, t1) = segment.domain();
753                let moved: Curve = segment.into();
754                make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
755            }
756            Curve::Circle(c) => {
757                // The moved pair's own analytic intersection, taken in the
758                // circle's old frame so parameters and orientations carry.
759                let circle = c.circle();
760                let found = ogeom_intersect::intersect_surfaces(
761                    &prepared[sides[0]].surface,
762                    &prepared[sides[1]].surface,
763                    ogeom_intersect::IntersectOptions::default(),
764                    tol,
765                )?;
766                let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
767                    ogeom_bail!(
768                        Construction,
769                        "the moved faces no longer meet along the edge they \
770                         shared; the offset collapses it"
771                    );
772                };
773                let mut best: Option<(ogeom_math::Circle, f64)> = None;
774                for section in &candidates {
775                    let Curve::Circle(cc) = &section.curve else {
776                        continue;
777                    };
778                    let candidate = cc.circle();
779                    let score = candidate.centre().distance(circle.centre())
780                        + (candidate.radius() - circle.radius()).abs();
781                    if best.as_ref().is_none_or(|(_, held)| score < *held) {
782                        best = Some((candidate, score));
783                    }
784                }
785                let Some((candidate, _)) = best else {
786                    ogeom_bail!(
787                        Construction,
788                        "the moved faces meet along nothing circular where a \
789                         circle was; the offset needs the general rebuild"
790                    );
791                };
792                let reframed = ogeom_math::Circle::new(
793                    Frame::new(
794                        candidate.centre(),
795                        circle.frame().z(),
796                        circle.frame().x(),
797                        tol,
798                    )?,
799                    candidate.radius(),
800                    tol,
801                )?;
802                let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
803                let closed = {
804                    let Some((sv, ev)) = edge_vertices(model, &forward)? else {
805                        ogeom_bail!(Construction, "a ring has no vertex");
806                    };
807                    sv.node() == ev.node()
808                };
809                if closed {
810                    make_edge(model, moved, range, tol)?.shape
811                } else {
812                    let Some((sv, ev)) = edge_vertices(model, &forward)? else {
813                        ogeom_bail!(Construction, "an arc has no vertices");
814                    };
815                    let (Some((v_from, p_from)), Some((v_to, p_to))) = (
816                        new_vertices.get(&sv.node()).cloned(),
817                        new_vertices.get(&ev.node()).cloned(),
818                    ) else {
819                        ogeom_bail!(Construction, "an arc end has no re-solved vertex");
820                    };
821                    let angle_of = |p: Point| {
822                        let l = reframed.frame().to_local(p);
823                        l.y.atan2(l.x)
824                    };
825                    let tau = core::f64::consts::TAU;
826                    let mut t0 = angle_of(p_from);
827                    let mut t1 = angle_of(p_to);
828                    // Keep the new range in the old one's winding and span.
829                    while t0 < range.0 - core::f64::consts::PI {
830                        t0 += tau;
831                    }
832                    while t0 > range.0 + core::f64::consts::PI {
833                        t0 -= tau;
834                    }
835                    while t1 <= t0 + tol.parametric() {
836                        t1 += tau;
837                    }
838                    if (t1 - t0) - (range.1 - range.0) > core::f64::consts::PI {
839                        t1 -= tau;
840                    }
841                    if t1 <= t0 + tol.parametric() {
842                        ogeom_bail!(Construction, "the offset collapses an arc");
843                    }
844                    make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
845                }
846            }
847            _ => {
848                // The general edge. First the still question: a hinge edge —
849                // a draft's neutral crossing — sits on both moved supports
850                // exactly where it always was, and an edge that did not move
851                // rebuilds on its own curve rather than on a march of it.
852                let unmoved = {
853                    let mut worst = 0.0_f64;
854                    'probe: for i in 0..9 {
855                        #[allow(clippy::cast_precision_loss)]
856                        let t = range.0 + (range.1 - range.0) * (i as f64) / 8.0;
857                        let p = curve.point_at(t, tol)?;
858                        for side in [sides[0], sides[1]] {
859                            let Ok(near) =
860                                ogeom_algo::project_on_surface(&prepared[side].surface, p, 17, tol)
861                            else {
862                                worst = f64::INFINITY;
863                                break 'probe;
864                            };
865                            worst = worst.max(near.distance);
866                        }
867                    }
868                    // Within the moved supports' own stated accuracy: a
869                    // fitted support holds its points only to the fit
870                    // target, and the hinge is exactly on it by less.
871                    (worst <= (tol.confusion() * 1e3).max(1e-4)).then_some(worst)
872                };
873                if let Some(worst) = unmoved {
874                    let Some((sv, ev)) = edge_vertices(model, &forward)? else {
875                        ogeom_bail!(Construction, "an edge has no vertices");
876                    };
877                    let closed = sv.node() == ev.node();
878                    let built = if closed {
879                        make_edge(model, curve.clone(), range, tol)?.shape
880                    } else {
881                        let (Some((v_from, p_from)), Some((v_to, p_to))) = (
882                            new_vertices.get(&sv.node()).cloned(),
883                            new_vertices.get(&ev.node()).cloned(),
884                        ) else {
885                            ogeom_bail!(Construction, "an edge end has no re-solved vertex");
886                        };
887                        // The ends re-solved against a fitted support land a
888                        // fit's breadth from the curve that did not move; the
889                        // vertices own that breadth.
890                        let gap = curve
891                            .point_at(range.0, tol)?
892                            .distance(p_from)
893                            .min(curve.point_at(range.0, tol)?.distance(p_to))
894                            .max(
895                                curve
896                                    .point_at(range.1, tol)?
897                                    .distance(p_to)
898                                    .min(curve.point_at(range.1, tol)?.distance(p_from)),
899                            );
900                        if gap > tol.confusion() {
901                            for v in [&v_from, &v_to] {
902                                model.widen(v, ogeom_core::Tolerance::new(gap * 2.0)?)?;
903                            }
904                        }
905                        make_edge_between(model, curve.clone(), range, &v_from, &v_to, tol)?.shape
906                    };
907                    if worst > tol.confusion()
908                        && let Some(node) = model.node_mut(&built)
909                        && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
910                    {
911                        data.tolerance = data.tolerance.widen_to(worst);
912                    }
913                    history.modify(&edge, built.clone());
914                    new_edges.insert(edge.node(), built);
915                    continue;
916                }
917                // Otherwise the moved pair's own intersection, marched where
918                // no closed form exists — a drafted spline wall re-meeting
919                // its cap plane — with the candidate nearest the old edge
920                // kept and trimmed between the re-solved ends. The section's
921                // stated slop widens the edge; nothing pretends the fit is
922                // exact.
923                let mid = curve.point_at(f64::midpoint(range.0, range.1), tol)?;
924                let found = ogeom_intersect::intersect_surfaces(
925                    &prepared[sides[0]].surface,
926                    &prepared[sides[1]].surface,
927                    ogeom_intersect::IntersectOptions::default(),
928                    tol,
929                )?;
930                let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
931                    ogeom_bail!(
932                        Construction,
933                        "the moved faces no longer meet along the edge they \
934                         shared; the offset collapses it"
935                    );
936                };
937                let mut best: Option<(Curve, f64, f64)> = None;
938                for section in candidates {
939                    let Ok(projected) = ogeom_algo::project_on_curve(&section.curve, mid, 64, tol)
940                    else {
941                        continue;
942                    };
943                    if best
944                        .as_ref()
945                        .is_none_or(|(_, _, held)| projected.distance < *held)
946                    {
947                        best = Some((section.curve, section.tolerance, projected.distance));
948                    }
949                }
950                let Some((moved, slop, _)) = best else {
951                    ogeom_bail!(
952                        Construction,
953                        "the moved faces meet along nothing where the edge \
954                         was; the offset collapses it"
955                    );
956                };
957                let closed = {
958                    let Some((sv, ev)) = edge_vertices(model, &forward)? else {
959                        ogeom_bail!(Construction, "an edge has no vertices");
960                    };
961                    sv.node() == ev.node()
962                };
963                let built = if closed {
964                    let window = moved.domain();
965                    make_edge(model, moved, window, tol)?.shape
966                } else {
967                    let Some((sv, ev)) = edge_vertices(model, &forward)? else {
968                        ogeom_bail!(Construction, "an edge has no vertices");
969                    };
970                    let (Some((v_from, p_from)), Some((v_to, p_to))) = (
971                        new_vertices.get(&sv.node()).cloned(),
972                        new_vertices.get(&ev.node()).cloned(),
973                    ) else {
974                        ogeom_bail!(Construction, "an edge end has no re-solved vertex");
975                    };
976                    // The fitted section lands within its stated slop of the
977                    // re-solved ends; the vertices own that slop.
978                    if slop > tol.confusion() {
979                        for v in [&v_from, &v_to] {
980                            model.widen(v, ogeom_core::Tolerance::new(slop * 2.0)?)?;
981                        }
982                    }
983                    let ta = ogeom_algo::project_on_curve(&moved, p_from, 64, tol)?.parameter;
984                    let tb = ogeom_algo::project_on_curve(&moved, p_to, 64, tol)?.parameter;
985                    if (tb - ta).abs() <= tol.parametric() {
986                        ogeom_bail!(Construction, "the offset collapses an edge");
987                    }
988                    // The ends run with the curve or against it; a run
989                    // against builds on the reversed parameterization so
990                    // the edge still leaves `v_from` first.
991                    let (moved, ta, tb) = if ta <= tb {
992                        (moved, ta, tb)
993                    } else {
994                        use ogeom_geom::Reversible as _;
995                        let (lo, hi) = moved.domain();
996                        (moved.reversed(), lo + hi - ta, lo + hi - tb)
997                    };
998                    make_edge_between(model, moved, (ta, tb), &v_from, &v_to, tol)?.shape
999                };
1000                if slop > tol.confusion()
1001                    && let Some(node) = model.node_mut(&built)
1002                    && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
1003                {
1004                    data.tolerance = data.tolerance.widen_to(slop);
1005                }
1006                built
1007            }
1008        };
1009        history.modify(&edge, built.clone());
1010        new_edges.insert(edge.node(), built);
1011    }
1012
1013    // Faces: bands wholesale, everything else wire by wire with exact
1014    // pcurves on the moved surface.
1015    let mut rebuilt_faces: Vec<Shape> = Vec::with_capacity(prepared.len());
1016    for prep in &prepared {
1017        let built = if let Some(rings) = &prep.rings {
1018            let (Some(lo), Some(hi)) = (
1019                new_edges.get(&rings[0].node()),
1020                new_edges.get(&rings[1].node()),
1021            ) else {
1022                ogeom_bail!(Construction, "a band's ring was not rebuilt");
1023            };
1024            let band = make_revolution_band(model, &prep.surface, lo, hi, tol)?;
1025            if prep.shape.orientation() == Orientation::Reversed {
1026                band.reversed()
1027            } else {
1028                band
1029            }
1030        } else {
1031            let mut wires: Vec<Vec<Shape>> = Vec::new();
1032            let mut face_uses: HashMap<TShapeId, usize> = HashMap::new();
1033            for wire in explore(model, &prep.shape, Filter::OfType(ShapeType::Wire))? {
1034                let mut edges: Vec<Shape> = Vec::new();
1035                // The wire's own order, not the walker's: a rebuilt wire is
1036                // re-chained edge to edge, and the walk order is not a chain.
1037                for used in model.ordered_children_of(&wire)? {
1038                    *face_uses.entry(used.node()).or_insert(0) += 1;
1039                    let Some(fresh) = new_edges.get(&used.node()) else {
1040                        ogeom_bail!(Construction, "a face edge was not rebuilt");
1041                    };
1042                    edges.push(if used.orientation() == Orientation::Reversed {
1043                        fresh.reversed()
1044                    } else {
1045                        fresh.clone()
1046                    });
1047                }
1048                wires.push(edges);
1049            }
1050            let face = if face_uses.values().any(|c| *c >= 2) {
1051                // A seam in a wire-assembled face — a band a boolean split
1052                // into arc rings. Every ordinary pcurve is recomputed on the
1053                // moved surface; the seam's columns carry over, which the
1054                // seam rebuild already validated against the re-solved ends.
1055                assembled_with_seam(model, prep, &wires, &new_edges, tol)?
1056            } else {
1057                make_face_with_pcurves(model, prep.surface.clone(), &wires, tol)?.shape
1058            };
1059            if prep.shape.orientation() == Orientation::Reversed {
1060                face.reversed()
1061            } else {
1062                face
1063            }
1064        };
1065        history.modify(&prep.shape, built.clone());
1066        rebuilt_faces.push(built);
1067    }
1068
1069    let sewn = sew(model, &rebuilt_faces, tol)?;
1070    if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
1071        ogeom_bail!(Construction, "the offset solid did not close");
1072    }
1073    // The faces carried their use-orientations through; the *shell* has one
1074    // too, and a solid whose outer shell was used reversed reads inside out
1075    // if the rebuilt shell forgets it.
1076    let outer = {
1077        let old_reversed = model
1078            .children_of(solid)?
1079            .first()
1080            .is_some_and(|s| s.orientation() == Orientation::Reversed);
1081        if old_reversed {
1082            sewn.shells[0].reversed()
1083        } else {
1084            sewn.shells[0].clone()
1085        }
1086    };
1087    let built = make_solid(model, std::slice::from_ref(&outer))?;
1088
1089    // The one global guard the local checks cannot give: an offset that
1090    // moved faces past each other builds a shell that is closed and inside
1091    // out. Its measured volume is the tell.
1092    // The guard meshes at the default deflection, and a thin tangential
1093    // cusp — a small blend meeting its face — can defeat that resolution
1094    // without anything being wrong. One finer retry separates a mesh that
1095    // cannot see the cusp from a solid that is genuinely inside out.
1096    let mut mass = None;
1097    for chord in [ogeom_mesh::Deflection::default().chord, 1e-4] {
1098        let deflection = ogeom_mesh::Deflection {
1099            chord,
1100            ..ogeom_mesh::Deflection::default()
1101        };
1102        if let Ok(props) = ogeom_algo::volume_properties(model, &built.shape, deflection, tol) {
1103            mass = Some(props.mass);
1104            break;
1105        }
1106    }
1107    let Some(mass) = mass else {
1108        ogeom_bail!(
1109            Construction,
1110            "the offset solid's mesh does not close at any tried resolution"
1111        );
1112    };
1113    if !mass.is_finite() || mass <= tol.confusion() {
1114        ogeom_bail!(Construction, "the offset collapses the solid");
1115    }
1116
1117    history.modify(solid, built.shape.clone());
1118    Ok(Built::new(built.shape, history))
1119}
1120
1121/// The displacement that puts a point back on every moved plane: solve
1122/// `x · nᵢ = wᵢ` for the corner's normals, exactly for three, in the least
1123/// squares sense beyond.
1124/// Rebuild a seam for a face assembled wire by wire: the same iso-column on
1125/// the moved surface, over the same rows.
1126///
1127/// A same-family move preserves the chart — every point travels along its
1128/// own normal without changing its parameters — so the moved seam sits at
1129/// the column the old one's own pcurves state, between the re-solved end
1130/// vertices. `None` when the old edge carries no seam representation on this
1131/// face's surface.
1132fn rebuilt_seam_edge(
1133    model: &mut Model,
1134    edge: &Shape,
1135    prep: &Prepared,
1136    new_vertices: &HashMap<TShapeId, (Shape, Point)>,
1137    tol: Tolerances,
1138) -> OgeomResult<Option<Shape>> {
1139    use ogeom_geom::Curve2d as _;
1140
1141    let old_surface = {
1142        let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1143        else {
1144            ogeom_bail!(Construction, "face node holds no face data");
1145        };
1146        data.surface
1147    };
1148    let found = {
1149        let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1150            ogeom_bail!(Construction, "edge node holds no edge data");
1151        };
1152        let mut found = None;
1153        for repr in &data.representations {
1154            if let EdgeRepr::Seam {
1155                forward,
1156                surface,
1157                range,
1158                ..
1159            } = repr
1160                && *surface == old_surface
1161            {
1162                let Some(pcurve) = model.geometry().pcurve(*forward) else {
1163                    ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1164                };
1165                // The pcurve states the column the seam sits at. The rows
1166                // cannot come from it: the seam's ends are corner vertices,
1167                // moved by the corner solve rather than by this face alone.
1168                found = Some(pcurve.point_at(range.0, tol)?.x);
1169                break;
1170            }
1171        }
1172        found
1173    };
1174    let Some(column) = found else {
1175        return Ok(None);
1176    };
1177    let Some((sv, ev)) = edge_vertices(model, edge)? else {
1178        ogeom_bail!(Construction, "a seam has no vertices");
1179    };
1180    let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1181        new_vertices.get(&sv.node()).cloned(),
1182        new_vertices.get(&ev.node()).cloned(),
1183    ) else {
1184        ogeom_bail!(Construction, "a seam end has no re-solved vertex");
1185    };
1186    let Some(curve) = ogeom_algo::surface_iso_u_curve(&prep.surface, column, tol) else {
1187        ogeom_bail!(
1188            Construction,
1189            "the moved surface's iso-curve has no closed form; no seam can \
1190             be rebuilt"
1191        );
1192    };
1193    // The parameters the re-solved ends land at, by the iso-curve's own
1194    // closed form — the ends were Newton-polished onto this very surface, so
1195    // they lie on the curve exactly.
1196    let along = |p: Point| -> OgeomResult<f64> {
1197        match &curve {
1198            Curve::Line(l) => Ok((p - l.axis().location).dot(l.axis().direction.vector())),
1199            Curve::Circle(c) => {
1200                let local = c.circle().frame().to_local(p);
1201                let mut angle = local.y.atan2(local.x);
1202                if angle < 0.0 {
1203                    angle += core::f64::consts::TAU;
1204                }
1205                Ok(angle)
1206            }
1207            _ => ogeom_bail!(
1208                Construction,
1209                "the moved seam's iso-curve is neither straight nor circular"
1210            ),
1211        }
1212    };
1213    let (t_start, t_end) = (along(p_from)?, along(p_to)?);
1214    // Self-validation instead of trusting the move: a turned support only
1215    // keeps its column when the turn was built to — the re-solved ends say
1216    // whether it was.
1217    for (t, p) in [(t_start, p_from), (t_end, p_to)] {
1218        if curve.point_at(t, tol)?.distance(p) > tol.confusion() * 100.0 {
1219            return Ok(None);
1220        }
1221    }
1222    Ok(Some(if t_start <= t_end {
1223        make_edge_between(model, curve, (t_start, t_end), &v_from, &v_to, tol)?.shape
1224    } else {
1225        // The old seam ran against the iso-curve's own direction: build it
1226        // the way the curve runs, then hand back the reversed occurrence so
1227        // the wire's stored orientations still compose.
1228        make_edge_between(model, curve, (t_end, t_start), &v_to, &v_from, tol)?
1229            .shape
1230            .reversed()
1231    }))
1232}
1233
1234/// Assemble a moved face whose wires contain a seam.
1235///
1236/// Every ordinary edge gets its exact pcurve recomputed on the moved
1237/// surface. The seam is the one edge no closed-form projection can answer —
1238/// it needs a column per side — so its columns carry over from the old
1239/// face's own seam representation (a same-family move leaves the columns
1240/// where they were), rebuilt over the rows the moved seam actually spans.
1241fn assembled_with_seam(
1242    model: &mut Model,
1243    prep: &Prepared,
1244    wires: &[Vec<Shape>],
1245    new_edges: &HashMap<TShapeId, Shape>,
1246    tol: Tolerances,
1247) -> OgeomResult<Shape> {
1248    let mut rings: Vec<Shape> = Vec::with_capacity(wires.len());
1249    for edges in wires {
1250        rings.push(ogeom_algo::make_wire(model, edges, tol)?.shape);
1251    }
1252    let face = ogeom_algo::make_face(model, prep.surface.clone(), &rings, tol)?.shape;
1253    let new_surface = {
1254        let Some(NodeData::Face(data)) = model.node(&face).map(ogeom_topo::TShape::data) else {
1255            ogeom_bail!(Construction, "the face just built holds no face data");
1256        };
1257        data.surface
1258    };
1259    let old_surface = {
1260        let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1261        else {
1262            ogeom_bail!(Construction, "face node holds no face data");
1263        };
1264        data.surface
1265    };
1266
1267    let mut done: Vec<TShapeId> = Vec::new();
1268    for used in explore(model, &prep.shape, Filter::OfType(ShapeType::Edge))? {
1269        if done.contains(&used.node()) {
1270            continue;
1271        }
1272        done.push(used.node());
1273        let Some(fresh) = new_edges.get(&used.node()).cloned() else {
1274            ogeom_bail!(Construction, "a face edge was not rebuilt");
1275        };
1276        let (fresh_curve, fresh_range) = {
1277            let Some(data) = model.node(&fresh).and_then(|n| n.data().as_edge()) else {
1278                ogeom_bail!(Construction, "a rebuilt edge holds no edge data");
1279            };
1280            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1281                ogeom_bail!(Construction, "a rebuilt edge has no curve");
1282            };
1283            let Some(geometry) = model.geometry().curve(*curve) else {
1284                ogeom_bail!(Dangling, "curve is not in this model");
1285            };
1286            (geometry.clone(), *range)
1287        };
1288        let columns = {
1289            let Some(data) = model.node(&used).and_then(|n| n.data().as_edge()) else {
1290                ogeom_bail!(Construction, "edge node holds no edge data");
1291            };
1292            let mut columns = None;
1293            for repr in &data.representations {
1294                if let EdgeRepr::Seam {
1295                    forward,
1296                    reversed,
1297                    surface,
1298                    range,
1299                    ..
1300                } = repr
1301                    && *surface == old_surface
1302                {
1303                    use ogeom_geom::Curve2d as _;
1304                    let (Some(f), Some(r)) = (
1305                        model.geometry().pcurve(*forward),
1306                        model.geometry().pcurve(*reversed),
1307                    ) else {
1308                        ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1309                    };
1310                    columns = Some((f.point_at(range.0, tol)?.x, r.point_at(range.0, tol)?.x));
1311                    break;
1312                }
1313            }
1314            columns
1315        };
1316        if let Some((forward_col, reversed_col)) = columns {
1317            // The rows the moved seam spans, from its own rebuilt range —
1318            // identical to the curve range except a cone's slant rescale.
1319            let rows = match &prep.surface {
1320                SurfaceGeometry::Cone(c) => {
1321                    let cos = c.cone().half_angle().cos();
1322                    (fresh_range.0 * cos, fresh_range.1 * cos)
1323                }
1324                _ => fresh_range,
1325            };
1326            let column = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1327                Ok(ogeom_geom::Line2d::over(
1328                    ogeom_math::Axis2::new(
1329                        ogeom_math::Point2::new(u, 0.0),
1330                        ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, 1.0), tol)?,
1331                    ),
1332                    rows.0 - 1.0,
1333                    rows.1 + 1.0,
1334                )?
1335                .into())
1336            };
1337            ogeom_algo::attach_seam(
1338                model,
1339                &fresh,
1340                column(forward_col)?,
1341                column(reversed_col)?,
1342                new_surface,
1343                ogeom_topo::Location::identity(),
1344                rows,
1345            )?;
1346        } else {
1347            let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&fresh_curve, &prep.surface, tol)
1348            else {
1349                ogeom_bail!(
1350                    Construction,
1351                    "a moved face edge has no closed-form pcurve on its surface"
1352                );
1353            };
1354            ogeom_algo::attach_pcurve(
1355                model,
1356                &fresh,
1357                pcurve,
1358                new_surface,
1359                ogeom_topo::Location::identity(),
1360                fresh_range,
1361            )?;
1362        }
1363    }
1364    Ok(face)
1365}
1366
1367/// Rebuild an edge only one face owns: the ring a boolean left coincident
1368/// with a neighbour's twin, or a cone's apex.
1369///
1370/// A normal offset moves every point of a face along the face's own normal,
1371/// so three displaced samples of a ring pin the moved ring exactly — no
1372/// second face required. `None` when the edge is neither shape.
1373fn rebuilt_lone_edge(
1374    model: &mut Model,
1375    edge: &Shape,
1376    sides: &[usize],
1377    constraint: &Displacement<'_>,
1378    new_vertices: &HashMap<TShapeId, (Shape, Point)>,
1379    tol: Tolerances,
1380) -> OgeomResult<Option<Shape>> {
1381    use ogeom_geom::Curve3d as _;
1382
1383    let (degenerate, curve) = {
1384        let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1385            ogeom_bail!(Construction, "edge node holds no edge data");
1386        };
1387        let curve = data.curve3d().and_then(|repr| {
1388            let EdgeRepr::Curve3d { curve, range, .. } = repr else {
1389                return None;
1390            };
1391            model.geometry().curve(*curve).cloned().map(|c| (c, *range))
1392        });
1393        (data.degenerate, curve)
1394    };
1395    let Some((start, end)) = edge_vertices(model, edge)? else {
1396        ogeom_bail!(Construction, "a lone edge has no vertices");
1397    };
1398    if degenerate {
1399        // An apex: a rim of no length at the re-solved vertex.
1400        let Some((vertex, _)) = new_vertices.get(&start.node()) else {
1401            ogeom_bail!(Construction, "an apex has no re-solved vertex");
1402        };
1403        let mut data = EdgeData::new();
1404        data.degenerate = true;
1405        return Ok(Some(
1406            model.add_edge(data, &[vertex.clone(), vertex.clone()])?,
1407        ));
1408    }
1409    let (Some((Curve::Circle(c), range)), true, &[fi]) = (curve, start.node() == end.node(), sides)
1410    else {
1411        return Ok(None);
1412    };
1413    let circle = c.circle();
1414    let mut moved_points = Vec::with_capacity(3);
1415    for k in 0..3 {
1416        #[allow(clippy::cast_precision_loss, reason = "k is 0..3")]
1417        let t = (range.1 - range.0).mul_add(k as f64 / 3.0, range.0);
1418        let p = Curve::Circle(c).point_at(t, tol)?;
1419        let Some((n, w)) = constraint(model, fi, p)? else {
1420            return Ok(None);
1421        };
1422        moved_points.push(p + n * w);
1423    }
1424    // Equally spaced samples average to the centre; the displacement is
1425    // rotationally symmetric about the ring's own axis, so the moved ring is
1426    // concentric on it.
1427    let centre = Point::from_vector(
1428        moved_points
1429            .iter()
1430            .fold(Vector::new(0.0, 0.0, 0.0), |a, p| a + p.to_vector())
1431            / 3.0,
1432    );
1433    let radius = centre.distance(moved_points[0]);
1434    let reframed = ogeom_math::Circle::new(
1435        Frame::new(centre, circle.frame().z(), circle.frame().x(), tol)?,
1436        radius,
1437        tol,
1438    )?;
1439    let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
1440    Ok(Some(make_edge(model, moved, range, tol)?.shape))
1441}
1442
1443fn solve_corner(normals: &[Vector], amounts: &[f64], tol: Tolerances) -> OgeomResult<Vector> {
1444    // Normal equations: (NᵀN) x = Nᵀw — 3×3 whatever the seat count.
1445    let mut a = [[0.0_f64; 3]; 3];
1446    let mut b = [0.0_f64; 3];
1447    for (n, w) in normals.iter().zip(amounts) {
1448        let row = [n.x, n.y, n.z];
1449        for i in 0..3 {
1450            for j in 0..3 {
1451                a[i][j] += row[i] * row[j];
1452            }
1453            b[i] += row[i] * w;
1454        }
1455    }
1456    // For an edge between two planes the system is rank two; regularize
1457    // along the null direction (the edge itself), where the displacement is
1458    // rightly zero.
1459    if normals.len() == 2 {
1460        let along = normals[0].cross(normals[1]);
1461        let m = along.magnitude();
1462        if m <= tol.angular() {
1463            ogeom_bail!(Construction, "an edge between parallel faces has no corner");
1464        }
1465        let d = along / m;
1466        let row = [d.x, d.y, d.z];
1467        for i in 0..3 {
1468            for j in 0..3 {
1469                a[i][j] += row[i] * row[j];
1470            }
1471        }
1472    }
1473    let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
1474        - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
1475        + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
1476    if det.abs() <= tol.angular() * tol.angular() {
1477        ogeom_bail!(
1478            Construction,
1479            "a corner's faces are too nearly parallel to re-solve"
1480        );
1481    }
1482    let inv = |r: usize, c: usize| -> f64 {
1483        let (r1, r2) = ((r + 1) % 3, (r + 2) % 3);
1484        let (c1, c2) = ((c + 1) % 3, (c + 2) % 3);
1485        (a[c1][r1] * a[c2][r2] - a[c1][r2] * a[c2][r1]) / det
1486    };
1487    let mut x = [0.0_f64; 3];
1488    for (i, xi) in x.iter_mut().enumerate() {
1489        for (j, bj) in b.iter().enumerate() {
1490            *xi += inv(i, j) * bj;
1491        }
1492    }
1493    Ok(Vector::new(x[0], x[1], x[2]))
1494}