Skip to main content

brep_kernel/edit/direct_edit/
face_move.rs

1use super::*;
2
3// ---------------------------------------------------------------------------
4// §6.12 sibling operation: move a face group.
5// ---------------------------------------------------------------------------
6
7/// How the moved group uses an edge: not at all, on both sides (carried
8/// rigidly), or on exactly one side (the seam to re-intersect).
9enum EdgeMoveClass {
10    Fixed,
11    Interior,
12    Boundary { moved_face: u64, fixed_face: u64 },
13}
14
15enum EdgeMoveAction {
16    /// Translate the curve rigidly; parameters and pcurves stay exact.
17    Translate,
18    /// Replace the curve by the straight line between re-solved endpoints.
19    Rebuild { start: Vec3, end: Vec3 },
20    /// Map the curve by an exact affine (SM1b: the radial-scale-about-axis of a
21    /// rim re-intersecting a ruled carrier under an axis-parallel cap push).
22    /// Rational-quadratic circles map exactly, so parametrisation is preserved.
23    Transform(AffineTransform),
24    /// Replace the curve outright by an exactly RE-BUILT conic arc — the section
25    /// `plane ∩ ruled carrier` between two re-solved endpoints
26    /// (`conic_arc_on_ruled`). A re-trim cannot serve here: the boolean that
27    /// created these edges SPLIT them at the corner, so `domain == [t0, t1]` and
28    /// there is no parameter headroom for an outward push (verified on the
29    /// flatted-frustum fixture — the flat×cone hyperbolas and the cap's conic
30    /// rims alike). Used for a CONE's oblique multi-rim cap, where the rim's
31    /// homothety about the apex carries the whole conic exactly but slides the
32    /// ARC's endpoints off the fixed wall the corners must stay on.
33    Replace {
34        curve: NurbsCurve,
35    },
36}
37
38enum FaceMoveAction {
39    /// Shift the whole control net; pcurves stay exact for any carrier type.
40    TranslateSurface,
41    /// Rebuild the (planar) carrier around the new boundary and recompute
42    /// every pcurve — the direct-edit equivalent of "extend the neighbour".
43    Retrim(Plane),
44}
45
46/// `plane_of_surface` with per-face memoisation, because a face is consulted
47/// once per boundary edge, once per touched vertex, and once at re-trim time.
48fn cached_plane(
49    cache: &mut HashMap<u64, Plane>,
50    solid: &BrepSolid,
51    face_lookup: &HashMap<u64, (usize, usize)>,
52    face_id: u64,
53    tolerance: f64,
54) -> Result<Plane, String> {
55    if let Some(plane) = cache.get(&face_id) {
56        return Ok(*plane);
57    }
58    let (shell_index, face_index) = *face_lookup
59        .get(&face_id)
60        .ok_or_else(|| format!("move_faces: missing face {face_id}"))?;
61    let plane = plane_of_surface(
62        &solid.shells[shell_index].faces[face_index].surface,
63        tolerance,
64        "move_faces",
65    )?;
66    cache.insert(face_id, plane);
67    Ok(plane)
68}
69
70/// The carrier kind of a face, in the words a user would use — for refusals
71/// that must say WHAT the offending face is.
72fn carrier_kind_name(surface: &NurbsSurface) -> &'static str {
73    match surface.analytic() {
74        Some(AnalyticSurface::Plane { .. }) => "plane",
75        Some(AnalyticSurface::RuledRevolution { rho0, rho1, .. }) => {
76            let scale = rho0.abs().max(rho1.abs()).max(1.0);
77            if (rho1 - rho0).abs() <= 1e-9 * scale {
78                "cylinder"
79            } else {
80                "cone"
81            }
82        }
83        Some(AnalyticSurface::Sphere { .. }) => "sphere",
84        Some(AnalyticSurface::Torus { .. }) => "torus",
85        Some(AnalyticSurface::Revolution { .. }) => "general surface of revolution",
86        None => "free-form surface",
87    }
88}
89
90/// The refusal a face whose carrier this operation cannot use deserves: one
91/// that names the FACE, its CARRIER KIND and the ROLE it plays in the push.
92///
93/// Every call site below reaches `cached_plane` on a face it has already
94/// decided is not a supported carrier — so the string a user got was
95/// `plane_of_surface`'s own `move_faces: face is not planar (curved neighbours
96/// are deferred in this slice)` (`offset/retrim.rs:152`): a message from a
97/// shared helper that three features call, naming neither the face nor the
98/// neighbour, and carrying slice-scoped wording out of a general predicate. A
99/// torus groove, a barrel boss and a NURBS dimple all produced the same
100/// sentence, and the census that measured this file could not tell from the
101/// text which of the *nine* such call sites had fired
102/// (`offset-refusal-census.md` §7.2 attributed it to the wrong one).
103///
104/// This changes no verdict anywhere: it is applied as `map_err`, so a face
105/// `plane_of_surface` accepts is still accepted, on exactly the same inputs.
106fn unsupported_carrier(
107    solid: &BrepSolid,
108    face_lookup: &HashMap<u64, (usize, usize)>,
109    face_id: u64,
110    role: &str,
111) -> String {
112    let kind = face_lookup
113        .get(&face_id)
114        .map(|&(shell, face)| carrier_kind_name(&solid.shells[shell].faces[face].surface))
115        .unwrap_or("missing");
116    format!(
117        "move_faces: {role} (face {face_id}) is a {kind}; the plane push re-intersects only \
118         planar and ruled (cylinder / cone) carriers here — refusing"
119    )
120}
121
122/// Intersect three-or-more planes in one point: take the best-conditioned
123/// pair for the line, then the plane most transverse to that line for the
124/// point. The caller checks the residual against EVERY plane afterwards, so
125/// this only has to find *a* candidate, not prove consistency.
126fn solve_corner(planes: &[Plane]) -> Option<Vec3> {
127    let mut best_pair: Option<(usize, usize, f64)> = None;
128    for first in 0..planes.len() {
129        for second in first + 1..planes.len() {
130            let spread = planes[first].normal.cross(planes[second].normal).length();
131            if best_pair.map(|(_, _, best)| spread > best).unwrap_or(true) {
132                best_pair = Some((first, second, spread));
133            }
134        }
135    }
136    let (first, second, spread) = best_pair?;
137    if spread <= PARALLEL_EPS {
138        return None;
139    }
140    let line = intersect_planes(&planes[first], &planes[second])?;
141    let mut best_third: Option<(usize, f64)> = None;
142    for third in 0..planes.len() {
143        if third == first || third == second {
144            continue;
145        }
146        let transversality = line.dir.dot(planes[third].normal).abs();
147        if best_third
148            .map(|(_, best)| transversality > best)
149            .unwrap_or(true)
150        {
151            best_third = Some((third, transversality));
152        }
153    }
154    let (third, transversality) = best_third?;
155    if transversality <= PARALLEL_EPS {
156        return None;
157    }
158    intersect_line_plane(&line, &planes[third])
159}
160
161/// Rebuild a straight edge between two re-solved endpoints. Refuses the
162/// degenerate and inverted cases — a zero or reversed chord means the
163/// translation drove a moved face onto or past the neighbour this edge
164/// belongs to (e.g. pushing a box face through its opposite face).
165fn plan_straight_rebuild(
166    edge: &EdgeRecord,
167    start_old: Vec3,
168    end_old: Vec3,
169    start_new: Vec3,
170    end_new: Vec3,
171    tolerance: f64,
172) -> Result<EdgeMoveAction, String> {
173    if edge.degenerate {
174        return Err(format!(
175            "move_faces: degenerate edge {} would need re-stretching (deferred)",
176            edge.id
177        ));
178    }
179    if edge.curve.degree != 1 || edge.curve.control_points.len() != 2 {
180        return Err(format!(
181            "move_faces: edge {} must be re-stretched but is not a straight line \
182             (curved re-intersection edges are deferred in this slice)",
183            edge.id
184        ));
185    }
186    let new_chord = end_new.sub(start_new);
187    if new_chord.length() <= tolerance {
188        return Err(format!(
189            "move_faces: the translation collapses edge {} to zero length (a moved \
190             face lands exactly on its neighbour) — refusing",
191            edge.id
192        ));
193    }
194    if end_old.sub(start_old).dot(new_chord) <= 0.0 {
195        return Err(format!(
196            "move_faces: the translation inverts edge {} (a moved face passes beyond \
197             its neighbour) — refusing",
198            edge.id
199        ));
200    }
201    Ok(EdgeMoveAction::Rebuild {
202        start: start_new,
203        end: end_new,
204    })
205}
206
207/// The (linearly-varying) radius of a ruled revolution at axial coordinate
208/// `axial`: `rho0` at the base, `rho1` at `height`. Constant for a cylinder.
209fn rho_at(rho0: f64, rho1: f64, height: f64, axial: f64) -> f64 {
210    if height == 0.0 {
211        rho0
212    } else {
213        rho0 + (rho1 - rho0) * axial / height
214    }
215}
216
217/// Frame/radii/height of any revolution with a STRAIGHT (degree-1, unit-weight)
218/// generatrix — the full-2π `RuledRevolution` quadrics AND partial-sweep
219/// `Revolution`s whose generatrix is a line. The latter is how a fillet band /
220/// partial cylinder-or-cone wall recognizes (a straight profile swept less than
221/// 2π is the general `Revolution`, not `RuledRevolution`), so treating it as a
222/// ruled carrier is exactly what lets a face adjacent to a fillet band be pushed.
223///
224/// This mirrors `analytic_surface::intersect::ruled_revolution_data`, which
225/// already extracts the same `(frame, rho0, rho1, height)` from a partial-sweep
226/// `Revolution`; that function is private behind a private module (unreachable
227/// from here without editing `analytic_surface.rs`), so the tiny extraction is
228/// re-derived from the (public) frame basis instead of shared. A CURVED
229/// generatrix (sphere/torus-like, non-ruled) returns None → it must still refuse.
230fn ruled_revolution_carrier(
231    surface: &NurbsSurface,
232) -> Option<(crate::RevolutionFrame, f64, f64, f64)> {
233    match surface.analytic() {
234        Some(AnalyticSurface::RuledRevolution {
235            frame,
236            rho0,
237            rho1,
238            height,
239        }) => Some((frame.clone(), *rho0, *rho1, *height)),
240        Some(AnalyticSurface::Revolution {
241            frame, generatrix, ..
242        }) => {
243            // Unit-weight straight line only; a rational or higher-degree
244            // generatrix is a genuinely curved surface of revolution.
245            const UNIT_WEIGHT_TOL: f64 = 1e-9;
246            let controls = &generatrix.control_points;
247            if generatrix.degree != 1
248                || controls.len() != 2
249                || (controls[0].w - 1.0).abs() > UNIT_WEIGHT_TOL
250                || (controls[1].w - 1.0).abs() > UNIT_WEIGHT_TOL
251            {
252                return None;
253            }
254            // Cylindrical decomposition (radius, axial) from the public frame
255            // basis — `RevolutionFrame::cylindrical` is module-private.
256            let decompose = |point: Vec3| -> (f64, f64) {
257                let d = point.sub(frame.origin);
258                let axial = d.dot(frame.axis);
259                let radial = d.sub(frame.axis.scale(axial)).length();
260                (radial, axial)
261            };
262            let (rho0, z0) = decompose(controls[0].point().ok()?);
263            let (rho1, z1) = decompose(controls[1].point().ok()?);
264            let height = z1 - z0;
265            if height.abs() <= 1e-12 * (1.0 + rho0.abs().max(rho1.abs())) {
266                return None;
267            }
268            // Rebase the origin to the generatrix start's axial position so
269            // v = axial / height, exactly as the `RuledRevolution` variant does.
270            let origin = frame.origin.add(frame.axis.scale(z0));
271            Some((
272                crate::RevolutionFrame {
273                    origin,
274                    ..frame.clone()
275                },
276                rho0,
277                rho1,
278                height,
279            ))
280        }
281        _ => None,
282    }
283}
284
285/// The ruled-revolution carrier (cylinder OR cone, full-2π OR partial-sweep
286/// fillet band) of a face resolved by id, for ANY push direction (SM1/SM1b
287/// axis-parallel, SM1c oblique). A planar cap re-intersecting such a carrier
288/// under a push keeps the SAME surface and only re-trims; the rim maps by the
289/// exact affine `rim_ruled_map` (a homothety about the cone apex, or an axis
290/// translation for a cylinder), so unlike the earlier `axis_parallel_ruled`
291/// predicate this no longer requires the push to be parallel to the axis.
292fn carrier_ruled(
293    solid: &BrepSolid,
294    face_lookup: &HashMap<u64, (usize, usize)>,
295    face_id: u64,
296) -> Option<(crate::RevolutionFrame, f64, f64, f64)> {
297    let &(shell, face) = face_lookup.get(&face_id)?;
298    ruled_revolution_carrier(&solid.shells[shell].faces[face].surface)
299}
300
301/// The SPHERE carrier (frame + radius) of a face resolved by id, or `None` when
302/// it is not an analytic sphere. A planar push that borders a FIXED sphere
303/// re-intersects it in an EXACT circle (`intersect_plane_quadric` = plane ×
304/// sphere) and re-trims the sphere to that new rim — see
305/// `move_planar_face_across_sphere` (backlog #5, Plane × Sphere).
306fn carrier_sphere(
307    solid: &BrepSolid,
308    face_lookup: &HashMap<u64, (usize, usize)>,
309    face_id: u64,
310) -> Option<(crate::RevolutionFrame, f64)> {
311    let &(shell, face) = face_lookup.get(&face_id)?;
312    match solid.shells[shell].faces[face].surface.analytic() {
313        Some(AnalyticSurface::Sphere { frame, radius }) => Some((frame.clone(), *radius)),
314        _ => None,
315    }
316}
317
318/// True iff the face's carrier is a plane the push is PARALLEL to — the corner
319/// slides within it, carried rigidly by `+translation` (SM1's invariant-plane
320/// case, e.g. a box side wall as the top is pushed up).
321fn plane_parallel_to(
322    solid: &BrepSolid,
323    face_lookup: &HashMap<u64, (usize, usize)>,
324    face_id: u64,
325    translation: Vec3,
326    parallel_tol: f64,
327) -> bool {
328    let Some(&(shell, face)) = face_lookup.get(&face_id) else {
329        return false;
330    };
331    match solid.shells[shell].faces[face].surface.analytic() {
332        Some(AnalyticSurface::Plane { u_dir, v_dir, .. }) => {
333            let normal = u_dir.cross(*v_dir);
334            let len = normal.length();
335            len > 0.0 && translation.dot(normal).abs() / len <= parallel_tol
336        }
337        _ => false,
338    }
339}
340
341/// True iff a FIXED carrier is INVARIANT under `translation`, so a corner on it
342/// rides rigidly by `+translation` and provably stays on it. Two carriers are:
343///   • a plane the push is PARALLEL to (the corner slides in-plane), and
344///   • a CYLINDER whose axis the push is PARALLEL to (the point shifts along a
345///     generatrix, radius unchanged).
346/// A CONE is deliberately excluded: its radius varies with the axial coordinate,
347/// so an axis translation moves a point OFF the carrier — a corner there must
348/// re-intersect, not ride. This lets a corner shared by a fillet band (an
349/// axis-parallel partial cylinder) and a parallel wall ride rigidly under an
350/// axis-parallel push, while any oblique push falls through to the (planar-only)
351/// re-intersection path and refuses.
352fn carrier_invariant_under(
353    solid: &BrepSolid,
354    face_lookup: &HashMap<u64, (usize, usize)>,
355    face_id: u64,
356    translation: Vec3,
357    parallel_tol: f64,
358) -> bool {
359    if plane_parallel_to(solid, face_lookup, face_id, translation, parallel_tol) {
360        return true;
361    }
362    if let Some((frame, rho0, rho1, _height)) = carrier_ruled(solid, face_lookup, face_id) {
363        let radius_scale = rho0.abs().max(rho1.abs()).max(1.0);
364        let is_cylinder = (rho1 - rho0).abs() <= 1e-9 * radius_scale;
365        if is_cylinder {
366            // Invariant iff the translation is parallel to the axis, i.e. its
367            // component perpendicular to the axis is negligible.
368            let axis = frame.axis;
369            let perpendicular = translation.sub(axis.scale(translation.dot(axis)));
370            return perpendicular.length() <= parallel_tol;
371        }
372    }
373    false
374}
375
376/// The EXACT affine that re-intersects a planar cap's rim with a ruled
377/// revolution under a push of ANY direction — the SM1c generalisation of SM1b's
378/// axis-parallel radial-scale map:
379///
380/// - **Cone/frustum:** a homothety (central dilation) about the apex with ratio
381///   `λ = 1 + (n·T)/D₀`, where `n` is the cap-plane unit normal, `T` the
382///   translation, and `D₀ = n·(cap_origin − apex)` the signed apex→plane
383///   distance along `n`. The dilation maps the cone to itself and the cap plane
384///   to the TRANSLATED cap plane, so it maps the old rim exactly to the new one
385///   — for any push direction (oblique sections are hyperbola/ellipse arcs, and
386///   an affine maps rational curves control-point-wise, so parametrisation is
387///   preserved). For an axis-parallel ⊥-cap this reduces to the radial scale
388///   `s = rho_at(z+d)/rho_at(z)`.
389/// - **Cylinder (`rho0 == rho1`):** the carrier is invariant under axis
390///   translation, so the cap plane's shift maps to a pure axis shift `t·axis`
391///   with `t = (n·T)/(n·axis)` (SM1's `n = axis` case gives `t = d`).
392///
393/// Refuses a cap plane through the apex (`D₀ ≈ 0`), a push to/through the apex
394/// (`λ ≤ tol`), and a cap plane parallel to a cylinder axis (`n·axis ≈ 0`, a
395/// straight generatrix section handled by the straight-rebuild path).
396fn rim_ruled_map(
397    frame: &crate::RevolutionFrame,
398    rho0: f64,
399    rho1: f64,
400    height: f64,
401    moved_plane: &Plane,
402    translation: Vec3,
403    tolerance: f64,
404) -> Result<AffineTransform, String> {
405    let n = moved_plane.normal;
406    let axis = frame.axis;
407    let radius_scale = rho0.abs().max(rho1.abs()).max(1.0);
408    // Cylinder: axis-invariant carrier ⇒ the cap shift is a pure axis translation.
409    if (rho1 - rho0).abs() <= 1e-9 * radius_scale {
410        let axial_component = n.dot(axis);
411        if axial_component.abs() <= 1e-9 {
412            return Err(
413                "move_faces: the cap plane is parallel to the cylinder axis \
414                 (straight generatrix section) — refusing"
415                    .into(),
416            );
417        }
418        let shift = axis.scale(n.dot(translation) / axial_component);
419        return AffineTransform::new([
420            1.0, 0.0, 0.0, shift.x, //
421            0.0, 1.0, 0.0, shift.y, //
422            0.0, 0.0, 1.0, shift.z, //
423            0.0, 0.0, 0.0, 1.0,
424        ]);
425    }
426    // Cone/frustum: homothety about the apex (where rho_at → 0).
427    let z_apex = rho0 * height / (rho0 - rho1);
428    let apex = frame.origin.add(axis.scale(z_apex));
429    let d0 = n.dot(moved_plane.origin.sub(apex));
430    if d0.abs() <= tolerance {
431        return Err("move_faces: the cap plane passes through the cone apex — refusing".into());
432    }
433    let lambda = 1.0 + n.dot(translation) / d0;
434    if lambda <= tolerance {
435        return Err(
436            "move_faces: the push drives the cap to or past the cone apex \
437             (scale → 0) — refusing"
438                .into(),
439        );
440    }
441    // Y = apex + λ·(X − apex) = λ·X + (1−λ)·apex.
442    let offset = apex.scale(1.0 - lambda);
443    AffineTransform::new([
444        lambda, 0.0, 0.0, offset.x, //
445        0.0, lambda, 0.0, offset.y, //
446        0.0, 0.0, lambda, offset.z, //
447        0.0, 0.0, 0.0, 1.0,
448    ])
449}
450
451/// The checked contract for SM1b (the user's "reapply the trimming" semantics):
452/// the constructed rim must lie ON both modified carriers — the fixed ruled
453/// carrier (radius `rho_at`) and the translated moved plane. Samples the mapped
454/// rim; refuses if any sample drifts off either surface. The map is exact by
455/// construction, so this is a fail-safe guard, not the primary computation.
456fn verify_rim_on_carriers(
457    edge: &EdgeRecord,
458    map: &AffineTransform,
459    frame: &crate::RevolutionFrame,
460    rho0: f64,
461    rho1: f64,
462    height: f64,
463    moved_plane: &Plane,
464    translation: Vec3,
465    tolerance: f64,
466) -> Result<(), String> {
467    let (origin, axis) = (frame.origin, frame.axis);
468    let normal = moved_plane.normal;
469    let plane_point = moved_plane.origin.add(translation);
470    for step in 0..=8 {
471        let t = edge.t0 + (edge.t1 - edge.t0) * (step as f64 / 8.0);
472        let mapped = map.point(edge.curve.evaluate(t)?);
473        let delta = mapped.sub(origin);
474        let axial = delta.dot(axis);
475        let radial = delta.sub(axis.scale(axial)).length();
476        let off_ruled = (radial - rho_at(rho0, rho1, height, axial)).abs();
477        let off_plane = mapped.sub(plane_point).dot(normal).abs();
478        if off_ruled > 10.0 * tolerance || off_plane > 10.0 * tolerance {
479            return Err(format!(
480                "move_faces: the re-intersected rim does not lie on both modified carriers \
481                 (off ruled {off_ruled:.3e}, off plane {off_plane:.3e}) — refusing"
482            ));
483        }
484    }
485    Ok(())
486}
487
488/// True iff the face's carrier is a plane (the today path — planar retrim).
489fn carrier_is_planar(
490    solid: &BrepSolid,
491    face_lookup: &HashMap<u64, (usize, usize)>,
492    face_id: u64,
493) -> bool {
494    face_lookup
495        .get(&face_id)
496        .map(|&(shell, face)| {
497            matches!(
498                solid.shells[shell].faces[face].surface.analytic(),
499                Some(AnalyticSurface::Plane { .. })
500            )
501        })
502        .unwrap_or(false)
503}
504
505/// A rebuilt straight edge bordering a curved invariant carrier must still lie
506/// ON that carrier (endpoints + midpoint within `10·tol`), else the moved group
507/// tore off it — refuse rather than emit a bad solid.
508fn verify_chord_on_carrier(
509    solid: &BrepSolid,
510    face_lookup: &HashMap<u64, (usize, usize)>,
511    face_id: u64,
512    start: Vec3,
513    end: Vec3,
514    tolerance: f64,
515) -> Result<(), String> {
516    let (shell, face) = *face_lookup
517        .get(&face_id)
518        .ok_or_else(|| format!("move_faces: missing face {face_id}"))?;
519    // Measure against the ANALYTIC (unbounded) carrier, not the trimmed NURBS
520    // surface: the rebuilt chord routinely lands OUTSIDE the current v-domain
521    // (the carrier is grown to cover it afterwards), so a domain-clamped
522    // projection would report a false miss. For a cylinder "on the carrier" is
523    // just "radial distance from the axis == radius". Accepts a partial-sweep
524    // fillet band (`Revolution`) the same way as a full `RuledRevolution`.
525    let Some((frame, rho0, rho1, height)) =
526        ruled_revolution_carrier(&solid.shells[shell].faces[face].surface)
527    else {
528        return Err(format!(
529            "move_faces: chord-on-carrier check expects a ruled carrier (face {face_id})"
530        ));
531    };
532    let (origin, axis) = (frame.origin, frame.axis);
533    let midpoint = start.add(end).scale(0.5);
534    for point in [start, midpoint, end] {
535        let delta = point.sub(origin);
536        let axial = delta.dot(axis);
537        let radial = delta.sub(axis.scale(axial)).length();
538        // The generatrix radius VARIES along the axis on a cone, so compare
539        // against rho_at(axial), not a fixed rho0 (which is cylinder-only).
540        let radius = rho_at(rho0, rho1, height, axial);
541        if (radial - radius).abs() > 10.0 * tolerance {
542            return Err(format!(
543                "move_faces: rebuilt edge would leave its curved neighbour \
544                 (face {face_id}, off by {:.3e}) — refusing",
545                (radial - radius).abs()
546            ));
547        }
548    }
549    Ok(())
550}
551
552/// Grow a partial-sweep straight-generatrix `Revolution` neighbour (a fillet
553/// band / partial cylinder-or-cone wall) along its axis so its domain covers
554/// `points`. `extend_ruled_neighbour_over` only knows the full-2π
555/// `RuledRevolution` variant and no-ops on a partial `Revolution`; this is its
556/// partial-sweep analogue. The straight generatrix is prolonged along its own
557/// 3D line — which keeps its azimuth and radius law exactly — to the required
558/// axial span, and the band is re-revolved over the SAME sweep. `retrim_ruled_face`
559/// rebuilds every pcurve from scratch on the grown surface afterwards, so no
560/// v-remap is needed here. A non-`Revolution` surface, or one that already
561/// covers the boundary, is left untouched.
562fn extend_revolution_carrier_over(
563    solid: &mut BrepSolid,
564    face_id: u64,
565    points: &[Vec3],
566    tolerance: f64,
567) -> Result<(), String> {
568    let (shell, face_pos) = find_face(solid, face_id)
569        .ok_or_else(|| format!("move_faces: missing ruled face {face_id}"))?;
570    let Some(AnalyticSurface::Revolution {
571        frame,
572        sweep,
573        generatrix,
574        ..
575    }) = solid.shells[shell].faces[face_pos]
576        .surface
577        .analytic()
578        .cloned()
579    else {
580        return Ok(());
581    };
582    let controls = &generatrix.control_points;
583    if generatrix.degree != 1 || controls.len() != 2 {
584        return Ok(()); // curved generatrix — not a ruled band, nothing to grow
585    }
586    let p0 = controls[0].point()?;
587    let p1 = controls[1].point()?;
588    let axial = |p: Vec3| p.sub(frame.origin).dot(frame.axis);
589    let (z0, z1) = (axial(p0), axial(p1));
590    let (span_lo, span_hi) = (z0.min(z1), z0.max(z1));
591    let mut low = span_lo;
592    let mut high = span_hi;
593    for &point in points {
594        let a = axial(point);
595        low = low.min(a - tolerance);
596        high = high.max(a + tolerance);
597    }
598    if low >= span_lo - tolerance && high <= span_hi + tolerance {
599        return Ok(()); // the current band already covers the moved boundary
600    }
601    let denom = z1 - z0;
602    if denom.abs() <= tolerance {
603        return Ok(());
604    }
605    // Prolong the generatrix line to the required axial span (same 3D line ⇒
606    // same azimuth and radius law). Recognition validated the carrier by exact
607    // reconstruction, so `make_revolution(frame.origin, frame.axis, generatrix,
608    // sweep)` reproduces the SOURCE surface bit-for-bit — including its normal
609    // orientation — PROVIDED the generatrix keeps its control[0]→control[1]
610    // direction. So extend along that same sense (not blindly low→high), else
611    // the rebuilt surface's normal flips and the face reads as inside-out.
612    let point_at = |z: f64| -> Vec3 {
613        let t = (z - z0) / denom;
614        p0.add(p1.sub(p0).scale(t))
615    };
616    let (start_axial, end_axial) = if z1 >= z0 { (low, high) } else { (high, low) };
617    let extended = make_line(point_at(start_axial), point_at(end_axial))?;
618    let grown = make_revolution(frame.origin, frame.axis, &extended, sweep)?;
619    solid.shells[shell].faces[face_pos].surface = grown;
620    Ok(())
621}
622
623/// Re-trim a FIXED ruled neighbour whose boundary moved under an axis-parallel
624/// push: grow the carrier along its axis to cover the new boundary
625/// (`extend_ruled_neighbour_over` for a full-2π cylinder/cone,
626/// `extend_revolution_carrier_over` for a partial-sweep fillet band — both
627/// exact), then recompute every pcurve on the grown carrier from the
628/// already-updated edge curves. The direct-edit analogue of `retrim_planar_face`
629/// for a translation-invariant cylinder; all loops are visited, so holes on the
630/// neighbour are carried.
631///
632/// The three-phase body is `crate::offset_retrim::retrim_face_in_solid`; this is
633/// the two-step growth strategy and the `move_faces` refusal prefix. It differs
634/// from `face_offset::retrim_offset_ruled_face` in exactly that second growth
635/// call — the partial-sweep `Revolution` prolongation, which the offset push has
636/// never run.
637fn retrim_ruled_face(
638    solid: &mut BrepSolid,
639    face_id: u64,
640    final_edges: &HashMap<u64, EdgeRecord>,
641    tolerance: f64,
642) -> Result<(), String> {
643    let (shell, face_pos) = find_face(solid, face_id)
644        .ok_or_else(|| format!("move_faces: missing ruled face {face_id}"))?;
645    retrim_face_in_solid(
646        solid,
647        shell,
648        face_pos,
649        final_edges,
650        |solid, points| {
651            extend_ruled_neighbour_over(solid, face_id, points, tolerance)?;
652            extend_revolution_carrier_over(solid, face_id, points, tolerance)
653        },
654        PcurveFit::SubrangeAware { tolerance },
655        "move_faces",
656    )
657}
658
659/// The EXACT corner where a fixed PLANE, a fixed RULED carrier and the
660/// TRANSLATED cap plane meet: the line `fixed plane ∩ translated cap plane`
661/// intersected with the ruled carrier (a quadratic in the line parameter),
662/// taking the root nearest the corner's OLD position so the corner moves
663/// continuously.
664///
665/// This is the carrier-level solve that a CURVED fixed edge needs.
666/// `resolve_corner_on_fixed_edge` brackets strictly INSIDE the fixed edge's
667/// domain, and the boolean that produced such an edge split it exactly at the
668/// corner (`domain == [t0, t1]`), so riding the edge can only ever move a corner
669/// INWARD — an outward push would refuse for a purely representational reason.
670/// The carriers have no such horizon.
671fn corner_on_plane_and_ruled(
672    fixed_plane: &Plane,
673    cap_normal: Vec3,
674    cap_c: f64,
675    frame: &crate::RevolutionFrame,
676    rho0: f64,
677    rho1: f64,
678    height: f64,
679    old_corner: Vec3,
680    tolerance: f64,
681) -> Result<Vec3, String> {
682    let direction = fixed_plane.normal.cross(cap_normal);
683    if direction.length() <= PARALLEL_EPS {
684        return Err(
685            "move_faces: the pushed cap plane is parallel to a fixed planar neighbour \
686             (no corner) — refusing"
687                .into(),
688        );
689    }
690    let direction = direction.normalized()?;
691    // A point on both planes, taken in the 2-D span of the two normals.
692    let ca = fixed_plane.normal.dot(fixed_plane.origin);
693    let naa = fixed_plane.normal.dot(fixed_plane.normal);
694    let nab = fixed_plane.normal.dot(cap_normal);
695    let nbb = cap_normal.dot(cap_normal);
696    let determinant = naa * nbb - nab * nab;
697    if determinant.abs() <= PARALLEL_EPS {
698        return Err("move_faces: cannot place the corner's carrier line — refusing".into());
699    }
700    let alpha = (ca * nbb - cap_c * nab) / determinant;
701    let beta = (cap_c * naa - ca * nab) / determinant;
702    let base = fixed_plane
703        .normal
704        .scale(alpha)
705        .add(cap_normal.scale(beta));
706    // |P − O|² − axial² = rho_at(axial)² along P(s) = base + s·direction.
707    let offset = base.sub(frame.origin);
708    let axis = frame.axis;
709    let slope = if height == 0.0 {
710        0.0
711    } else {
712        (rho1 - rho0) / height
713    };
714    let a0 = offset.dot(axis);
715    let a1 = direction.dot(axis);
716    let r0 = rho0 + slope * a0;
717    let quad = 1.0 - a1 * a1 - slope * slope * a1 * a1;
718    let linear = 2.0 * offset.dot(direction) - 2.0 * a0 * a1 - 2.0 * slope * a1 * r0;
719    let constant = offset.dot(offset) - a0 * a0 - r0 * r0;
720    let mut roots: Vec<f64> = Vec::new();
721    if quad.abs() <= 1e-12 {
722        if linear.abs() > 1e-12 {
723            roots.push(-constant / linear);
724        }
725    } else {
726        let discriminant = linear * linear - 4.0 * quad * constant;
727        if discriminant >= 0.0 {
728            let root = discriminant.sqrt();
729            roots.push((-linear + root) / (2.0 * quad));
730            roots.push((-linear - root) / (2.0 * quad));
731        }
732    }
733    let mut best: Option<(Vec3, f64)> = None;
734    for s in roots {
735        let point = base.add(direction.scale(s));
736        // Only the nappe with a NON-NEGATIVE radius is the real carrier.
737        if rho_at(rho0, rho1, height, point.sub(frame.origin).dot(axis)) < -tolerance {
738            continue;
739        }
740        let distance = point.sub(old_corner).length();
741        if best.map(|(_, best)| distance < best).unwrap_or(true) {
742            best = Some((point, distance));
743        }
744    }
745    let (corner, _) = best.ok_or_else(|| {
746        "move_faces: the pushed cap plane no longer meets the fixed ruled neighbour \
747         (the push drives the corner off the carrier) — refusing"
748            .to_string()
749    })?;
750    Ok(corner)
751}
752
753/// True iff `curve[t0..t1]` sweeps in the POSITIVE azimuth sense about `frame`
754/// (the sense `make_arc` builds), decided by whether its midpoint's azimuth lies
755/// inside the positive sweep from the start's to the end's.
756fn arc_sweeps_forward(
757    frame: &crate::RevolutionFrame,
758    curve: &NurbsCurve,
759    t0: f64,
760    t1: f64,
761) -> Result<bool, String> {
762    let azimuth = |point: Vec3| {
763        let delta = point.sub(frame.origin);
764        delta.dot(frame.y_axis).atan2(delta.dot(frame.x_axis))
765    };
766    let tau = std::f64::consts::TAU;
767    let wrap = |angle: f64| {
768        let value = angle % tau;
769        if value < 0.0 {
770            value + tau
771        } else {
772            value
773        }
774    };
775    let start = azimuth(curve.evaluate(t0)?);
776    let end = azimuth(curve.evaluate(t1)?);
777    let middle = azimuth(curve.evaluate(0.5 * (t0 + t1))?);
778    Ok(wrap(middle - start) <= wrap(end - start))
779}
780
781/// The EXACT arc of `plane ∩ cone` between two endpoints that already lie on
782/// both, swept in the given sense.
783///
784/// A cone's plane section is the PROJECTIVE image, from the apex, of the base
785/// circle: the ray `apex → X` meets the plane at `apex + (k/((X−apex)·n))·(X−apex)`
786/// with `k = c − n·apex`, which is LINEAR in the homogeneous control point — so
787/// the image of a rational-quadratic circular arc is a rational-quadratic conic
788/// arc on the SAME knot vector, exactly. Azimuth is constant along a cone ray,
789/// so the endpoints' azimuths give the base arc directly. This is exact for
790/// ELLIPTIC and HYPERBOLIC sections alike (the full hyperbola cannot be one
791/// rational Bezier — its weights change sign — but an arc that stays on one
792/// nappe can, which is why the sign check below is the only restriction).
793///
794/// The kernel's own `intersect_plane_quadric` builds a cone section the same way
795/// but only ever for the FULL section, so it refuses exactly the hyperbolic case
796/// this arc-restricted form supports.
797fn conic_arc_on_ruled(
798    frame: &crate::RevolutionFrame,
799    rho0: f64,
800    rho1: f64,
801    height: f64,
802    plane_normal: Vec3,
803    plane_c: f64,
804    start: Vec3,
805    end: Vec3,
806    forward_sweep: bool,
807    tolerance: f64,
808) -> Result<NurbsCurve, String> {
809    let radius_scale = rho0.abs().max(rho1.abs()).max(1.0);
810    if (rho1 - rho0).abs() <= 1e-9 * radius_scale {
811        // A cylinder's plane section is an ellipse (or a generatrix pair); its
812        // straight sections are already handled by the chord rebuild and no
813        // fixture exercises a curved one, so it stays an honest refusal.
814        return Err(
815            "move_faces: rebuilding a curved section on a CYLINDER carrier is deferred \
816             — refusing"
817                .into(),
818        );
819    }
820    let axis = frame.axis;
821    let apex = frame
822        .origin
823        .add(axis.scale(rho0 * height / (rho0 - rho1)));
824    let k = plane_c - plane_normal.dot(apex);
825    if k.abs() <= tolerance {
826        return Err("move_faces: the section plane passes through the cone apex — refusing".into());
827    }
828    // Base circle at whichever end has the LARGER radius, so it never degenerates.
829    let (reference_rho, reference_axial) = if rho0.abs() >= rho1.abs() {
830        (rho0.abs(), 0.0)
831    } else {
832        (rho1.abs(), height)
833    };
834    if reference_rho <= tolerance {
835        return Err("move_faces: the cone carrier degenerates to its apex — refusing".into());
836    }
837    let azimuth = |point: Vec3| {
838        let delta = point.sub(frame.origin);
839        delta.dot(frame.y_axis).atan2(delta.dot(frame.x_axis))
840    };
841    let tau = std::f64::consts::TAU;
842    let wrap = |angle: f64| {
843        let value = angle % tau;
844        if value < 0.0 {
845            value + tau
846        } else {
847            value
848        }
849    };
850    let (start_angle, sweep, reverse) = if forward_sweep {
851        (azimuth(start), wrap(azimuth(end) - azimuth(start)), false)
852    } else {
853        (azimuth(end), wrap(azimuth(start) - azimuth(end)), true)
854    };
855    if sweep <= 1e-9 || sweep >= tau - 1e-9 {
856        return Err(
857            "move_faces: the re-intersected arc degenerates to a point or a full turn \
858             — refusing"
859                .into(),
860        );
861    }
862    let circle = crate::make_arc(
863        frame.origin.add(axis.scale(reference_axial)),
864        frame.x_axis,
865        frame.y_axis,
866        reference_rho,
867        start_angle,
868        start_angle + sweep,
869    )?;
870    let mut mapped: Vec<crate::Vec4> = Vec::with_capacity(circle.control_points.len());
871    let mut sign = 0.0f64;
872    for control in &circle.control_points {
873        let relative = Vec3::new(
874            control.x - control.w * apex.x,
875            control.y - control.w * apex.y,
876            control.z - control.w * apex.z,
877        );
878        let weight = relative.dot(plane_normal);
879        if weight.abs() <= 1e-9 * radius_scale {
880            return Err(
881                "move_faces: the re-intersected section runs along an asymptotic ruling \
882                 of the cone — refusing"
883                    .into(),
884            );
885        }
886        if sign == 0.0 {
887            sign = weight.signum();
888        } else if weight.signum() != sign {
889            return Err(
890                "move_faces: the re-intersected section crosses the cone's apex plane \
891                 (both nappes) — refusing"
892                    .into(),
893            );
894        }
895        let scaled = relative.scale(k);
896        mapped.push(crate::Vec4 {
897            x: apex.x * weight + scaled.x,
898            y: apex.y * weight + scaled.y,
899            z: apex.z * weight + scaled.z,
900            w: weight,
901        });
902    }
903    if sign < 0.0 {
904        // Homogeneously identical, but the kernel keeps weights positive.
905        for control in &mut mapped {
906            control.x = -control.x;
907            control.y = -control.y;
908            control.z = -control.z;
909            control.w = -control.w;
910        }
911    }
912    let mut curve = NurbsCurve::new(circle.degree, circle.knots.clone(), mapped)?;
913    if reverse {
914        curve = curve.reversed()?;
915    }
916    // Fail-safe: the rebuilt arc must run between the given corners and lie on
917    // BOTH carriers — the same "reapply the trimming" contract the affine rim map
918    // is held to in `verify_rim_on_carriers`.
919    let [d0, d1] = curve.domain()?;
920    for (parameter, target) in [(d0, start), (d1, end)] {
921        let drift = curve.evaluate(parameter)?.sub(target).length();
922        if drift > 10.0 * tolerance {
923            return Err(format!(
924                "move_faces: the rebuilt section misses its corner by {drift:.3e} — refusing"
925            ));
926        }
927    }
928    for step in 0..=8 {
929        let point = curve.evaluate(d0 + (d1 - d0) * (step as f64 / 8.0))?;
930        let delta = point.sub(frame.origin);
931        let axial = delta.dot(axis);
932        let off_ruled = (delta.sub(axis.scale(axial)).length()
933            - rho_at(rho0, rho1, height, axial))
934        .abs();
935        let off_plane = (point.dot(plane_normal) - plane_c).abs();
936        if off_ruled > 10.0 * tolerance || off_plane > 10.0 * tolerance {
937            return Err(format!(
938                "move_faces: the rebuilt section does not lie on both carriers \
939                 (off ruled {off_ruled:.3e}, off plane {off_plane:.3e}) — refusing"
940            ));
941        }
942    }
943    Ok(curve)
944}
945
946/// The fixed PLANE and the fixed RULED carrier an edge is shared by, when it is
947/// shared by exactly one of each (the flat×cone hyperbola configuration).
948fn plane_and_ruled_carriers(
949    solid: &BrepSolid,
950    face_lookup: &HashMap<u64, (usize, usize)>,
951    planes: &mut HashMap<u64, Plane>,
952    face_ids: &[u64],
953    plane_tolerance: f64,
954) -> Option<(Plane, (crate::RevolutionFrame, f64, f64, f64))> {
955    let mut plane = None;
956    let mut ruled = None;
957    for &face_id in face_ids {
958        if let Some(carrier) = carrier_ruled(solid, face_lookup, face_id) {
959            if ruled.is_some() {
960                return None;
961            }
962            ruled = Some(carrier);
963        } else if carrier_is_planar(solid, face_lookup, face_id) {
964            if plane.is_some() {
965                return None;
966            }
967            plane = Some(cached_plane(planes, solid, face_lookup, face_id, plane_tolerance).ok()?);
968        } else {
969            return None;
970        }
971    }
972    Some((plane?, ruled?))
973}
974
975/// Re-solve a cap corner that is shared with a FIXED ruled carrier (a split
976/// cylinder/cone band), which the planar 3-plane `solve_corner` cannot place
977/// (its carrier is not a plane). The corner rides the ONE fixed edge it shares
978/// with the body: the new corner is where the TRANSLATED cap plane crosses that
979/// fixed edge's curve. The fixed edge itself stays put — only its cap-side trim
980/// endpoint slides along it (`plan_straight_rebuild` re-lays a straight
981/// generatrix between the new and the untouched endpoints afterwards).
982///
983/// This is the SM1c multi-rim generalisation: the single-closed-rim oblique cap
984/// (no corners) is the `fixed_at.len() == 1` rim-ride; a cap bounded by several
985/// conic rims meeting at seam corners needs each corner re-placed here.
986///
987/// - **Straight generatrix (degree 1):** exact line solve. A line is its own
988///   natural extension, so a crossing OUTSIDE the fixed edge's current span
989///   (the outward push that lengthens the wall) is exact and accepted.
990/// - **Conic (degree 2+):** bracket a sign change WITHIN the domain only and
991///   refine — a rational conic extended past its span rides the end tangent,
992///   not the conic, so an out-of-domain crossing is refused. The root nearest
993///   the corner's own end parameter is chosen so the corner moves continuously.
994///   This is now a FALLBACK: booleans split such an edge exactly at the corner
995///   (`domain == [t0, t1]`), so an outward push has no room to bracket into.
996///   When the corner's fixed carriers are one plane and one ruled surface, the
997///   caller solves on the CARRIERS instead (`corner_on_plane_and_ruled`), which
998///   has no such horizon; this arm only runs for configurations that solve does
999///   not cover.
1000///
1001/// Refuses cleanly when the fixed edge runs in the cap plane (no crossing) or
1002/// the push drives the corner off the edge's reachable span (a tearing push).
1003fn resolve_corner_on_fixed_edge(
1004    fixed_edge: &EdgeRecord,
1005    seed_param: f64,
1006    plane_normal: Vec3,
1007    plane_c: f64,
1008    tolerance: f64,
1009) -> Result<Vec3, String> {
1010    let curve = &fixed_edge.curve;
1011    if curve.degree == 1 && curve.control_points.len() == 2 {
1012        let p0 = curve.control_points[0].point()?;
1013        let p1 = curve.control_points[1].point()?;
1014        let dir = p1.sub(p0);
1015        let denom = plane_normal.dot(dir);
1016        if denom.abs() <= PARALLEL_EPS * (1.0 + dir.length()) {
1017            return Err(format!(
1018                "move_faces: fixed edge {} runs parallel to the cap plane (no crossing) — refusing",
1019                fixed_edge.id
1020            ));
1021        }
1022        let s = (plane_c - plane_normal.dot(p0)) / denom;
1023        return Ok(p0.add(dir.scale(s)));
1024    }
1025    // Curved (conic) fixed edge — closed-form-in-spirit numeric solve strictly
1026    // within the domain. `n·C(u) − c` has the sign of the numerator polynomial
1027    // (weights are strictly positive), so its roots are the crossings.
1028    let [d0, d1] = curve.domain()?;
1029    let f = |u: f64| -> Result<f64, String> { Ok(plane_normal.dot(curve.evaluate(u)?) - plane_c) };
1030    const STEPS: usize = 96;
1031    let mut best: Option<(f64, f64)> = None;
1032    let mut prev_u = d0;
1033    let mut prev_f = f(d0)?;
1034    if prev_f.abs() <= tolerance {
1035        best = Some((d0, (d0 - seed_param).abs()));
1036    }
1037    for i in 1..=STEPS {
1038        let u = d0 + (d1 - d0) * (i as f64 / STEPS as f64);
1039        let fu = f(u)?;
1040        if prev_f * fu < 0.0 {
1041            let (mut lo, mut hi, mut flo) = (prev_u, u, prev_f);
1042            for _ in 0..64 {
1043                let mid = 0.5 * (lo + hi);
1044                let fm = f(mid)?;
1045                if flo * fm <= 0.0 {
1046                    hi = mid;
1047                } else {
1048                    lo = mid;
1049                    flo = fm;
1050                }
1051            }
1052            let root = 0.5 * (lo + hi);
1053            let dist = (root - seed_param).abs();
1054            if best.map(|(_, bd)| dist < bd).unwrap_or(true) {
1055                best = Some((root, dist));
1056            }
1057        }
1058        prev_u = u;
1059        prev_f = fu;
1060    }
1061    let (root, _) = best.ok_or_else(|| {
1062        format!(
1063            "move_faces: the pushed cap does not re-cross fixed edge {} within its span \
1064             (curved-fixed-edge extension deferred, or the push tears the face) — refusing",
1065            fixed_edge.id
1066        )
1067    })?;
1068    curve.evaluate(root)
1069}
1070
1071/// Golovanov §6.12 direct editing — translate a group of faces rigidly and
1072/// heal the adjacency with the faces that stay behind.
1073///
1074/// The moved carriers translate exactly (every control point shifts by the
1075/// translation, which is exact for ANY surface type), and each boundary edge
1076/// between a moved face and a fixed face is recomputed as the intersection of
1077/// the translated moved carrier with the fixed carrier:
1078///
1079/// - When the translation is parallel to every fixed plane a boundary vertex
1080///   touches, the whole neighbourhood translates rigidly — exact for any
1081///   moved carrier and any edge curve type. This is the extrude-like case:
1082///   pushing a face along its own normal slides the side walls in-plane.
1083/// - Otherwise the new corner is re-solved as the common point of ALL carrier
1084///   planes meeting at the vertex (moved ones translated), and every affected
1085///   straight edge is rebuilt between the re-solved corners — the same
1086///   relocate-onto-recovered-corners move `delete_face_and_heal` performs on
1087///   its side edges.
1088///
1089/// Scope (honest refusals, never a bad solid): the moved faces may be any
1090/// surface type. A FIXED face that must be re-intersected — the fixed side of a
1091/// boundary edge, or any face whose boundary edges must be rebuilt — must be
1092/// PLANAR, OR an AXIS-PARALLEL ruled revolution: a cylinder OR a cone whose axis
1093/// the push is parallel to (SM1/SM1b). Such a carrier keeps its SAME surface and
1094/// only re-trims — its rim re-intersects it at a new radius, constructed by the
1095/// exact radial-scale map `EdgeMoveAction::Transform` (`s = rho_at(z+d)/rho_at(z)`;
1096/// the cylinder is `s = 1`) and verified to lie on both modified carriers, then
1097/// grown along the axis (`retrim_ruled_face`). Every straight rebuilt edge (a
1098/// seam/generatrix) is verified on its carrier at its own `rho_at` radius.
1099///
1100/// An OBLIQUE cap push is supported on both carriers. The rim is the affine
1101/// image `rim_ruled_map` (a cylinder's axis translation, a cone's homothety
1102/// about the apex) — exact for the whole conic — and where that affine's image
1103/// of a corner disagrees with the corner itself (a CONE, whose homothety slides
1104/// the arc's endpoint off the fixed flat), the rim and the CURVED fixed edge it
1105/// meets are RE-BUILT as exact conic sections between the re-solved corners
1106/// (`conic_arc_on_ruled`, `EdgeMoveAction::Replace`); the corner itself comes
1107/// from the carrier-level solve `corner_on_plane_and_ruled`. A curved fixed edge
1108/// on a CYLINDER carrier, spheres, tori, general revolutions and a cap pushed
1109/// to/through the apex are refused (SM3 is the general offset path).
1110/// A translation that collapses an adjacent edge to zero length or reverses
1111/// its direction (moving a box face onto or past its opposite face) is
1112/// refused, as is a group that tears away from its neighbours. The input is
1113/// never mutated; the result is returned only when `validate()` is clean.
1114pub fn move_faces(
1115    solid: &BrepSolid,
1116    face_ids: &[u64],
1117    translation: Vec3,
1118) -> Result<BrepSolid, String> {
1119    if !(translation.x.is_finite() && translation.y.is_finite() && translation.z.is_finite()) {
1120        return Err("move_faces: translation must be finite".into());
1121    }
1122    if face_ids.is_empty() {
1123        return Err("move_faces: no faces selected".into());
1124    }
1125    let moved: HashSet<u64> = face_ids.iter().copied().collect();
1126    // face id -> (shell, face) built once. move_faces never mutates `solid`,
1127    // so this replaces the O(faces) `find_face` scans in the validation loop
1128    // below and in `cached_plane` (called up to once per unique fixed face).
1129    // `or_insert` keeps the first match, mirroring `find_face`.
1130    let mut face_lookup: HashMap<u64, (usize, usize)> = HashMap::default();
1131    for (shell_index, shell) in solid.shells.iter().enumerate() {
1132        for (face_index, face) in shell.faces.iter().enumerate() {
1133            face_lookup
1134                .entry(face.id)
1135                .or_insert((shell_index, face_index));
1136        }
1137    }
1138    for &face_id in face_ids {
1139        if !face_lookup.contains_key(&face_id) {
1140            return Err(format!("move_faces: no face with id {face_id}"));
1141        }
1142    }
1143
1144    let scale = solid_model_scale(solid);
1145    let tolerance = (scale * 1e-7).max(1e-9);
1146    let plane_tolerance = (scale * 1e-6).max(1e-7);
1147    // "Parallel to a fixed plane" means the translation's normal component
1148    // could not move any point off that plane at model precision.
1149    let parallel_tolerance = (translation.length() * 1e-9).max(1e-12);
1150    // "Rigid" endpoints moved by exactly the translation (they are assigned
1151    // `point + translation` verbatim, so this only absorbs rounding noise).
1152    let rigid_tolerance = (scale * 1e-9).max(1e-12);
1153
1154    // --- Classify every edge by how the group uses it ----------------------
1155    let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
1156    for shell in &solid.shells {
1157        for face in &shell.faces {
1158            for loop_record in &face.loops {
1159                for coedge in &loop_record.coedges {
1160                    faces_of_edge
1161                        .entry(coedge.edge_id)
1162                        .or_default()
1163                        .push(face.id);
1164                }
1165            }
1166        }
1167    }
1168    // --- Plane × Sphere fast path (backlog #5) -----------------------------
1169    // A planar push whose FIXED neighbour across a boundary edge is a SPHERE
1170    // cannot be healed by the planar/ruled machinery below: the sphere's seam
1171    // meridian is a CURVED fixed edge (which `plan_straight_rebuild` refuses),
1172    // and its periodic u=0/u=2π seam pcurves must be patched in parameter space,
1173    // not refit from scratch. Route those to a dedicated handler that
1174    // re-intersects the translated plane with the fixed sphere (an EXACT circle)
1175    // and re-trims the sphere. Every configuration that handler does not support
1176    // refuses cleanly there — and every such case refuses in the generic path
1177    // today too, so this routing can only turn a refusal into a heal (it never
1178    // changes an already-supported case).
1179    let borders_a_sphere = solid.edges.iter().any(|edge| {
1180        let uses = faces_of_edge
1181            .get(&edge.id)
1182            .map(Vec::as_slice)
1183            .unwrap_or(&[]);
1184        let moved_uses = uses.iter().filter(|f| moved.contains(*f)).count();
1185        moved_uses > 0
1186            && moved_uses < uses.len()
1187            && uses
1188                .iter()
1189                .any(|f| !moved.contains(f) && carrier_sphere(solid, &face_lookup, *f).is_some())
1190    });
1191    if borders_a_sphere {
1192        return move_planar_face_across_sphere(solid, &moved, &face_lookup, &faces_of_edge, translation);
1193    }
1194
1195    let mut classes: HashMap<u64, EdgeMoveClass> = HashMap::default();
1196    let mut planes: HashMap<u64, Plane> = HashMap::default();
1197    for edge in &solid.edges {
1198        let uses = faces_of_edge
1199            .get(&edge.id)
1200            .map(Vec::as_slice)
1201            .unwrap_or(&[]);
1202        let expected = if edge.degenerate { 1 } else { 2 };
1203        if uses.len() != expected {
1204            return Err(format!(
1205                "move_faces: edge {} is used {} times (non-manifold input)",
1206                edge.id,
1207                uses.len()
1208            ));
1209        }
1210        let moved_uses = uses
1211            .iter()
1212            .filter(|face_id| moved.contains(*face_id))
1213            .count();
1214        let class = if moved_uses == 0 {
1215            EdgeMoveClass::Fixed
1216        } else if moved_uses == uses.len() {
1217            EdgeMoveClass::Interior
1218        } else {
1219            let moved_face = *uses
1220                .iter()
1221                .find(|face_id| moved.contains(*face_id))
1222                .unwrap();
1223            let fixed_face = *uses
1224                .iter()
1225                .find(|face_id| !moved.contains(*face_id))
1226                .unwrap();
1227            // The face left behind across a boundary edge is the carrier we
1228            // re-intersect against. Planar → cache its plane (today path).
1229            // Non-planar but a ruled revolution (a cylinder OR a cone) → allowed:
1230            // the cap rim re-intersects the SAME carrier, re-trimmed as a ruled
1231            // carrier via the exact `rim_ruled_map` for ANY push direction
1232            // (SM1/SM1b axis-parallel, SM1c oblique). Any other non-planar
1233            // carrier still refuses.
1234            if carrier_is_planar(solid, &face_lookup, fixed_face) {
1235                cached_plane(&mut planes, solid, &face_lookup, fixed_face, plane_tolerance)?;
1236            } else if carrier_ruled(solid, &face_lookup, fixed_face).is_none() {
1237                // A geometrically-planar face the recognizer did not TAG as a
1238                // plane (an imported B-spline patch, say) still passes here —
1239                // only its message changes. A genuinely curved carrier refuses,
1240                // and this is the arm it refuses at: the classification loop,
1241                // long before the face-action loop the census cites.
1242                cached_plane(&mut planes, solid, &face_lookup, fixed_face, plane_tolerance)
1243                    .map_err(|_| {
1244                        unsupported_carrier(
1245                            solid,
1246                            &face_lookup,
1247                            fixed_face,
1248                            &format!(
1249                                "the fixed neighbour across boundary edge {}",
1250                                edge.id
1251                            ),
1252                        )
1253                    })?;
1254            }
1255            EdgeMoveClass::Boundary {
1256                moved_face,
1257                fixed_face,
1258            }
1259        };
1260        classes.insert(edge.id, class);
1261    }
1262
1263    // --- Relocate every vertex the group touches ---------------------------
1264    let mut vertex_faces: HashMap<u64, HashSet<u64>> = HashMap::default();
1265    for edge in &solid.edges {
1266        if let Some(uses) = faces_of_edge.get(&edge.id) {
1267            for vertex_id in [edge.start_vertex_id, edge.end_vertex_id] {
1268                vertex_faces
1269                    .entry(vertex_id)
1270                    .or_default()
1271                    .extend(uses.iter().copied());
1272            }
1273        }
1274    }
1275    let mut new_vertex: HashMap<u64, Vec3> = HashMap::default();
1276    for vertex in &solid.vertices {
1277        let Some(adjacent) = vertex_faces.get(&vertex.id) else {
1278            continue;
1279        };
1280        if !adjacent.iter().any(|face_id| moved.contains(face_id)) {
1281            continue;
1282        }
1283        let fixed_at: Vec<u64> = adjacent
1284            .iter()
1285            .copied()
1286            .filter(|face_id| !moved.contains(face_id))
1287            .collect();
1288        if fixed_at.is_empty() {
1289            // Interior vertex: carried rigidly with the group.
1290            new_vertex.insert(vertex.id, vertex.point.add(translation));
1291            continue;
1292        }
1293        // SM1b/SM1c: a corner on a single ruled neighbour rides that rim's exact
1294        // affine map — it stays on the (unchanged) cylinder/cone at the re-
1295        // intersected position. The map is the cap's homothety about the cone
1296        // apex (or an axis translation for a cylinder) and no longer requires the
1297        // push to be axis-parallel, so an OBLIQUE cap push relocates the seam
1298        // vertex onto the new conic rim exactly.
1299        if fixed_at.len() == 1 {
1300            if let Some((frame, rho0, rho1, height)) =
1301                carrier_ruled(solid, &face_lookup, fixed_at[0])
1302            {
1303                // The cap sharing this ruled rim is the moved planar face at the
1304                // vertex; its plane defines the homothety (D₀ from the apex).
1305                if let Some(cap) = adjacent.iter().copied().find(|f| moved.contains(f)) {
1306                    let moved_plane =
1307                        cached_plane(&mut planes, solid, &face_lookup, cap, plane_tolerance)
1308                            .map_err(|_| {
1309                                unsupported_carrier(
1310                                    solid,
1311                                    &face_lookup,
1312                                    cap,
1313                                    &format!(
1314                                        "the MOVED cap meeting a ruled neighbour at vertex {}",
1315                                        vertex.id
1316                                    ),
1317                                )
1318                            })?;
1319                    let map =
1320                        rim_ruled_map(&frame, rho0, rho1, height, &moved_plane, translation, tolerance)?;
1321                    new_vertex.insert(vertex.id, map.point(vertex.point));
1322                    continue;
1323                }
1324            }
1325        }
1326        // If every fixed carrier is INVARIANT under the push — a plane parallel
1327        // to it, or an axis-parallel cylinder (e.g. a fillet band) — the corner
1328        // rides rigidly (stays on all of them + on every translated moved plane).
1329        if fixed_at.iter().all(|&face_id| {
1330            carrier_invariant_under(solid, &face_lookup, face_id, translation, parallel_tolerance)
1331        }) {
1332            new_vertex.insert(vertex.id, vertex.point.add(translation));
1333            continue;
1334        }
1335        // SM1c multi-rim: a corner shared with a FIXED ruled carrier (a split
1336        // cylinder/cone band) cannot be placed by the planar 3-plane solver — its
1337        // carrier is not a plane. Ride it along the single fixed edge it shares:
1338        // the new corner is where the TRANSLATED cap plane crosses that fixed
1339        // edge's curve. Consistent + valid only when the affine rim map that
1340        // carries the adjacent conic rim agrees with this ride (an axis-parallel
1341        // cylinder generatrix); a cone homothety moves the corner off the fixed
1342        // wall, so the consistency gate below refuses that (curved multi-rim
1343        // cone deferred to the rim re-trim path).
1344        if fixed_at
1345            .iter()
1346            .any(|&f| carrier_ruled(solid, &face_lookup, f).is_some())
1347        {
1348            let moved_here: Vec<u64> = adjacent
1349                .iter()
1350                .copied()
1351                .filter(|f| moved.contains(f))
1352                .collect();
1353            if moved_here.len() != 1 {
1354                return Err(format!(
1355                    "move_faces: corner at vertex {} touches {} moved faces against a ruled \
1356                     neighbour (single-cap multi-rim only) — refusing",
1357                    vertex.id,
1358                    moved_here.len()
1359                ));
1360            }
1361            let cap_plane =
1362                cached_plane(&mut planes, solid, &face_lookup, moved_here[0], plane_tolerance)
1363                    .map_err(|_| {
1364                        unsupported_carrier(
1365                            solid,
1366                            &face_lookup,
1367                            moved_here[0],
1368                            &format!("the MOVED cap at vertex {}", vertex.id),
1369                        )
1370                    })?;
1371            let fixed_edges: Vec<&EdgeRecord> = solid
1372                .edges
1373                .iter()
1374                .filter(|e| {
1375                    (e.start_vertex_id == vertex.id || e.end_vertex_id == vertex.id)
1376                        && matches!(classes.get(&e.id), Some(EdgeMoveClass::Fixed))
1377                })
1378                .collect();
1379            if fixed_edges.len() != 1 {
1380                return Err(format!(
1381                    "move_faces: corner at vertex {} rides {} fixed edges against a ruled \
1382                     neighbour (exactly one required) — refusing",
1383                    vertex.id,
1384                    fixed_edges.len()
1385                ));
1386            }
1387            let fixed_edge = fixed_edges[0];
1388            let seed = if fixed_edge.start_vertex_id == vertex.id {
1389                fixed_edge.t0
1390            } else {
1391                fixed_edge.t1
1392            };
1393            let translated_origin = cap_plane.origin.add(translation);
1394            let plane_c = cap_plane.normal.dot(translated_origin);
1395            // A STRAIGHT fixed edge (a cylinder generatrix on a flat, or a cone's
1396            // seam meridian — which passes through the apex, so the rim homothety
1397            // agrees with it) rides its own line: exact, and unchanged since SM1c.
1398            // A CURVED fixed edge (the hyperbola where a flat cuts a cone) is
1399            // solved on the CARRIERS instead: the boolean split it exactly at the
1400            // corner, so riding it could only ever move the corner inward.
1401            let straight_fixed_edge = fixed_edge.curve.degree == 1
1402                && fixed_edge.curve.control_points.len() == 2;
1403            let carriers = if straight_fixed_edge {
1404                None
1405            } else {
1406                plane_and_ruled_carriers(
1407                    solid,
1408                    &face_lookup,
1409                    &mut planes,
1410                    &fixed_at,
1411                    plane_tolerance,
1412                )
1413            };
1414            let corner = match carriers {
1415                Some((fixed_plane, (frame, rho0, rho1, height))) => corner_on_plane_and_ruled(
1416                    &fixed_plane,
1417                    cap_plane.normal,
1418                    plane_c,
1419                    &frame,
1420                    rho0,
1421                    rho1,
1422                    height,
1423                    vertex.point,
1424                    tolerance,
1425                )?,
1426                None => resolve_corner_on_fixed_edge(
1427                    fixed_edge,
1428                    seed,
1429                    cap_plane.normal,
1430                    plane_c,
1431                    tolerance,
1432                )?,
1433            };
1434            // The new corner must genuinely sit on EVERY fixed carrier at the
1435            // vertex (ruled: at its rho_at radius; planar: on the plane), else
1436            // the group tore away — refuse rather than emit a bad solid.
1437            for &f in &fixed_at {
1438                if let Some((frame, rho0, rho1, height)) = carrier_ruled(solid, &face_lookup, f) {
1439                    let delta = corner.sub(frame.origin);
1440                    let axial = delta.dot(frame.axis);
1441                    let radial = delta.sub(frame.axis.scale(axial)).length();
1442                    let off = (radial - rho_at(rho0, rho1, height, axial)).abs();
1443                    if off > 10.0 * tolerance {
1444                        return Err(format!(
1445                            "move_faces: re-solved corner at vertex {} left its ruled neighbour \
1446                             (off {off:.3e}) — refusing",
1447                            vertex.id
1448                        ));
1449                    }
1450                } else {
1451                    let plane = cached_plane(&mut planes, solid, &face_lookup, f, plane_tolerance)
1452                        .map_err(|_| {
1453                            unsupported_carrier(
1454                                solid,
1455                                &face_lookup,
1456                                f,
1457                                &format!("a FIXED neighbour at vertex {}", vertex.id),
1458                            )
1459                        })?;
1460                    if corner.sub(plane.origin).dot(plane.normal).abs() > 10.0 * tolerance {
1461                        return Err(format!(
1462                            "move_faces: re-solved corner at vertex {} left a fixed planar \
1463                             neighbour — refusing",
1464                            vertex.id
1465                        ));
1466                    }
1467                }
1468            }
1469            // The re-solved corner is TRUTH: it is the only point lying on the
1470            // fixed wall, the fixed flat AND the translated cap plane at once.
1471            //
1472            // The conic rim bordering the fixed ruled carrier is carried by the
1473            // EXACT affine `rim_ruled_map`, which maps the WHOLE conic correctly
1474            // (carrier → itself, cap plane → translated cap plane). On a CYLINDER
1475            // — an axis translation along a generatrix of a flat that contains the
1476            // axis — its image of the old corner IS this corner, so the rim keeps
1477            // its affine map untouched. On a CONE it is a homothety about the
1478            // apex: the rim CURVE is still exact but the arc's ENDPOINT slides off
1479            // the fixed flat, so the rim edge is RE-BUILT between the re-solved
1480            // corners instead (`EdgeMoveAction::Replace`, see the Boundary arm).
1481            // This used to be a consistency gate that refused the cone outright.
1482            new_vertex.insert(vertex.id, corner);
1483            continue;
1484        }
1485        // Genuine re-intersection: every carrier meeting at the corner must
1486        // be planar to solve the new corner in closed form.
1487        let mut corner_planes = Vec::with_capacity(fixed_at.len());
1488        for &face_id in &fixed_at {
1489            corner_planes.push(
1490                cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance).map_err(
1491                    |_| {
1492                        unsupported_carrier(
1493                            solid,
1494                            &face_lookup,
1495                            face_id,
1496                            &format!("a FIXED carrier meeting the moved group at vertex {}", vertex.id),
1497                        )
1498                    },
1499                )?,
1500            );
1501        }
1502        for face_id in adjacent
1503            .iter()
1504            .copied()
1505            .filter(|face_id| moved.contains(face_id))
1506        {
1507            let mut plane = cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance)
1508                .map_err(|_| {
1509                    unsupported_carrier(
1510                        solid,
1511                        &face_lookup,
1512                        face_id,
1513                        &format!("a MOVED carrier meeting a fixed neighbour at vertex {}", vertex.id),
1514                    )
1515                })?;
1516            plane.origin = plane.origin.add(translation);
1517            corner_planes.push(plane);
1518        }
1519        let corner = solve_corner(&corner_planes).ok_or_else(|| {
1520            format!(
1521                "move_faces: cannot re-intersect the carriers meeting at vertex {} \
1522                 (parallel or under-constrained planes)",
1523                vertex.id
1524            )
1525        })?;
1526        // The corner must genuinely sit on EVERY carrier; otherwise the group
1527        // tears away from its fixed neighbours and no manifold heal exists.
1528        for plane in &corner_planes {
1529            if corner.sub(plane.origin).dot(plane.normal).abs() > tolerance {
1530                return Err(format!(
1531                    "move_faces: the moved group tears away from its neighbours at \
1532                     vertex {} — refusing rather than emitting an invalid solid",
1533                    vertex.id
1534                ));
1535            }
1536        }
1537        new_vertex.insert(vertex.id, corner);
1538    }
1539
1540    // --- Plan every edge update -------------------------------------------
1541    let vertex_position: HashMap<u64, Vec3> = solid
1542        .vertices
1543        .iter()
1544        .map(|vertex| (vertex.id, vertex.point))
1545        .collect();
1546    let mut actions: HashMap<u64, EdgeMoveAction> = HashMap::default();
1547    for edge in &solid.edges {
1548        let position = |vertex_id: u64| -> Result<Vec3, String> {
1549            vertex_position
1550                .get(&vertex_id)
1551                .copied()
1552                .ok_or_else(|| format!("move_faces: missing vertex {vertex_id}"))
1553        };
1554        let start_old = position(edge.start_vertex_id)?;
1555        let end_old = position(edge.end_vertex_id)?;
1556        let start_new = new_vertex
1557            .get(&edge.start_vertex_id)
1558            .copied()
1559            .unwrap_or(start_old);
1560        let end_new = new_vertex
1561            .get(&edge.end_vertex_id)
1562            .copied()
1563            .unwrap_or(end_old);
1564        let rigid = start_new.sub(start_old.add(translation)).length() <= rigid_tolerance
1565            && end_new.sub(end_old.add(translation)).length() <= rigid_tolerance;
1566        match &classes[&edge.id] {
1567            EdgeMoveClass::Fixed => {
1568                if start_new.sub(start_old).length() == 0.0 && end_new.sub(end_old).length() == 0.0
1569                {
1570                    continue; // no endpoint relocated — the edge is untouched
1571                }
1572                // A fixed side edge follows its re-solved endpoint, exactly as
1573                // delete_face_and_heal relocates side edges onto recovered
1574                // corners. Its faces get re-trimmed: planar faces need their
1575                // plane; a ruled carrier (the drilled-hole seam, or a cone's
1576                // extended generatrix) re-trims as a ruled carrier.
1577                for &face_id in &faces_of_edge[&edge.id] {
1578                    if carrier_ruled(solid, &face_lookup, face_id).is_none() {
1579                        cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance)
1580                            .map_err(|_| {
1581                                unsupported_carrier(
1582                                    solid,
1583                                    &face_lookup,
1584                                    face_id,
1585                                    &format!(
1586                                        "a carrier of fixed edge {}, whose trim the push relocates",
1587                                        edge.id
1588                                    ),
1589                                )
1590                            })?;
1591                    }
1592                }
1593                if !edge.degenerate
1594                    && (edge.curve.degree != 1 || edge.curve.control_points.len() != 2)
1595                {
1596                    // CURVED fixed edge — the hyperbola where a flat cuts a cone,
1597                    // whose cap-side endpoint rides along it. BOTH its carriers
1598                    // stayed put, so the section is unchanged as a SET, but the
1599                    // boolean split the curve exactly at the corner (`domain ==
1600                    // [t0, t1]`), leaving no parameter headroom for an outward
1601                    // push — so the arc is RE-BUILT between its endpoints. (A
1602                    // straight-chord rebuild, what a degree-1 generatrix gets,
1603                    // would leave the cone; that is why this used to refuse.)
1604                    // Same collapse/inversion guards as `plan_straight_rebuild`,
1605                    // so a tearing push still refuses.
1606                    let new_chord = end_new.sub(start_new);
1607                    if new_chord.length() <= tolerance {
1608                        return Err(format!(
1609                            "move_faces: the translation collapses edge {} to zero length (a \
1610                             moved face lands exactly on its neighbour) — refusing",
1611                            edge.id
1612                        ));
1613                    }
1614                    if end_old.sub(start_old).dot(new_chord) <= 0.0 {
1615                        return Err(format!(
1616                            "move_faces: the translation inverts edge {} (a moved face passes \
1617                             beyond its neighbour) — refusing",
1618                            edge.id
1619                        ));
1620                    }
1621                    let (fixed_plane, (frame, rho0, rho1, height)) = plane_and_ruled_carriers(
1622                        solid,
1623                        &face_lookup,
1624                        &mut planes,
1625                        &faces_of_edge[&edge.id],
1626                        plane_tolerance,
1627                    )
1628                    .ok_or_else(|| {
1629                        format!(
1630                            "move_faces: curved fixed edge {} is not shared by exactly one plane \
1631                             and one ruled carrier — refusing",
1632                            edge.id
1633                        )
1634                    })?;
1635                    let forward =
1636                        arc_sweeps_forward(&frame, &edge.curve, edge.t0, edge.t1)?;
1637                    let curve = conic_arc_on_ruled(
1638                        &frame,
1639                        rho0,
1640                        rho1,
1641                        height,
1642                        fixed_plane.normal,
1643                        fixed_plane.normal.dot(fixed_plane.origin),
1644                        start_new,
1645                        end_new,
1646                        forward,
1647                        tolerance,
1648                    )?;
1649                    actions.insert(edge.id, EdgeMoveAction::Replace { curve });
1650                    continue;
1651                }
1652                let action =
1653                    plan_straight_rebuild(edge, start_old, end_old, start_new, end_new, tolerance)?;
1654                // A chord rebuilt against a ruled carrier must stay ON it — the
1655                // seam/generatrix endpoints and midpoint at their own rho_at radius.
1656                if let EdgeMoveAction::Rebuild { start, end } = &action {
1657                    for &face_id in &faces_of_edge[&edge.id] {
1658                        if carrier_ruled(solid, &face_lookup, face_id).is_some() {
1659                            verify_chord_on_carrier(
1660                                solid,
1661                                &face_lookup,
1662                                face_id,
1663                                *start,
1664                                *end,
1665                                tolerance,
1666                            )?;
1667                        }
1668                    }
1669                }
1670                actions.insert(edge.id, action);
1671            }
1672            EdgeMoveClass::Interior => {
1673                if rigid {
1674                    actions.insert(edge.id, EdgeMoveAction::Translate);
1675                } else {
1676                    // A tangential translation left the carriers in place, so
1677                    // an interior edge must stretch between re-solved corners
1678                    // instead of riding along (both faces are planar-checked).
1679                    for &face_id in &faces_of_edge[&edge.id] {
1680                        cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance)
1681                            .map_err(|_| {
1682                                unsupported_carrier(
1683                                    solid,
1684                                    &face_lookup,
1685                                    face_id,
1686                                    &format!(
1687                                        "a carrier of interior edge {}, which must stretch between \
1688                                         re-solved corners",
1689                                        edge.id
1690                                    ),
1691                                )
1692                            })?;
1693                    }
1694                    actions.insert(
1695                        edge.id,
1696                        plan_straight_rebuild(
1697                            edge, start_old, end_old, start_new, end_new, tolerance,
1698                        )?,
1699                    );
1700                }
1701            }
1702            EdgeMoveClass::Boundary {
1703                moved_face,
1704                fixed_face,
1705            } => {
1706                if let Some((frame, rho0, rho1, height)) =
1707                    carrier_ruled(solid, &face_lookup, *fixed_face)
1708                {
1709                    // The rim re-intersects the (unchanged) ruled carrier. Build
1710                    // that trim as the EXACT affine map of the rim (= the
1711                    // intersection of the translated cap plane with the carrier):
1712                    // a homothety about the cone apex, or an axis translation for
1713                    // a cylinder — for ANY push direction. Then verify it lies on
1714                    // both modified carriers (the "reapply the trimming"
1715                    // contract). The moved side must be planar (a cap);
1716                    // `cached_plane` refuses otherwise.
1717                    let moved_plane = cached_plane(
1718                        &mut planes,
1719                        solid,
1720                        &face_lookup,
1721                        *moved_face,
1722                        plane_tolerance,
1723                    )
1724                    .map_err(|_| {
1725                        unsupported_carrier(
1726                            solid,
1727                            &face_lookup,
1728                            *moved_face,
1729                            &format!(
1730                                "the MOVED side of boundary edge {} against a ruled neighbour",
1731                                edge.id
1732                            ),
1733                        )
1734                    })?;
1735                    let map =
1736                        rim_ruled_map(&frame, rho0, rho1, height, &moved_plane, translation, tolerance)?;
1737                    verify_rim_on_carriers(
1738                        edge,
1739                        &map,
1740                        &frame,
1741                        rho0,
1742                        rho1,
1743                        height,
1744                        &moved_plane,
1745                        translation,
1746                        tolerance,
1747                    )?;
1748                    // The affine carries the WHOLE conic exactly, but a CONE's
1749                    // homothety about the apex slides the ARC's endpoints off the
1750                    // fixed flat the corners must stay on — and the boolean left
1751                    // the rim with no parameter headroom (`domain == [t0, t1]`),
1752                    // so it cannot simply be re-trimmed either. When the map and
1753                    // the re-solved corners disagree, RE-BUILD the rim as the
1754                    // exact section of the TRANSLATED cap plane with the carrier,
1755                    // between those corners. A closed rim (one vertex, no corner)
1756                    // and a cylinder (whose map already lands on the corners) take
1757                    // the untouched affine path.
1758                    let mut replacement = None;
1759                    if edge.start_vertex_id != edge.end_vertex_id {
1760                        let old_start = edge.curve.evaluate(edge.t0)?;
1761                        let old_end = edge.curve.evaluate(edge.t1)?;
1762                        let rim_start = new_vertex
1763                            .get(&edge.start_vertex_id)
1764                            .copied()
1765                            .unwrap_or(old_start);
1766                        let rim_end = new_vertex
1767                            .get(&edge.end_vertex_id)
1768                            .copied()
1769                            .unwrap_or(old_end);
1770                        let drift = map
1771                            .point(old_start)
1772                            .sub(rim_start)
1773                            .length()
1774                            .max(map.point(old_end).sub(rim_end).length());
1775                        if drift > 10.0 * tolerance {
1776                            let forward =
1777                                arc_sweeps_forward(&frame, &edge.curve, edge.t0, edge.t1)?;
1778                            let plane_c = moved_plane
1779                                .normal
1780                                .dot(moved_plane.origin.add(translation));
1781                            replacement = Some(conic_arc_on_ruled(
1782                                &frame,
1783                                rho0,
1784                                rho1,
1785                                height,
1786                                moved_plane.normal,
1787                                plane_c,
1788                                rim_start,
1789                                rim_end,
1790                                forward,
1791                                tolerance,
1792                            )?);
1793                        }
1794                    }
1795                    actions.insert(
1796                        edge.id,
1797                        match replacement {
1798                            Some(curve) => EdgeMoveAction::Replace { curve },
1799                            None => EdgeMoveAction::Transform(map),
1800                        },
1801                    );
1802                } else if carrier_is_planar(solid, &face_lookup, *fixed_face) {
1803                    let fixed_plane = planes[fixed_face];
1804                    if rigid && translation.dot(fixed_plane.normal).abs() <= parallel_tolerance {
1805                        // The whole edge slides inside the fixed plane while
1806                        // staying on the translated moved carrier — exact for any
1807                        // curve type, no re-intersection needed.
1808                        actions.insert(edge.id, EdgeMoveAction::Translate);
1809                    } else {
1810                        // Real re-intersection: line = translated moved plane ∩
1811                        // fixed plane, delimited by the re-solved corners. The
1812                        // moved side must be planar for the chord to stay on it.
1813                        cached_plane(
1814                            &mut planes,
1815                            solid,
1816                            &face_lookup,
1817                            *moved_face,
1818                            plane_tolerance,
1819                        )
1820                        .map_err(|_| {
1821                            unsupported_carrier(
1822                                solid,
1823                                &face_lookup,
1824                                *moved_face,
1825                                &format!(
1826                                    "the MOVED side of boundary edge {} against a planar neighbour",
1827                                    edge.id
1828                                ),
1829                            )
1830                        })?;
1831                        actions.insert(
1832                            edge.id,
1833                            plan_straight_rebuild(
1834                                edge, start_old, end_old, start_new, end_new, tolerance,
1835                            )?,
1836                        );
1837                    }
1838                } else {
1839                    return Err(format!(
1840                        "move_faces: boundary edge {} borders a non-planar, non-axis-parallel \
1841                         carrier — refusing (SM3 territory)",
1842                        edge.id
1843                    ));
1844                }
1845            }
1846        }
1847    }
1848
1849    // --- Plan face updates -------------------------------------------------
1850    // Edges whose SHAPE changed — rebuilt straight OR affine-transformed rim. A
1851    // moved face bounding one of these must be RE-TRIMMED (its rim moved to a new
1852    // curve), not merely surface-translated; else its pcurve goes stale.
1853    let reshaped: HashSet<u64> = actions
1854        .iter()
1855        .filter(|(_, action)| {
1856            matches!(
1857                action,
1858                EdgeMoveAction::Rebuild { .. }
1859                    | EdgeMoveAction::Transform(_)
1860                    | EdgeMoveAction::Replace { .. }
1861            )
1862        })
1863        .map(|(edge_id, _)| *edge_id)
1864        .collect();
1865    let dirty: HashSet<u64> = actions.keys().copied().collect();
1866    let mut face_actions: Vec<(usize, usize, FaceMoveAction)> = Vec::new();
1867    // Fixed cylinder neighbours re-trim in a separate post-pass (they grow the
1868    // carrier via `extend_ruled_neighbour_over`, which needs `&mut solid`).
1869    let mut ruled_retrim_faces: Vec<u64> = Vec::new();
1870    for (shell_index, shell) in solid.shells.iter().enumerate() {
1871        for (face_index, face) in shell.faces.iter().enumerate() {
1872            let edge_ids = || {
1873                face.loops
1874                    .iter()
1875                    .flat_map(|loop_record| &loop_record.coedges)
1876                    .map(|coedge| coedge.edge_id)
1877            };
1878            if moved.contains(&face.id) {
1879                if edge_ids().any(|edge_id| reshaped.contains(&edge_id)) {
1880                    // A boundary edge changed shape (stretched, or a cap rim grown
1881                    // by the radial-scale map), so the patch must be re-trimmed
1882                    // around it; the moved cap is planar (translated), so its plane
1883                    // simply shifts by `translation` before the retrim.
1884                    let mut plane =
1885                        cached_plane(&mut planes, solid, &face_lookup, face.id, plane_tolerance)
1886                            .map_err(|_| {
1887                                unsupported_carrier(
1888                                    solid,
1889                                    &face_lookup,
1890                                    face.id,
1891                                    "the MOVED face whose rim the push reshaped",
1892                                )
1893                            })?;
1894                    plane.origin = plane.origin.add(translation);
1895                    face_actions.push((shell_index, face_index, FaceMoveAction::Retrim(plane)));
1896                } else {
1897                    // Every edge of the face rode along rigidly: shifting the
1898                    // control net keeps surface, curves, and pcurves in exact
1899                    // agreement for ANY carrier type.
1900                    face_actions.push((shell_index, face_index, FaceMoveAction::TranslateSurface));
1901                }
1902            } else if edge_ids().any(|edge_id| dirty.contains(&edge_id)) {
1903                if carrier_is_planar(solid, &face_lookup, face.id) {
1904                    let plane =
1905                        cached_plane(&mut planes, solid, &face_lookup, face.id, plane_tolerance)?;
1906                    face_actions.push((shell_index, face_index, FaceMoveAction::Retrim(plane)));
1907                } else if carrier_ruled(solid, &face_lookup, face.id).is_some() {
1908                    // Ruled carrier (drilled hole, boss cap, OR a cone whose trim
1909                    // extended under an oblique cap push): grow it along its axis +
1910                    // recompute pcurves in the post-pass.
1911                    ruled_retrim_faces.push(face.id);
1912                } else {
1913                    // Non-planar, non-ruled fixed carrier — refuse (the general
1914                    // offset path does the genuine curved re-intersection).
1915                    //
1916                    // MEASURED, and it corrects a just-landed claim: this arm is
1917                    // NOT the one a curved fixed neighbour reaches. A curved
1918                    // neighbour that borders the moved group is refused by the
1919                    // classification loop's own carrier gate (search
1920                    // `the fixed neighbour across boundary edge`) hundreds of
1921                    // lines earlier, so this arm can only be entered by a face
1922                    // whose edges went dirty WITHOUT it sharing a boundary edge
1923                    // with the moved group — which needs a vertex of valence
1924                    // four or more. `offset-refusal-census.md` §7.2 attributes
1925                    // the Plane × Torus refusal here; the probe
1926                    // (`examples/plane_push_refusal_probe.rs`,
1927                    // `carrier/torus.*`) shows the classification arm firing.
1928                    cached_plane(&mut planes, solid, &face_lookup, face.id, plane_tolerance)
1929                        .map_err(|_| {
1930                            unsupported_carrier(
1931                                solid,
1932                                &face_lookup,
1933                                face.id,
1934                                "a FIXED face the push must re-trim",
1935                            )
1936                        })?;
1937                }
1938            }
1939        }
1940    }
1941
1942    // --- Apply to a fresh clone (the input is never touched) ---------------
1943    let translate = AffineTransform::new([
1944        1.0,
1945        0.0,
1946        0.0,
1947        translation.x,
1948        0.0,
1949        1.0,
1950        0.0,
1951        translation.y,
1952        0.0,
1953        0.0,
1954        1.0,
1955        translation.z,
1956        0.0,
1957        0.0,
1958        0.0,
1959        1.0,
1960    ])?;
1961    let mut result = solid.clone();
1962    for edge in &mut result.edges {
1963        match actions.get(&edge.id) {
1964            Some(EdgeMoveAction::Translate) => {
1965                edge.curve = transform_curve(&edge.curve, translate)?;
1966            }
1967            Some(EdgeMoveAction::Rebuild { start, end }) => {
1968                edge.curve = make_line(*start, *end)?;
1969                edge.t0 = 0.0;
1970                edge.t1 = 1.0;
1971            }
1972            Some(EdgeMoveAction::Transform(map)) => {
1973                // Affine-map the rim (radial scale about the axis + translate).
1974                // Rational-quadratic circles map exactly; parameters unchanged.
1975                edge.curve = transform_curve(&edge.curve, *map)?;
1976            }
1977            Some(EdgeMoveAction::Replace { curve }) => {
1978                // An exactly rebuilt conic arc spans its whole domain by
1979                // construction, so the trim is the domain.
1980                let [d0, d1] = curve.domain()?;
1981                edge.curve = curve.clone();
1982                edge.t0 = d0;
1983                edge.t1 = d1;
1984            }
1985            None => {}
1986        }
1987    }
1988    for vertex in &mut result.vertices {
1989        if let Some(point) = new_vertex.get(&vertex.id) {
1990            vertex.point = *point;
1991        }
1992    }
1993    let final_edges: HashMap<u64, EdgeRecord> = result
1994        .edges
1995        .iter()
1996        .map(|edge| (edge.id, edge.clone()))
1997        .collect();
1998    for (shell_index, face_index, action) in face_actions {
1999        let face = &mut result.shells[shell_index].faces[face_index];
2000        match action {
2001            FaceMoveAction::TranslateSurface => {
2002                face.surface = transform_surface(&face.surface, translate)?;
2003            }
2004            FaceMoveAction::Retrim(plane) => {
2005                retrim_planar_face(face, &plane, &final_edges, scale, "move_faces")?;
2006                // `retrim_planar_face` maps each edge's WHOLE curve onto the
2007                // rebuilt plane, so any edge that represents a strict SUBRANGE
2008                // of its curve (e.g. a box wall edge a fillet trimmed back)
2009                // needs the range fitter to span exactly [t0, t1]; full-domain
2010                // edges keep the exact affine pcurve just built. Same pairing
2011                // the delete/heal planar retrim uses.
2012                let face_edges: HashSet<u64> = face
2013                    .loops
2014                    .iter()
2015                    .flat_map(|loop_record| loop_record.coedges.iter().map(|c| c.edge_id))
2016                    .collect();
2017                refit_touched_pcurves(
2018                    face,
2019                    &final_edges,
2020                    &face_edges,
2021                    true,
2022                    tolerance,
2023                    "move_faces",
2024                )?;
2025            }
2026        }
2027    }
2028    // SM1: fixed ruled (cylinder) neighbours grow along their axis and recompute
2029    // pcurves on the grown carrier — a separate pass because it needs `&mut result`.
2030    for face_id in ruled_retrim_faces {
2031        retrim_ruled_face(&mut result, face_id, &final_edges, tolerance)?;
2032    }
2033
2034    // Topology (and therefore genus) is untouched — only geometry moved — so
2035    // validate() re-checks Euler, loop closure, and pcurve agreement.
2036    let issues = result.validate();
2037    if !issues.is_empty() {
2038        return Err(format!(
2039            "move_faces: moved solid failed validation: {issues:?}"
2040        ));
2041    }
2042    // Belt and braces on top of the per-edge inversion guard: a global
2043    // inversion flips the signed volume even if every edge kept its direction.
2044    if let (Ok(before), Ok(after)) = (solid_signed_volume(solid), solid_signed_volume(&result)) {
2045        if before * after <= 0.0 {
2046            return Err(
2047                "move_faces: the translation inverts the solid (signed volume changed sign) \
2048                 — refusing"
2049                    .into(),
2050            );
2051        }
2052    }
2053    Ok(result)
2054}
2055
2056/// Backlog #5 (Plane × Sphere): push a single PLANAR face whose FIXED boundary
2057/// neighbour(s) are SPHERES — e.g. a flat capping a spherical dome / spherical-
2058/// bottomed pocket. The moved plane translates; where it borders a fixed sphere
2059/// the new boundary rim is the EXACT circle `translated-plane ∩ sphere` (built
2060/// seam-aligned from the sphere's own frame so its pcurve crosses the seam
2061/// cleanly), the sphere's coupled seam meridian slides its rim endpoint to the
2062/// new latitude (its pcurve is patched in parameter space — the periodic u=0/u=2π
2063/// pairing is preserved), and both carriers are re-trimmed to the new rim.
2064///
2065/// Honest scope (SPHERE neighbours only, this slice): only the AXIS-PERPENDICULAR
2066/// single-closed-circle rim is supported. Refused cleanly (never a bad solid): a
2067/// moved GROUP, a moved face that is not planar, any FIXED neighbour that is not
2068/// a sphere (planar corner re-solve / torus / general revolution are other
2069/// slices), an OBLIQUE plane × sphere rim, an open / multi-edge rim, a sphere
2070/// seam that does not lie where the plane re-intersects it, a vanished / tangent
2071/// cap, and a push that collapses or inverts the solid.
2072fn move_planar_face_across_sphere(
2073    solid: &BrepSolid,
2074    moved: &HashSet<u64>,
2075    face_lookup: &HashMap<u64, (usize, usize)>,
2076    faces_of_edge: &HashMap<u64, Vec<u64>>,
2077    translation: Vec3,
2078) -> Result<BrepSolid, String> {
2079    let scale = solid_model_scale(solid);
2080    let tolerance = (scale * 1e-7).max(1e-9);
2081    let plane_tolerance = (scale * 1e-6).max(1e-7);
2082
2083    // Single moved planar face only (groups deferred — they would need the
2084    // generic corner re-solve against the remaining planar neighbours).
2085    if moved.len() != 1 {
2086        return Err(
2087            "move_faces: a moved GROUP against a sphere neighbour is deferred \
2088             (single planar face only) — refusing"
2089                .into(),
2090        );
2091    }
2092    let moved_id = *moved.iter().next().unwrap();
2093    let &(mshell, mface) = face_lookup
2094        .get(&moved_id)
2095        .ok_or_else(|| format!("move_faces: missing moved face {moved_id}"))?;
2096    // The moved face must itself be planar (it translates rigidly).
2097    let moved_plane = plane_of_surface(
2098        &solid.shells[mshell].faces[mface].surface,
2099        plane_tolerance,
2100        "move_faces",
2101    )?;
2102    let translated_origin = moved_plane.origin.add(translation);
2103
2104    let edge_by_id: HashMap<u64, &EdgeRecord> =
2105        solid.edges.iter().map(|e| (e.id, e)).collect();
2106
2107    // --- Build every sphere rim of the moved planar face -------------------
2108    struct SphereRim {
2109        edge_id: u64,
2110        sphere_id: u64,
2111        closure_vertex: u64,
2112        new_circle: NurbsCurve,
2113        closure_point: Vec3,
2114        v_rim: f64,
2115    }
2116    let mut rims: Vec<SphereRim> = Vec::new();
2117    let mut closure_vertices: HashSet<u64> = HashSet::default();
2118    let mut sphere_ids: HashSet<u64> = HashSet::default();
2119
2120    for loop_record in &solid.shells[mshell].faces[mface].loops {
2121        for coedge in &loop_record.coedges {
2122            let edge = *edge_by_id
2123                .get(&coedge.edge_id)
2124                .ok_or_else(|| format!("move_faces: missing edge {}", coedge.edge_id))?;
2125            let uses = faces_of_edge
2126                .get(&edge.id)
2127                .map(Vec::as_slice)
2128                .unwrap_or(&[]);
2129            // The one fixed face across this boundary edge.
2130            let neighbour = uses.iter().copied().find(|f| *f != moved_id);
2131            let Some(neighbour) = neighbour else {
2132                return Err(format!(
2133                    "move_faces: the moved face borders itself along edge {} \
2134                     (unexpected own edge) — refusing",
2135                    edge.id
2136                ));
2137            };
2138            let Some((frame, radius)) = carrier_sphere(solid, face_lookup, neighbour) else {
2139                return Err(format!(
2140                    "move_faces: the moved planar face borders a non-sphere fixed \
2141                     neighbour along edge {} (mixed / planar-corner / torus / \
2142                     revolution neighbours are other slices) — refusing",
2143                    edge.id
2144                ));
2145            };
2146            // A single CLOSED-circle rim only (start == end vertex).
2147            if edge.start_vertex_id != edge.end_vertex_id {
2148                return Err(format!(
2149                    "move_faces: the plane × sphere rim (edge {}) is not a single closed \
2150                     circle (open / multi-edge rims are deferred) — refusing",
2151                    edge.id
2152                ));
2153            }
2154            let center = frame.origin;
2155            let axis = frame.axis;
2156            // Only the axis-perpendicular cap keeps the rim a fixed-latitude
2157            // circle we can build seam-aligned; an oblique section crossing the
2158            // seam is deferred (matches the sphere-pushed path's own refusal).
2159            if moved_plane.normal.dot(axis).abs() < 1.0 - 1e-6 {
2160                return Err(format!(
2161                    "move_faces: an OBLIQUE plane × sphere rim (edge {}) is deferred \
2162                     (only axis-perpendicular caps) — refusing",
2163                    edge.id
2164                ));
2165            }
2166            // Signed axial offset of the TRANSLATED plane from the sphere centre;
2167            // the new rim is the small circle of radius √(r²−a²) at that height.
2168            let a = translated_origin.sub(center).dot(axis);
2169            let rr2 = radius * radius - a * a;
2170            if rr2 <= tolerance * tolerance {
2171                return Err(format!(
2172                    "move_faces: the pushed plane no longer meets the sphere (edge {}: the \
2173                     cap vanishes / is tangent) — refusing",
2174                    edge.id
2175                ));
2176            }
2177            let radius_new = rr2.sqrt();
2178            let circle_center = center.add(axis.scale(a));
2179            let mut new_circle = crate::make_arc(
2180                circle_center,
2181                frame.x_axis,
2182                frame.y_axis,
2183                radius_new,
2184                0.0,
2185                std::f64::consts::TAU,
2186            )?;
2187            // Match the new rim's traversal to the old edge so the preserved
2188            // coedge `forward` flags keep both loops' winding consistent. A
2189            // closed circle's reversal keeps its start point, so the seam-aligned
2190            // closure point is unaffected.
2191            let closure_old = edge.curve.evaluate(edge.t0)?;
2192            let old_tan = edge
2193                .curve
2194                .evaluate(edge.t0 + 0.01 * (edge.t1 - edge.t0))?
2195                .sub(closure_old);
2196            let [c0, c1] = new_circle.domain()?;
2197            let new_start = new_circle.evaluate(c0)?;
2198            let new_tan = new_circle.evaluate(c0 + 0.01 * (c1 - c0))?.sub(new_start);
2199            if old_tan.dot(new_tan) < 0.0 {
2200                new_circle = new_circle.reversed()?;
2201            }
2202            let closure_point = new_circle.evaluate(new_circle.domain()?[0])?;
2203
2204            // The rim latitude in the (unchanged) sphere's (u, v) space, read off
2205            // the seam-crossing pcurve (constant v). The coupled seam meridian's
2206            // rim endpoint slides to this v.
2207            let (nshell, nface) = find_face(solid, neighbour)
2208                .ok_or_else(|| format!("move_faces: missing sphere neighbour {neighbour}"))?;
2209            let sphere_surface = &solid.shells[nshell].faces[nface].surface;
2210            let rim_pcurve = build_pcurve_on_surface(sphere_surface, &new_circle)?;
2211            let [q0, q1] = rim_pcurve.domain()?;
2212            let v_rim = rim_pcurve.evaluate(0.5 * (q0 + q1))?.y;
2213
2214            closure_vertices.insert(edge.start_vertex_id);
2215            sphere_ids.insert(neighbour);
2216            rims.push(SphereRim {
2217                edge_id: edge.id,
2218                sphere_id: neighbour,
2219                closure_vertex: edge.start_vertex_id,
2220                new_circle,
2221                closure_point,
2222                v_rim,
2223            });
2224        }
2225    }
2226    if rims.is_empty() {
2227        return Err("move_faces: no plane × sphere rim found — refusing".into());
2228    }
2229    // Per closure vertex: where it moves + its new rim latitude (for the meridian).
2230    let closure_of: HashMap<u64, (Vec3, f64)> = rims
2231        .iter()
2232        .map(|r| (r.closure_vertex, (r.closure_point, r.v_rim)))
2233        .collect();
2234
2235    // --- Coupled seam meridians (own-only edges of the fixed sphere whose rim
2236    // endpoint is one of the moved closure vertices) ------------------------
2237    struct MeridianRetrim {
2238        edge_id: u64,
2239        sphere_id: u64,
2240        moved_end_is_start: bool,
2241        new_t: f64,
2242        moved_vertex_old: Vec3,
2243        moved_vertex_new: Vec3,
2244        v_rim: f64,
2245    }
2246    let mut meridians: Vec<MeridianRetrim> = Vec::new();
2247    for edge in &solid.edges {
2248        if edge.degenerate {
2249            continue; // a pole degeneracy does not move
2250        }
2251        let uses = faces_of_edge
2252            .get(&edge.id)
2253            .map(Vec::as_slice)
2254            .unwrap_or(&[]);
2255        // Own-only to exactly one of the fixed spheres (a seam meridian).
2256        let owner = uses.first().copied();
2257        let Some(owner) = owner else { continue };
2258        if !sphere_ids.contains(&owner) || !uses.iter().all(|f| *f == owner) {
2259            continue;
2260        }
2261        let start_is_closure = closure_vertices.contains(&edge.start_vertex_id);
2262        let end_is_closure = closure_vertices.contains(&edge.end_vertex_id);
2263        if !start_is_closure && !end_is_closure {
2264            continue; // an uncoupled seam meridian: untouched
2265        }
2266        if start_is_closure && end_is_closure {
2267            return Err(format!(
2268                "move_faces: sphere seam meridian {} moves at BOTH ends (a sphere zone \
2269                 with two pushed rims) — deferred, refusing",
2270                edge.id
2271            ));
2272        }
2273        let moved_end_is_start = start_is_closure;
2274        let closure_vertex = if moved_end_is_start {
2275            edge.start_vertex_id
2276        } else {
2277            edge.end_vertex_id
2278        };
2279        let (moved_vertex_new, v_rim) = *closure_of
2280            .get(&closure_vertex)
2281            .ok_or_else(|| "move_faces: seam meridian is not paired with a rim — refusing".to_string())?;
2282        // Slide the moved endpoint along the (unchanged) meridian curve. The
2283        // curve is fixed (the sphere is fixed), so this only re-parametrises the
2284        // trim; project the new rim point onto it and verify it truly lands there
2285        // (the safety net if `frame.x_axis` were not the seam azimuth).
2286        let projection = project_point_to_curve(&edge.curve, moved_vertex_new)?;
2287        if projection.distance > 10.0 * tolerance {
2288            return Err(format!(
2289                "move_faces: the re-intersected rim point does not lie on the sphere seam \
2290                 meridian {} (off {:.3e}) — refusing",
2291                edge.id, projection.distance
2292            ));
2293        }
2294        let new_t = projection.u;
2295        let fixed_t = if moved_end_is_start { edge.t1 } else { edge.t0 };
2296        let old_moved_t = if moved_end_is_start { edge.t0 } else { edge.t1 };
2297        // The trim must neither collapse nor invert: the moved parameter must
2298        // stay on the same side of the fixed endpoint as before, with a real span.
2299        let [dom0, dom1] = edge.curve.domain()?;
2300        let span = (dom1 - dom0).max(1e-12);
2301        if (new_t - fixed_t) * (old_moved_t - fixed_t) <= 0.0
2302            || (new_t - fixed_t).abs() <= 1e-7 * span
2303            || edge.curve.evaluate(new_t)?.sub(edge.curve.evaluate(fixed_t)?).length() <= tolerance
2304        {
2305            return Err(format!(
2306                "move_faces: the sphere seam meridian {} trim collapses or inverts under \
2307                 the push — refusing",
2308                edge.id
2309            ));
2310        }
2311        let moved_vertex_old = edge_point(solid, closure_vertex)?;
2312        meridians.push(MeridianRetrim {
2313            edge_id: edge.id,
2314            sphere_id: owner,
2315            moved_end_is_start,
2316            new_t,
2317            moved_vertex_old,
2318            moved_vertex_new,
2319            v_rim,
2320        });
2321    }
2322
2323    // Every moved-face boundary vertex must be an accounted-for rim closure
2324    // vertex; anything else means an unmodelled corner (refuse rather than leave
2325    // a vertex un-relocated and fail late).
2326    for loop_record in &solid.shells[mshell].faces[mface].loops {
2327        for coedge in &loop_record.coedges {
2328            let edge = *edge_by_id
2329                .get(&coedge.edge_id)
2330                .ok_or_else(|| format!("move_faces: missing edge {}", coedge.edge_id))?;
2331            for v in [edge.start_vertex_id, edge.end_vertex_id] {
2332                if !closure_vertices.contains(&v) {
2333                    return Err(format!(
2334                        "move_faces: the moved face has a boundary vertex {v} that is not a \
2335                         sphere-rim closure — refusing",
2336                    ));
2337                }
2338            }
2339        }
2340    }
2341
2342    // --- Apply to a fresh clone (the input is never mutated) ---------------
2343    let mut result = solid.clone();
2344    // Rim edges take their new circle.
2345    for rim in &rims {
2346        if let Some(edge) = result.edges.iter_mut().find(|e| e.id == rim.edge_id) {
2347            let [d0, d1] = rim.new_circle.domain()?;
2348            edge.curve = rim.new_circle.clone();
2349            edge.t0 = d0;
2350            edge.t1 = d1;
2351        }
2352    }
2353    // Seam meridians keep their curve; only the moved-end trim slides.
2354    for mer in &meridians {
2355        if let Some(edge) = result.edges.iter_mut().find(|e| e.id == mer.edge_id) {
2356            if mer.moved_end_is_start {
2357                edge.t0 = mer.new_t;
2358            } else {
2359                edge.t1 = mer.new_t;
2360            }
2361        }
2362    }
2363    // Relocate the rim closure vertices.
2364    for rim in &rims {
2365        if let Some(v) = result.vertices.iter_mut().find(|v| v.id == rim.closure_vertex) {
2366            v.point = rim.closure_point;
2367        }
2368    }
2369
2370    // The moved planar face rides the translated plane and re-trims around its
2371    // new (smaller/larger) rim circle.
2372    let final_edges: HashMap<u64, EdgeRecord> =
2373        result.edges.iter().map(|e| (e.id, e.clone())).collect();
2374    {
2375        let mut plane = moved_plane;
2376        plane.origin = plane.origin.add(translation);
2377        let face = &mut result.shells[mshell].faces[mface];
2378        retrim_planar_face(face, &plane, &final_edges, scale, "move_faces")?;
2379    }
2380
2381    // Re-trim every fixed sphere: rebuild the rim coedge pcurve on the (unchanged)
2382    // sphere surface, and patch each coupled seam-meridian coedge's pcurve in
2383    // parameter space (keep u — preserving the periodic u=0/u=2π pairing — and
2384    // slide only the moved endpoint's v to the new rim latitude).
2385    for sphere_id in &sphere_ids {
2386        let (nshell, nface) = find_face(&result, *sphere_id)
2387            .ok_or_else(|| format!("move_faces: missing sphere neighbour {sphere_id}"))?;
2388        let sphere_surface = result.shells[nshell].faces[nface].surface.clone();
2389        for loop_record in &mut result.shells[nshell].faces[nface].loops {
2390            for coedge in &mut loop_record.coedges {
2391                if let Some(rim) = rims
2392                    .iter()
2393                    .find(|r| r.edge_id == coedge.edge_id && r.sphere_id == *sphere_id)
2394                {
2395                    let mut pcurve = build_pcurve_on_surface(&sphere_surface, &rim.new_circle)?;
2396                    if !coedge.forward {
2397                        pcurve = pcurve.reversed()?;
2398                    }
2399                    coedge.pcurve = pcurve;
2400                } else if let Some(mer) = meridians
2401                    .iter()
2402                    .find(|m| m.edge_id == coedge.edge_id && m.sphere_id == *sphere_id)
2403                {
2404                    coedge.pcurve = patch_seam_meridian_pcurve(
2405                        &coedge.pcurve,
2406                        &sphere_surface,
2407                        mer.moved_vertex_old,
2408                        mer.moved_vertex_new,
2409                        mer.v_rim,
2410                        tolerance,
2411                    )?;
2412                }
2413            }
2414        }
2415    }
2416
2417    let issues = result.validate();
2418    if !issues.is_empty() {
2419        return Err(format!(
2420            "move_faces: moved solid failed validation: {issues:?}"
2421        ));
2422    }
2423    if let (Ok(before), Ok(after)) = (solid_signed_volume(solid), solid_signed_volume(&result)) {
2424        if before * after <= 0.0 {
2425            return Err(
2426                "move_faces: the push inverts the solid (signed volume changed sign) — refusing"
2427                    .into(),
2428            );
2429        }
2430    }
2431    Ok(result)
2432}
2433
2434/// Slide a sphere seam-meridian pcurve's RIM endpoint to the new latitude,
2435/// keeping its constant u (so the periodic u=0 / u=2π seam pairing survives) and
2436/// its fixed (pole / other-rim) endpoint. The moved endpoint is identified by
2437/// which pcurve end maps (through the surface) to the moved vertex's OLD
2438/// position — not by pole detection — so it makes no assumption about the cap's
2439/// topology. Endpoint order (domain start→end) is preserved.
2440fn patch_seam_meridian_pcurve(
2441    pcurve: &NurbsCurve,
2442    surface: &NurbsSurface,
2443    moved_vertex_old: Vec3,
2444    moved_vertex_new: Vec3,
2445    v_rim: f64,
2446    tolerance: f64,
2447) -> Result<NurbsCurve, String> {
2448    let [q0, q1] = pcurve.domain()?;
2449    let a = pcurve.evaluate(q0)?;
2450    let b = pcurve.evaluate(q1)?;
2451    let a3 = surface.evaluate(a.x, a.y)?;
2452    let b3 = surface.evaluate(b.x, b.y)?;
2453    let da = a3.sub(moved_vertex_old).length();
2454    let db = b3.sub(moved_vertex_old).length();
2455    // Guard: one endpoint must genuinely be the moved vertex, and the surface
2456    // point at the patched (u, v_rim) must land on the relocated vertex.
2457    let moved_is_a = da <= db;
2458    let (moved_uv, fixed_uv) = if moved_is_a { (a, b) } else { (b, a) };
2459    let patched = Vec3::new(moved_uv.x, v_rim, 0.0);
2460    if surface.evaluate(patched.x, patched.y)?.sub(moved_vertex_new).length() > 10.0 * tolerance {
2461        return Err(
2462            "move_faces: patched seam-meridian pcurve endpoint does not reach the new rim \
2463             vertex — refusing"
2464                .into(),
2465        );
2466    }
2467    let fixed = Vec3::new(fixed_uv.x, fixed_uv.y, 0.0);
2468    if moved_is_a {
2469        make_line(patched, fixed)
2470    } else {
2471        make_line(fixed, patched)
2472    }
2473}
2474
2475#[cfg(test)]
2476mod ruled_carrier_tests {
2477    use super::*;
2478
2479    // The discrimination the partial-sweep ruled-neighbour heal relies on:
2480    // a partial-sweep revolve of a STRAIGHT generatrix (a fillet band / partial
2481    // cylinder) IS a ruled carrier; a partial-sweep revolve of a CURVED
2482    // generatrix (an arc → sphere/torus-like) is NOT, and must return None so
2483    // the heal refuses it cleanly rather than treating it as ruled.
2484    #[test]
2485    fn ruled_revolution_carrier_accepts_straight_generatrix_rejects_curved() {
2486        let axis = Vec3::new(0.0, 0.0, 1.0);
2487        let origin = Vec3::default();
2488
2489        // Straight generatrix parallel to the axis at radius 2, z ∈ [0, 5]:
2490        // a quarter-cylinder band (sweep = π/2), which recognizes as the general
2491        // `Revolution`, not `RuledRevolution`.
2492        let line = make_line(Vec3::new(2.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 5.0)).unwrap();
2493        let band = make_revolution(origin, axis, &line, std::f64::consts::FRAC_PI_2).unwrap();
2494        assert!(
2495            matches!(band.analytic(), Some(AnalyticSurface::Revolution { .. })),
2496            "a partial-sweep straight-generatrix revolve must recognize as Revolution"
2497        );
2498        let (frame, rho0, rho1, height) =
2499            ruled_revolution_carrier(&band).expect("straight-generatrix band is a ruled carrier");
2500        assert!((rho0 - 2.0).abs() < 1e-9 && (rho1 - 2.0).abs() < 1e-9, "radii {rho0}, {rho1}");
2501        assert!((height.abs() - 5.0).abs() < 1e-9, "height {height}");
2502        assert!(frame.axis.sub(axis).length() < 1e-9, "axis preserved");
2503
2504        // Curved generatrix (a meridian arc in the x–z half-plane): a partial
2505        // sphere/torus-like band — NOT ruled.
2506        let arc = crate::make_arc(
2507            Vec3::new(0.0, 0.0, 3.0),
2508            Vec3::new(1.0, 0.0, 0.0),
2509            Vec3::new(0.0, 0.0, 1.0),
2510            2.0,
2511            -std::f64::consts::FRAC_PI_4,
2512            std::f64::consts::FRAC_PI_4,
2513        )
2514        .unwrap();
2515        let curved = make_revolution(origin, axis, &arc, std::f64::consts::FRAC_PI_2).unwrap();
2516        assert!(
2517            ruled_revolution_carrier(&curved).is_none(),
2518            "a curved-generatrix revolution must not be treated as ruled"
2519        );
2520    }
2521}