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