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