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 earlier hand-read census of these refusals could not tell
101/// from the text which of the *nine* such call sites had fired: it attributed
102/// the torus groove's refusal 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/// Re-trim a FIXED ruled neighbour whose boundary moved under an axis-parallel
553/// push: grow the carrier along its axis to cover the new boundary
554/// (`extend_ruled_neighbour_over` for a full-2π cylinder/cone,
555/// `extend_revolution_carrier_over` for a partial-sweep fillet band — both
556/// exact), then recompute every pcurve on the grown carrier from the
557/// already-updated edge curves. The direct-edit analogue of `retrim_planar_face`
558/// for a translation-invariant cylinder; all loops are visited, so holes on the
559/// neighbour are carried.
560///
561/// The three-phase body is `crate::offset_retrim::retrim_face_in_solid`; this is
562/// the two-step growth strategy and the `move_faces` refusal prefix. It differs
563/// from `face_offset::retrim_offset_ruled_face` in exactly that second growth
564/// call — the partial-sweep `Revolution` prolongation, which the offset push has
565/// never run.
566fn retrim_ruled_face(
567    solid: &mut BrepSolid,
568    face_id: u64,
569    final_edges: &HashMap<u64, EdgeRecord>,
570    tolerance: f64,
571) -> Result<(), String> {
572    let (shell, face_pos) = find_face(solid, face_id)
573        .ok_or_else(|| format!("move_faces: missing ruled face {face_id}"))?;
574    retrim_face_in_solid(
575        solid,
576        shell,
577        face_pos,
578        final_edges,
579        |solid, points| {
580            extend_ruled_neighbour_over(solid, face_id, points, tolerance)?;
581            extend_revolution_carrier_over(solid, face_id, points, tolerance)
582        },
583        PcurveFit::SubrangeAware { tolerance },
584        "move_faces",
585    )
586}
587
588/// The EXACT corner where a fixed PLANE, a fixed RULED carrier and the
589/// TRANSLATED cap plane meet: the line `fixed plane ∩ translated cap plane`
590/// intersected with the ruled carrier (a quadratic in the line parameter),
591/// taking the root nearest the corner's OLD position so the corner moves
592/// continuously.
593///
594/// This is the carrier-level solve that a CURVED fixed edge needs.
595/// `resolve_corner_on_fixed_edge` brackets strictly INSIDE the fixed edge's
596/// domain, and the boolean that produced such an edge split it exactly at the
597/// corner (`domain == [t0, t1]`), so riding the edge can only ever move a corner
598/// INWARD — an outward push would refuse for a purely representational reason.
599/// The carriers have no such horizon.
600fn corner_on_plane_and_ruled(
601    fixed_plane: &Plane,
602    cap_normal: Vec3,
603    cap_c: f64,
604    frame: &crate::RevolutionFrame,
605    rho0: f64,
606    rho1: f64,
607    height: f64,
608    old_corner: Vec3,
609    tolerance: f64,
610) -> Result<Vec3, String> {
611    let direction = fixed_plane.normal.cross(cap_normal);
612    if direction.length() <= PARALLEL_EPS {
613        return Err(
614            "move_faces: the pushed cap plane is parallel to a fixed planar neighbour \
615             (no corner) — refusing"
616                .into(),
617        );
618    }
619    let direction = direction.normalized()?;
620    // A point on both planes, taken in the 2-D span of the two normals.
621    let ca = fixed_plane.normal.dot(fixed_plane.origin);
622    let naa = fixed_plane.normal.dot(fixed_plane.normal);
623    let nab = fixed_plane.normal.dot(cap_normal);
624    let nbb = cap_normal.dot(cap_normal);
625    let determinant = naa * nbb - nab * nab;
626    if determinant.abs() <= PARALLEL_EPS {
627        return Err("move_faces: cannot place the corner's carrier line — refusing".into());
628    }
629    let alpha = (ca * nbb - cap_c * nab) / determinant;
630    let beta = (cap_c * naa - ca * nab) / determinant;
631    let base = fixed_plane
632        .normal
633        .scale(alpha)
634        .add(cap_normal.scale(beta));
635    // |P − O|² − axial² = rho_at(axial)² along P(s) = base + s·direction.
636    let offset = base.sub(frame.origin);
637    let axis = frame.axis;
638    let slope = if height == 0.0 {
639        0.0
640    } else {
641        (rho1 - rho0) / height
642    };
643    let a0 = offset.dot(axis);
644    let a1 = direction.dot(axis);
645    let r0 = rho0 + slope * a0;
646    let quad = 1.0 - a1 * a1 - slope * slope * a1 * a1;
647    let linear = 2.0 * offset.dot(direction) - 2.0 * a0 * a1 - 2.0 * slope * a1 * r0;
648    let constant = offset.dot(offset) - a0 * a0 - r0 * r0;
649    let mut roots: Vec<f64> = Vec::new();
650    if quad.abs() <= 1e-12 {
651        if linear.abs() > 1e-12 {
652            roots.push(-constant / linear);
653        }
654    } else {
655        let discriminant = linear * linear - 4.0 * quad * constant;
656        if discriminant >= 0.0 {
657            let root = discriminant.sqrt();
658            roots.push((-linear + root) / (2.0 * quad));
659            roots.push((-linear - root) / (2.0 * quad));
660        }
661    }
662    let mut best: Option<(Vec3, f64)> = None;
663    for s in roots {
664        let point = base.add(direction.scale(s));
665        // Only the nappe with a NON-NEGATIVE radius is the real carrier.
666        if rho_at(rho0, rho1, height, point.sub(frame.origin).dot(axis)) < -tolerance {
667            continue;
668        }
669        let distance = point.sub(old_corner).length();
670        if best.map(|(_, best)| distance < best).unwrap_or(true) {
671            best = Some((point, distance));
672        }
673    }
674    let (corner, _) = best.ok_or_else(|| {
675        "move_faces: the pushed cap plane no longer meets the fixed ruled neighbour \
676         (the push drives the corner off the carrier) — refusing"
677            .to_string()
678    })?;
679    Ok(corner)
680}
681
682/// True iff `curve[t0..t1]` sweeps in the POSITIVE azimuth sense about `frame`
683/// (the sense `make_arc` builds), decided by whether its midpoint's azimuth lies
684/// inside the positive sweep from the start's to the end's.
685fn arc_sweeps_forward(
686    frame: &crate::RevolutionFrame,
687    curve: &NurbsCurve,
688    t0: f64,
689    t1: f64,
690) -> Result<bool, String> {
691    let azimuth = |point: Vec3| {
692        let delta = point.sub(frame.origin);
693        delta.dot(frame.y_axis).atan2(delta.dot(frame.x_axis))
694    };
695    let tau = std::f64::consts::TAU;
696    let wrap = |angle: f64| {
697        let value = angle % tau;
698        if value < 0.0 {
699            value + tau
700        } else {
701            value
702        }
703    };
704    let start = azimuth(curve.evaluate(t0)?);
705    let end = azimuth(curve.evaluate(t1)?);
706    let middle = azimuth(curve.evaluate(0.5 * (t0 + t1))?);
707    Ok(wrap(middle - start) <= wrap(end - start))
708}
709
710/// The EXACT arc of `plane ∩ cone` between two endpoints that already lie on
711/// both, swept in the given sense.
712///
713/// A cone's plane section is the PROJECTIVE image, from the apex, of the base
714/// circle: the ray `apex → X` meets the plane at `apex + (k/((X−apex)·n))·(X−apex)`
715/// with `k = c − n·apex`, which is LINEAR in the homogeneous control point — so
716/// the image of a rational-quadratic circular arc is a rational-quadratic conic
717/// arc on the SAME knot vector, exactly. Azimuth is constant along a cone ray,
718/// so the endpoints' azimuths give the base arc directly. This is exact for
719/// ELLIPTIC and HYPERBOLIC sections alike (the full hyperbola cannot be one
720/// rational Bezier — its weights change sign — but an arc that stays on one
721/// nappe can, which is why the sign check below is the only restriction).
722///
723/// The kernel's own `intersect_plane_quadric` builds a cone section the same way
724/// but only ever for the FULL section, so it refuses exactly the hyperbolic case
725/// this arc-restricted form supports.
726fn conic_arc_on_ruled(
727    frame: &crate::RevolutionFrame,
728    rho0: f64,
729    rho1: f64,
730    height: f64,
731    plane_normal: Vec3,
732    plane_c: f64,
733    start: Vec3,
734    end: Vec3,
735    forward_sweep: bool,
736    tolerance: f64,
737) -> Result<NurbsCurve, String> {
738    let radius_scale = rho0.abs().max(rho1.abs()).max(1.0);
739    if (rho1 - rho0).abs() <= 1e-9 * radius_scale {
740        // A cylinder's plane section is an ellipse (or a generatrix pair); its
741        // straight sections are already handled by the chord rebuild and no
742        // fixture exercises a curved one, so it stays an honest refusal.
743        return Err(
744            "move_faces: rebuilding a curved section on a CYLINDER carrier is deferred \
745             — refusing"
746                .into(),
747        );
748    }
749    let axis = frame.axis;
750    let apex = frame
751        .origin
752        .add(axis.scale(rho0 * height / (rho0 - rho1)));
753    let k = plane_c - plane_normal.dot(apex);
754    if k.abs() <= tolerance {
755        return Err("move_faces: the section plane passes through the cone apex — refusing".into());
756    }
757    // Base circle at whichever end has the LARGER radius, so it never degenerates.
758    let (reference_rho, reference_axial) = if rho0.abs() >= rho1.abs() {
759        (rho0.abs(), 0.0)
760    } else {
761        (rho1.abs(), height)
762    };
763    if reference_rho <= tolerance {
764        return Err("move_faces: the cone carrier degenerates to its apex — refusing".into());
765    }
766    let azimuth = |point: Vec3| {
767        let delta = point.sub(frame.origin);
768        delta.dot(frame.y_axis).atan2(delta.dot(frame.x_axis))
769    };
770    let tau = std::f64::consts::TAU;
771    let wrap = |angle: f64| {
772        let value = angle % tau;
773        if value < 0.0 {
774            value + tau
775        } else {
776            value
777        }
778    };
779    let (start_angle, sweep, reverse) = if forward_sweep {
780        (azimuth(start), wrap(azimuth(end) - azimuth(start)), false)
781    } else {
782        (azimuth(end), wrap(azimuth(start) - azimuth(end)), true)
783    };
784    if sweep <= 1e-9 || sweep >= tau - 1e-9 {
785        return Err(
786            "move_faces: the re-intersected arc degenerates to a point or a full turn \
787             — refusing"
788                .into(),
789        );
790    }
791    let circle = crate::make_arc(
792        frame.origin.add(axis.scale(reference_axial)),
793        frame.x_axis,
794        frame.y_axis,
795        reference_rho,
796        start_angle,
797        start_angle + sweep,
798    )?;
799    let mut mapped: Vec<crate::Vec4> = Vec::with_capacity(circle.control_points.len());
800    let mut sign = 0.0f64;
801    for control in &circle.control_points {
802        let relative = Vec3::new(
803            control.x - control.w * apex.x,
804            control.y - control.w * apex.y,
805            control.z - control.w * apex.z,
806        );
807        let weight = relative.dot(plane_normal);
808        if weight.abs() <= 1e-9 * radius_scale {
809            return Err(
810                "move_faces: the re-intersected section runs along an asymptotic ruling \
811                 of the cone — refusing"
812                    .into(),
813            );
814        }
815        if sign == 0.0 {
816            sign = weight.signum();
817        } else if weight.signum() != sign {
818            return Err(
819                "move_faces: the re-intersected section crosses the cone's apex plane \
820                 (both nappes) — refusing"
821                    .into(),
822            );
823        }
824        let scaled = relative.scale(k);
825        mapped.push(crate::Vec4 {
826            x: apex.x * weight + scaled.x,
827            y: apex.y * weight + scaled.y,
828            z: apex.z * weight + scaled.z,
829            w: weight,
830        });
831    }
832    if sign < 0.0 {
833        // Homogeneously identical, but the kernel keeps weights positive.
834        for control in &mut mapped {
835            control.x = -control.x;
836            control.y = -control.y;
837            control.z = -control.z;
838            control.w = -control.w;
839        }
840    }
841    let mut curve = NurbsCurve::new(circle.degree, circle.knots.clone(), mapped)?;
842    if reverse {
843        curve = curve.reversed()?;
844    }
845    // Fail-safe: the rebuilt arc must run between the given corners and lie on
846    // BOTH carriers — the same "reapply the trimming" contract the affine rim map
847    // is held to in `verify_rim_on_carriers`.
848    let [d0, d1] = curve.domain()?;
849    for (parameter, target) in [(d0, start), (d1, end)] {
850        let drift = curve.evaluate(parameter)?.sub(target).length();
851        if drift > 10.0 * tolerance {
852            return Err(format!(
853                "move_faces: the rebuilt section misses its corner by {drift:.3e} — refusing"
854            ));
855        }
856    }
857    for step in 0..=8 {
858        let point = curve.evaluate(d0 + (d1 - d0) * (step as f64 / 8.0))?;
859        let delta = point.sub(frame.origin);
860        let axial = delta.dot(axis);
861        let off_ruled = (delta.sub(axis.scale(axial)).length()
862            - rho_at(rho0, rho1, height, axial))
863        .abs();
864        let off_plane = (point.dot(plane_normal) - plane_c).abs();
865        if off_ruled > 10.0 * tolerance || off_plane > 10.0 * tolerance {
866            return Err(format!(
867                "move_faces: the rebuilt section does not lie on both carriers \
868                 (off ruled {off_ruled:.3e}, off plane {off_plane:.3e}) — refusing"
869            ));
870        }
871    }
872    Ok(curve)
873}
874
875/// The fixed PLANE and the fixed RULED carrier an edge is shared by, when it is
876/// shared by exactly one of each (the flat×cone hyperbola configuration).
877fn plane_and_ruled_carriers(
878    solid: &BrepSolid,
879    face_lookup: &HashMap<u64, (usize, usize)>,
880    planes: &mut HashMap<u64, Plane>,
881    face_ids: &[u64],
882    plane_tolerance: f64,
883) -> Option<(Plane, (crate::RevolutionFrame, f64, f64, f64))> {
884    let mut plane = None;
885    let mut ruled = None;
886    for &face_id in face_ids {
887        if let Some(carrier) = carrier_ruled(solid, face_lookup, face_id) {
888            if ruled.is_some() {
889                return None;
890            }
891            ruled = Some(carrier);
892        } else if carrier_is_planar(solid, face_lookup, face_id) {
893            if plane.is_some() {
894                return None;
895            }
896            plane = Some(cached_plane(planes, solid, face_lookup, face_id, plane_tolerance).ok()?);
897        } else {
898            return None;
899        }
900    }
901    Some((plane?, ruled?))
902}
903
904/// Re-solve a cap corner that is shared with a FIXED ruled carrier (a split
905/// cylinder/cone band), which the planar 3-plane `solve_corner` cannot place
906/// (its carrier is not a plane). The corner rides the ONE fixed edge it shares
907/// with the body: the new corner is where the TRANSLATED cap plane crosses that
908/// fixed edge's curve. The fixed edge itself stays put — only its cap-side trim
909/// endpoint slides along it (`plan_straight_rebuild` re-lays a straight
910/// generatrix between the new and the untouched endpoints afterwards).
911///
912/// This is the SM1c multi-rim generalisation: the single-closed-rim oblique cap
913/// (no corners) is the `fixed_at.len() == 1` rim-ride; a cap bounded by several
914/// conic rims meeting at seam corners needs each corner re-placed here.
915///
916/// - **Straight generatrix (degree 1):** exact line solve. A line is its own
917///   natural extension, so a crossing OUTSIDE the fixed edge's current span
918///   (the outward push that lengthens the wall) is exact and accepted.
919/// - **Conic (degree 2+):** bracket a sign change WITHIN the domain only and
920///   refine — a rational conic extended past its span rides the end tangent,
921///   not the conic, so an out-of-domain crossing is refused. The root nearest
922///   the corner's own end parameter is chosen so the corner moves continuously.
923///   This is now a FALLBACK: booleans split such an edge exactly at the corner
924///   (`domain == [t0, t1]`), so an outward push has no room to bracket into.
925///   When the corner's fixed carriers are one plane and one ruled surface, the
926///   caller solves on the CARRIERS instead (`corner_on_plane_and_ruled`), which
927///   has no such horizon; this arm only runs for configurations that solve does
928///   not cover.
929///
930/// Refuses cleanly when the fixed edge runs in the cap plane (no crossing) or
931/// the push drives the corner off the edge's reachable span (a tearing push).
932fn resolve_corner_on_fixed_edge(
933    fixed_edge: &EdgeRecord,
934    seed_param: f64,
935    plane_normal: Vec3,
936    plane_c: f64,
937    tolerance: f64,
938) -> Result<Vec3, String> {
939    let curve = &fixed_edge.curve;
940    if curve.degree == 1 && curve.control_points.len() == 2 {
941        let p0 = curve.control_points[0].point()?;
942        let p1 = curve.control_points[1].point()?;
943        let dir = p1.sub(p0);
944        let denom = plane_normal.dot(dir);
945        if denom.abs() <= PARALLEL_EPS * (1.0 + dir.length()) {
946            return Err(format!(
947                "move_faces: fixed edge {} runs parallel to the cap plane (no crossing) — refusing",
948                fixed_edge.id
949            ));
950        }
951        let s = (plane_c - plane_normal.dot(p0)) / denom;
952        return Ok(p0.add(dir.scale(s)));
953    }
954    // Curved (conic) fixed edge — closed-form-in-spirit numeric solve strictly
955    // within the domain. `n·C(u) − c` has the sign of the numerator polynomial
956    // (weights are strictly positive), so its roots are the crossings.
957    let [d0, d1] = curve.domain()?;
958    let f = |u: f64| -> Result<f64, String> { Ok(plane_normal.dot(curve.evaluate(u)?) - plane_c) };
959    const STEPS: usize = 96;
960    let mut best: Option<(f64, f64)> = None;
961    let mut prev_u = d0;
962    let mut prev_f = f(d0)?;
963    if prev_f.abs() <= tolerance {
964        best = Some((d0, (d0 - seed_param).abs()));
965    }
966    for i in 1..=STEPS {
967        let u = d0 + (d1 - d0) * (i as f64 / STEPS as f64);
968        let fu = f(u)?;
969        if prev_f * fu < 0.0 {
970            let (mut lo, mut hi, mut flo) = (prev_u, u, prev_f);
971            for _ in 0..64 {
972                let mid = 0.5 * (lo + hi);
973                let fm = f(mid)?;
974                if flo * fm <= 0.0 {
975                    hi = mid;
976                } else {
977                    lo = mid;
978                    flo = fm;
979                }
980            }
981            let root = 0.5 * (lo + hi);
982            let dist = (root - seed_param).abs();
983            if best.map(|(_, bd)| dist < bd).unwrap_or(true) {
984                best = Some((root, dist));
985            }
986        }
987        prev_u = u;
988        prev_f = fu;
989    }
990    let (root, _) = best.ok_or_else(|| {
991        format!(
992            "move_faces: the pushed cap does not re-cross fixed edge {} within its span \
993             (curved-fixed-edge extension deferred, or the push tears the face) — refusing",
994            fixed_edge.id
995        )
996    })?;
997    curve.evaluate(root)
998}
999
1000/// Golovanov §6.12 direct editing — translate a group of faces rigidly and
1001/// heal the adjacency with the faces that stay behind.
1002///
1003/// The moved carriers translate exactly (every control point shifts by the
1004/// translation, which is exact for ANY surface type), and each boundary edge
1005/// between a moved face and a fixed face is recomputed as the intersection of
1006/// the translated moved carrier with the fixed carrier:
1007///
1008/// - When the translation is parallel to every fixed plane a boundary vertex
1009///   touches, the whole neighbourhood translates rigidly — exact for any
1010///   moved carrier and any edge curve type. This is the extrude-like case:
1011///   pushing a face along its own normal slides the side walls in-plane.
1012/// - Otherwise the new corner is re-solved as the common point of ALL carrier
1013///   planes meeting at the vertex (moved ones translated), and every affected
1014///   straight edge is rebuilt between the re-solved corners — the same
1015///   relocate-onto-recovered-corners move `delete_face_and_heal` performs on
1016///   its side edges.
1017///
1018/// Scope (honest refusals, never a bad solid): the moved faces may be any
1019/// surface type. A FIXED face that must be re-intersected — the fixed side of a
1020/// boundary edge, or any face whose boundary edges must be rebuilt — must be
1021/// PLANAR, OR an AXIS-PARALLEL ruled revolution: a cylinder OR a cone whose axis
1022/// the push is parallel to (SM1/SM1b). Such a carrier keeps its SAME surface and
1023/// only re-trims — its rim re-intersects it at a new radius, constructed by the
1024/// exact radial-scale map `EdgeMoveAction::Transform` (`s = rho_at(z+d)/rho_at(z)`;
1025/// the cylinder is `s = 1`) and verified to lie on both modified carriers, then
1026/// grown along the axis (`retrim_ruled_face`). Every straight rebuilt edge (a
1027/// seam/generatrix) is verified on its carrier at its own `rho_at` radius.
1028///
1029/// An OBLIQUE cap push is supported on both carriers. The rim is the affine
1030/// image `rim_ruled_map` (a cylinder's axis translation, a cone's homothety
1031/// about the apex) — exact for the whole conic — and where that affine's image
1032/// of a corner disagrees with the corner itself (a CONE, whose homothety slides
1033/// the arc's endpoint off the fixed flat), the rim and the CURVED fixed edge it
1034/// meets are RE-BUILT as exact conic sections between the re-solved corners
1035/// (`conic_arc_on_ruled`, `EdgeMoveAction::Replace`); the corner itself comes
1036/// from the carrier-level solve `corner_on_plane_and_ruled`. A curved fixed edge
1037/// on a CYLINDER carrier, spheres, tori, general revolutions and a cap pushed
1038/// to/through the apex are refused (SM3 is the general offset path).
1039/// A translation that collapses an adjacent edge to zero length or reverses
1040/// its direction (moving a box face onto or past its opposite face) is
1041/// refused, as is a group that tears away from its neighbours. The input is
1042/// never mutated; the result is returned only when `validate()` is clean.
1043pub fn move_faces(
1044    solid: &BrepSolid,
1045    face_ids: &[u64],
1046    translation: Vec3,
1047) -> Result<BrepSolid, String> {
1048    if !(translation.x.is_finite() && translation.y.is_finite() && translation.z.is_finite()) {
1049        return Err("move_faces: translation must be finite".into());
1050    }
1051    if face_ids.is_empty() {
1052        return Err("move_faces: no faces selected".into());
1053    }
1054    let moved: HashSet<u64> = face_ids.iter().copied().collect();
1055    // face id -> (shell, face) built once. move_faces never mutates `solid`,
1056    // so this replaces the O(faces) `find_face` scans in the validation loop
1057    // below and in `cached_plane` (called up to once per unique fixed face).
1058    // `or_insert` keeps the first match, mirroring `find_face`.
1059    let mut face_lookup: HashMap<u64, (usize, usize)> = HashMap::default();
1060    for (shell_index, shell) in solid.shells.iter().enumerate() {
1061        for (face_index, face) in shell.faces.iter().enumerate() {
1062            face_lookup
1063                .entry(face.id)
1064                .or_insert((shell_index, face_index));
1065        }
1066    }
1067    for &face_id in face_ids {
1068        if !face_lookup.contains_key(&face_id) {
1069            return Err(format!("move_faces: no face with id {face_id}"));
1070        }
1071    }
1072
1073    let scale = solid_model_scale(solid);
1074    let tolerance = (scale * 1e-7).max(1e-9);
1075    let plane_tolerance = (scale * 1e-6).max(1e-7);
1076    // "Parallel to a fixed plane" means the translation's normal component
1077    // could not move any point off that plane at model precision.
1078    let parallel_tolerance = (translation.length() * 1e-9).max(1e-12);
1079    // "Rigid" endpoints moved by exactly the translation (they are assigned
1080    // `point + translation` verbatim, so this only absorbs rounding noise).
1081    let rigid_tolerance = (scale * 1e-9).max(1e-12);
1082
1083    // --- Classify every edge by how the group uses it ----------------------
1084    let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
1085    for shell in &solid.shells {
1086        for face in &shell.faces {
1087            for loop_record in &face.loops {
1088                for coedge in &loop_record.coedges {
1089                    faces_of_edge
1090                        .entry(coedge.edge_id)
1091                        .or_default()
1092                        .push(face.id);
1093                }
1094            }
1095        }
1096    }
1097    // --- Plane × Sphere fast path (backlog #5) -----------------------------
1098    // A planar push whose FIXED neighbour across a boundary edge is a SPHERE
1099    // cannot be healed by the planar/ruled machinery below: the sphere's seam
1100    // meridian is a CURVED fixed edge (which `plan_straight_rebuild` refuses),
1101    // and its periodic u=0/u=2π seam pcurves must be patched in parameter space,
1102    // not refit from scratch. Route those to a dedicated handler that
1103    // re-intersects the translated plane with the fixed sphere (an EXACT circle)
1104    // and re-trims the sphere. Every configuration that handler does not support
1105    // refuses cleanly there — and every such case refuses in the generic path
1106    // today too, so this routing can only turn a refusal into a heal (it never
1107    // changes an already-supported case).
1108    let borders_a_sphere = solid.edges.iter().any(|edge| {
1109        let uses = faces_of_edge
1110            .get(&edge.id)
1111            .map(Vec::as_slice)
1112            .unwrap_or(&[]);
1113        let moved_uses = uses.iter().filter(|f| moved.contains(*f)).count();
1114        moved_uses > 0
1115            && moved_uses < uses.len()
1116            && uses
1117                .iter()
1118                .any(|f| !moved.contains(f) && carrier_sphere(solid, &face_lookup, *f).is_some())
1119    });
1120    if borders_a_sphere {
1121        return move_planar_face_across_sphere(solid, &moved, &face_lookup, &faces_of_edge, translation);
1122    }
1123
1124    let mut classes: HashMap<u64, EdgeMoveClass> = HashMap::default();
1125    let mut planes: HashMap<u64, Plane> = HashMap::default();
1126    for edge in &solid.edges {
1127        let uses = faces_of_edge
1128            .get(&edge.id)
1129            .map(Vec::as_slice)
1130            .unwrap_or(&[]);
1131        let expected = if edge.degenerate { 1 } else { 2 };
1132        if uses.len() != expected {
1133            return Err(format!(
1134                "move_faces: edge {} is used {} times (non-manifold input)",
1135                edge.id,
1136                uses.len()
1137            ));
1138        }
1139        let moved_uses = uses
1140            .iter()
1141            .filter(|face_id| moved.contains(*face_id))
1142            .count();
1143        let class = if moved_uses == 0 {
1144            EdgeMoveClass::Fixed
1145        } else if moved_uses == uses.len() {
1146            EdgeMoveClass::Interior
1147        } else {
1148            let moved_face = *uses
1149                .iter()
1150                .find(|face_id| moved.contains(*face_id))
1151                .unwrap();
1152            let fixed_face = *uses
1153                .iter()
1154                .find(|face_id| !moved.contains(*face_id))
1155                .unwrap();
1156            // The face left behind across a boundary edge is the carrier we
1157            // re-intersect against. Planar → cache its plane (today path).
1158            // Non-planar but a ruled revolution (a cylinder OR a cone) → allowed:
1159            // the cap rim re-intersects the SAME carrier, re-trimmed as a ruled
1160            // carrier via the exact `rim_ruled_map` for ANY push direction
1161            // (SM1/SM1b axis-parallel, SM1c oblique). Any other non-planar
1162            // carrier still refuses.
1163            if carrier_is_planar(solid, &face_lookup, fixed_face) {
1164                cached_plane(&mut planes, solid, &face_lookup, fixed_face, plane_tolerance)?;
1165            } else if carrier_ruled(solid, &face_lookup, fixed_face).is_none() {
1166                // A geometrically-planar face the recognizer did not TAG as a
1167                // plane (an imported B-spline patch, say) still passes here —
1168                // only its message changes. A genuinely curved carrier refuses,
1169                // and this is the arm it refuses at: the classification loop,
1170                // long before the face-action loop the census cites.
1171                cached_plane(&mut planes, solid, &face_lookup, fixed_face, plane_tolerance)
1172                    .map_err(|_| {
1173                        unsupported_carrier(
1174                            solid,
1175                            &face_lookup,
1176                            fixed_face,
1177                            &format!(
1178                                "the fixed neighbour across boundary edge {}",
1179                                edge.id
1180                            ),
1181                        )
1182                    })?;
1183            }
1184            EdgeMoveClass::Boundary {
1185                moved_face,
1186                fixed_face,
1187            }
1188        };
1189        classes.insert(edge.id, class);
1190    }
1191
1192    // --- Relocate every vertex the group touches ---------------------------
1193    let mut vertex_faces: HashMap<u64, HashSet<u64>> = HashMap::default();
1194    for edge in &solid.edges {
1195        if let Some(uses) = faces_of_edge.get(&edge.id) {
1196            for vertex_id in [edge.start_vertex_id, edge.end_vertex_id] {
1197                vertex_faces
1198                    .entry(vertex_id)
1199                    .or_default()
1200                    .extend(uses.iter().copied());
1201            }
1202        }
1203    }
1204    let mut new_vertex: HashMap<u64, Vec3> = HashMap::default();
1205    for vertex in &solid.vertices {
1206        let Some(adjacent) = vertex_faces.get(&vertex.id) else {
1207            continue;
1208        };
1209        if !adjacent.iter().any(|face_id| moved.contains(face_id)) {
1210            continue;
1211        }
1212        let fixed_at: Vec<u64> = adjacent
1213            .iter()
1214            .copied()
1215            .filter(|face_id| !moved.contains(face_id))
1216            .collect();
1217        if fixed_at.is_empty() {
1218            // Interior vertex: carried rigidly with the group.
1219            new_vertex.insert(vertex.id, vertex.point.add(translation));
1220            continue;
1221        }
1222        // SM1b/SM1c: a corner on a single ruled neighbour rides that rim's exact
1223        // affine map — it stays on the (unchanged) cylinder/cone at the re-
1224        // intersected position. The map is the cap's homothety about the cone
1225        // apex (or an axis translation for a cylinder) and no longer requires the
1226        // push to be axis-parallel, so an OBLIQUE cap push relocates the seam
1227        // vertex onto the new conic rim exactly.
1228        if fixed_at.len() == 1 {
1229            if let Some((frame, rho0, rho1, height)) =
1230                carrier_ruled(solid, &face_lookup, fixed_at[0])
1231            {
1232                // The cap sharing this ruled rim is the moved planar face at the
1233                // vertex; its plane defines the homothety (D₀ from the apex).
1234                if let Some(cap) = adjacent.iter().copied().find(|f| moved.contains(f)) {
1235                    let moved_plane =
1236                        cached_plane(&mut planes, solid, &face_lookup, cap, plane_tolerance)
1237                            .map_err(|_| {
1238                                unsupported_carrier(
1239                                    solid,
1240                                    &face_lookup,
1241                                    cap,
1242                                    &format!(
1243                                        "the MOVED cap meeting a ruled neighbour at vertex {}",
1244                                        vertex.id
1245                                    ),
1246                                )
1247                            })?;
1248                    let map =
1249                        rim_ruled_map(&frame, rho0, rho1, height, &moved_plane, translation, tolerance)?;
1250                    new_vertex.insert(vertex.id, map.point(vertex.point));
1251                    continue;
1252                }
1253            }
1254        }
1255        // If every fixed carrier is INVARIANT under the push — a plane parallel
1256        // to it, or an axis-parallel cylinder (e.g. a fillet band) — the corner
1257        // rides rigidly (stays on all of them + on every translated moved plane).
1258        if fixed_at.iter().all(|&face_id| {
1259            carrier_invariant_under(solid, &face_lookup, face_id, translation, parallel_tolerance)
1260        }) {
1261            new_vertex.insert(vertex.id, vertex.point.add(translation));
1262            continue;
1263        }
1264        // SM1c multi-rim: a corner shared with a FIXED ruled carrier (a split
1265        // cylinder/cone band) cannot be placed by the planar 3-plane solver — its
1266        // carrier is not a plane. Ride it along the single fixed edge it shares:
1267        // the new corner is where the TRANSLATED cap plane crosses that fixed
1268        // edge's curve. Consistent + valid only when the affine rim map that
1269        // carries the adjacent conic rim agrees with this ride (an axis-parallel
1270        // cylinder generatrix); a cone homothety moves the corner off the fixed
1271        // wall, so the consistency gate below refuses that (curved multi-rim
1272        // cone deferred to the rim re-trim path).
1273        if fixed_at
1274            .iter()
1275            .any(|&f| carrier_ruled(solid, &face_lookup, f).is_some())
1276        {
1277            let moved_here: Vec<u64> = adjacent
1278                .iter()
1279                .copied()
1280                .filter(|f| moved.contains(f))
1281                .collect();
1282            if moved_here.len() != 1 {
1283                return Err(format!(
1284                    "move_faces: corner at vertex {} touches {} moved faces against a ruled \
1285                     neighbour (single-cap multi-rim only) — refusing",
1286                    vertex.id,
1287                    moved_here.len()
1288                ));
1289            }
1290            let cap_plane =
1291                cached_plane(&mut planes, solid, &face_lookup, moved_here[0], plane_tolerance)
1292                    .map_err(|_| {
1293                        unsupported_carrier(
1294                            solid,
1295                            &face_lookup,
1296                            moved_here[0],
1297                            &format!("the MOVED cap at vertex {}", vertex.id),
1298                        )
1299                    })?;
1300            let fixed_edges: Vec<&EdgeRecord> = solid
1301                .edges
1302                .iter()
1303                .filter(|e| {
1304                    (e.start_vertex_id == vertex.id || e.end_vertex_id == vertex.id)
1305                        && matches!(classes.get(&e.id), Some(EdgeMoveClass::Fixed))
1306                })
1307                .collect();
1308            if fixed_edges.len() != 1 {
1309                return Err(format!(
1310                    "move_faces: corner at vertex {} rides {} fixed edges against a ruled \
1311                     neighbour (exactly one required) — refusing",
1312                    vertex.id,
1313                    fixed_edges.len()
1314                ));
1315            }
1316            let fixed_edge = fixed_edges[0];
1317            let seed = if fixed_edge.start_vertex_id == vertex.id {
1318                fixed_edge.t0
1319            } else {
1320                fixed_edge.t1
1321            };
1322            let translated_origin = cap_plane.origin.add(translation);
1323            let plane_c = cap_plane.normal.dot(translated_origin);
1324            // A STRAIGHT fixed edge (a cylinder generatrix on a flat, or a cone's
1325            // seam meridian — which passes through the apex, so the rim homothety
1326            // agrees with it) rides its own line: exact, and unchanged since SM1c.
1327            // A CURVED fixed edge (the hyperbola where a flat cuts a cone) is
1328            // solved on the CARRIERS instead: the boolean split it exactly at the
1329            // corner, so riding it could only ever move the corner inward.
1330            let straight_fixed_edge = fixed_edge.curve.degree == 1
1331                && fixed_edge.curve.control_points.len() == 2;
1332            let carriers = if straight_fixed_edge {
1333                None
1334            } else {
1335                plane_and_ruled_carriers(
1336                    solid,
1337                    &face_lookup,
1338                    &mut planes,
1339                    &fixed_at,
1340                    plane_tolerance,
1341                )
1342            };
1343            let corner = match carriers {
1344                Some((fixed_plane, (frame, rho0, rho1, height))) => corner_on_plane_and_ruled(
1345                    &fixed_plane,
1346                    cap_plane.normal,
1347                    plane_c,
1348                    &frame,
1349                    rho0,
1350                    rho1,
1351                    height,
1352                    vertex.point,
1353                    tolerance,
1354                )?,
1355                None => resolve_corner_on_fixed_edge(
1356                    fixed_edge,
1357                    seed,
1358                    cap_plane.normal,
1359                    plane_c,
1360                    tolerance,
1361                )?,
1362            };
1363            // The new corner must genuinely sit on EVERY fixed carrier at the
1364            // vertex (ruled: at its rho_at radius; planar: on the plane), else
1365            // the group tore away — refuse rather than emit a bad solid.
1366            for &f in &fixed_at {
1367                if let Some((frame, rho0, rho1, height)) = carrier_ruled(solid, &face_lookup, f) {
1368                    let delta = corner.sub(frame.origin);
1369                    let axial = delta.dot(frame.axis);
1370                    let radial = delta.sub(frame.axis.scale(axial)).length();
1371                    let off = (radial - rho_at(rho0, rho1, height, axial)).abs();
1372                    if off > 10.0 * tolerance {
1373                        return Err(format!(
1374                            "move_faces: re-solved corner at vertex {} left its ruled neighbour \
1375                             (off {off:.3e}) — refusing",
1376                            vertex.id
1377                        ));
1378                    }
1379                } else {
1380                    let plane = cached_plane(&mut planes, solid, &face_lookup, f, plane_tolerance)
1381                        .map_err(|_| {
1382                            unsupported_carrier(
1383                                solid,
1384                                &face_lookup,
1385                                f,
1386                                &format!("a FIXED neighbour at vertex {}", vertex.id),
1387                            )
1388                        })?;
1389                    if corner.sub(plane.origin).dot(plane.normal).abs() > 10.0 * tolerance {
1390                        return Err(format!(
1391                            "move_faces: re-solved corner at vertex {} left a fixed planar \
1392                             neighbour — refusing",
1393                            vertex.id
1394                        ));
1395                    }
1396                }
1397            }
1398            // The re-solved corner is TRUTH: it is the only point lying on the
1399            // fixed wall, the fixed flat AND the translated cap plane at once.
1400            //
1401            // The conic rim bordering the fixed ruled carrier is carried by the
1402            // EXACT affine `rim_ruled_map`, which maps the WHOLE conic correctly
1403            // (carrier → itself, cap plane → translated cap plane). On a CYLINDER
1404            // — an axis translation along a generatrix of a flat that contains the
1405            // axis — its image of the old corner IS this corner, so the rim keeps
1406            // its affine map untouched. On a CONE it is a homothety about the
1407            // apex: the rim CURVE is still exact but the arc's ENDPOINT slides off
1408            // the fixed flat, so the rim edge is RE-BUILT between the re-solved
1409            // corners instead (`EdgeMoveAction::Replace`, see the Boundary arm).
1410            // This used to be a consistency gate that refused the cone outright.
1411            new_vertex.insert(vertex.id, corner);
1412            continue;
1413        }
1414        // Genuine re-intersection: every carrier meeting at the corner must
1415        // be planar to solve the new corner in closed form.
1416        let mut corner_planes = Vec::with_capacity(fixed_at.len());
1417        for &face_id in &fixed_at {
1418            corner_planes.push(
1419                cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance).map_err(
1420                    |_| {
1421                        unsupported_carrier(
1422                            solid,
1423                            &face_lookup,
1424                            face_id,
1425                            &format!("a FIXED carrier meeting the moved group at vertex {}", vertex.id),
1426                        )
1427                    },
1428                )?,
1429            );
1430        }
1431        for face_id in adjacent
1432            .iter()
1433            .copied()
1434            .filter(|face_id| moved.contains(face_id))
1435        {
1436            let mut plane = cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance)
1437                .map_err(|_| {
1438                    unsupported_carrier(
1439                        solid,
1440                        &face_lookup,
1441                        face_id,
1442                        &format!("a MOVED carrier meeting a fixed neighbour at vertex {}", vertex.id),
1443                    )
1444                })?;
1445            plane.origin = plane.origin.add(translation);
1446            corner_planes.push(plane);
1447        }
1448        let corner = solve_corner(&corner_planes).ok_or_else(|| {
1449            format!(
1450                "move_faces: cannot re-intersect the carriers meeting at vertex {} \
1451                 (parallel or under-constrained planes)",
1452                vertex.id
1453            )
1454        })?;
1455        // The corner must genuinely sit on EVERY carrier; otherwise the group
1456        // tears away from its fixed neighbours and no manifold heal exists.
1457        for plane in &corner_planes {
1458            if corner.sub(plane.origin).dot(plane.normal).abs() > tolerance {
1459                return Err(format!(
1460                    "move_faces: the moved group tears away from its neighbours at \
1461                     vertex {} — refusing rather than emitting an invalid solid",
1462                    vertex.id
1463                ));
1464            }
1465        }
1466        new_vertex.insert(vertex.id, corner);
1467    }
1468
1469    // --- Plan every edge update -------------------------------------------
1470    let vertex_position: HashMap<u64, Vec3> = solid
1471        .vertices
1472        .iter()
1473        .map(|vertex| (vertex.id, vertex.point))
1474        .collect();
1475    let mut actions: HashMap<u64, EdgeMoveAction> = HashMap::default();
1476    for edge in &solid.edges {
1477        let position = |vertex_id: u64| -> Result<Vec3, String> {
1478            vertex_position
1479                .get(&vertex_id)
1480                .copied()
1481                .ok_or_else(|| format!("move_faces: missing vertex {vertex_id}"))
1482        };
1483        let start_old = position(edge.start_vertex_id)?;
1484        let end_old = position(edge.end_vertex_id)?;
1485        let start_new = new_vertex
1486            .get(&edge.start_vertex_id)
1487            .copied()
1488            .unwrap_or(start_old);
1489        let end_new = new_vertex
1490            .get(&edge.end_vertex_id)
1491            .copied()
1492            .unwrap_or(end_old);
1493        let rigid = start_new.sub(start_old.add(translation)).length() <= rigid_tolerance
1494            && end_new.sub(end_old.add(translation)).length() <= rigid_tolerance;
1495        match &classes[&edge.id] {
1496            EdgeMoveClass::Fixed => {
1497                if start_new.sub(start_old).length() == 0.0 && end_new.sub(end_old).length() == 0.0
1498                {
1499                    continue; // no endpoint relocated — the edge is untouched
1500                }
1501                // A fixed side edge follows its re-solved endpoint, exactly as
1502                // delete_face_and_heal relocates side edges onto recovered
1503                // corners. Its faces get re-trimmed: planar faces need their
1504                // plane; a ruled carrier (the drilled-hole seam, or a cone's
1505                // extended generatrix) re-trims as a ruled carrier.
1506                for &face_id in &faces_of_edge[&edge.id] {
1507                    if carrier_ruled(solid, &face_lookup, face_id).is_none() {
1508                        cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance)
1509                            .map_err(|_| {
1510                                unsupported_carrier(
1511                                    solid,
1512                                    &face_lookup,
1513                                    face_id,
1514                                    &format!(
1515                                        "a carrier of fixed edge {}, whose trim the push relocates",
1516                                        edge.id
1517                                    ),
1518                                )
1519                            })?;
1520                    }
1521                }
1522                if !edge.degenerate
1523                    && (edge.curve.degree != 1 || edge.curve.control_points.len() != 2)
1524                {
1525                    // CURVED fixed edge — the hyperbola where a flat cuts a cone,
1526                    // whose cap-side endpoint rides along it. BOTH its carriers
1527                    // stayed put, so the section is unchanged as a SET, but the
1528                    // boolean split the curve exactly at the corner (`domain ==
1529                    // [t0, t1]`), leaving no parameter headroom for an outward
1530                    // push — so the arc is RE-BUILT between its endpoints. (A
1531                    // straight-chord rebuild, what a degree-1 generatrix gets,
1532                    // would leave the cone; that is why this used to refuse.)
1533                    // Same collapse/inversion guards as `plan_straight_rebuild`,
1534                    // so a tearing push still refuses.
1535                    let new_chord = end_new.sub(start_new);
1536                    if new_chord.length() <= tolerance {
1537                        return Err(format!(
1538                            "move_faces: the translation collapses edge {} to zero length (a \
1539                             moved face lands exactly on its neighbour) — refusing",
1540                            edge.id
1541                        ));
1542                    }
1543                    if end_old.sub(start_old).dot(new_chord) <= 0.0 {
1544                        return Err(format!(
1545                            "move_faces: the translation inverts edge {} (a moved face passes \
1546                             beyond its neighbour) — refusing",
1547                            edge.id
1548                        ));
1549                    }
1550                    let (fixed_plane, (frame, rho0, rho1, height)) = plane_and_ruled_carriers(
1551                        solid,
1552                        &face_lookup,
1553                        &mut planes,
1554                        &faces_of_edge[&edge.id],
1555                        plane_tolerance,
1556                    )
1557                    .ok_or_else(|| {
1558                        format!(
1559                            "move_faces: curved fixed edge {} is not shared by exactly one plane \
1560                             and one ruled carrier — refusing",
1561                            edge.id
1562                        )
1563                    })?;
1564                    let forward =
1565                        arc_sweeps_forward(&frame, &edge.curve, edge.t0, edge.t1)?;
1566                    let curve = conic_arc_on_ruled(
1567                        &frame,
1568                        rho0,
1569                        rho1,
1570                        height,
1571                        fixed_plane.normal,
1572                        fixed_plane.normal.dot(fixed_plane.origin),
1573                        start_new,
1574                        end_new,
1575                        forward,
1576                        tolerance,
1577                    )?;
1578                    actions.insert(edge.id, EdgeMoveAction::Replace { curve });
1579                    continue;
1580                }
1581                let action =
1582                    plan_straight_rebuild(edge, start_old, end_old, start_new, end_new, tolerance)?;
1583                // A chord rebuilt against a ruled carrier must stay ON it — the
1584                // seam/generatrix endpoints and midpoint at their own rho_at radius.
1585                if let EdgeMoveAction::Rebuild { start, end } = &action {
1586                    for &face_id in &faces_of_edge[&edge.id] {
1587                        if carrier_ruled(solid, &face_lookup, face_id).is_some() {
1588                            verify_chord_on_carrier(
1589                                solid,
1590                                &face_lookup,
1591                                face_id,
1592                                *start,
1593                                *end,
1594                                tolerance,
1595                            )?;
1596                        }
1597                    }
1598                }
1599                actions.insert(edge.id, action);
1600            }
1601            EdgeMoveClass::Interior => {
1602                if rigid {
1603                    actions.insert(edge.id, EdgeMoveAction::Translate);
1604                } else {
1605                    // A tangential translation left the carriers in place, so
1606                    // an interior edge must stretch between re-solved corners
1607                    // instead of riding along (both faces are planar-checked).
1608                    for &face_id in &faces_of_edge[&edge.id] {
1609                        cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance)
1610                            .map_err(|_| {
1611                                unsupported_carrier(
1612                                    solid,
1613                                    &face_lookup,
1614                                    face_id,
1615                                    &format!(
1616                                        "a carrier of interior edge {}, which must stretch between \
1617                                         re-solved corners",
1618                                        edge.id
1619                                    ),
1620                                )
1621                            })?;
1622                    }
1623                    actions.insert(
1624                        edge.id,
1625                        plan_straight_rebuild(
1626                            edge, start_old, end_old, start_new, end_new, tolerance,
1627                        )?,
1628                    );
1629                }
1630            }
1631            EdgeMoveClass::Boundary {
1632                moved_face,
1633                fixed_face,
1634            } => {
1635                if let Some((frame, rho0, rho1, height)) =
1636                    carrier_ruled(solid, &face_lookup, *fixed_face)
1637                {
1638                    // The rim re-intersects the (unchanged) ruled carrier. Build
1639                    // that trim as the EXACT affine map of the rim (= the
1640                    // intersection of the translated cap plane with the carrier):
1641                    // a homothety about the cone apex, or an axis translation for
1642                    // a cylinder — for ANY push direction. Then verify it lies on
1643                    // both modified carriers (the "reapply the trimming"
1644                    // contract). The moved side must be planar (a cap);
1645                    // `cached_plane` refuses otherwise.
1646                    let moved_plane = cached_plane(
1647                        &mut planes,
1648                        solid,
1649                        &face_lookup,
1650                        *moved_face,
1651                        plane_tolerance,
1652                    )
1653                    .map_err(|_| {
1654                        unsupported_carrier(
1655                            solid,
1656                            &face_lookup,
1657                            *moved_face,
1658                            &format!(
1659                                "the MOVED side of boundary edge {} against a ruled neighbour",
1660                                edge.id
1661                            ),
1662                        )
1663                    })?;
1664                    let map =
1665                        rim_ruled_map(&frame, rho0, rho1, height, &moved_plane, translation, tolerance)?;
1666                    verify_rim_on_carriers(
1667                        edge,
1668                        &map,
1669                        &frame,
1670                        rho0,
1671                        rho1,
1672                        height,
1673                        &moved_plane,
1674                        translation,
1675                        tolerance,
1676                    )?;
1677                    // The affine carries the WHOLE conic exactly, but a CONE's
1678                    // homothety about the apex slides the ARC's endpoints off the
1679                    // fixed flat the corners must stay on — and the boolean left
1680                    // the rim with no parameter headroom (`domain == [t0, t1]`),
1681                    // so it cannot simply be re-trimmed either. When the map and
1682                    // the re-solved corners disagree, RE-BUILD the rim as the
1683                    // exact section of the TRANSLATED cap plane with the carrier,
1684                    // between those corners. A closed rim (one vertex, no corner)
1685                    // and a cylinder (whose map already lands on the corners) take
1686                    // the untouched affine path.
1687                    let mut replacement = None;
1688                    if edge.start_vertex_id != edge.end_vertex_id {
1689                        let old_start = edge.curve.evaluate(edge.t0)?;
1690                        let old_end = edge.curve.evaluate(edge.t1)?;
1691                        let rim_start = new_vertex
1692                            .get(&edge.start_vertex_id)
1693                            .copied()
1694                            .unwrap_or(old_start);
1695                        let rim_end = new_vertex
1696                            .get(&edge.end_vertex_id)
1697                            .copied()
1698                            .unwrap_or(old_end);
1699                        let drift = map
1700                            .point(old_start)
1701                            .sub(rim_start)
1702                            .length()
1703                            .max(map.point(old_end).sub(rim_end).length());
1704                        if drift > 10.0 * tolerance {
1705                            let forward =
1706                                arc_sweeps_forward(&frame, &edge.curve, edge.t0, edge.t1)?;
1707                            let plane_c = moved_plane
1708                                .normal
1709                                .dot(moved_plane.origin.add(translation));
1710                            replacement = Some(conic_arc_on_ruled(
1711                                &frame,
1712                                rho0,
1713                                rho1,
1714                                height,
1715                                moved_plane.normal,
1716                                plane_c,
1717                                rim_start,
1718                                rim_end,
1719                                forward,
1720                                tolerance,
1721                            )?);
1722                        }
1723                    }
1724                    actions.insert(
1725                        edge.id,
1726                        match replacement {
1727                            Some(curve) => EdgeMoveAction::Replace { curve },
1728                            None => EdgeMoveAction::Transform(map),
1729                        },
1730                    );
1731                } else if carrier_is_planar(solid, &face_lookup, *fixed_face) {
1732                    let fixed_plane = planes[fixed_face];
1733                    if rigid && translation.dot(fixed_plane.normal).abs() <= parallel_tolerance {
1734                        // The whole edge slides inside the fixed plane while
1735                        // staying on the translated moved carrier — exact for any
1736                        // curve type, no re-intersection needed.
1737                        actions.insert(edge.id, EdgeMoveAction::Translate);
1738                    } else {
1739                        // Real re-intersection: line = translated moved plane ∩
1740                        // fixed plane, delimited by the re-solved corners. The
1741                        // moved side must be planar for the chord to stay on it.
1742                        cached_plane(
1743                            &mut planes,
1744                            solid,
1745                            &face_lookup,
1746                            *moved_face,
1747                            plane_tolerance,
1748                        )
1749                        .map_err(|_| {
1750                            unsupported_carrier(
1751                                solid,
1752                                &face_lookup,
1753                                *moved_face,
1754                                &format!(
1755                                    "the MOVED side of boundary edge {} against a planar neighbour",
1756                                    edge.id
1757                                ),
1758                            )
1759                        })?;
1760                        actions.insert(
1761                            edge.id,
1762                            plan_straight_rebuild(
1763                                edge, start_old, end_old, start_new, end_new, tolerance,
1764                            )?,
1765                        );
1766                    }
1767                } else {
1768                    return Err(format!(
1769                        "move_faces: boundary edge {} borders a non-planar, non-axis-parallel \
1770                         carrier — refusing (SM3 territory)",
1771                        edge.id
1772                    ));
1773                }
1774            }
1775        }
1776    }
1777
1778    // --- Plan face updates -------------------------------------------------
1779    // Edges whose SHAPE changed — rebuilt straight OR affine-transformed rim. A
1780    // moved face bounding one of these must be RE-TRIMMED (its rim moved to a new
1781    // curve), not merely surface-translated; else its pcurve goes stale.
1782    let reshaped: HashSet<u64> = actions
1783        .iter()
1784        .filter(|(_, action)| {
1785            matches!(
1786                action,
1787                EdgeMoveAction::Rebuild { .. }
1788                    | EdgeMoveAction::Transform(_)
1789                    | EdgeMoveAction::Replace { .. }
1790            )
1791        })
1792        .map(|(edge_id, _)| *edge_id)
1793        .collect();
1794    let dirty: HashSet<u64> = actions.keys().copied().collect();
1795    let mut face_actions: Vec<(usize, usize, FaceMoveAction)> = Vec::new();
1796    // Fixed cylinder neighbours re-trim in a separate post-pass (they grow the
1797    // carrier via `extend_ruled_neighbour_over`, which needs `&mut solid`).
1798    let mut ruled_retrim_faces: Vec<u64> = Vec::new();
1799    for (shell_index, shell) in solid.shells.iter().enumerate() {
1800        for (face_index, face) in shell.faces.iter().enumerate() {
1801            let edge_ids = || {
1802                face.loops
1803                    .iter()
1804                    .flat_map(|loop_record| &loop_record.coedges)
1805                    .map(|coedge| coedge.edge_id)
1806            };
1807            if moved.contains(&face.id) {
1808                if edge_ids().any(|edge_id| reshaped.contains(&edge_id)) {
1809                    // A boundary edge changed shape (stretched, or a cap rim grown
1810                    // by the radial-scale map), so the patch must be re-trimmed
1811                    // around it; the moved cap is planar (translated), so its plane
1812                    // simply shifts by `translation` before the retrim.
1813                    let mut plane =
1814                        cached_plane(&mut planes, solid, &face_lookup, face.id, plane_tolerance)
1815                            .map_err(|_| {
1816                                unsupported_carrier(
1817                                    solid,
1818                                    &face_lookup,
1819                                    face.id,
1820                                    "the MOVED face whose rim the push reshaped",
1821                                )
1822                            })?;
1823                    plane.origin = plane.origin.add(translation);
1824                    face_actions.push((shell_index, face_index, FaceMoveAction::Retrim(plane)));
1825                } else {
1826                    // Every edge of the face rode along rigidly: shifting the
1827                    // control net keeps surface, curves, and pcurves in exact
1828                    // agreement for ANY carrier type.
1829                    face_actions.push((shell_index, face_index, FaceMoveAction::TranslateSurface));
1830                }
1831            } else if edge_ids().any(|edge_id| dirty.contains(&edge_id)) {
1832                if carrier_is_planar(solid, &face_lookup, face.id) {
1833                    let plane =
1834                        cached_plane(&mut planes, solid, &face_lookup, face.id, plane_tolerance)?;
1835                    face_actions.push((shell_index, face_index, FaceMoveAction::Retrim(plane)));
1836                } else if carrier_ruled(solid, &face_lookup, face.id).is_some() {
1837                    // Ruled carrier (drilled hole, boss cap, OR a cone whose trim
1838                    // extended under an oblique cap push): grow it along its axis +
1839                    // recompute pcurves in the post-pass.
1840                    ruled_retrim_faces.push(face.id);
1841                } else {
1842                    // Non-planar, non-ruled fixed carrier — refuse (the general
1843                    // offset path does the genuine curved re-intersection).
1844                    //
1845                    // MEASURED, and it corrects a just-landed claim: this arm is
1846                    // NOT the one a curved fixed neighbour reaches. A curved
1847                    // neighbour that borders the moved group is refused by the
1848                    // classification loop's own carrier gate (search
1849                    // `the fixed neighbour across boundary edge`) hundreds of
1850                    // lines earlier, so this arm can only be entered by a face
1851                    // whose edges went dirty WITHOUT it sharing a boundary edge
1852                    // with the moved group — which needs a vertex of valence
1853                    // four or more. The earlier census, reading the code rather
1854                    // than running it, attributed the Plane × Torus refusal to
1855                    // this arm; the probe (`examples/plane_push_refusal_probe.rs`,
1856                    // `carrier/torus.*`) shows the classification arm firing.
1857                    cached_plane(&mut planes, solid, &face_lookup, face.id, plane_tolerance)
1858                        .map_err(|_| {
1859                            unsupported_carrier(
1860                                solid,
1861                                &face_lookup,
1862                                face.id,
1863                                "a FIXED face the push must re-trim",
1864                            )
1865                        })?;
1866                }
1867            }
1868        }
1869    }
1870
1871    // --- Apply to a fresh clone (the input is never touched) ---------------
1872    let translate = AffineTransform::new([
1873        1.0,
1874        0.0,
1875        0.0,
1876        translation.x,
1877        0.0,
1878        1.0,
1879        0.0,
1880        translation.y,
1881        0.0,
1882        0.0,
1883        1.0,
1884        translation.z,
1885        0.0,
1886        0.0,
1887        0.0,
1888        1.0,
1889    ])?;
1890    let mut result = solid.clone();
1891    for edge in &mut result.edges {
1892        match actions.get(&edge.id) {
1893            Some(EdgeMoveAction::Translate) => {
1894                edge.curve = transform_curve(&edge.curve, translate)?;
1895            }
1896            Some(EdgeMoveAction::Rebuild { start, end }) => {
1897                edge.curve = make_line(*start, *end)?;
1898                edge.t0 = 0.0;
1899                edge.t1 = 1.0;
1900            }
1901            Some(EdgeMoveAction::Transform(map)) => {
1902                // Affine-map the rim (radial scale about the axis + translate).
1903                // Rational-quadratic circles map exactly; parameters unchanged.
1904                edge.curve = transform_curve(&edge.curve, *map)?;
1905            }
1906            Some(EdgeMoveAction::Replace { curve }) => {
1907                // An exactly rebuilt conic arc spans its whole domain by
1908                // construction, so the trim is the domain.
1909                let [d0, d1] = curve.domain()?;
1910                edge.curve = curve.clone();
1911                edge.t0 = d0;
1912                edge.t1 = d1;
1913            }
1914            None => {}
1915        }
1916    }
1917    for vertex in &mut result.vertices {
1918        if let Some(point) = new_vertex.get(&vertex.id) {
1919            vertex.point = *point;
1920        }
1921    }
1922    let final_edges: HashMap<u64, EdgeRecord> = result
1923        .edges
1924        .iter()
1925        .map(|edge| (edge.id, edge.clone()))
1926        .collect();
1927    for (shell_index, face_index, action) in face_actions {
1928        let face = &mut result.shells[shell_index].faces[face_index];
1929        match action {
1930            FaceMoveAction::TranslateSurface => {
1931                face.surface = transform_surface(&face.surface, translate)?;
1932            }
1933            FaceMoveAction::Retrim(plane) => {
1934                retrim_planar_face(face, &plane, &final_edges, scale, "move_faces")?;
1935                // `retrim_planar_face` maps each edge's WHOLE curve onto the
1936                // rebuilt plane, so any edge that represents a strict SUBRANGE
1937                // of its curve (e.g. a box wall edge a fillet trimmed back)
1938                // needs the range fitter to span exactly [t0, t1]; full-domain
1939                // edges keep the exact affine pcurve just built. Same pairing
1940                // the delete/heal planar retrim uses.
1941                let face_edges: HashSet<u64> = face
1942                    .loops
1943                    .iter()
1944                    .flat_map(|loop_record| loop_record.coedges.iter().map(|c| c.edge_id))
1945                    .collect();
1946                refit_touched_pcurves(
1947                    face,
1948                    &final_edges,
1949                    &face_edges,
1950                    true,
1951                    tolerance,
1952                    "move_faces",
1953                )?;
1954            }
1955        }
1956    }
1957    // SM1: fixed ruled (cylinder) neighbours grow along their axis and recompute
1958    // pcurves on the grown carrier — a separate pass because it needs `&mut result`.
1959    for face_id in ruled_retrim_faces {
1960        retrim_ruled_face(&mut result, face_id, &final_edges, tolerance)?;
1961    }
1962
1963    // Topology (and therefore genus) is untouched — only geometry moved — so
1964    // validate() re-checks Euler, loop closure, and pcurve agreement.
1965    let issues = result.validate();
1966    if !issues.is_empty() {
1967        return Err(format!(
1968            "move_faces: moved solid failed validation: {issues:?}"
1969        ));
1970    }
1971    // Belt and braces on top of the per-edge inversion guard: a global
1972    // inversion flips the signed volume even if every edge kept its direction.
1973    if let (Ok(before), Ok(after)) = (solid_signed_volume(solid), solid_signed_volume(&result)) {
1974        if before * after <= 0.0 {
1975            return Err(
1976                "move_faces: the translation inverts the solid (signed volume changed sign) \
1977                 — refusing"
1978                    .into(),
1979            );
1980        }
1981    }
1982    Ok(result)
1983}
1984
1985/// Backlog #5 (Plane × Sphere): push a single PLANAR face whose FIXED boundary
1986/// neighbour(s) are SPHERES — e.g. a flat capping a spherical dome / spherical-
1987/// bottomed pocket. The moved plane translates; where it borders a fixed sphere
1988/// the new boundary rim is the EXACT circle `translated-plane ∩ sphere` (built
1989/// seam-aligned from the sphere's own frame so its pcurve crosses the seam
1990/// cleanly), the sphere's coupled seam meridian slides its rim endpoint to the
1991/// new latitude (its pcurve is patched in parameter space — the periodic u=0/u=2π
1992/// pairing is preserved), and both carriers are re-trimmed to the new rim.
1993///
1994/// Honest scope (SPHERE neighbours only, this slice): only the AXIS-PERPENDICULAR
1995/// single-closed-circle rim is supported. Refused cleanly (never a bad solid): a
1996/// moved GROUP, a moved face that is not planar, any FIXED neighbour that is not
1997/// a sphere (planar corner re-solve / torus / general revolution are other
1998/// slices), an OBLIQUE plane × sphere rim, an open / multi-edge rim, a sphere
1999/// seam that does not lie where the plane re-intersects it, a vanished / tangent
2000/// cap, and a push that collapses or inverts the solid.
2001fn move_planar_face_across_sphere(
2002    solid: &BrepSolid,
2003    moved: &HashSet<u64>,
2004    face_lookup: &HashMap<u64, (usize, usize)>,
2005    faces_of_edge: &HashMap<u64, Vec<u64>>,
2006    translation: Vec3,
2007) -> Result<BrepSolid, String> {
2008    let scale = solid_model_scale(solid);
2009    let tolerance = (scale * 1e-7).max(1e-9);
2010    let plane_tolerance = (scale * 1e-6).max(1e-7);
2011
2012    // Single moved planar face only (groups deferred — they would need the
2013    // generic corner re-solve against the remaining planar neighbours).
2014    if moved.len() != 1 {
2015        return Err(
2016            "move_faces: a moved GROUP against a sphere neighbour is deferred \
2017             (single planar face only) — refusing"
2018                .into(),
2019        );
2020    }
2021    let moved_id = *moved.iter().next().unwrap();
2022    let &(mshell, mface) = face_lookup
2023        .get(&moved_id)
2024        .ok_or_else(|| format!("move_faces: missing moved face {moved_id}"))?;
2025    // The moved face must itself be planar (it translates rigidly).
2026    let moved_plane = plane_of_surface(
2027        &solid.shells[mshell].faces[mface].surface,
2028        plane_tolerance,
2029        "move_faces",
2030    )?;
2031    let translated_origin = moved_plane.origin.add(translation);
2032
2033    let edge_by_id: HashMap<u64, &EdgeRecord> =
2034        solid.edges.iter().map(|e| (e.id, e)).collect();
2035
2036    // --- Build every sphere rim of the moved planar face -------------------
2037    struct SphereRim {
2038        edge_id: u64,
2039        sphere_id: u64,
2040        closure_vertex: u64,
2041        new_circle: NurbsCurve,
2042        closure_point: Vec3,
2043        v_rim: f64,
2044    }
2045    let mut rims: Vec<SphereRim> = Vec::new();
2046    let mut closure_vertices: HashSet<u64> = HashSet::default();
2047    let mut sphere_ids: HashSet<u64> = HashSet::default();
2048
2049    for loop_record in &solid.shells[mshell].faces[mface].loops {
2050        for coedge in &loop_record.coedges {
2051            let edge = *edge_by_id
2052                .get(&coedge.edge_id)
2053                .ok_or_else(|| format!("move_faces: missing edge {}", coedge.edge_id))?;
2054            let uses = faces_of_edge
2055                .get(&edge.id)
2056                .map(Vec::as_slice)
2057                .unwrap_or(&[]);
2058            // The one fixed face across this boundary edge.
2059            let neighbour = uses.iter().copied().find(|f| *f != moved_id);
2060            let Some(neighbour) = neighbour else {
2061                return Err(format!(
2062                    "move_faces: the moved face borders itself along edge {} \
2063                     (unexpected own edge) — refusing",
2064                    edge.id
2065                ));
2066            };
2067            let Some((frame, radius)) = carrier_sphere(solid, face_lookup, neighbour) else {
2068                return Err(format!(
2069                    "move_faces: the moved planar face borders a non-sphere fixed \
2070                     neighbour along edge {} (mixed / planar-corner / torus / \
2071                     revolution neighbours are other slices) — refusing",
2072                    edge.id
2073                ));
2074            };
2075            // A single CLOSED-circle rim only (start == end vertex).
2076            if edge.start_vertex_id != edge.end_vertex_id {
2077                return Err(format!(
2078                    "move_faces: the plane × sphere rim (edge {}) is not a single closed \
2079                     circle (open / multi-edge rims are deferred) — refusing",
2080                    edge.id
2081                ));
2082            }
2083            let center = frame.origin;
2084            let axis = frame.axis;
2085            // Only the axis-perpendicular cap keeps the rim a fixed-latitude
2086            // circle we can build seam-aligned; an oblique section crossing the
2087            // seam is deferred (matches the sphere-pushed path's own refusal).
2088            if moved_plane.normal.dot(axis).abs() < 1.0 - 1e-6 {
2089                return Err(format!(
2090                    "move_faces: an OBLIQUE plane × sphere rim (edge {}) is deferred \
2091                     (only axis-perpendicular caps) — refusing",
2092                    edge.id
2093                ));
2094            }
2095            // Signed axial offset of the TRANSLATED plane from the sphere centre;
2096            // the new rim is the small circle of radius √(r²−a²) at that height.
2097            let a = translated_origin.sub(center).dot(axis);
2098            let rr2 = radius * radius - a * a;
2099            if rr2 <= tolerance * tolerance {
2100                return Err(format!(
2101                    "move_faces: the pushed plane no longer meets the sphere (edge {}: the \
2102                     cap vanishes / is tangent) — refusing",
2103                    edge.id
2104                ));
2105            }
2106            let radius_new = rr2.sqrt();
2107            let circle_center = center.add(axis.scale(a));
2108            let mut new_circle = crate::make_arc(
2109                circle_center,
2110                frame.x_axis,
2111                frame.y_axis,
2112                radius_new,
2113                0.0,
2114                std::f64::consts::TAU,
2115            )?;
2116            // Match the new rim's traversal to the old edge so the preserved
2117            // coedge `forward` flags keep both loops' winding consistent. A
2118            // closed circle's reversal keeps its start point, so the seam-aligned
2119            // closure point is unaffected.
2120            let closure_old = edge.curve.evaluate(edge.t0)?;
2121            let old_tan = edge
2122                .curve
2123                .evaluate(edge.t0 + 0.01 * (edge.t1 - edge.t0))?
2124                .sub(closure_old);
2125            let [c0, c1] = new_circle.domain()?;
2126            let new_start = new_circle.evaluate(c0)?;
2127            let new_tan = new_circle.evaluate(c0 + 0.01 * (c1 - c0))?.sub(new_start);
2128            if old_tan.dot(new_tan) < 0.0 {
2129                new_circle = new_circle.reversed()?;
2130            }
2131            let closure_point = new_circle.evaluate(new_circle.domain()?[0])?;
2132
2133            // The rim latitude in the (unchanged) sphere's (u, v) space, read off
2134            // the seam-crossing pcurve (constant v). The coupled seam meridian's
2135            // rim endpoint slides to this v.
2136            let (nshell, nface) = find_face(solid, neighbour)
2137                .ok_or_else(|| format!("move_faces: missing sphere neighbour {neighbour}"))?;
2138            let sphere_surface = &solid.shells[nshell].faces[nface].surface;
2139            let rim_pcurve = build_pcurve_on_surface(sphere_surface, &new_circle)?;
2140            let [q0, q1] = rim_pcurve.domain()?;
2141            let v_rim = rim_pcurve.evaluate(0.5 * (q0 + q1))?.y;
2142
2143            closure_vertices.insert(edge.start_vertex_id);
2144            sphere_ids.insert(neighbour);
2145            rims.push(SphereRim {
2146                edge_id: edge.id,
2147                sphere_id: neighbour,
2148                closure_vertex: edge.start_vertex_id,
2149                new_circle,
2150                closure_point,
2151                v_rim,
2152            });
2153        }
2154    }
2155    if rims.is_empty() {
2156        return Err("move_faces: no plane × sphere rim found — refusing".into());
2157    }
2158    // Per closure vertex: where it moves + its new rim latitude (for the meridian).
2159    let closure_of: HashMap<u64, (Vec3, f64)> = rims
2160        .iter()
2161        .map(|r| (r.closure_vertex, (r.closure_point, r.v_rim)))
2162        .collect();
2163
2164    // --- Coupled seam meridians (own-only edges of the fixed sphere whose rim
2165    // endpoint is one of the moved closure vertices) ------------------------
2166    struct MeridianRetrim {
2167        edge_id: u64,
2168        sphere_id: u64,
2169        moved_end_is_start: bool,
2170        new_t: f64,
2171        moved_vertex_old: Vec3,
2172        moved_vertex_new: Vec3,
2173        v_rim: f64,
2174    }
2175    let mut meridians: Vec<MeridianRetrim> = Vec::new();
2176    for edge in &solid.edges {
2177        if edge.degenerate {
2178            continue; // a pole degeneracy does not move
2179        }
2180        let uses = faces_of_edge
2181            .get(&edge.id)
2182            .map(Vec::as_slice)
2183            .unwrap_or(&[]);
2184        // Own-only to exactly one of the fixed spheres (a seam meridian).
2185        let owner = uses.first().copied();
2186        let Some(owner) = owner else { continue };
2187        if !sphere_ids.contains(&owner) || !uses.iter().all(|f| *f == owner) {
2188            continue;
2189        }
2190        let start_is_closure = closure_vertices.contains(&edge.start_vertex_id);
2191        let end_is_closure = closure_vertices.contains(&edge.end_vertex_id);
2192        if !start_is_closure && !end_is_closure {
2193            continue; // an uncoupled seam meridian: untouched
2194        }
2195        if start_is_closure && end_is_closure {
2196            return Err(format!(
2197                "move_faces: sphere seam meridian {} moves at BOTH ends (a sphere zone \
2198                 with two pushed rims) — deferred, refusing",
2199                edge.id
2200            ));
2201        }
2202        let moved_end_is_start = start_is_closure;
2203        let closure_vertex = if moved_end_is_start {
2204            edge.start_vertex_id
2205        } else {
2206            edge.end_vertex_id
2207        };
2208        let (moved_vertex_new, v_rim) = *closure_of
2209            .get(&closure_vertex)
2210            .ok_or_else(|| "move_faces: seam meridian is not paired with a rim — refusing".to_string())?;
2211        // Slide the moved endpoint along the (unchanged) meridian curve. The
2212        // curve is fixed (the sphere is fixed), so this only re-parametrises the
2213        // trim; project the new rim point onto it and verify it truly lands there
2214        // (the safety net if `frame.x_axis` were not the seam azimuth).
2215        let projection = project_point_to_curve(&edge.curve, moved_vertex_new)?;
2216        if projection.distance > 10.0 * tolerance {
2217            return Err(format!(
2218                "move_faces: the re-intersected rim point does not lie on the sphere seam \
2219                 meridian {} (off {:.3e}) — refusing",
2220                edge.id, projection.distance
2221            ));
2222        }
2223        let new_t = projection.u;
2224        let fixed_t = if moved_end_is_start { edge.t1 } else { edge.t0 };
2225        let old_moved_t = if moved_end_is_start { edge.t0 } else { edge.t1 };
2226        // The trim must neither collapse nor invert: the moved parameter must
2227        // stay on the same side of the fixed endpoint as before, with a real span.
2228        let [dom0, dom1] = edge.curve.domain()?;
2229        let span = (dom1 - dom0).max(1e-12);
2230        if (new_t - fixed_t) * (old_moved_t - fixed_t) <= 0.0
2231            || (new_t - fixed_t).abs() <= 1e-7 * span
2232            || edge.curve.evaluate(new_t)?.sub(edge.curve.evaluate(fixed_t)?).length() <= tolerance
2233        {
2234            return Err(format!(
2235                "move_faces: the sphere seam meridian {} trim collapses or inverts under \
2236                 the push — refusing",
2237                edge.id
2238            ));
2239        }
2240        let moved_vertex_old = edge_point(solid, closure_vertex)?;
2241        meridians.push(MeridianRetrim {
2242            edge_id: edge.id,
2243            sphere_id: owner,
2244            moved_end_is_start,
2245            new_t,
2246            moved_vertex_old,
2247            moved_vertex_new,
2248            v_rim,
2249        });
2250    }
2251
2252    // Every moved-face boundary vertex must be an accounted-for rim closure
2253    // vertex; anything else means an unmodelled corner (refuse rather than leave
2254    // a vertex un-relocated and fail late).
2255    for loop_record in &solid.shells[mshell].faces[mface].loops {
2256        for coedge in &loop_record.coedges {
2257            let edge = *edge_by_id
2258                .get(&coedge.edge_id)
2259                .ok_or_else(|| format!("move_faces: missing edge {}", coedge.edge_id))?;
2260            for v in [edge.start_vertex_id, edge.end_vertex_id] {
2261                if !closure_vertices.contains(&v) {
2262                    return Err(format!(
2263                        "move_faces: the moved face has a boundary vertex {v} that is not a \
2264                         sphere-rim closure — refusing",
2265                    ));
2266                }
2267            }
2268        }
2269    }
2270
2271    // --- Apply to a fresh clone (the input is never mutated) ---------------
2272    let mut result = solid.clone();
2273    // Rim edges take their new circle.
2274    for rim in &rims {
2275        if let Some(edge) = result.edges.iter_mut().find(|e| e.id == rim.edge_id) {
2276            let [d0, d1] = rim.new_circle.domain()?;
2277            edge.curve = rim.new_circle.clone();
2278            edge.t0 = d0;
2279            edge.t1 = d1;
2280        }
2281    }
2282    // Seam meridians keep their curve; only the moved-end trim slides.
2283    for mer in &meridians {
2284        if let Some(edge) = result.edges.iter_mut().find(|e| e.id == mer.edge_id) {
2285            if mer.moved_end_is_start {
2286                edge.t0 = mer.new_t;
2287            } else {
2288                edge.t1 = mer.new_t;
2289            }
2290        }
2291    }
2292    // Relocate the rim closure vertices.
2293    for rim in &rims {
2294        if let Some(v) = result.vertices.iter_mut().find(|v| v.id == rim.closure_vertex) {
2295            v.point = rim.closure_point;
2296        }
2297    }
2298
2299    // The moved planar face rides the translated plane and re-trims around its
2300    // new (smaller/larger) rim circle.
2301    let final_edges: HashMap<u64, EdgeRecord> =
2302        result.edges.iter().map(|e| (e.id, e.clone())).collect();
2303    {
2304        let mut plane = moved_plane;
2305        plane.origin = plane.origin.add(translation);
2306        let face = &mut result.shells[mshell].faces[mface];
2307        retrim_planar_face(face, &plane, &final_edges, scale, "move_faces")?;
2308    }
2309
2310    // Re-trim every fixed sphere: rebuild the rim coedge pcurve on the (unchanged)
2311    // sphere surface, and patch each coupled seam-meridian coedge's pcurve in
2312    // parameter space (keep u — preserving the periodic u=0/u=2π pairing — and
2313    // slide only the moved endpoint's v to the new rim latitude).
2314    for sphere_id in &sphere_ids {
2315        let (nshell, nface) = find_face(&result, *sphere_id)
2316            .ok_or_else(|| format!("move_faces: missing sphere neighbour {sphere_id}"))?;
2317        let sphere_surface = result.shells[nshell].faces[nface].surface.clone();
2318        for loop_record in &mut result.shells[nshell].faces[nface].loops {
2319            for coedge in &mut loop_record.coedges {
2320                if let Some(rim) = rims
2321                    .iter()
2322                    .find(|r| r.edge_id == coedge.edge_id && r.sphere_id == *sphere_id)
2323                {
2324                    let mut pcurve = build_pcurve_on_surface(&sphere_surface, &rim.new_circle)?;
2325                    if !coedge.forward {
2326                        pcurve = pcurve.reversed()?;
2327                    }
2328                    coedge.pcurve = pcurve;
2329                } else if let Some(mer) = meridians
2330                    .iter()
2331                    .find(|m| m.edge_id == coedge.edge_id && m.sphere_id == *sphere_id)
2332                {
2333                    coedge.pcurve = patch_seam_meridian_pcurve(
2334                        &coedge.pcurve,
2335                        &sphere_surface,
2336                        mer.moved_vertex_old,
2337                        mer.moved_vertex_new,
2338                        mer.v_rim,
2339                        tolerance,
2340                    )?;
2341                }
2342            }
2343        }
2344    }
2345
2346    let issues = result.validate();
2347    if !issues.is_empty() {
2348        return Err(format!(
2349            "move_faces: moved solid failed validation: {issues:?}"
2350        ));
2351    }
2352    if let (Ok(before), Ok(after)) = (solid_signed_volume(solid), solid_signed_volume(&result)) {
2353        if before * after <= 0.0 {
2354            return Err(
2355                "move_faces: the push inverts the solid (signed volume changed sign) — refusing"
2356                    .into(),
2357            );
2358        }
2359    }
2360    Ok(result)
2361}
2362
2363/// Slide a sphere seam-meridian pcurve's RIM endpoint to the new latitude,
2364/// keeping its constant u (so the periodic u=0 / u=2π seam pairing survives) and
2365/// its fixed (pole / other-rim) endpoint. The moved endpoint is identified by
2366/// which pcurve end maps (through the surface) to the moved vertex's OLD
2367/// position — not by pole detection — so it makes no assumption about the cap's
2368/// topology. Endpoint order (domain start→end) is preserved.
2369fn patch_seam_meridian_pcurve(
2370    pcurve: &NurbsCurve,
2371    surface: &NurbsSurface,
2372    moved_vertex_old: Vec3,
2373    moved_vertex_new: Vec3,
2374    v_rim: f64,
2375    tolerance: f64,
2376) -> Result<NurbsCurve, String> {
2377    let [q0, q1] = pcurve.domain()?;
2378    let a = pcurve.evaluate(q0)?;
2379    let b = pcurve.evaluate(q1)?;
2380    let a3 = surface.evaluate(a.x, a.y)?;
2381    let b3 = surface.evaluate(b.x, b.y)?;
2382    let da = a3.sub(moved_vertex_old).length();
2383    let db = b3.sub(moved_vertex_old).length();
2384    // Guard: one endpoint must genuinely be the moved vertex, and the surface
2385    // point at the patched (u, v_rim) must land on the relocated vertex.
2386    let moved_is_a = da <= db;
2387    let (moved_uv, fixed_uv) = if moved_is_a { (a, b) } else { (b, a) };
2388    let patched = Vec3::new(moved_uv.x, v_rim, 0.0);
2389    if surface.evaluate(patched.x, patched.y)?.sub(moved_vertex_new).length() > 10.0 * tolerance {
2390        return Err(
2391            "move_faces: patched seam-meridian pcurve endpoint does not reach the new rim \
2392             vertex — refusing"
2393                .into(),
2394        );
2395    }
2396    let fixed = Vec3::new(fixed_uv.x, fixed_uv.y, 0.0);
2397    if moved_is_a {
2398        make_line(patched, fixed)
2399    } else {
2400        make_line(fixed, patched)
2401    }
2402}
2403
2404// BREP private tests: 3d717f5b2660e67f