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