Skip to main content

brep_kernel/edit/direct_edit/
face_move.rs

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