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.
733///
734/// **The growth covers the rim's axial extremes, not only the five stations
735/// per coedge `boundary_samples` pins (2026-10-03).** A rim re-intersected on a
736/// tilted wall is an ellipse whose axial extremes fall BETWEEN those stations:
737/// on the 2026-09-21 runaway-bore document the rotated `Pin_S` was grown to
738/// `[0, 53.0875]` along its axis while its mouth ellipses truly ran
739/// `[-2.5436, 17.1686]` and `[35.9189, 55.6311]`, so each left the chart by
740/// 2.5436 mm and the pcurve fitted against the bounded chart stood 0.3722 /
741/// 0.4222 mm off its edge (`validate()` read exactly that; the acceptance's
742/// crossing repair happened to rebuild them later). The growth now reads each
743/// rim edge's axial extent with [`rim_axial_extent`] — every local extremum of
744/// the sampled axial coordinate refined, and a CERTIFIED bound from the control
745/// polygons of the pieces between stations — and grows the carrier to the
746/// certified bound, so the rim is inside the chart by construction; a carrier
747/// the five stations already grew far enough is left exactly as it was.
748pub(super) fn retrim_ruled_face(
749    solid: &mut BrepSolid,
750    face_id: u64,
751    final_edges: &HashMap<u64, EdgeRecord>,
752    tolerance: f64,
753    op: &str,
754) -> Result<(), KernelRefusal> {
755    let (shell, face_pos) = find_face(solid, face_id).ok_or_else(|| {
756        KernelRefusal::internal(
757            KernelStage::Sew,
758            "ruled_face_lookup",
759            format!("{op}: missing ruled face {face_id}"),
760        )
761    })?;
762    retrim_face_in_solid(
763        solid,
764        shell,
765        face_pos,
766        final_edges,
767        |solid, points| {
768            let mut points = points.to_vec();
769            points.extend(rim_axial_growth_points(
770                &solid.shells[shell].faces[face_pos],
771                final_edges,
772                op,
773            )?);
774            // The carrier helpers are typed; this retrim's closure is still
775            // the stringly shape its callee asks for (lossy exit).
776            extend_ruled_neighbour_over(solid, face_id, &points, tolerance)?;
777            Ok(extend_revolution_carrier_over(solid, face_id, &points, tolerance)?)
778        },
779        tolerance,
780        op,
781    )
782    
783}
784
785/// The points where each rim edge of `face` reaches its lowest and highest
786/// axial coordinate on the face's ruled or partial-sweep revolution carrier —
787/// the two points per coedge the carrier must be grown to cover, read from
788/// the caller's UPDATED edge table like `boundary_samples`. A face on any other
789/// carrier contributes nothing (the growth helpers no-op there anyway).
790///
791/// Each edge's axial coordinate `a(t) = (c(t) − origin) · axis` is swept at 64
792/// stations over `[t0, t1]`; the extreme station's two neighbours bracket the
793/// true extreme, and golden-section refinement closes that bracket to 1e-13 of
794/// the span. A grid alone would leave the chart short by the sampling
795/// shortfall (`A·(π/n)²/2`, 5e-5 mm on this document's 10 mm amplitude at
796/// n = 1024), and the fitter would read that shortfall back as a pcurve
797/// deviation.
798/// The two readings of [`rim_axial_extent`].
799#[derive(Clone, Copy, Debug)]
800pub(super) struct RimAxialExtent {
801    pub(super) refined_low: f64,
802    pub(super) refined_high: f64,
803    pub(super) certified_low: f64,
804    pub(super) certified_high: f64,
805}
806
807/// A rim's axial extent on a ruled or partial-sweep revolution carrier: the
808/// lowest and highest axial coordinate any point of the face's rim edges
809/// reaches, read from the caller's UPDATED edge table like `boundary_samples`.
810///
811/// Two readings, and the growth uses the second:
812///
813/// * `refined_low` / `refined_high` — the axial coordinate
814///   `a(t) = (c(t) − origin) · axis` is sampled at 16 stations per native knot
815///   span of each edge (plus the span and trim ends); EVERY sampled local
816///   extremum is then refined by golden section inside its two neighbouring
817///   stations to 1e-13 of the trim's span. This is where the extremum IS to
818///   the parameter's resolution when it lies in a sampled bracket; it is not a
819///   proof that no narrower extremum hides between two stations.
820/// * `certified_low` / `certified_high` — the control polygons of the curve
821///   pieces between consecutive stations (the curve split there): a rational
822///   B-spline with positive weights lies in the convex hull of its control
823///   points, so the union of the pieces' hulls CONTAINS THE WHOLE RIM. That
824///   is the one guarantee: `[certified_low, certified_high]` contains the true
825///   extent. The refined reading is a separate, unguaranteed one (a sampled
826///   extremum can sit INSIDE the true one), and the certified extent is only
827///   widened to contain it. The excess over the true extreme is the hull's,
828///   `O(h²)` in the station spacing (read on the fixtures in the record); a
829///   chart grown to it covers the rim with certainty and is never short by a
830///   sampling miss. A station closer than the curve's own split tolerance
831///   (`KNOT_IDENTITY_TOL`, absolute) or 1e-9 of the domain to a piece's end
832///   is not split at: the unsplit piece's hull still contains its whole
833///   interval, so coverage holds, only looser.
834///
835/// A rim edge with a non-positive control weight has no hull bound; the
836/// reading refuses by name rather than certify what it cannot. A face on any
837/// other carrier reads `None` (the growth helpers no-op there anyway).
838pub(super) fn rim_axial_extent(
839    face: &FaceRecord,
840    edges: &HashMap<u64, EdgeRecord>,
841    op: &str,
842) -> Result<Option<RimAxialExtent>, KernelRefusal> {
843    let frame = match face.surface.analytic() {
844        Some(AnalyticSurface::RuledRevolution { frame, .. })
845        | Some(AnalyticSurface::Revolution { frame, .. }) => frame.clone(),
846        _ => return Ok(None),
847    };
848    const STATIONS_PER_SPAN: usize = 16;
849    let mut extent = RimAxialExtent {
850        refined_low: f64::INFINITY,
851        refined_high: f64::NEG_INFINITY,
852        certified_low: f64::INFINITY,
853        certified_high: f64::NEG_INFINITY,
854    };
855    let mut read_any = false;
856    for loop_record in &face.loops {
857        for coedge in &loop_record.coedges {
858            let edge = edges
859                .get(&coedge.edge_id)
860                .ok_or_else(|| format!("{op}: missing edge {}", coedge.edge_id))
861                .or_refuse(KernelStage::Refine, "retrim_missing_edge")?;
862            if edge.degenerate {
863                continue;
864            }
865            let curve = &edge.curve;
866            let axial_of = |point: Vec3| point.sub(frame.origin).dot(frame.axis);
867            let axial = |t: f64| -> Result<f64, KernelRefusal> {
868                Ok(axial_of(curve.evaluate(t).or_refuse(KernelStage::Refine, "evaluate")?))
869            };
870            let (lo, hi) = (edge.t0.min(edge.t1), edge.t0.max(edge.t1));
871            let span = hi - lo;
872            if !(span > 0.0) {
873                continue;
874            }
875            // Stations: the trim ends, every native knot inside the trim, and
876            // 16 per knot span between them.
877            let mut stations: Vec<f64> = vec![lo];
878            let mut knots: Vec<f64> = curve.knots.iter().copied().filter(|&k| k > lo && k < hi).collect();
879            knots.dedup_by(|a, b| (*a - *b).abs() <= 1e-12 * span);
880            let mut previous = lo;
881            for knot in knots.into_iter().chain(std::iter::once(hi)) {
882                if knot - previous <= 1e-12 * span {
883                    continue;
884                }
885                for step in 1..=STATIONS_PER_SPAN {
886                    stations.push(previous + (knot - previous) * step as f64 / STATIONS_PER_SPAN as f64);
887                }
888                previous = knot;
889            }
890            let readings: Vec<f64> = stations.iter().map(|&t| axial(t)).collect::<Result<_, _>>()?;
891            let last = readings.len() - 1;
892            // Every sampled local extremum, refined inside its bracket.
893            for sign in [1.0f64, -1.0] {
894                for index in 0..=last {
895                    let value = sign * readings[index];
896                    let left_ok = index == 0 || value >= sign * readings[index - 1];
897                    let right_ok = index == last || value >= sign * readings[index + 1];
898                    if !(left_ok && right_ok) {
899                        continue;
900                    }
901                    let (mut a, mut b) = (stations[index.saturating_sub(1)], stations[(index + 1).min(last)]);
902                    let f = |t: f64| -> Result<f64, KernelRefusal> { Ok(-sign * axial(t)?) };
903                    let phi = 0.618_033_988_749_894_9_f64;
904                    let mut c = b - phi * (b - a);
905                    let mut d = a + phi * (b - a);
906                    let (mut fc, mut fd) = (f(c)?, f(d)?);
907                    for _ in 0..200 {
908                        if (b - a).abs() <= 1e-13 * span {
909                            break;
910                        }
911                        if fc < fd {
912                            b = d;
913                            d = c;
914                            fd = fc;
915                            c = b - phi * (b - a);
916                            fc = f(c)?;
917                        } else {
918                            a = c;
919                            c = d;
920                            fc = fd;
921                            d = a + phi * (b - a);
922                            fd = f(d)?;
923                        }
924                    }
925                    let best = (-fc).max(-fd).max(value) * sign;
926                    if sign > 0.0 {
927                        extent.refined_high = extent.refined_high.max(best);
928                    } else {
929                        extent.refined_low = extent.refined_low.min(best);
930                    }
931                }
932            }
933            // The certified bound: the control polygon of every piece between
934            // consecutive stations, the curve split there.
935            let mut rest = curve.clone();
936            let [d0, d1] = rest.domain().or_refuse(KernelStage::Refine, "domain")?;
937            // `NurbsCurve::split` refuses a parameter within the ABSOLUTE
938            // `KNOT_IDENTITY_TOL` of a domain end; the guard is that floor or
939            // 1e-9 of the domain, whichever is larger, and a station inside
940            // it is skipped (the unsplit piece's hull still covers it).
941            let guard = (1e-9 * (d1 - d0)).max(crate::KNOT_IDENTITY_TOL);
942            let mut pieces: Vec<NurbsCurve> = Vec::with_capacity(stations.len());
943            if lo > d0 + guard {
944                rest = rest.split(lo).or_refuse(KernelStage::Refine, "split")?.1;
945            }
946            for &t in &stations[1..last] {
947                let [r0, r1] = rest.domain().or_refuse(KernelStage::Refine, "domain")?;
948                if t <= r0 + guard || t >= r1 - guard {
949                    continue;
950                }
951                let (left, right) = rest.split(t).or_refuse(KernelStage::Refine, "split")?;
952                pieces.push(left);
953                rest = right;
954            }
955            if hi < d1 - guard {
956                let [r0, r1] = rest.domain().or_refuse(KernelStage::Refine, "domain")?;
957                if hi > r0 + guard && hi < r1 - guard {
958                    rest = rest.split(hi).or_refuse(KernelStage::Refine, "split")?.0;
959                }
960            }
961            pieces.push(rest);
962            for piece in &pieces {
963                for control in &piece.control_points {
964                    if !(control.w > 0.0) {
965                        return Err(KernelRefusal::unsupported(
966                            KernelStage::Refine,
967                            "rim_axial_extent_weights",
968                            format!(
969                                "{op}: rim edge {} carries a control weight {} that is not positive, so its \
970                                 axial extent has no convex-hull bound to certify the carrier's growth against",
971                                edge.id, control.w
972                            ),
973                        ));
974                    }
975                    let a = axial_of(control.point().or_refuse(KernelStage::Refine, "control_point")?);
976                    extent.certified_low = extent.certified_low.min(a);
977                    extent.certified_high = extent.certified_high.max(a);
978                }
979            }
980            read_any = true;
981        }
982    }
983    if !read_any {
984        return Ok(None);
985    }
986    // The hull contains the curve, so it contains the refined readings too;
987    // taking the union guards the bound against a rounding hair.
988    extent.certified_low = extent.certified_low.min(extent.refined_low);
989    extent.certified_high = extent.certified_high.max(extent.refined_high);
990    Ok(Some(extent))
991}
992
993/// The points `retrim_ruled_face` grows a carrier over beyond the five pinned
994/// stations: the rim's CERTIFIED axial bound, as two points on the axis.
995pub(super) fn rim_axial_growth_points(
996    face: &FaceRecord,
997    edges: &HashMap<u64, EdgeRecord>,
998    op: &str,
999) -> Result<Vec<Vec3>, KernelRefusal> {
1000    let Some(extent) = rim_axial_extent(face, edges, op)? else {
1001        return Ok(Vec::new());
1002    };
1003    let frame = match face.surface.analytic() {
1004        Some(AnalyticSurface::RuledRevolution { frame, .. })
1005        | Some(AnalyticSurface::Revolution { frame, .. }) => frame.clone(),
1006        _ => return Ok(Vec::new()),
1007    };
1008    Ok(vec![
1009        frame.origin.add(frame.axis.scale(extent.certified_low)),
1010        frame.origin.add(frame.axis.scale(extent.certified_high)),
1011    ])
1012}
1013
1014/// The EXACT corner where a fixed PLANE, a fixed RULED carrier and the
1015/// TRANSLATED cap plane meet: the line `fixed plane ∩ translated cap plane`
1016/// intersected with the ruled carrier (a quadratic in the line parameter),
1017/// taking the root nearest the corner's OLD position so the corner moves
1018/// continuously.
1019///
1020/// This is the carrier-level solve that a CURVED fixed edge needs.
1021/// `resolve_corner_on_fixed_edge` brackets strictly INSIDE the fixed edge's
1022/// domain, and the boolean that produced such an edge split it exactly at the
1023/// corner (`domain == [t0, t1]`), so riding the edge can only ever move a corner
1024/// INWARD — an outward push would refuse for a purely representational reason.
1025/// The carriers have no such horizon.
1026pub(super) fn corner_on_plane_and_ruled(
1027    fixed_plane: &Plane,
1028    cap_normal: Vec3,
1029    cap_c: f64,
1030    frame: &crate::RevolutionFrame,
1031    rho0: f64,
1032    rho1: f64,
1033    height: f64,
1034    old_corner: Vec3,
1035    tolerance: f64,
1036) -> Result<Vec3, KernelRefusal> {
1037    let direction = fixed_plane.normal.cross(cap_normal);
1038    if direction.length() <= PARALLEL_EPS {
1039        return Err(KernelRefusal::unsupported(KernelStage::Refine, "parallel_carriers",
1040            "move_faces: the pushed cap plane is parallel to a fixed planar neighbour \
1041             (no corner) — refusing"));
1042    }
1043    let direction = direction.normalized().or_refuse(KernelStage::Refine, "normalized")?;
1044    // A point on both planes, taken in the 2-D span of the two normals.
1045    let ca = fixed_plane.normal.dot(fixed_plane.origin);
1046    let naa = fixed_plane.normal.dot(fixed_plane.normal);
1047    let nab = fixed_plane.normal.dot(cap_normal);
1048    let nbb = cap_normal.dot(cap_normal);
1049    let determinant = naa * nbb - nab * nab;
1050    if determinant.abs() <= PARALLEL_EPS {
1051        return Err(KernelRefusal::internal(KernelStage::Refine, "carrier_line", "move_faces: cannot place the corner's carrier line — refusing"));
1052    }
1053    let alpha = (ca * nbb - cap_c * nab) / determinant;
1054    let beta = (cap_c * naa - ca * nab) / determinant;
1055    let base = fixed_plane
1056        .normal
1057        .scale(alpha)
1058        .add(cap_normal.scale(beta));
1059    // |P − O|² − axial² = rho_at(axial)² along P(s) = base + s·direction.
1060    let offset = base.sub(frame.origin);
1061    let axis = frame.axis;
1062    let slope = if height == 0.0 {
1063        0.0
1064    } else {
1065        (rho1 - rho0) / height
1066    };
1067    let a0 = offset.dot(axis);
1068    let a1 = direction.dot(axis);
1069    let r0 = rho0 + slope * a0;
1070    let quad = 1.0 - a1 * a1 - slope * slope * a1 * a1;
1071    let linear = 2.0 * offset.dot(direction) - 2.0 * a0 * a1 - 2.0 * slope * a1 * r0;
1072    let constant = offset.dot(offset) - a0 * a0 - r0 * r0;
1073    let mut roots: Vec<f64> = Vec::new();
1074    if quad.abs() <= 1e-12 {
1075        if linear.abs() > 1e-12 {
1076            roots.push(-constant / linear);
1077        }
1078    } else {
1079        let discriminant = linear * linear - 4.0 * quad * constant;
1080        if discriminant >= 0.0 {
1081            roots.extend(crate::poly::solve_quadratic(constant, linear, quad).into_iter().rev());
1082        }
1083    }
1084    let mut best: Option<(Vec3, f64)> = None;
1085    for s in roots {
1086        let point = base.add(direction.scale(s));
1087        // Only the nappe with a NON-NEGATIVE radius is the real carrier.
1088        if rho_at(rho0, rho1, height, point.sub(frame.origin).dot(axis)) < -tolerance {
1089            continue;
1090        }
1091        let distance = point.sub(old_corner).length();
1092        if best.map(|(_, best)| distance < best).unwrap_or(true) {
1093            best = Some((point, distance));
1094        }
1095    }
1096    let (corner, _) = best.ok_or_else(|| {
1097        KernelRefusal::unsupported(
1098            KernelStage::Refine,
1099            "corner_off_carrier",
1100            "move_faces: the pushed cap plane no longer meets the fixed ruled neighbour \
1101         (the push drives the corner off the carrier) — refusing",
1102        )
1103    })?;
1104    Ok(corner)
1105}
1106
1107/// True iff `curve[t0..t1]` sweeps in the POSITIVE azimuth sense about `frame`
1108/// (the sense `make_arc` builds), decided by whether its midpoint's azimuth lies
1109/// inside the positive sweep from the start's to the end's.
1110pub(super) fn arc_sweeps_forward(
1111    frame: &crate::RevolutionFrame,
1112    curve: &NurbsCurve,
1113    t0: f64,
1114    t1: f64,
1115) -> Result<bool, String> {
1116    let azimuth = |point: Vec3| {
1117        let delta = point.sub(frame.origin);
1118        delta.dot(frame.y_axis).atan2(delta.dot(frame.x_axis))
1119    };
1120    let tau = std::f64::consts::TAU;
1121    let wrap = |angle: f64| {
1122        let value = angle % tau;
1123        if value < 0.0 {
1124            value + tau
1125        } else {
1126            value
1127        }
1128    };
1129    let start = azimuth(curve.evaluate(t0)?);
1130    let end = azimuth(curve.evaluate(t1)?);
1131    let middle = azimuth(curve.evaluate(0.5 * (t0 + t1))?);
1132    Ok(wrap(middle - start) <= wrap(end - start))
1133}
1134
1135/// The EXACT arc of `plane ∩ cone` between two endpoints that already lie on
1136/// both, swept in the given sense.
1137///
1138/// A cone's plane section is the PROJECTIVE image, from the apex, of the base
1139/// circle: the ray `apex → X` meets the plane at `apex + (k/((X−apex)·n))·(X−apex)`
1140/// with `k = c − n·apex`, which is LINEAR in the homogeneous control point — so
1141/// the image of a rational-quadratic circular arc is a rational-quadratic conic
1142/// arc on the SAME knot vector, exactly. Azimuth is constant along a cone ray,
1143/// so the endpoints' azimuths give the base arc directly. This is exact for
1144/// ELLIPTIC and HYPERBOLIC sections alike (the full hyperbola cannot be one
1145/// rational Bezier — its weights change sign — but an arc that stays on one
1146/// nappe can, which is why the sign check below is the only restriction).
1147///
1148/// The kernel's own `intersect_plane_quadric` builds a cone section the same way
1149/// but only ever for the FULL section, so it refuses exactly the hyperbolic case
1150/// this arc-restricted form supports.
1151pub(super) fn conic_arc_on_ruled(
1152    frame: &crate::RevolutionFrame,
1153    rho0: f64,
1154    rho1: f64,
1155    height: f64,
1156    plane_normal: Vec3,
1157    plane_c: f64,
1158    start: Vec3,
1159    end: Vec3,
1160    forward_sweep: bool,
1161    tolerance: f64,
1162) -> Result<NurbsCurve, KernelRefusal> {
1163    let radius_scale = rho0.abs().max(rho1.abs()).max(1.0);
1164    let axis = frame.axis;
1165    // Azimuth is preserved by BOTH section constructions — a cone's central
1166    // projection from a point on the axis, and a cylinder's axial shear add no
1167    // azimuthal component — so the arc between two points on the section is the
1168    // arc between their azimuths on the base circle, exactly.
1169    let azimuth = |point: Vec3| {
1170        let delta = point.sub(frame.origin);
1171        delta.dot(frame.y_axis).atan2(delta.dot(frame.x_axis))
1172    };
1173    let tau = std::f64::consts::TAU;
1174    let wrap = |angle: f64| {
1175        let value = angle % tau;
1176        if value < 0.0 {
1177            value + tau
1178        } else {
1179            value
1180        }
1181    };
1182    let (start_angle, sweep, reverse) = if forward_sweep {
1183        (azimuth(start), wrap(azimuth(end) - azimuth(start)), false)
1184    } else {
1185        (azimuth(end), wrap(azimuth(start) - azimuth(end)), true)
1186    };
1187    if sweep <= 1e-9 || sweep >= tau - 1e-9 {
1188        return Err(KernelRefusal::unsupported(KernelStage::Refine, "section_degenerate",
1189            "move_faces: the re-intersected arc degenerates to a point or a full turn \
1190             — refusing"));
1191    }
1192    if (rho1 - rho0).abs() <= 1e-9 * radius_scale {
1193        // A CYLINDER's plane section is an ellipse, built by the shared
1194        // `plane_ruled_section_arc` — the same axial-shear image of a base arc
1195        // `intersect_plane_quadric` uses for the whole section. This arm used
1196        // to be an honest refusal ("deferred"), which is what stopped a curved
1197        // fixed edge on a cylinder carrier from rebuilding.
1198        let mut curve = plane_ruled_section_arc(
1199            plane_normal.scale(plane_c / plane_normal.dot(plane_normal)),
1200            plane_normal,
1201            frame,
1202            rho0,
1203            rho1,
1204            height,
1205            start_angle,
1206            sweep,
1207            tolerance,
1208        )
1209        .map_err(|error| {
1210            KernelRefusal::unsupported(
1211                KernelStage::Refine,
1212                "ruled_section",
1213                error.replace("plane_ruled_section_arc:", "move_faces:"),
1214            )
1215        })?;
1216        if reverse {
1217            curve = curve.reversed().or_refuse(KernelStage::Refine, "reversed")?;
1218        }
1219        verify_section_between(&curve, frame, rho0, rho1, height, plane_normal, plane_c, start, end, tolerance)?;
1220        return Ok(curve);
1221    }
1222    let apex = frame
1223        .origin
1224        .add(axis.scale(rho0 * height / (rho0 - rho1)));
1225    let k = plane_c - plane_normal.dot(apex);
1226    if k.abs() <= tolerance {
1227        return Err(KernelRefusal::unsupported(KernelStage::Refine, "section_through_apex", "move_faces: the section plane passes through the cone apex — refusing"));
1228    }
1229    // Base circle at whichever end has the LARGER radius, so it never degenerates.
1230    let (reference_rho, reference_axial) = if rho0.abs() >= rho1.abs() {
1231        (rho0.abs(), 0.0)
1232    } else {
1233        (rho1.abs(), height)
1234    };
1235    if reference_rho <= tolerance {
1236        return Err(KernelRefusal::unsupported(KernelStage::Refine, "degenerate_cone", "move_faces: the cone carrier degenerates to its apex — refusing"));
1237    }
1238    let circle = crate::make_arc(
1239        frame.origin.add(axis.scale(reference_axial)),
1240        frame.x_axis,
1241        frame.y_axis,
1242        reference_rho,
1243        start_angle,
1244        start_angle + sweep,
1245    ).or_refuse(KernelStage::Refine, "make_arc")?;
1246    let mut mapped: Vec<crate::Vec4> = Vec::with_capacity(circle.control_points.len());
1247    let mut sign = 0.0f64;
1248    for control in &circle.control_points {
1249        let relative = Vec3::new(
1250            control.x - control.w * apex.x,
1251            control.y - control.w * apex.y,
1252            control.z - control.w * apex.z,
1253        );
1254        let weight = relative.dot(plane_normal);
1255        if weight.abs() <= 1e-9 * radius_scale {
1256            return Err(KernelRefusal::unsupported(KernelStage::Refine, "asymptotic_ruling",
1257                "move_faces: the re-intersected section runs along an asymptotic ruling \
1258                 of the cone — refusing"));
1259        }
1260        if sign == 0.0 {
1261            sign = weight.signum();
1262        } else if weight.signum() != sign {
1263            return Err(KernelRefusal::unsupported(KernelStage::Refine, "both_nappes",
1264                "move_faces: the re-intersected section crosses the cone's apex plane \
1265                 (both nappes) — refusing"));
1266        }
1267        let scaled = relative.scale(k);
1268        mapped.push(crate::Vec4 {
1269            x: apex.x * weight + scaled.x,
1270            y: apex.y * weight + scaled.y,
1271            z: apex.z * weight + scaled.z,
1272            w: weight,
1273        });
1274    }
1275    if sign < 0.0 {
1276        // Homogeneously identical, but the kernel keeps weights positive.
1277        for control in &mut mapped {
1278            control.x = -control.x;
1279            control.y = -control.y;
1280            control.z = -control.z;
1281            control.w = -control.w;
1282        }
1283    }
1284    let mut curve = NurbsCurve::new(circle.degree, circle.knots.clone(), mapped).or_refuse(KernelStage::Refine, "curve_new")?;
1285    if reverse {
1286        curve = curve.reversed().or_refuse(KernelStage::Refine, "reversed")?;
1287    }
1288    verify_section_between(
1289        &curve, frame, rho0, rho1, height, plane_normal, plane_c, start, end, tolerance,
1290    )?;
1291    Ok(curve)
1292}
1293
1294/// The "reapply the trimming" contract a rebuilt section is held to, shared by
1295/// both branches of [`conic_arc_on_ruled`] and by the rotation's rim rebuild:
1296/// the arc must RUN BETWEEN the two given corners and every sample of it must
1297/// lie on BOTH carriers. The constructions are exact, so this measures rather
1298/// than fits — it is the same fail-safe [`verify_rim_on_carriers`] applies to
1299/// the affine rim map.
1300pub(super) fn verify_section_between(
1301    curve: &NurbsCurve,
1302    frame: &crate::RevolutionFrame,
1303    rho0: f64,
1304    rho1: f64,
1305    height: f64,
1306    plane_normal: Vec3,
1307    plane_c: f64,
1308    start: Vec3,
1309    end: Vec3,
1310    tolerance: f64,
1311) -> Result<(), KernelRefusal> {
1312    let axis = frame.axis;
1313    let [d0, d1] = curve.domain().or_refuse(KernelStage::Validate, "domain")?;
1314    for (parameter, target) in [(d0, start), (d1, end)] {
1315        let drift = curve.evaluate(parameter).or_refuse(KernelStage::Validate, "evaluate")?.sub(target).length();
1316        if drift > 10.0 * tolerance {
1317            return Err(KernelRefusal::internal(KernelStage::Validate, "section_corner", format!(
1318                "move_faces: the rebuilt section misses its corner by {drift:.3e} — refusing"
1319            )));
1320        }
1321    }
1322    for step in 0..=8 {
1323        let point = curve.evaluate(d0 + (d1 - d0) * (step as f64 / 8.0)).or_refuse(KernelStage::Validate, "evaluate")?;
1324        let delta = point.sub(frame.origin);
1325        let axial = delta.dot(axis);
1326        let off_ruled = (delta.sub(axis.scale(axial)).length()
1327            - rho_at(rho0, rho1, height, axial))
1328        .abs();
1329        let off_plane = (point.dot(plane_normal) - plane_c).abs();
1330        if off_ruled > 10.0 * tolerance || off_plane > 10.0 * tolerance {
1331            return Err(KernelRefusal::internal(KernelStage::Validate, "section_on_carriers", format!(
1332                "move_faces: the rebuilt section does not lie on both carriers \
1333                 (off ruled {off_ruled:.3e}, off plane {off_plane:.3e}) — refusing"
1334            )));
1335        }
1336    }
1337    Ok(())
1338}
1339
1340/// The fixed PLANE and the fixed RULED carrier an edge is shared by, when it is
1341/// shared by exactly one of each (the flat×cone hyperbola configuration).
1342pub(super) fn plane_and_ruled_carriers(
1343    solid: &BrepSolid,
1344    face_lookup: &HashMap<u64, (usize, usize)>,
1345    planes: &mut HashMap<u64, Plane>,
1346    face_ids: &[u64],
1347    plane_tolerance: f64,
1348) -> Option<(Plane, (crate::RevolutionFrame, f64, f64, f64))> {
1349    let mut plane = None;
1350    let mut ruled = None;
1351    for &face_id in face_ids {
1352        if let Some(carrier) = carrier_ruled(solid, face_lookup, face_id) {
1353            if ruled.is_some() {
1354                return None;
1355            }
1356            ruled = Some(carrier);
1357        } else if carrier_is_planar(solid, face_lookup, face_id) {
1358            if plane.is_some() {
1359                return None;
1360            }
1361            plane = Some(cached_plane(planes, solid, face_lookup, face_id, plane_tolerance).ok()?);
1362        } else {
1363            return None;
1364        }
1365    }
1366    Some((plane?, ruled?))
1367}
1368
1369/// Re-solve a cap corner that is shared with a FIXED ruled carrier (a split
1370/// cylinder/cone band), which the planar 3-plane `solve_corner` cannot place
1371/// (its carrier is not a plane). The corner rides the ONE fixed edge it shares
1372/// with the body: the new corner is where the TRANSLATED cap plane crosses that
1373/// fixed edge's curve. The fixed edge itself stays put — only its cap-side trim
1374/// endpoint slides along it (`plan_straight_rebuild` re-lays a straight
1375/// generatrix between the new and the untouched endpoints afterwards).
1376///
1377/// This is the SM1c multi-rim generalisation: the single-closed-rim oblique cap
1378/// (no corners) is the `fixed_at.len() == 1` rim-ride; a cap bounded by several
1379/// conic rims meeting at seam corners needs each corner re-placed here.
1380///
1381/// - **Straight generatrix (degree 1):** exact line solve. A line is its own
1382///   natural extension, so a crossing OUTSIDE the fixed edge's current span
1383///   (the outward push that lengthens the wall) is exact and accepted.
1384/// - **Conic (degree 2+):** bracket a sign change WITHIN the domain only and
1385///   refine — a rational conic extended past its span rides the end tangent,
1386///   not the conic, so an out-of-domain crossing is refused. The root nearest
1387///   the corner's own end parameter is chosen so the corner moves continuously.
1388///   This is now a FALLBACK: booleans split such an edge exactly at the corner
1389///   (`domain == [t0, t1]`), so an outward push has no room to bracket into.
1390///   When the corner's fixed carriers are one plane and one ruled surface, the
1391///   caller solves on the CARRIERS instead (`corner_on_plane_and_ruled`), which
1392///   has no such horizon; this arm only runs for configurations that solve does
1393///   not cover.
1394///
1395/// Refuses cleanly when the fixed edge runs in the cap plane (no crossing) or
1396/// the push drives the corner off the edge's reachable span (a tearing push).
1397pub(super) fn resolve_corner_on_fixed_edge(
1398    fixed_edge: &EdgeRecord,
1399    seed_param: f64,
1400    plane_normal: Vec3,
1401    plane_c: f64,
1402    tolerance: f64,
1403) -> Result<Vec3, KernelRefusal> {
1404    let curve = &fixed_edge.curve;
1405    if let Some((p0, p1)) = curve.straight_segment(tolerance) {
1406        let dir = p1.sub(p0);
1407        let denom = plane_normal.dot(dir);
1408        if denom.abs() <= PARALLEL_EPS * (1.0 + dir.length()) {
1409            return Err(KernelRefusal::unsupported(KernelStage::Refine, "edge_parallel_to_cap", format!(
1410                "move_faces: fixed edge {} runs parallel to the cap plane (no crossing) — refusing",
1411                fixed_edge.id
1412            )));
1413        }
1414        let s = (plane_c - plane_normal.dot(p0)) / denom;
1415        return Ok(p0.add(dir.scale(s)));
1416    }
1417    // Curved (conic) fixed edge — closed-form-in-spirit numeric solve strictly
1418    // within the domain. `n·C(u) − c` has the sign of the numerator polynomial
1419    // (weights are strictly positive), so its roots are the crossings.
1420    let [d0, d1] = curve.domain().or_refuse(KernelStage::Refine, "domain")?;
1421    let f = |u: f64| -> Result<f64, KernelRefusal> { Ok(plane_normal.dot(curve.evaluate(u).or_refuse(KernelStage::Refine, "evaluate")?) - plane_c) };
1422    const STEPS: usize = 96;
1423    let mut best: Option<(f64, f64)> = None;
1424    let mut prev_u = d0;
1425    let mut prev_f = f(d0)?;
1426    if prev_f.abs() <= tolerance {
1427        best = Some((d0, (d0 - seed_param).abs()));
1428    }
1429    for i in 1..=STEPS {
1430        let u = d0 + (d1 - d0) * (i as f64 / STEPS as f64);
1431        let fu = f(u)?;
1432        if prev_f * fu < 0.0 {
1433            let (mut lo, mut hi, mut flo) = (prev_u, u, prev_f);
1434            for _ in 0..64 {
1435                let mid = 0.5 * (lo + hi);
1436                let fm = f(mid)?;
1437                if flo * fm <= 0.0 {
1438                    hi = mid;
1439                } else {
1440                    lo = mid;
1441                    flo = fm;
1442                }
1443            }
1444            let root = 0.5 * (lo + hi);
1445            let dist = (root - seed_param).abs();
1446            if best.map(|(_, bd)| dist < bd).unwrap_or(true) {
1447                best = Some((root, dist));
1448            }
1449        }
1450        prev_u = u;
1451        prev_f = fu;
1452    }
1453    let (root, _) = best.ok_or_else(|| {
1454        KernelRefusal::unsupported(KernelStage::Refine, "fixed_edge_span", format!(
1455            "move_faces: the pushed cap does not re-cross fixed edge {} within its span \
1456             (curved-fixed-edge extension deferred, or the push tears the face) — refusing",
1457            fixed_edge.id
1458        ))
1459    })?;
1460    curve.evaluate(root).or_refuse(KernelStage::Refine, "evaluate")
1461}
1462
1463/// Golovanov §6.12 direct editing — translate a group of faces rigidly and
1464/// heal the adjacency with the faces that stay behind.
1465///
1466/// The moved carriers translate exactly (every control point shifts by the
1467/// translation, which is exact for ANY surface type), and each boundary edge
1468/// between a moved face and a fixed face is recomputed as the intersection of
1469/// the translated moved carrier with the fixed carrier:
1470///
1471/// - When the translation is parallel to every fixed plane a boundary vertex
1472///   touches, the whole neighbourhood translates rigidly — exact for any
1473///   moved carrier and any edge curve type. This is the extrude-like case:
1474///   pushing a face along its own normal slides the side walls in-plane.
1475/// - Otherwise the new corner is re-solved as the common point of ALL carrier
1476///   planes meeting at the vertex (moved ones translated), and every affected
1477///   straight edge is rebuilt between the re-solved corners — the same
1478///   relocate-onto-recovered-corners move `delete_face_and_heal` performs on
1479///   its side edges.
1480///
1481/// Scope (honest refusals, never a bad solid): the moved faces may be any
1482/// surface type. A FIXED face that must be re-intersected — the fixed side of a
1483/// boundary edge, or any face whose boundary edges must be rebuilt — must be
1484/// PLANAR, OR an AXIS-PARALLEL ruled revolution: a cylinder OR a cone whose axis
1485/// the push is parallel to (SM1/SM1b). Such a carrier keeps its SAME surface and
1486/// only re-trims — its rim re-intersects it at a new radius, constructed by the
1487/// exact radial-scale map `EdgeMoveAction::Transform` (`s = rho_at(z+d)/rho_at(z)`;
1488/// the cylinder is `s = 1`) and verified to lie on both modified carriers, then
1489/// grown along the axis (`retrim_ruled_face`). Every straight rebuilt edge (a
1490/// seam/generatrix) is verified on its carrier at its own `rho_at` radius.
1491///
1492/// An OBLIQUE cap push is supported on both carriers. The rim is the affine
1493/// image `rim_ruled_map` (a cylinder's axis translation, a cone's homothety
1494/// about the apex) — exact for the whole conic — and where that affine's image
1495/// of a corner disagrees with the corner itself (a CONE, whose homothety slides
1496/// the arc's endpoint off the fixed flat), the rim and the CURVED fixed edge it
1497/// meets are RE-BUILT as exact conic sections between the re-solved corners
1498/// (`conic_arc_on_ruled`, `EdgeMoveAction::Replace`); the corner itself comes
1499/// from the carrier-level solve `corner_on_plane_and_ruled`. A curved fixed edge
1500/// rebuilds on a CYLINDER carrier as its ellipse and on a cone as its conic.
1501/// Spheres (outside their dedicated route), tori, general revolutions and a cap
1502/// pushed to/through the apex are refused (SM3 is the general offset path).
1503/// A translation that collapses an adjacent edge to zero length or reverses
1504/// its direction (moving a box face onto or past its opposite face) is
1505/// refused, as is a group that tears away from its neighbours. The input is
1506/// never mutated; the result is returned only when `validate()` is clean.
1507pub fn move_faces(
1508    solid: &BrepSolid,
1509    face_ids: &[u64],
1510    translation: Vec3,
1511) -> Result<BrepSolid, KernelRefusal> {
1512    if !(translation.x.is_finite() && translation.y.is_finite() && translation.z.is_finite()) {
1513        return Err(KernelRefusal::input(KernelStage::Collect, "translation", "move_faces: translation must be finite"));
1514    }
1515    if face_ids.is_empty() {
1516        return Err(KernelRefusal::input(KernelStage::Collect, "selection", "move_faces: no faces selected"));
1517    }
1518    let moved: HashSet<u64> = face_ids.iter().copied().collect();
1519    // face id -> (shell, face) built once. move_faces never mutates `solid`,
1520    // so this replaces the O(faces) `find_face` scans in the validation loop
1521    // below and in `cached_plane` (called up to once per unique fixed face).
1522    // `or_insert` keeps the first match, mirroring `find_face`.
1523    let mut face_lookup: HashMap<u64, (usize, usize)> = HashMap::default();
1524    for (shell_index, shell) in solid.shells.iter().enumerate() {
1525        for (face_index, face) in shell.faces.iter().enumerate() {
1526            face_lookup
1527                .entry(face.id)
1528                .or_insert((shell_index, face_index));
1529        }
1530    }
1531    for &face_id in face_ids {
1532        if !face_lookup.contains_key(&face_id) {
1533            return Err(KernelRefusal::input(KernelStage::Collect, "face_id", format!("move_faces: no face with id {face_id}")));
1534        }
1535    }
1536
1537    // THE PARALLEL ROAD. `BREP_FACE_MOVE_ROAD=carrier` re-intersects the
1538    // selection's UNBOUNDED carriers against the rest of the solid instead of
1539    // classifying each boundary edge into one of four actions — the
1540    // construction the operator asked for, built alongside this one so the two
1541    // can be compared cell by cell on the capability matrix before anything is
1542    // rewired. With the variable unset this line is the only trace of it.
1543    if carrier_road_selected() {
1544        return move_faces_by_carrier(solid, &moved, translation);
1545    }
1546
1547    let scale = solid_model_scale(solid);
1548    let tolerance = (scale * 1e-7).max(1e-9);
1549    let plane_tolerance = (scale * 1e-6).max(1e-7);
1550    // "Parallel to a fixed plane" means the translation's normal component
1551    // could not move any point off that plane at model precision.
1552    let parallel_tolerance = (translation.length() * 1e-9).max(1e-12);
1553    // "Rigid" endpoints moved by exactly the translation (they are assigned
1554    // `point + translation` verbatim, so this only absorbs rounding noise).
1555    let rigid_tolerance = (scale * 1e-9).max(1e-12);
1556
1557    // --- Classify every edge by how the group uses it ----------------------
1558    let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
1559    for shell in &solid.shells {
1560        for face in &shell.faces {
1561            for loop_record in &face.loops {
1562                for coedge in &loop_record.coedges {
1563                    faces_of_edge
1564                        .entry(coedge.edge_id)
1565                        .or_default()
1566                        .push(face.id);
1567                }
1568            }
1569        }
1570    }
1571    // --- The manifold contract, asked once and early -----------------------
1572    // Hoisted out of the classification loop below so that the identity lane
1573    // that follows cannot hand a non-manifold document straight back unread.
1574    // The message is that loop's own, unchanged, and so is the verdict: this is
1575    // the same test at an earlier line.
1576    for edge in &solid.edges {
1577        let uses = faces_of_edge
1578            .get(&edge.id)
1579            .map(Vec::as_slice)
1580            .unwrap_or(&[]);
1581        let expected = if edge.degenerate { 1 } else { 2 };
1582        if uses.len() != expected {
1583            return Err(KernelRefusal::input(KernelStage::Collect, "non_manifold", format!(
1584                "move_faces: edge {} is used {} times (non-manifold input)",
1585                edge.id,
1586                uses.len()
1587            )));
1588        }
1589    }
1590
1591    // --- THE IDENTITY: a translation no selected carrier can feel ----------
1592    // A plane slid WITHIN itself and a cylinder pushed along its OWN axis are
1593    // the same statement — `S + T == S` — and the answer to both is the input
1594    // body. Answered here, ahead of every road below, because each of those
1595    // chooses its lane from a neighbour's carrier pair BEFORE the motion is
1596    // looked at: a slide over a fillet band took the curved-fixed-edge road and
1597    // a bore wall along its axis took the three-plane corner solve, and both
1598    // refused a motion that moves nothing. See `translation_is_identity_on` for
1599    // why no guard below is being relaxed by answering first.
1600    if translation_is_identity_on(solid, &face_lookup, &moved, translation, parallel_tolerance) {
1601        return Ok(solid.clone());
1602    }
1603
1604    // --- Plane × Sphere fast path (backlog #5) -----------------------------
1605    // A planar push whose FIXED neighbour across a boundary edge is a SPHERE
1606    // cannot be healed by the planar/ruled machinery below: the sphere's seam
1607    // meridian is a CURVED fixed edge (which `plan_straight_rebuild` refuses),
1608    // and its periodic u=0/u=2π seam pcurves must be patched in parameter space,
1609    // not refit from scratch. Route those to a dedicated handler that
1610    // re-intersects the translated plane with the fixed sphere (an EXACT circle)
1611    // and re-trims the sphere. Every configuration that handler does not support
1612    // refuses cleanly there — and every such case refuses in the generic path
1613    // today too, so this routing can only turn a refusal into a heal (it never
1614    // changes an already-supported case).
1615    let borders_a_sphere = solid.edges.iter().any(|edge| {
1616        let uses = faces_of_edge
1617            .get(&edge.id)
1618            .map(Vec::as_slice)
1619            .unwrap_or(&[]);
1620        let moved_uses = uses.iter().filter(|f| moved.contains(*f)).count();
1621        moved_uses > 0
1622            && moved_uses < uses.len()
1623            && uses
1624                .iter()
1625                .any(|f| !moved.contains(f) && carrier_sphere(solid, &face_lookup, *f).is_some())
1626    });
1627    if borders_a_sphere {
1628        return move_planar_face_across_sphere(solid, &moved, &face_lookup, &faces_of_edge, translation);
1629    }
1630
1631    let mut classes: HashMap<u64, EdgeMoveClass> = HashMap::default();
1632    let mut planes: HashMap<u64, Plane> = HashMap::default();
1633    for edge in &solid.edges {
1634        let uses = faces_of_edge
1635            .get(&edge.id)
1636            .map(Vec::as_slice)
1637            .unwrap_or(&[]);
1638        // Manifold use counts were checked above, before the identity lane.
1639        let moved_uses = uses
1640            .iter()
1641            .filter(|face_id| moved.contains(*face_id))
1642            .count();
1643        let class = if moved_uses == 0 {
1644            EdgeMoveClass::Fixed
1645        } else if moved_uses == uses.len() {
1646            EdgeMoveClass::Interior
1647        } else {
1648            let moved_face = *uses
1649                .iter()
1650                .find(|face_id| moved.contains(*face_id))
1651                .unwrap();
1652            let fixed_face = *uses
1653                .iter()
1654                .find(|face_id| !moved.contains(*face_id))
1655                .unwrap();
1656            // The face left behind across a boundary edge is the carrier we
1657            // re-intersect against. Planar → cache its plane (today path).
1658            // Non-planar but a ruled revolution (a cylinder OR a cone) → allowed:
1659            // the cap rim re-intersects the SAME carrier, re-trimmed as a ruled
1660            // carrier via the exact `rim_ruled_map` for ANY push direction
1661            // (SM1/SM1b axis-parallel, SM1c oblique). Any other non-planar
1662            // carrier still refuses.
1663            if carrier_is_planar(solid, &face_lookup, fixed_face) {
1664                cached_plane(&mut planes, solid, &face_lookup, fixed_face, plane_tolerance)?;
1665            } else if carrier_ruled(solid, &face_lookup, fixed_face).is_none() {
1666                // A geometrically-planar face the recognizer did not TAG as a
1667                // plane (an imported B-spline patch, say) still passes here —
1668                // only its message changes. A genuinely curved carrier refuses,
1669                // and this is the arm it refuses at: the classification loop,
1670                // long before the face-action loop the census cites.
1671                cached_plane(&mut planes, solid, &face_lookup, fixed_face, plane_tolerance)
1672                    .map_err(|_| {
1673                        unsupported_carrier(
1674                            solid,
1675                            &face_lookup,
1676                            fixed_face,
1677                            &format!(
1678                                "the fixed neighbour across boundary edge {}",
1679                                edge.id
1680                            ),
1681                        )
1682                    })?;
1683            }
1684            EdgeMoveClass::Boundary {
1685                moved_face,
1686                fixed_face,
1687            }
1688        };
1689        classes.insert(edge.id, class);
1690    }
1691
1692    // --- Relocate every vertex the group touches ---------------------------
1693    let mut vertex_faces: HashMap<u64, HashSet<u64>> = HashMap::default();
1694    for edge in &solid.edges {
1695        if let Some(uses) = faces_of_edge.get(&edge.id) {
1696            for vertex_id in [edge.start_vertex_id, edge.end_vertex_id] {
1697                vertex_faces
1698                    .entry(vertex_id)
1699                    .or_default()
1700                    .extend(uses.iter().copied());
1701            }
1702        }
1703    }
1704    let mut new_vertex: HashMap<u64, Vec3> = HashMap::default();
1705    for vertex in &solid.vertices {
1706        let Some(adjacent) = vertex_faces.get(&vertex.id) else {
1707            continue;
1708        };
1709        if !adjacent.iter().any(|face_id| moved.contains(face_id)) {
1710            continue;
1711        }
1712        let fixed_at: Vec<u64> = adjacent
1713            .iter()
1714            .copied()
1715            .filter(|face_id| !moved.contains(face_id))
1716            .collect();
1717        if fixed_at.is_empty() {
1718            // Interior vertex: carried rigidly with the group.
1719            new_vertex.insert(vertex.id, vertex.point.add(translation));
1720            continue;
1721        }
1722        // SM1b/SM1c: a corner on a single ruled neighbour rides that rim's exact
1723        // affine map — it stays on the (unchanged) cylinder/cone at the re-
1724        // intersected position. The map is the cap's homothety about the cone
1725        // apex (or an axis translation for a cylinder) and no longer requires the
1726        // push to be axis-parallel, so an OBLIQUE cap push relocates the seam
1727        // vertex onto the new conic rim exactly.
1728        if fixed_at.len() == 1 {
1729            if let Some((frame, rho0, rho1, height)) =
1730                carrier_ruled(solid, &face_lookup, fixed_at[0])
1731            {
1732                // The cap sharing this ruled rim is the moved planar face at the
1733                // vertex; its plane defines the homothety (D₀ from the apex).
1734                if let Some(cap) = adjacent.iter().copied().find(|f| moved.contains(f)) {
1735                    let moved_plane =
1736                        cached_plane(&mut planes, solid, &face_lookup, cap, plane_tolerance)
1737                            .map_err(|_| {
1738                                unsupported_carrier(
1739                                    solid,
1740                                    &face_lookup,
1741                                    cap,
1742                                    &format!(
1743                                        "the MOVED cap meeting a ruled neighbour at vertex {}",
1744                                        vertex.id
1745                                    ),
1746                                )
1747                            })?;
1748                    let map =
1749                        rim_ruled_map(&frame, rho0, rho1, height, &moved_plane, translation, tolerance)?;
1750                    new_vertex.insert(vertex.id, map.point(vertex.point));
1751                    continue;
1752                }
1753            }
1754        }
1755        // If every fixed carrier is INVARIANT under the push — a plane parallel
1756        // to it, or an axis-parallel cylinder (e.g. a fillet band) — the corner
1757        // rides rigidly (stays on all of them + on every translated moved plane).
1758        if fixed_at.iter().all(|&face_id| {
1759            carrier_invariant_under(solid, &face_lookup, face_id, translation, parallel_tolerance)
1760        }) {
1761            new_vertex.insert(vertex.id, vertex.point.add(translation));
1762            continue;
1763        }
1764        // THE MIRROR CORNER — a MOVED ruled carrier against a fixed FLAT. This
1765        // is the bore wall the user drags: the moved face is the cylinder (or
1766        // cone), the fixed neighbour is the flat it opens through, and the rim
1767        // they meet in is a conic on that flat. Neither road below can place
1768        // this corner — the three-plane solve because the moved carrier is not
1769        // a plane, the multi-rim ride because there it is the FIXED side that
1770        // is curved. What places it is the rim's OWN exact affine: the corner
1771        // is the image of the old corner under the map that carries
1772        // `P ∩ C → P ∩ (C + T)`, and that is the same map the rim edge is then
1773        // built with, so the corner and its rim cannot disagree.
1774        if let Some((moved_face, frame, rho0, rho1, height)) = moved_ruled_carrier_at(
1775            solid,
1776            &face_lookup,
1777            &moved,
1778            adjacent,
1779            translation,
1780            parallel_tolerance,
1781        ) {
1782            // Two fixed flats at the corner give two different maps (each
1783            // absorbs its own flat's share of the push along the axis) and no
1784            // single image for the vertex. It refuses by name rather than
1785            // picking one.
1786            if fixed_at.len() != 1 {
1787                return Err(KernelRefusal::unsupported(KernelStage::Classify, "mirror_corner_flats", format!(
1788                    "move_faces: the corner at vertex {} where the MOVED {} meets its \
1789                     neighbours touches {} fixed faces (a pushed cylinder or cone is \
1790                     re-intersected against exactly one flat there) — refusing",
1791                    vertex.id,
1792                    carrier_kind_name(
1793                        &solid.shells[face_lookup[&moved_face].0].faces[face_lookup[&moved_face].1]
1794                            .surface
1795                    ),
1796                    fixed_at.len()
1797                )));
1798            }
1799            let fixed_face = fixed_at[0];
1800            let flat = cached_plane(&mut planes, solid, &face_lookup, fixed_face, plane_tolerance)
1801                .map_err(|_| {
1802                    unsupported_carrier(
1803                        solid,
1804                        &face_lookup,
1805                        fixed_face,
1806                        &format!(
1807                            "the fixed neighbour of the MOVED ruled carrier (face \
1808                             {moved_face}) at vertex {}",
1809                            vertex.id
1810                        ),
1811                    )
1812                })?;
1813            let map =
1814                moved_ruled_rim_map(&frame, rho0, rho1, height, &flat, translation, tolerance)?;
1815            let corner = map.point(vertex.point);
1816            // Checked, never asserted, exactly as every other corner here is:
1817            // on the flat it must stay on, and on the carrier the push leaves.
1818            let off_flat = corner.sub(flat.origin).dot(flat.normal).abs();
1819            if off_flat > 10.0 * tolerance {
1820                return Err(KernelRefusal::internal(KernelStage::Validate, "mirror_corner_on_flat", format!(
1821                    "move_faces: the re-solved corner at vertex {} left its fixed flat \
1822                     (off {off_flat:.3e}) — refusing",
1823                    vertex.id
1824                )));
1825            }
1826            let pushed = shift_ruled_frame(&frame, translation);
1827            let delta = corner.sub(pushed.origin);
1828            let axial = delta.dot(pushed.axis);
1829            let radial = delta.sub(pushed.axis.scale(axial)).length();
1830            let off_carrier = (radial - rho_at(rho0, rho1, height, axial)).abs();
1831            if off_carrier > 10.0 * tolerance {
1832                return Err(KernelRefusal::internal(KernelStage::Validate, "mirror_corner_on_carrier", format!(
1833                    "move_faces: the re-solved corner at vertex {} left the MOVED carrier \
1834                     (face {moved_face}, off {off_carrier:.3e}) — refusing",
1835                    vertex.id
1836                )));
1837            }
1838            new_vertex.insert(vertex.id, corner);
1839            continue;
1840        }
1841        // SM1c multi-rim: a corner shared with a FIXED ruled carrier (a split
1842        // cylinder/cone band) cannot be placed by the planar 3-plane solver — its
1843        // carrier is not a plane. Ride it along the single fixed edge it shares:
1844        // the new corner is where the TRANSLATED cap plane crosses that fixed
1845        // edge's curve. Consistent + valid only when the affine rim map that
1846        // carries the adjacent conic rim agrees with this ride (an axis-parallel
1847        // cylinder generatrix); a cone homothety moves the corner off the fixed
1848        // wall, so the consistency gate below refuses that (curved multi-rim
1849        // cone deferred to the rim re-trim path).
1850        if fixed_at
1851            .iter()
1852            .any(|&f| carrier_ruled(solid, &face_lookup, f).is_some())
1853        {
1854            let moved_here: Vec<u64> = adjacent
1855                .iter()
1856                .copied()
1857                .filter(|f| moved.contains(f))
1858                .collect();
1859            if moved_here.len() != 1 {
1860                return Err(KernelRefusal::unsupported(KernelStage::Classify, "multi_rim_caps", format!(
1861                    "move_faces: corner at vertex {} touches {} moved faces against a ruled \
1862                     neighbour (single-cap multi-rim only) — refusing",
1863                    vertex.id,
1864                    moved_here.len()
1865                )));
1866            }
1867            let cap_plane =
1868                cached_plane(&mut planes, solid, &face_lookup, moved_here[0], plane_tolerance)
1869                    .map_err(|_| {
1870                        unsupported_carrier(
1871                            solid,
1872                            &face_lookup,
1873                            moved_here[0],
1874                            &format!("the MOVED cap at vertex {}", vertex.id),
1875                        )
1876                    })?;
1877            let fixed_edges: Vec<&EdgeRecord> = solid
1878                .edges
1879                .iter()
1880                .filter(|e| {
1881                    (e.start_vertex_id == vertex.id || e.end_vertex_id == vertex.id)
1882                        && matches!(classes.get(&e.id), Some(EdgeMoveClass::Fixed))
1883                })
1884                .collect();
1885            if fixed_edges.len() != 1 {
1886                return Err(KernelRefusal::unsupported(KernelStage::Classify, "multi_rim_fixed_edges", format!(
1887                    "move_faces: corner at vertex {} rides {} fixed edges against a ruled \
1888                     neighbour (exactly one required) — refusing",
1889                    vertex.id,
1890                    fixed_edges.len()
1891                )));
1892            }
1893            let fixed_edge = fixed_edges[0];
1894            let seed = if fixed_edge.start_vertex_id == vertex.id {
1895                fixed_edge.t0
1896            } else {
1897                fixed_edge.t1
1898            };
1899            let translated_origin = cap_plane.origin.add(translation);
1900            let plane_c = cap_plane.normal.dot(translated_origin);
1901            // A STRAIGHT fixed edge (a cylinder generatrix on a flat, or a cone's
1902            // seam meridian — which passes through the apex, so the rim homothety
1903            // agrees with it) rides its own line: exact, and unchanged since SM1c.
1904            // Straightness is geometric: a line rejoined with an interior knot is
1905            // still a line, and rides too.
1906            // A CURVED fixed edge (the hyperbola where a flat cuts a cone) is
1907            // solved on the CARRIERS instead: the boolean split it exactly at the
1908            // corner, so riding it could only ever move the corner inward.
1909            let straight_fixed_edge =
1910                fixed_edge.curve.straight_segment(tolerance).is_some();
1911            let carriers = if straight_fixed_edge {
1912                None
1913            } else {
1914                plane_and_ruled_carriers(
1915                    solid,
1916                    &face_lookup,
1917                    &mut planes,
1918                    &fixed_at,
1919                    plane_tolerance,
1920                )
1921            };
1922            let corner = match carriers {
1923                Some((fixed_plane, (frame, rho0, rho1, height))) => corner_on_plane_and_ruled(
1924                    &fixed_plane,
1925                    cap_plane.normal,
1926                    plane_c,
1927                    &frame,
1928                    rho0,
1929                    rho1,
1930                    height,
1931                    vertex.point,
1932                    tolerance,
1933                )?,
1934                None => resolve_corner_on_fixed_edge(
1935                    fixed_edge,
1936                    seed,
1937                    cap_plane.normal,
1938                    plane_c,
1939                    tolerance,
1940                )?,
1941            };
1942            // The new corner must genuinely sit on EVERY fixed carrier at the
1943            // vertex (ruled: at its rho_at radius; planar: on the plane), else
1944            // the group tore away — refuse rather than emit a bad solid.
1945            for &f in &fixed_at {
1946                if let Some((frame, rho0, rho1, height)) = carrier_ruled(solid, &face_lookup, f) {
1947                    let delta = corner.sub(frame.origin);
1948                    let axial = delta.dot(frame.axis);
1949                    let radial = delta.sub(frame.axis.scale(axial)).length();
1950                    let off = (radial - rho_at(rho0, rho1, height, axial)).abs();
1951                    if off > 10.0 * tolerance {
1952                        return Err(KernelRefusal::unsupported(KernelStage::Validate, "corner_off_ruled_neighbour", format!(
1953                            "move_faces: re-solved corner at vertex {} left its ruled neighbour \
1954                             (off {off:.3e}) — refusing",
1955                            vertex.id
1956                        )));
1957                    }
1958                } else {
1959                    let plane = cached_plane(&mut planes, solid, &face_lookup, f, plane_tolerance)
1960                        .map_err(|_| {
1961                            unsupported_carrier(
1962                                solid,
1963                                &face_lookup,
1964                                f,
1965                                &format!("a FIXED neighbour at vertex {}", vertex.id),
1966                            )
1967                        })?;
1968                    if corner.sub(plane.origin).dot(plane.normal).abs() > 10.0 * tolerance {
1969                        return Err(KernelRefusal::unsupported(KernelStage::Validate, "corner_off_planar_neighbour", format!(
1970                            "move_faces: re-solved corner at vertex {} left a fixed planar \
1971                             neighbour — refusing",
1972                            vertex.id
1973                        )));
1974                    }
1975                }
1976            }
1977            // The re-solved corner is TRUTH: it is the only point lying on the
1978            // fixed wall, the fixed flat AND the translated cap plane at once.
1979            //
1980            // The conic rim bordering the fixed ruled carrier is carried by the
1981            // EXACT affine `rim_ruled_map`, which maps the WHOLE conic correctly
1982            // (carrier → itself, cap plane → translated cap plane). On a CYLINDER
1983            // — an axis translation along a generatrix of a flat that contains the
1984            // axis — its image of the old corner IS this corner, so the rim keeps
1985            // its affine map untouched. On a CONE it is a homothety about the
1986            // apex: the rim CURVE is still exact but the arc's ENDPOINT slides off
1987            // the fixed flat, so the rim edge is RE-BUILT between the re-solved
1988            // corners instead (`EdgeMoveAction::Replace`, see the Boundary arm).
1989            // This used to be a consistency gate that refused the cone outright.
1990            new_vertex.insert(vertex.id, corner);
1991            continue;
1992        }
1993        // Genuine re-intersection: every carrier meeting at the corner must
1994        // be planar to solve the new corner in closed form.
1995        let mut corner_planes = Vec::with_capacity(fixed_at.len());
1996        for &face_id in &fixed_at {
1997            corner_planes.push(
1998                cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance).map_err(
1999                    |_| {
2000                        unsupported_carrier(
2001                            solid,
2002                            &face_lookup,
2003                            face_id,
2004                            &format!("a FIXED carrier meeting the moved group at vertex {}", vertex.id),
2005                        )
2006                    },
2007                )?,
2008            );
2009        }
2010        for face_id in adjacent
2011            .iter()
2012            .copied()
2013            .filter(|face_id| moved.contains(face_id))
2014        {
2015            let mut plane = cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance)
2016                .map_err(|_| {
2017                    unsupported_carrier(
2018                        solid,
2019                        &face_lookup,
2020                        face_id,
2021                        &format!("a MOVED carrier meeting a fixed neighbour at vertex {}", vertex.id),
2022                    )
2023                })?;
2024            plane.origin = plane.origin.add(translation);
2025            corner_planes.push(plane);
2026        }
2027        let corner = solve_corner(&corner_planes).ok_or_else(|| {
2028            KernelRefusal::ill_posed(KernelStage::Refine, "corner_underconstrained", format!(
2029                "move_faces: cannot re-intersect the carriers meeting at vertex {} \
2030                 (parallel or under-constrained planes)",
2031                vertex.id
2032            ))
2033        })?;
2034        // The corner must genuinely sit on EVERY carrier; otherwise the group
2035        // tears away from its fixed neighbours and no manifold heal exists.
2036        for plane in &corner_planes {
2037            if corner.sub(plane.origin).dot(plane.normal).abs() > tolerance {
2038                return Err(KernelRefusal::unsupported(KernelStage::Refine, "corner_tear", format!(
2039                    "move_faces: the moved group tears away from its neighbours at \
2040                     vertex {} — refusing rather than emitting an invalid solid",
2041                    vertex.id
2042                )));
2043            }
2044        }
2045        new_vertex.insert(vertex.id, corner);
2046    }
2047
2048    // --- Plan every edge update -------------------------------------------
2049    let vertex_position: HashMap<u64, Vec3> = solid
2050        .vertices
2051        .iter()
2052        .map(|vertex| (vertex.id, vertex.point))
2053        .collect();
2054    let mut actions: HashMap<u64, EdgeMoveAction> = HashMap::default();
2055    for edge in &solid.edges {
2056        let position = |vertex_id: u64| -> Result<Vec3, KernelRefusal> {
2057            vertex_position.get(&vertex_id).copied().ok_or_else(|| {
2058                KernelRefusal::internal(
2059                    KernelStage::Collect,
2060                    "vertex_lookup",
2061                    format!("move_faces: missing vertex {vertex_id}"),
2062                )
2063            })
2064        };
2065        let start_old = position(edge.start_vertex_id)?;
2066        let end_old = position(edge.end_vertex_id)?;
2067        let start_new = new_vertex
2068            .get(&edge.start_vertex_id)
2069            .copied()
2070            .unwrap_or(start_old);
2071        let end_new = new_vertex
2072            .get(&edge.end_vertex_id)
2073            .copied()
2074            .unwrap_or(end_old);
2075        let rigid = start_new.sub(start_old.add(translation)).length() <= rigid_tolerance
2076            && end_new.sub(end_old.add(translation)).length() <= rigid_tolerance;
2077        match &classes[&edge.id] {
2078            EdgeMoveClass::Fixed => {
2079                if start_new.sub(start_old).length() == 0.0 && end_new.sub(end_old).length() == 0.0
2080                {
2081                    continue; // no endpoint relocated — the edge is untouched
2082                }
2083                // A fixed side edge follows its re-solved endpoint, exactly as
2084                // delete_face_and_heal relocates side edges onto recovered
2085                // corners. Its faces get re-trimmed: planar faces need their
2086                // plane; a ruled carrier (the drilled-hole seam, or a cone's
2087                // extended generatrix) re-trims as a ruled carrier.
2088                for &face_id in &faces_of_edge[&edge.id] {
2089                    if carrier_ruled(solid, &face_lookup, face_id).is_none() {
2090                        cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance)
2091                            .map_err(|_| {
2092                                unsupported_carrier(
2093                                    solid,
2094                                    &face_lookup,
2095                                    face_id,
2096                                    &format!(
2097                                        "a carrier of fixed edge {}, whose trim the push relocates",
2098                                        edge.id
2099                                    ),
2100                                )
2101                            })?;
2102                    }
2103                }
2104                if !edge.degenerate && edge.curve.straight_segment(tolerance).is_none() {
2105                    // CURVED fixed edge — the hyperbola where a flat cuts a cone,
2106                    // whose cap-side endpoint rides along it. BOTH its carriers
2107                    // stayed put, so the section is unchanged as a SET, but the
2108                    // boolean split the curve exactly at the corner (`domain ==
2109                    // [t0, t1]`), leaving no parameter headroom for an outward
2110                    // push — so the arc is RE-BUILT between its endpoints. (A
2111                    // straight-chord rebuild, what a straight generatrix gets,
2112                    // would leave the cone; that is why this used to refuse.)
2113                    // "Curved" is asked of the GEOMETRY (`straight_segment`), not
2114                    // of the pole count: a line an offset shell rejoined out of
2115                    // two pieces carries an interior knot, and counting poles
2116                    // sent it down this arm to be refused for having two planar
2117                    // carriers instead of a plane and a ruled one.
2118                    // Same collapse/inversion guards as `plan_straight_rebuild`,
2119                    // so a tearing push still refuses.
2120                    let new_chord = end_new.sub(start_new);
2121                    if new_chord.length() <= tolerance {
2122                        return Err(KernelRefusal::unsupported(KernelStage::Refine, "curved_edge_collapse", format!(
2123                            "move_faces: the translation collapses edge {} to zero length (a \
2124                             moved face lands exactly on its neighbour) — refusing",
2125                            edge.id
2126                        )));
2127                    }
2128                    if end_old.sub(start_old).dot(new_chord) <= 0.0 {
2129                        return Err(KernelRefusal::unsupported(KernelStage::Refine, CURVED_EDGE_INVERSION, format!(
2130                            "move_faces: the translation inverts edge {} (a moved face passes \
2131                             beyond its neighbour) — refusing",
2132                            edge.id
2133                        )));
2134                    }
2135                    let (fixed_plane, (frame, rho0, rho1, height)) = plane_and_ruled_carriers(
2136                        solid,
2137                        &face_lookup,
2138                        &mut planes,
2139                        &faces_of_edge[&edge.id],
2140                        plane_tolerance,
2141                    )
2142                    .ok_or_else(|| {
2143                        KernelRefusal::unsupported(KernelStage::Classify, "curved_fixed_edge_pair", format!(
2144                            "move_faces: curved fixed edge {} is not shared by exactly one plane \
2145                             and one ruled carrier — refusing",
2146                            edge.id
2147                        ))
2148                    })?;
2149                    let forward =
2150                        arc_sweeps_forward(&frame, &edge.curve, edge.t0, edge.t1).or_refuse(KernelStage::Refine, "arc_sweeps_forward")?;
2151                    let curve = conic_arc_on_ruled(
2152                        &frame,
2153                        rho0,
2154                        rho1,
2155                        height,
2156                        fixed_plane.normal,
2157                        fixed_plane.normal.dot(fixed_plane.origin),
2158                        start_new,
2159                        end_new,
2160                        forward,
2161                        tolerance,
2162                    )?;
2163                    actions.insert(edge.id, EdgeMoveAction::Replace { curve });
2164                    continue;
2165                }
2166                let action =
2167                    plan_straight_rebuild(edge, start_old, end_old, start_new, end_new, tolerance)?;
2168                // A chord rebuilt against a ruled carrier must stay ON it — the
2169                // seam/generatrix endpoints and midpoint at their own rho_at radius.
2170                if let EdgeMoveAction::Rebuild { start, end } = &action {
2171                    for &face_id in &faces_of_edge[&edge.id] {
2172                        if carrier_ruled(solid, &face_lookup, face_id).is_some() {
2173                            verify_chord_on_carrier(
2174                                solid,
2175                                &face_lookup,
2176                                face_id,
2177                                *start,
2178                                *end,
2179                                moved.contains(&face_id).then_some(translation),
2180                                tolerance,
2181                            )?;
2182                        }
2183                    }
2184                }
2185                actions.insert(edge.id, action);
2186            }
2187            EdgeMoveClass::Interior => {
2188                if rigid {
2189                    actions.insert(edge.id, EdgeMoveAction::Translate);
2190                } else {
2191                    // A tangential translation left the carriers in place, so
2192                    // an interior edge must stretch between re-solved corners
2193                    // instead of riding along (both faces are planar-checked —
2194                    // except a MOVED ruled carrier's own seam generatrix, whose
2195                    // straight chord the carrier check below proves instead).
2196                    for &face_id in &faces_of_edge[&edge.id] {
2197                        if carrier_ruled(solid, &face_lookup, face_id).is_some() {
2198                            continue;
2199                        }
2200                        cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance)
2201                            .map_err(|_| {
2202                                unsupported_carrier(
2203                                    solid,
2204                                    &face_lookup,
2205                                    face_id,
2206                                    &format!(
2207                                        "a carrier of interior edge {}, which must stretch between \
2208                                         re-solved corners",
2209                                        edge.id
2210                                    ),
2211                                )
2212                            })?;
2213                    }
2214                    let action = plan_straight_rebuild(
2215                        edge, start_old, end_old, start_new, end_new, tolerance,
2216                    )?;
2217                    if let EdgeMoveAction::Rebuild { start, end } = &action {
2218                        for &face_id in &faces_of_edge[&edge.id] {
2219                            if carrier_ruled(solid, &face_lookup, face_id).is_some() {
2220                                verify_chord_on_carrier(
2221                                    solid,
2222                                    &face_lookup,
2223                                    face_id,
2224                                    *start,
2225                                    *end,
2226                                    moved.contains(&face_id).then_some(translation),
2227                                    tolerance,
2228                                )?;
2229                            }
2230                        }
2231                    }
2232                    actions.insert(edge.id, action);
2233                }
2234            }
2235            EdgeMoveClass::Boundary {
2236                moved_face,
2237                fixed_face,
2238            } => {
2239                if let Some((frame, rho0, rho1, height)) =
2240                    carrier_ruled(solid, &face_lookup, *fixed_face)
2241                {
2242                    // The rim re-intersects the (unchanged) ruled carrier. Build
2243                    // that trim as the EXACT affine map of the rim (= the
2244                    // intersection of the translated cap plane with the carrier):
2245                    // a homothety about the cone apex, or an axis translation for
2246                    // a cylinder — for ANY push direction. Then verify it lies on
2247                    // both modified carriers (the "reapply the trimming"
2248                    // contract). The moved side must be planar (a cap);
2249                    // `cached_plane` refuses otherwise.
2250                    let moved_plane = cached_plane(
2251                        &mut planes,
2252                        solid,
2253                        &face_lookup,
2254                        *moved_face,
2255                        plane_tolerance,
2256                    )
2257                    .map_err(|_| {
2258                        unsupported_carrier(
2259                            solid,
2260                            &face_lookup,
2261                            *moved_face,
2262                            &format!(
2263                                "the MOVED side of boundary edge {} against a ruled neighbour",
2264                                edge.id
2265                            ),
2266                        )
2267                    })?;
2268                    let map =
2269                        rim_ruled_map(&frame, rho0, rho1, height, &moved_plane, translation, tolerance)?;
2270                    verify_rim_on_carriers(
2271                        edge,
2272                        &map,
2273                        &frame,
2274                        rho0,
2275                        rho1,
2276                        height,
2277                        &moved_plane,
2278                        translation,
2279                        tolerance,
2280                    )?;
2281                    // The affine carries the WHOLE conic exactly, but a CONE's
2282                    // homothety about the apex slides the ARC's endpoints off the
2283                    // fixed flat the corners must stay on — and the boolean left
2284                    // the rim with no parameter headroom (`domain == [t0, t1]`),
2285                    // so it cannot simply be re-trimmed either. When the map and
2286                    // the re-solved corners disagree, RE-BUILD the rim as the
2287                    // exact section of the TRANSLATED cap plane with the carrier,
2288                    // between those corners. A closed rim (one vertex, no corner)
2289                    // and a cylinder (whose map already lands on the corners) take
2290                    // the untouched affine path.
2291                    let mut replacement = None;
2292                    if edge.start_vertex_id != edge.end_vertex_id {
2293                        let old_start = edge.curve.evaluate(edge.t0).or_refuse(KernelStage::Refine, "evaluate")?;
2294                        let old_end = edge.curve.evaluate(edge.t1).or_refuse(KernelStage::Refine, "evaluate")?;
2295                        let rim_start = new_vertex
2296                            .get(&edge.start_vertex_id)
2297                            .copied()
2298                            .unwrap_or(old_start);
2299                        let rim_end = new_vertex
2300                            .get(&edge.end_vertex_id)
2301                            .copied()
2302                            .unwrap_or(old_end);
2303                        let drift = map
2304                            .point(old_start)
2305                            .sub(rim_start)
2306                            .length()
2307                            .max(map.point(old_end).sub(rim_end).length());
2308                        if drift > 10.0 * tolerance {
2309                            let forward =
2310                                arc_sweeps_forward(&frame, &edge.curve, edge.t0, edge.t1).or_refuse(KernelStage::Refine, "arc_sweeps_forward")?;
2311                            let plane_c = moved_plane
2312                                .normal
2313                                .dot(moved_plane.origin.add(translation));
2314                            replacement = Some(conic_arc_on_ruled(
2315                                &frame,
2316                                rho0,
2317                                rho1,
2318                                height,
2319                                moved_plane.normal,
2320                                plane_c,
2321                                rim_start,
2322                                rim_end,
2323                                forward,
2324                                tolerance,
2325                            )?);
2326                        }
2327                    }
2328                    actions.insert(
2329                        edge.id,
2330                        match replacement {
2331                            Some(curve) => EdgeMoveAction::Replace { curve },
2332                            None => EdgeMoveAction::Transform(map),
2333                        },
2334                    );
2335                } else if carrier_is_planar(solid, &face_lookup, *fixed_face) {
2336                    let fixed_plane = planes[fixed_face];
2337                    if rigid && translation.dot(fixed_plane.normal).abs() <= parallel_tolerance {
2338                        // The whole edge slides inside the fixed plane while
2339                        // staying on the translated moved carrier — exact for any
2340                        // curve type, no re-intersection needed.
2341                        actions.insert(edge.id, EdgeMoveAction::Translate);
2342                    } else if let Some((frame, rho0, rho1, height)) =
2343                        carrier_ruled(solid, &face_lookup, *moved_face).filter(|_| {
2344                            !carrier_invariant_under(
2345                                solid,
2346                                &face_lookup,
2347                                *moved_face,
2348                                translation,
2349                                parallel_tolerance,
2350                            )
2351                        })
2352                    {
2353                        // THE MIRROR RIM — the pushed bore's mouth. The same
2354                        // `plane ∩ quadric` the arm above re-intersects, with the
2355                        // roles swapped: here the CYLINDER (or cone) is what
2356                        // moved and the FLAT is what stayed, so the rim's exact
2357                        // affine is `moved_ruled_rim_map` rather than
2358                        // `rim_ruled_map`. It carries the whole conic — a closed
2359                        // mouth included — so the parametrisation and every
2360                        // coedge's sense survive the push untouched.
2361                        let map = moved_ruled_rim_map(
2362                            &frame,
2363                            rho0,
2364                            rho1,
2365                            height,
2366                            &fixed_plane,
2367                            translation,
2368                            tolerance,
2369                        )?;
2370                        verify_rim_on_carriers(
2371                            edge,
2372                            &map,
2373                            &shift_ruled_frame(&frame, translation),
2374                            rho0,
2375                            rho1,
2376                            height,
2377                            &fixed_plane,
2378                            Vec3::default(),
2379                            tolerance,
2380                        )?;
2381                        // The affine carries the conic, but it only agrees with
2382                        // the corners the vertex pass solved when both ends of
2383                        // this rim ride the SAME flat. An open rim whose two ends
2384                        // sit on different flats has two maps and no single
2385                        // affine; it refuses by name rather than tearing.
2386                        if edge.start_vertex_id != edge.end_vertex_id {
2387                            let drift = map
2388                                .point(start_old)
2389                                .sub(start_new)
2390                                .length()
2391                                .max(map.point(end_old).sub(end_new).length());
2392                            if drift > 10.0 * tolerance {
2393                                return Err(KernelRefusal::unsupported(KernelStage::Refine, "mirror_rim_flats", format!(
2394                                    "move_faces: the rim of boundary edge {} does not reach the corners the \
2395                                     push re-solved (off {drift:.3e}) — its two ends ride \
2396                                     different fixed flats, which no single affine carries — \
2397                                     refusing",
2398                                    edge.id
2399                                )));
2400                            }
2401                        }
2402                        actions.insert(edge.id, EdgeMoveAction::Transform(map));
2403                    } else {
2404                        // Real re-intersection: line = translated moved plane ∩
2405                        // fixed plane, delimited by the re-solved corners. The
2406                        // moved side must be planar for the chord to stay on it.
2407                        cached_plane(
2408                            &mut planes,
2409                            solid,
2410                            &face_lookup,
2411                            *moved_face,
2412                            plane_tolerance,
2413                        )
2414                        .map_err(|_| {
2415                            unsupported_carrier(
2416                                solid,
2417                                &face_lookup,
2418                                *moved_face,
2419                                &format!(
2420                                    "the MOVED side of boundary edge {} against a planar neighbour",
2421                                    edge.id
2422                                ),
2423                            )
2424                        })?;
2425                        actions.insert(
2426                            edge.id,
2427                            plan_straight_rebuild(
2428                                edge, start_old, end_old, start_new, end_new, tolerance,
2429                            )?,
2430                        );
2431                    }
2432                } else {
2433                    return Err(KernelRefusal::unsupported(KernelStage::Classify, "sm3_carrier", format!(
2434                        "move_faces: boundary edge {} borders a non-planar, non-axis-parallel \
2435                         carrier — refusing (SM3 territory)",
2436                        edge.id
2437                    )));
2438                }
2439            }
2440        }
2441    }
2442
2443    // --- Plan face updates -------------------------------------------------
2444    // Edges whose SHAPE changed — rebuilt straight OR affine-transformed rim. A
2445    // moved face bounding one of these must be RE-TRIMMED (its rim moved to a new
2446    // curve), not merely surface-translated; else its pcurve goes stale.
2447    let reshaped: HashSet<u64> = actions
2448        .iter()
2449        .filter(|(_, action)| {
2450            matches!(
2451                action,
2452                EdgeMoveAction::Rebuild { .. }
2453                    | EdgeMoveAction::Transform(_)
2454                    | EdgeMoveAction::Replace { .. }
2455            )
2456        })
2457        .map(|(edge_id, _)| *edge_id)
2458        .collect();
2459    let dirty: HashSet<u64> = actions.keys().copied().collect();
2460    let mut face_actions: Vec<(usize, usize, FaceMoveAction)> = Vec::new();
2461    // Fixed cylinder neighbours re-trim in a separate post-pass (they grow the
2462    // carrier via `extend_ruled_neighbour_over`, which needs `&mut solid`).
2463    let mut ruled_retrim_faces: Vec<u64> = Vec::new();
2464    for (shell_index, shell) in solid.shells.iter().enumerate() {
2465        for (face_index, face) in shell.faces.iter().enumerate() {
2466            let edge_ids = || {
2467                face.loops
2468                    .iter()
2469                    .flat_map(|loop_record| &loop_record.coedges)
2470                    .map(|coedge| coedge.edge_id)
2471            };
2472            if moved.contains(&face.id) {
2473                if edge_ids().any(|edge_id| reshaped.contains(&edge_id))
2474                    && carrier_ruled(solid, &face_lookup, face.id).is_some()
2475                    && !carrier_invariant_under(
2476                        solid,
2477                        &face_lookup,
2478                        face.id,
2479                        translation,
2480                        parallel_tolerance,
2481                    )
2482                {
2483                    // THE PUSHED WALL ITSELF. Its rims were re-intersected, so
2484                    // the pcurves on record are stale — a bore whose mouths sat
2485                    // at one station of its own carrier now meets the flats at
2486                    // another, and in the wall's own parameters that is a shift
2487                    // in v, not the curve on file. Shifting the control net is
2488                    // exact (a translated cylinder is the same cylinder moved),
2489                    // and the re-trim afterwards rebuilds the loops on THAT
2490                    // carrier, grown along its axis to cover the new rims.
2491                    face_actions.push((shell_index, face_index, FaceMoveAction::TranslateSurface));
2492                    ruled_retrim_faces.push(face.id);
2493                } else if edge_ids().any(|edge_id| reshaped.contains(&edge_id)) {
2494                    // A boundary edge changed shape (stretched, or a cap rim grown
2495                    // by the radial-scale map), so the patch must be re-trimmed
2496                    // around it; the moved cap is planar (translated), so its plane
2497                    // simply shifts by `translation` before the retrim.
2498                    let mut plane =
2499                        cached_plane(&mut planes, solid, &face_lookup, face.id, plane_tolerance)
2500                            .map_err(|_| {
2501                                unsupported_carrier(
2502                                    solid,
2503                                    &face_lookup,
2504                                    face.id,
2505                                    "the MOVED face whose rim the push reshaped",
2506                                )
2507                            })?;
2508                    plane.origin = plane.origin.add(translation);
2509                    face_actions.push((shell_index, face_index, FaceMoveAction::Retrim(plane)));
2510                } else {
2511                    // Every edge of the face rode along rigidly: shifting the
2512                    // control net keeps surface, curves, and pcurves in exact
2513                    // agreement for ANY carrier type.
2514                    face_actions.push((shell_index, face_index, FaceMoveAction::TranslateSurface));
2515                }
2516            } else if edge_ids().any(|edge_id| dirty.contains(&edge_id)) {
2517                if carrier_is_planar(solid, &face_lookup, face.id) {
2518                    let plane =
2519                        cached_plane(&mut planes, solid, &face_lookup, face.id, plane_tolerance)?;
2520                    face_actions.push((shell_index, face_index, FaceMoveAction::Retrim(plane)));
2521                } else if carrier_ruled(solid, &face_lookup, face.id).is_some() {
2522                    // Ruled carrier (drilled hole, boss cap, OR a cone whose trim
2523                    // extended under an oblique cap push): grow it along its axis +
2524                    // recompute pcurves in the post-pass.
2525                    ruled_retrim_faces.push(face.id);
2526                } else {
2527                    // Non-planar, non-ruled fixed carrier — refuse (the general
2528                    // offset path does the genuine curved re-intersection).
2529                    //
2530                    // MEASURED, and it corrects a just-landed claim: this arm is
2531                    // NOT the one a curved fixed neighbour reaches. A curved
2532                    // neighbour that borders the moved group is refused by the
2533                    // classification loop's own carrier gate (search
2534                    // `the fixed neighbour across boundary edge`) hundreds of
2535                    // lines earlier, so this arm can only be entered by a face
2536                    // whose edges went dirty WITHOUT it sharing a boundary edge
2537                    // with the moved group — which needs a vertex of valence
2538                    // four or more. The earlier census, reading the code rather
2539                    // than running it, attributed the Plane × Torus refusal to
2540                    // this arm; the probe (`examples/plane_push_refusal_probe.rs`,
2541                    // `carrier/torus.*`) shows the classification arm firing.
2542                    cached_plane(&mut planes, solid, &face_lookup, face.id, plane_tolerance)
2543                        .map_err(|_| {
2544                            unsupported_carrier(
2545                                solid,
2546                                &face_lookup,
2547                                face.id,
2548                                "a FIXED face the push must re-trim",
2549                            )
2550                        })?;
2551                }
2552            }
2553        }
2554    }
2555
2556    // --- Apply to a fresh clone (the input is never touched) ---------------
2557    let translate = AffineTransform::new([
2558        1.0,
2559        0.0,
2560        0.0,
2561        translation.x,
2562        0.0,
2563        1.0,
2564        0.0,
2565        translation.y,
2566        0.0,
2567        0.0,
2568        1.0,
2569        translation.z,
2570        0.0,
2571        0.0,
2572        0.0,
2573        1.0,
2574    ]).or_refuse(KernelStage::Sew, "affine")?;
2575    let mut result = solid.clone();
2576    for edge in &mut result.edges {
2577        match actions.get(&edge.id) {
2578            Some(EdgeMoveAction::Translate) => {
2579                edge.curve = transform_curve(&edge.curve, translate).or_refuse(KernelStage::Sew, "transform_curve")?;
2580            }
2581            Some(EdgeMoveAction::Rebuild { start, end }) => {
2582                edge.curve = make_line(*start, *end).or_refuse(KernelStage::Sew, "make_line")?;
2583                edge.t0 = 0.0;
2584                edge.t1 = 1.0;
2585            }
2586            Some(EdgeMoveAction::Transform(map)) => {
2587                // Affine-map the rim (radial scale about the axis + translate).
2588                // Rational-quadratic circles map exactly; parameters unchanged.
2589                edge.curve = transform_curve(&edge.curve, *map).or_refuse(KernelStage::Sew, "transform_curve")?;
2590            }
2591            Some(EdgeMoveAction::Replace { curve }) => {
2592                // An exactly rebuilt conic arc spans its whole domain by
2593                // construction, so the trim is the domain.
2594                let [d0, d1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
2595                edge.curve = curve.clone();
2596                edge.t0 = d0;
2597                edge.t1 = d1;
2598            }
2599            None => {}
2600        }
2601    }
2602    for vertex in &mut result.vertices {
2603        if let Some(point) = new_vertex.get(&vertex.id) {
2604            vertex.point = *point;
2605        }
2606    }
2607    let final_edges: HashMap<u64, EdgeRecord> = result
2608        .edges
2609        .iter()
2610        .map(|edge| (edge.id, edge.clone()))
2611        .collect();
2612    for (shell_index, face_index, action) in face_actions {
2613        let face = &mut result.shells[shell_index].faces[face_index];
2614        match action {
2615            FaceMoveAction::TranslateSurface => {
2616                face.surface = transform_surface(&face.surface, translate).or_refuse(KernelStage::Sew, "transform_surface")?;
2617            }
2618            FaceMoveAction::Retrim(plane) => {
2619                retrim_planar_face(face, &plane, &final_edges, scale, "move_faces")?;
2620                // `retrim_planar_face` maps each edge's WHOLE curve onto the
2621                // rebuilt plane, so any edge that represents a strict SUBRANGE
2622                // of its curve (e.g. a box wall edge a fillet trimmed back)
2623                // needs the range fitter to span exactly [t0, t1]; full-domain
2624                // edges keep the exact affine pcurve just built. Same pairing
2625                // the delete/heal planar retrim uses.
2626                let face_edges: HashSet<u64> = face
2627                    .loops
2628                    .iter()
2629                    .flat_map(|loop_record| loop_record.coedges.iter().map(|c| c.edge_id))
2630                    .collect();
2631                refit_touched_pcurves(
2632                    face,
2633                    &final_edges,
2634                    &face_edges,
2635                    true,
2636                    tolerance,
2637                    "move_faces",
2638                )?;
2639            }
2640        }
2641    }
2642    // SM1: fixed ruled (cylinder) neighbours grow along their axis and recompute
2643    // pcurves on the grown carrier — a separate pass because it needs `&mut result`.
2644    for face_id in ruled_retrim_faces {
2645        retrim_ruled_face(&mut result, face_id, &final_edges, tolerance, "move_faces")?;
2646    }
2647
2648    // Topology (and therefore genus) is untouched — only geometry moved — so
2649    // validate() re-checks Euler, loop closure, and pcurve agreement.
2650    let issues = result.validate();
2651    if !issues.is_empty() {
2652        return Err(KernelRefusal::internal(KernelStage::Validate, "validate", format!(
2653            "move_faces: moved solid failed validation: {issues:?}"
2654        )));
2655    }
2656    // Belt and braces on top of the per-edge inversion guard: a global
2657    // inversion flips the signed volume even if every edge kept its direction.
2658    // Sign only, through the per-face term memo (`solid_signed_volume_for_sign`:
2659    // the verdict against the bar is the exact integral's; a face read about
2660    // the same reference before is served its term). 2026-10-17, the
2661    // shelled-box report: the exact reads cost 0.26 s a body here.
2662    if let (Ok(before), Ok(after)) =
2663        (crate::signed_volume_against(solid, 0.0), crate::signed_volume_against(&result, 0.0))
2664    {
2665        if before * after <= 0.0 {
2666            return Err(KernelRefusal::unsupported(KernelStage::Validate, SOLID_INVERSION,
2667                "move_faces: the translation inverts the solid (signed volume changed sign) \
2668                 — refusing"));
2669        }
2670    }
2671    Ok(result)
2672}
2673
2674/// Backlog #5 (Plane × Sphere): push a single PLANAR face whose FIXED boundary
2675/// neighbour(s) are SPHERES — e.g. a flat capping a spherical dome / spherical-
2676/// bottomed pocket. The moved plane translates; where it borders a fixed sphere
2677/// the new boundary rim is the EXACT circle `translated-plane ∩ sphere` (built
2678/// seam-aligned from the sphere's own frame so its pcurve crosses the seam
2679/// cleanly), the sphere's coupled seam meridian slides its rim endpoint to the
2680/// new latitude (its pcurve is patched in parameter space — the periodic u=0/u=2π
2681/// pairing is preserved), and both carriers are re-trimmed to the new rim.
2682///
2683/// Honest scope (SPHERE neighbours only, this slice): only the AXIS-PERPENDICULAR
2684/// single-closed-circle rim is supported. Refused cleanly (never a bad solid): a
2685/// moved GROUP, a moved face that is not planar, any FIXED neighbour that is not
2686/// a sphere (planar corner re-solve / torus / general revolution are other
2687/// slices), an OBLIQUE plane × sphere rim, an open / multi-edge rim, a sphere
2688/// seam that does not lie where the plane re-intersects it, a vanished / tangent
2689/// cap, and a push that collapses or inverts the solid.
2690fn move_planar_face_across_sphere(
2691    solid: &BrepSolid,
2692    moved: &HashSet<u64>,
2693    face_lookup: &HashMap<u64, (usize, usize)>,
2694    faces_of_edge: &HashMap<u64, Vec<u64>>,
2695    translation: Vec3,
2696) -> Result<BrepSolid, KernelRefusal> {
2697    let scale = solid_model_scale(solid);
2698    let tolerance = (scale * 1e-7).max(1e-9);
2699    let plane_tolerance = (scale * 1e-6).max(1e-7);
2700
2701    // Single moved planar face only (groups deferred — they would need the
2702    // generic corner re-solve against the remaining planar neighbours).
2703    if moved.len() != 1 {
2704        return Err(KernelRefusal::unsupported(KernelStage::Collect, "sphere_group",
2705            "move_faces: a moved GROUP against a sphere neighbour is deferred \
2706             (single planar face only) — refusing"));
2707    }
2708    let moved_id = *moved.iter().next().unwrap();
2709    let &(mshell, mface) = face_lookup.get(&moved_id).ok_or_else(|| {
2710        KernelRefusal::internal(
2711            KernelStage::Collect,
2712            "moved_face_lookup",
2713            format!("move_faces: missing moved face {moved_id}"),
2714        )
2715    })?;
2716    // The moved face must itself be planar (it translates rigidly).
2717    let moved_plane = plane_of_surface(
2718        &solid.shells[mshell].faces[mface].surface,
2719        plane_tolerance,
2720        "move_faces",
2721    )
2722    .map_err(|message| {
2723        KernelRefusal::unsupported(KernelStage::Classify, "sphere_moved_non_planar", message)
2724    })?;
2725    let translated_origin = moved_plane.origin.add(translation);
2726
2727    let edge_by_id: HashMap<u64, &EdgeRecord> =
2728        solid.edges.iter().map(|e| (e.id, e)).collect();
2729
2730    // --- Build every sphere rim of the moved planar face -------------------
2731    struct SphereRim {
2732        edge_id: u64,
2733        sphere_id: u64,
2734        closure_vertex: u64,
2735        new_circle: NurbsCurve,
2736        closure_point: Vec3,
2737        v_rim: f64,
2738    }
2739    let mut rims: Vec<SphereRim> = Vec::new();
2740    let mut closure_vertices: HashSet<u64> = HashSet::default();
2741    let mut sphere_ids: HashSet<u64> = HashSet::default();
2742
2743    for loop_record in &solid.shells[mshell].faces[mface].loops {
2744        for coedge in &loop_record.coedges {
2745            let edge = *edge_by_id
2746                .get(&coedge.edge_id)
2747                .ok_or_else(|| {
2748                    KernelRefusal::internal(
2749                        KernelStage::Refine,
2750                        "rim_edge_lookup",
2751                        format!("move_faces: missing edge {}", coedge.edge_id),
2752                    )
2753                })?;
2754            let uses = faces_of_edge
2755                .get(&edge.id)
2756                .map(Vec::as_slice)
2757                .unwrap_or(&[]);
2758            // The one fixed face across this boundary edge.
2759            let neighbour = uses.iter().copied().find(|f| *f != moved_id);
2760            let Some(neighbour) = neighbour else {
2761                return Err(KernelRefusal::internal(KernelStage::Classify, "own_edge", format!(
2762                    "move_faces: the moved face borders itself along edge {} \
2763                     (unexpected own edge) — refusing",
2764                    edge.id
2765                )));
2766            };
2767            let Some((frame, radius)) = carrier_sphere(solid, face_lookup, neighbour) else {
2768                return Err(KernelRefusal::unsupported(KernelStage::Classify, "sphere_mixed_neighbours", format!(
2769                    "move_faces: the moved planar face borders a non-sphere fixed \
2770                     neighbour along edge {} (mixed / planar-corner / torus / \
2771                     revolution neighbours are other slices) — refusing",
2772                    edge.id
2773                )));
2774            };
2775            // A single CLOSED-circle rim only (start == end vertex).
2776            if edge.start_vertex_id != edge.end_vertex_id {
2777                return Err(KernelRefusal::unsupported(KernelStage::Classify, "sphere_open_rim", format!(
2778                    "move_faces: the plane × sphere rim (edge {}) is not a single closed \
2779                     circle (open / multi-edge rims are deferred) — refusing",
2780                    edge.id
2781                )));
2782            }
2783            let center = frame.origin;
2784            let axis = frame.axis;
2785            // Only the axis-perpendicular cap keeps the rim a fixed-latitude
2786            // circle we can build seam-aligned; an oblique section crossing the
2787            // seam is deferred (matches the sphere-pushed path's own refusal).
2788            if moved_plane.normal.dot(axis).abs() < 1.0 - 1e-6 {
2789                return Err(KernelRefusal::unsupported(KernelStage::Classify, "sphere_oblique_rim", format!(
2790                    "move_faces: an OBLIQUE plane × sphere rim (edge {}) is deferred \
2791                     (only axis-perpendicular caps) — refusing",
2792                    edge.id
2793                )));
2794            }
2795            // Signed axial offset of the TRANSLATED plane from the sphere centre;
2796            // the new rim is the small circle of radius √(r²−a²) at that height.
2797            let a = translated_origin.sub(center).dot(axis);
2798            let rr2 = radius * radius - a * a;
2799            if rr2 <= tolerance * tolerance {
2800                return Err(KernelRefusal::unsupported(KernelStage::Refine, "sphere_cap_vanishes", format!(
2801                    "move_faces: the pushed plane no longer meets the sphere (edge {}: the \
2802                     cap vanishes / is tangent) — refusing",
2803                    edge.id
2804                )));
2805            }
2806            let radius_new = rr2.sqrt();
2807            let circle_center = center.add(axis.scale(a));
2808            let mut new_circle = crate::make_arc(
2809                circle_center,
2810                frame.x_axis,
2811                frame.y_axis,
2812                radius_new,
2813                0.0,
2814                std::f64::consts::TAU,
2815            ).or_refuse(KernelStage::Refine, "make_arc")?;
2816            // Match the new rim's traversal to the old edge so the preserved
2817            // coedge `forward` flags keep both loops' winding consistent. A
2818            // closed circle's reversal keeps its start point, so the seam-aligned
2819            // closure point is unaffected.
2820            let closure_old = edge.curve.evaluate(edge.t0).or_refuse(KernelStage::Refine, "evaluate")?;
2821            let old_tan = edge
2822                .curve
2823                .evaluate(edge.t0 + 0.01 * (edge.t1 - edge.t0)).or_refuse(KernelStage::Refine, "evaluate")?
2824                .sub(closure_old);
2825            let [c0, c1] = new_circle.domain().or_refuse(KernelStage::Refine, "domain")?;
2826            let new_start = new_circle.evaluate(c0).or_refuse(KernelStage::Refine, "evaluate")?;
2827            let new_tan = new_circle.evaluate(c0 + 0.01 * (c1 - c0)).or_refuse(KernelStage::Refine, "evaluate")?.sub(new_start);
2828            if old_tan.dot(new_tan) < 0.0 {
2829                new_circle = new_circle.reversed().or_refuse(KernelStage::Refine, "reversed")?;
2830            }
2831            let closure_point = new_circle
2832                .evaluate(new_circle.domain().or_refuse(KernelStage::Refine, "domain")?[0])
2833                .or_refuse(KernelStage::Refine, "evaluate")?;
2834
2835            // The rim latitude in the (unchanged) sphere's (u, v) space, read off
2836            // the seam-crossing pcurve (constant v). The coupled seam meridian's
2837            // rim endpoint slides to this v.
2838            let (nshell, nface) = find_face(solid, neighbour)
2839                .ok_or_else(|| {
2840                KernelRefusal::internal(
2841                    KernelStage::Refine,
2842                    "sphere_lookup",
2843                    format!("move_faces: missing sphere neighbour {neighbour}"),
2844                )
2845            })?;
2846            let sphere_surface = &solid.shells[nshell].faces[nface].surface;
2847            let rim_pcurve = build_pcurve_on_surface(sphere_surface, &new_circle).or_refuse(KernelStage::Refine, "build_pcurve_on_surface")?;
2848            let [q0, q1] = rim_pcurve.domain().or_refuse(KernelStage::Refine, "domain")?;
2849            let v_rim = rim_pcurve.evaluate(0.5 * (q0 + q1)).or_refuse(KernelStage::Refine, "evaluate")?.y;
2850
2851            closure_vertices.insert(edge.start_vertex_id);
2852            sphere_ids.insert(neighbour);
2853            rims.push(SphereRim {
2854                edge_id: edge.id,
2855                sphere_id: neighbour,
2856                closure_vertex: edge.start_vertex_id,
2857                new_circle,
2858                closure_point,
2859                v_rim,
2860            });
2861        }
2862    }
2863    if rims.is_empty() {
2864        return Err(KernelRefusal::internal(KernelStage::Classify, "sphere_rim_missing", "move_faces: no plane × sphere rim found — refusing"));
2865    }
2866    // Per closure vertex: where it moves + its new rim latitude (for the meridian).
2867    let closure_of: HashMap<u64, (Vec3, f64)> = rims
2868        .iter()
2869        .map(|r| (r.closure_vertex, (r.closure_point, r.v_rim)))
2870        .collect();
2871
2872    // --- Coupled seam meridians (own-only edges of the fixed sphere whose rim
2873    // endpoint is one of the moved closure vertices) ------------------------
2874    struct MeridianRetrim {
2875        edge_id: u64,
2876        sphere_id: u64,
2877        moved_end_is_start: bool,
2878        new_t: f64,
2879        moved_vertex_old: Vec3,
2880        moved_vertex_new: Vec3,
2881        v_rim: f64,
2882    }
2883    let mut meridians: Vec<MeridianRetrim> = Vec::new();
2884    for edge in &solid.edges {
2885        if edge.degenerate {
2886            continue; // a pole degeneracy does not move
2887        }
2888        let uses = faces_of_edge
2889            .get(&edge.id)
2890            .map(Vec::as_slice)
2891            .unwrap_or(&[]);
2892        // Own-only to exactly one of the fixed spheres (a seam meridian).
2893        let owner = uses.first().copied();
2894        let Some(owner) = owner else { continue };
2895        if !sphere_ids.contains(&owner) || !uses.iter().all(|f| *f == owner) {
2896            continue;
2897        }
2898        let start_is_closure = closure_vertices.contains(&edge.start_vertex_id);
2899        let end_is_closure = closure_vertices.contains(&edge.end_vertex_id);
2900        if !start_is_closure && !end_is_closure {
2901            continue; // an uncoupled seam meridian: untouched
2902        }
2903        if start_is_closure && end_is_closure {
2904            return Err(KernelRefusal::unsupported(KernelStage::Classify, "sphere_zone", format!(
2905                "move_faces: sphere seam meridian {} moves at BOTH ends (a sphere zone \
2906                 with two pushed rims) — deferred, refusing",
2907                edge.id
2908            )));
2909        }
2910        let moved_end_is_start = start_is_closure;
2911        let closure_vertex = if moved_end_is_start {
2912            edge.start_vertex_id
2913        } else {
2914            edge.end_vertex_id
2915        };
2916        let (moved_vertex_new, v_rim) = *closure_of
2917            .get(&closure_vertex)
2918            .ok_or_else(|| {
2919                KernelRefusal::internal(
2920                    KernelStage::Refine,
2921                    "meridian_pairing",
2922                    "move_faces: seam meridian is not paired with a rim — refusing",
2923                )
2924            })?;
2925        // Slide the moved endpoint along the (unchanged) meridian curve. The
2926        // curve is fixed (the sphere is fixed), so this only re-parametrises the
2927        // trim; project the new rim point onto it and verify it truly lands there
2928        // (the safety net if `frame.x_axis` were not the seam azimuth).
2929        let projection = project_point_to_curve(&edge.curve, moved_vertex_new).or_refuse(KernelStage::Refine, "project_point_to_curve")?;
2930        if projection.distance > 10.0 * tolerance {
2931            return Err(KernelRefusal::unsupported(KernelStage::Refine, "seam_off_rim", format!(
2932                "move_faces: the re-intersected rim point does not lie on the sphere seam \
2933                 meridian {} (off {:.3e}) — refusing",
2934                edge.id, projection.distance
2935            )));
2936        }
2937        let new_t = projection.u;
2938        let fixed_t = if moved_end_is_start { edge.t1 } else { edge.t0 };
2939        let old_moved_t = if moved_end_is_start { edge.t0 } else { edge.t1 };
2940        // The trim must neither collapse nor invert: the moved parameter must
2941        // stay on the same side of the fixed endpoint as before, with a real span.
2942        let [dom0, dom1] = edge.curve.domain().or_refuse(KernelStage::Refine, "domain")?;
2943        let span = (dom1 - dom0).max(1e-12);
2944        if (new_t - fixed_t) * (old_moved_t - fixed_t) <= 0.0
2945            || (new_t - fixed_t).abs() <= 1e-7 * span
2946            || edge.curve.evaluate(new_t).or_refuse(KernelStage::Refine, "evaluate")?.sub(edge.curve.evaluate(fixed_t).or_refuse(KernelStage::Refine, "evaluate")?).length() <= tolerance
2947        {
2948            return Err(KernelRefusal::unsupported(KernelStage::Refine, "meridian_collapse", format!(
2949                "move_faces: the sphere seam meridian {} trim collapses or inverts under \
2950                 the push — refusing",
2951                edge.id
2952            )));
2953        }
2954        let moved_vertex_old = edge_point(solid, closure_vertex)?;
2955        meridians.push(MeridianRetrim {
2956            edge_id: edge.id,
2957            sphere_id: owner,
2958            moved_end_is_start,
2959            new_t,
2960            moved_vertex_old,
2961            moved_vertex_new,
2962            v_rim,
2963        });
2964    }
2965
2966    // Every moved-face boundary vertex must be an accounted-for rim closure
2967    // vertex; anything else means an unmodelled corner (refuse rather than leave
2968    // a vertex un-relocated and fail late).
2969    for loop_record in &solid.shells[mshell].faces[mface].loops {
2970        for coedge in &loop_record.coedges {
2971            let edge = *edge_by_id
2972                .get(&coedge.edge_id)
2973                .ok_or_else(|| {
2974                    KernelRefusal::internal(
2975                        KernelStage::Refine,
2976                        "boundary_edge_lookup",
2977                        format!("move_faces: missing edge {}", coedge.edge_id),
2978                    )
2979                })?;
2980            for v in [edge.start_vertex_id, edge.end_vertex_id] {
2981                if !closure_vertices.contains(&v) {
2982                    return Err(KernelRefusal::unsupported(KernelStage::Classify, "unmodelled_corner", format!(
2983                        "move_faces: the moved face has a boundary vertex {v} that is not a \
2984                         sphere-rim closure — refusing",
2985                    )));
2986                }
2987            }
2988        }
2989    }
2990
2991    // --- Apply to a fresh clone (the input is never mutated) ---------------
2992    let mut result = solid.clone();
2993    // Rim edges take their new circle.
2994    for rim in &rims {
2995        if let Some(edge) = result.edges.iter_mut().find(|e| e.id == rim.edge_id) {
2996            let [d0, d1] = rim.new_circle.domain().or_refuse(KernelStage::Sew, "domain")?;
2997            edge.curve = rim.new_circle.clone();
2998            edge.t0 = d0;
2999            edge.t1 = d1;
3000        }
3001    }
3002    // Seam meridians keep their curve; only the moved-end trim slides.
3003    for mer in &meridians {
3004        if let Some(edge) = result.edges.iter_mut().find(|e| e.id == mer.edge_id) {
3005            if mer.moved_end_is_start {
3006                edge.t0 = mer.new_t;
3007            } else {
3008                edge.t1 = mer.new_t;
3009            }
3010        }
3011    }
3012    // Relocate the rim closure vertices.
3013    for rim in &rims {
3014        if let Some(v) = result.vertices.iter_mut().find(|v| v.id == rim.closure_vertex) {
3015            v.point = rim.closure_point;
3016        }
3017    }
3018
3019    // The moved planar face rides the translated plane and re-trims around its
3020    // new (smaller/larger) rim circle.
3021    let final_edges: HashMap<u64, EdgeRecord> =
3022        result.edges.iter().map(|e| (e.id, e.clone())).collect();
3023    {
3024        let mut plane = moved_plane;
3025        plane.origin = plane.origin.add(translation);
3026        let face = &mut result.shells[mshell].faces[mface];
3027        retrim_planar_face(face, &plane, &final_edges, scale, "move_faces")?;
3028    }
3029
3030    // Re-trim every fixed sphere: rebuild the rim coedge pcurve on the (unchanged)
3031    // sphere surface, and patch each coupled seam-meridian coedge's pcurve in
3032    // parameter space (keep u — preserving the periodic u=0/u=2π pairing — and
3033    // slide only the moved endpoint's v to the new rim latitude).
3034    for sphere_id in &sphere_ids {
3035        let (nshell, nface) = find_face(&result, *sphere_id)
3036            .ok_or_else(|| {
3037                KernelRefusal::internal(
3038                    KernelStage::Sew,
3039                    "sphere_retrim_lookup",
3040                    format!("move_faces: missing sphere neighbour {sphere_id}"),
3041                )
3042            })?;
3043        let sphere_surface = result.shells[nshell].faces[nface].surface.clone();
3044        for loop_record in &mut result.shells[nshell].faces[nface].loops {
3045            for coedge in &mut loop_record.coedges {
3046                if let Some(rim) = rims
3047                    .iter()
3048                    .find(|r| r.edge_id == coedge.edge_id && r.sphere_id == *sphere_id)
3049                {
3050                    let mut pcurve = build_pcurve_on_surface(&sphere_surface, &rim.new_circle).or_refuse(KernelStage::Sew, "build_pcurve_on_surface")?;
3051                    if !coedge.forward {
3052                        pcurve = pcurve.reversed().or_refuse(KernelStage::Sew, "reversed")?;
3053                    }
3054                    coedge.pcurve = pcurve;
3055                } else if let Some(mer) = meridians
3056                    .iter()
3057                    .find(|m| m.edge_id == coedge.edge_id && m.sphere_id == *sphere_id)
3058                {
3059                    coedge.pcurve = patch_seam_meridian_pcurve(
3060                        &coedge.pcurve,
3061                        &sphere_surface,
3062                        mer.moved_vertex_old,
3063                        mer.moved_vertex_new,
3064                        mer.v_rim,
3065                        tolerance,
3066                    )?;
3067                }
3068            }
3069        }
3070    }
3071
3072    let issues = result.validate();
3073    if !issues.is_empty() {
3074        return Err(KernelRefusal::internal(KernelStage::Validate, "sphere_validate", format!(
3075            "move_faces: moved solid failed validation: {issues:?}"
3076        )));
3077    }
3078    // Sign only, through the per-face term memo (`solid_signed_volume_for_sign`:
3079    // the verdict against the bar is the exact integral's; a face read about
3080    // the same reference before is served its term). 2026-10-17, the
3081    // shelled-box report: the exact reads cost 0.26 s a body here.
3082    if let (Ok(before), Ok(after)) =
3083        (crate::signed_volume_against(solid, 0.0), crate::signed_volume_against(&result, 0.0))
3084    {
3085        if before * after <= 0.0 {
3086            return Err(KernelRefusal::unsupported(KernelStage::Validate, SPHERE_SOLID_INVERSION,
3087                "move_faces: the push inverts the solid (signed volume changed sign) — refusing"));
3088        }
3089    }
3090    Ok(result)
3091}
3092
3093/// Slide a sphere seam-meridian pcurve's RIM endpoint to the new latitude,
3094/// keeping its constant u (so the periodic u=0 / u=2π seam pairing survives) and
3095/// its fixed (pole / other-rim) endpoint. The moved endpoint is identified by
3096/// which pcurve end maps (through the surface) to the moved vertex's OLD
3097/// position — not by pole detection — so it makes no assumption about the cap's
3098/// topology. Endpoint order (domain start→end) is preserved.
3099fn patch_seam_meridian_pcurve(
3100    pcurve: &NurbsCurve,
3101    surface: &NurbsSurface,
3102    moved_vertex_old: Vec3,
3103    moved_vertex_new: Vec3,
3104    v_rim: f64,
3105    tolerance: f64,
3106) -> Result<NurbsCurve, KernelRefusal> {
3107    let [q0, q1] = pcurve.domain().or_refuse(KernelStage::Sew, "domain")?;
3108    let a = pcurve.evaluate(q0).or_refuse(KernelStage::Sew, "evaluate")?;
3109    let b = pcurve.evaluate(q1).or_refuse(KernelStage::Sew, "evaluate")?;
3110    let a3 = surface.evaluate(a.x, a.y).or_refuse(KernelStage::Sew, "evaluate")?;
3111    let b3 = surface.evaluate(b.x, b.y).or_refuse(KernelStage::Sew, "evaluate")?;
3112    let da = a3.sub(moved_vertex_old).length();
3113    let db = b3.sub(moved_vertex_old).length();
3114    // Guard: one endpoint must genuinely be the moved vertex, and the surface
3115    // point at the patched (u, v_rim) must land on the relocated vertex.
3116    let moved_is_a = da <= db;
3117    let (moved_uv, fixed_uv) = if moved_is_a { (a, b) } else { (b, a) };
3118    let patched = Vec3::new(moved_uv.x, v_rim, 0.0);
3119    if surface.evaluate(patched.x, patched.y).or_refuse(KernelStage::Sew, "evaluate")?.sub(moved_vertex_new).length() > 10.0 * tolerance {
3120        return Err(KernelRefusal::internal(KernelStage::Sew, "seam_patch",
3121            "move_faces: patched seam-meridian pcurve endpoint does not reach the new rim \
3122             vertex — refusing"));
3123    }
3124    let fixed = Vec3::new(fixed_uv.x, fixed_uv.y, 0.0);
3125    if moved_is_a {
3126        make_line(patched, fixed)
3127    } else {
3128        make_line(fixed, patched)
3129    }
3130    .or_refuse(KernelStage::Sew, "make_line")
3131}
3132