Skip to main content

brep_kernel/edit/direct_edit/
face_move.rs

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