brep_kernel/edit/direct_edit/face_rotate.rs
1use super::*;
2use crate::{KernelRefusal, KernelStage, OrRefuse};
3use super::face_move::{
4 cached_plane, carrier_is_planar, carrier_kind_name, carrier_ruled,
5 corner_on_plane_and_ruled, plane_and_ruled_carriers, resolve_corner_on_fixed_edge,
6 retrim_ruled_face, solve_corner, verify_section_between,
7};
8
9// ---------------------------------------------------------------------------
10// §6.12 sibling operation: ROTATE a face group about a picked axis.
11//
12// This file is what implements it, and nothing here reaches past what that
13// section claims.
14//
15// Rotation is NOT a mode of the translation. `move_faces` moves a carrier's
16// POSITION and leaves its normal alone, which is why a plane the translation is
17// parallel to is invariant and why a moved rim can ride an exact affine of the
18// fixed carrier. A rotation moves the normal too: the invariants are different
19// (a cone about its OWN axis is invariant here and not there), the sections are
20// different (plane × oblique cylinder is an ellipse, not a translated circle),
21// and the corners must be solved from the ROTATED carriers rather than mapped.
22// Sharing the machinery would mean an `if rotation` in every one of those
23// places; the shared parts are the pure helpers this file imports.
24// ---------------------------------------------------------------------------
25
26/// How the rotated group uses an edge — the same three-way split
27/// [`super::face_move`] makes, because the question ("is this edge inside the
28/// group, on its boundary, or untouched?") is about the SELECTION, not the
29/// transform.
30enum EdgeClass {
31 Fixed,
32 Interior,
33 Boundary { moved_face: u64, fixed_face: u64 },
34}
35
36enum EdgeAction {
37 /// Map the curve by the rotation. Exact for any curve type: an affine maps
38 /// a rational NURBS control-point-wise, so the parametrisation survives.
39 Rotate,
40 /// Replace the curve by the straight line between two re-solved corners.
41 Rebuild { start: Vec3, end: Vec3 },
42 /// Replace the curve outright by the EXACT conic section of the ROTATED
43 /// planar carrier with a fixed OBLIQUE cylinder or cone — an ellipse on a
44 /// cylinder, a conic on a cone. Unlike a translation, a rotation has no
45 /// affine that carries the old rim to the new one (it moves the carrier's
46 /// NORMAL, and neither the cylinder's axial shift nor the cone's homothety
47 /// about the apex is a symmetry that does that), so the rim is
48 /// re-intersected rather than mapped. The curve spans its own whole domain,
49 /// so the edge's trim becomes that domain.
50 Replace { curve: NurbsCurve },
51}
52
53enum FaceAction {
54 /// Rotate the whole control net. Exact for any carrier type; the analytic
55 /// tag is re-derived from the moved net, so a rotated plane is still a
56 /// plane and a rotated cylinder still a cylinder of the same radius.
57 RotateSurface,
58 /// Rebuild the (planar) carrier around the new boundary and recompute every
59 /// pcurve. `plane` is the carrier the face ends up on: the ROTATED plane for
60 /// a moved face, its own unchanged plane for a re-trimmed fixed neighbour.
61 Retrim(Plane),
62}
63
64/// The refusal a face whose carrier this operation cannot use deserves: one
65/// that names the FACE, its CARRIER KIND and the ROLE it plays in the rotation.
66///
67/// [`super::face_move`] has the same helper with its own wording; the sentence
68/// differs because the reach differs in its details. Both re-intersect a PLANE
69/// and a CYLINDER or CONE neighbour, but a translation carries a ruled rim by
70/// an exact affine while a rotation re-intersects it, and a rotation carries
71/// any other curved carrier only when the axis leaves it invariant. Either side
72/// of such a pair may be the one that turns — a tilted cap against a fixed bore,
73/// or a tilted bore against a fixed flat — but ONE of the two must be the plane.
74/// What is refused through here — a sphere, a torus, a general revolution, a
75/// free-form patch, or a quadric meeting a quadric — is what the sentence names.
76fn unsupported_carrier(
77 solid: &BrepSolid,
78 face_lookup: &HashMap<u64, (usize, usize)>,
79 face_id: u64,
80 role: &str,
81) -> KernelRefusal {
82 KernelRefusal::unsupported(KernelStage::Classify, "carrier_kind", format!(
83 "rotate_faces: {role} is a {}; a rotation re-intersects a PLANE with a plane, a \
84 cylinder or a cone — whichever of the two it turns — and carries any other curved \
85 carrier only when the axis leaves it invariant — refusing",
86 face_label(solid, face_lookup, face_id)
87 ))
88}
89
90/// `name (face id) … kind` in one phrase, so every refusal names the thing the
91/// user picked and not just an integer they have no way to see.
92fn face_label(
93 solid: &BrepSolid,
94 face_lookup: &HashMap<u64, (usize, usize)>,
95 face_id: u64,
96) -> String {
97 match face_lookup.get(&face_id) {
98 Some(&(shell, face)) => {
99 let record = &solid.shells[shell].faces[face];
100 let kind = carrier_kind_name(&record.surface);
101 match record.name.as_deref() {
102 Some(name) => format!("`{name}` (face {face_id}) {kind}"),
103 None => format!("face {face_id} {kind}"),
104 }
105 }
106 None => format!("face {face_id} (missing)"),
107 }
108}
109
110/// A short `name (id)` for a face, for messages that name a PAIR and would be
111/// unreadable with two carrier kinds in them.
112fn face_short(
113 solid: &BrepSolid,
114 face_lookup: &HashMap<u64, (usize, usize)>,
115 face_id: u64,
116) -> String {
117 match face_lookup.get(&face_id) {
118 Some(&(shell, face)) => match solid.shells[shell].faces[face].name.as_deref() {
119 Some(name) => format!("`{name}` (face {face_id})"),
120 None => format!("face {face_id}"),
121 },
122 None => format!("face {face_id}"),
123 }
124}
125
126/// The rigid motion `p ↦ a + R·(p − a)` as one affine: Rodrigues' rotation of
127/// `angle` radians about the unit `direction`, with the translation that pins
128/// the axis point `a`.
129pub(super) fn rotation_about(point: Vec3, direction: Vec3, angle: f64) -> Result<AffineTransform, String> {
130 let (s, c) = angle.sin_cos();
131 let t = 1.0 - c;
132 let (x, y, z) = (direction.x, direction.y, direction.z);
133 // Row-major 3x3, the layout `AffineTransform` indexes.
134 let r = [
135 t * x * x + c,
136 t * x * y - s * z,
137 t * x * z + s * y,
138 t * x * y + s * z,
139 t * y * y + c,
140 t * y * z - s * x,
141 t * x * z - s * y,
142 t * y * z + s * x,
143 t * z * z + c,
144 ];
145 let a = [point.x, point.y, point.z];
146 let mut elements = [0.0f64; 16];
147 for row in 0..3 {
148 for col in 0..3 {
149 elements[row * 4 + col] = r[row * 3 + col];
150 }
151 // a − R·a, so the axis point is a fixed point of the map.
152 elements[row * 4 + 3] = a[row] - (0..3).map(|k| r[row * 3 + k] * a[k]).sum::<f64>();
153 }
154 elements[15] = 1.0;
155 AffineTransform::new(elements)
156}
157
158/// The rotation applied to a DIRECTION: the linear part alone, without the
159/// translation that pins the axis point. A plane's normal, and the `u_dir` /
160/// `v_dir` that carry its `same_sense`, all move this way.
161pub(super) fn rotate_direction(rotation: &AffineTransform, vector: Vec3) -> Vec3 {
162 let m = rotation.elements;
163 Vec3::new(
164 m[0] * vector.x + m[1] * vector.y + m[2] * vector.z,
165 m[4] * vector.x + m[5] * vector.y + m[6] * vector.z,
166 m[8] * vector.x + m[9] * vector.y + m[10] * vector.z,
167 )
168}
169
170/// A whole plane carried by the rotation. `retrim_planar_face` rebuilds the
171/// carrier from `origin`/`u_dir`/`v_dir` and relies on `normal = u × v` matching
172/// the surface's own normal, so all four move together — rotating the origin and
173/// leaving the frame behind would silently flip a face's `same_sense`.
174fn rotate_plane(rotation: &AffineTransform, plane: &Plane) -> Plane {
175 Plane {
176 origin: rotation.point(plane.origin),
177 u_dir: rotate_direction(rotation, plane.u_dir),
178 v_dir: rotate_direction(rotation, plane.v_dir),
179 normal: rotate_direction(rotation, plane.normal),
180 }
181}
182
183/// The SIGNED AREA VECTOR of a trim loop, walked in its own coedge order in 3D:
184/// `½ Σ pᵢ × pᵢ₊₁` over samples of every coedge, taken in the direction the
185/// coedge runs. On a PLANAR loop this is Newell's normal — it points along the
186/// side the loop winds counter-clockwise about, and its length is the loop's
187/// area, so a degenerate loop reports a short vector rather than a wrong
188/// direction.
189///
190/// This is the quantity that decides a re-trimmed planar carrier's FRAME. The
191/// loop itself is the authority because the rotation does not move it: this
192/// operation RE-SOLVES every corner from the carriers meeting there, so a
193/// re-trimmed face's boundary is wherever those carriers put it and its 3D
194/// traversal order is the topology's, which the edit never touches.
195fn loop_area_vector(
196 loop_record: &LoopRecord,
197 edges: &HashMap<u64, EdgeRecord>,
198 op: &str,
199) -> Result<Vec3, KernelRefusal> {
200 let mut points: Vec<Vec3> = Vec::new();
201 for coedge in &loop_record.coedges {
202 let edge = edges.get(&coedge.edge_id).ok_or_else(|| {
203 KernelRefusal::internal(
204 KernelStage::Sew,
205 "loop_edge_lookup",
206 format!("{op}: missing edge {}", coedge.edge_id),
207 )
208 })?;
209 if edge.degenerate {
210 continue;
211 }
212 for step in 0..8 {
213 let fraction = step as f64 / 8.0;
214 // ...in the direction the COEDGE runs, which is the direction the
215 // loop is traversed and therefore the one the winding is about.
216 let fraction = if coedge.forward {
217 fraction
218 } else {
219 1.0 - fraction
220 };
221 points.push(edge.curve.evaluate(edge.t0 + (edge.t1 - edge.t0) * fraction).or_refuse(KernelStage::Sew, "evaluate")?);
222 }
223 }
224 if points.len() < 3 {
225 return Ok(Vec3::default());
226 }
227 let mut total = Vec3::default();
228 for index in 0..points.len() {
229 let a = points[index];
230 let b = points[(index + 1) % points.len()];
231 total = total.add(a.cross(b));
232 }
233 Ok(total.scale(0.5))
234}
235
236/// Point a re-trimmed planar carrier's frame the way the face's OWN outer loop
237/// winds, flipping `v_dir` (and with it the carrier normal `u × v`) when the
238/// candidate frame would hand the face a LEFT-HANDED chart.
239///
240/// # Why a rotated frame is not always the legal one
241///
242/// [`rotate_plane`] carries a moved face's frame by `R`, which is right for as
243/// long as the face's boundary rides with it. It is not right once the rotation
244/// has carried the carrier far enough that the face's re-solved boundary comes
245/// back around the OTHER side of it: a half-turn about an axis IN the face maps
246/// the carrier onto itself with its normal reversed, every corner re-solves to
247/// exactly where it was, and the transported frame then describes the same
248/// patch INSIDE OUT. `validate()` cannot see that — it tests incidence, and the
249/// incidences are all still perfect — but the face's outer wire now winds
250/// against its own sense, which is the chart convention
251/// `validate_uv_wire` asserts and that `face_profile`, `thicken` and the
252/// periodic trimmer all read as kernel-wide law.
253///
254/// The loop decides because the loop is the fixed point of the rebuild: its 3D
255/// traversal is the topology's, and the neighbours on the other side of every
256/// one of its edges keep the winding they had. Choosing the frame that agrees
257/// with it is what keeps the shell COHERENT — and it is a real geometric
258/// decision, not bookkeeping, because it is the face's outward normal that
259/// changes with it. Whether the shell as a whole ends up inside out is a
260/// separate question, and the signed-volume guard at the end is what answers it.
261fn orient_plane_to_loop(
262 face: &FaceRecord,
263 plane: &Plane,
264 edges: &HashMap<u64, EdgeRecord>,
265) -> Result<Plane, KernelRefusal> {
266 let Some(outer) = face.loops.first() else {
267 return Ok(*plane);
268 };
269 let area = loop_area_vector(outer, edges, "rotate_faces")?;
270 let projection = area.dot(plane.normal);
271 // A loop with no area in this carrier decides nothing; leave the carried
272 // frame alone rather than flip on rounding noise.
273 if projection.abs() <= 1e-12 * area.length().max(1.0) {
274 return Ok(*plane);
275 }
276 // The chart is right-handed (`normal = u × v`), so the outer loop winds
277 // POSITIVELY in (u, v) exactly when it winds counter-clockwise about the
278 // carrier normal — which is what `same_sense` asks of it.
279 if (projection > 0.0) == face.same_sense {
280 return Ok(*plane);
281 }
282 Ok(Plane {
283 origin: plane.origin,
284 u_dir: plane.u_dir,
285 v_dir: plane.v_dir.scale(-1.0),
286 normal: plane.normal.scale(-1.0),
287 })
288}
289
290/// A ruled revolution's frame carried by the rotation. `R` is rigid, so the
291/// turned frame describes the turned carrier EXACTLY and its radii and height
292/// are untouched — which is what lets the bore wall's own section against a
293/// fixed flat be re-intersected with the same closed form the mirror case uses.
294fn rotate_ruled_frame(
295 rotation: &AffineTransform,
296 frame: &crate::RevolutionFrame,
297) -> crate::RevolutionFrame {
298 crate::RevolutionFrame {
299 origin: rotation.point(frame.origin),
300 axis: rotate_direction(rotation, frame.axis),
301 x_axis: rotate_direction(rotation, frame.x_axis),
302 y_axis: rotate_direction(rotation, frame.y_axis),
303 }
304}
305
306/// True iff a FIXED carrier is INVARIANT under the rotation, so a corner on it
307/// rides by `R` and provably stays on it. Two carriers are:
308/// • a PLANE whose normal is parallel to the axis direction — the rotation
309/// spins it within itself, at any offset along the axis; and
310/// • a RULED REVOLUTION (cylinder, cone, or a partial-sweep fillet band) whose
311/// own axis LINE *is* the rotation axis line.
312///
313/// The cone is the interesting difference from the translation's rule, which
314/// excludes it: an axial TRANSLATION moves a cone's point to a station with a
315/// different radius and so leaves the carrier, while a rotation about the cone's
316/// own axis maps it exactly onto itself.
317///
318/// A sphere centred on the axis and a torus about it are invariant too, and are
319/// deliberately NOT admitted here: this operation has no re-trim route for
320/// either carrier, so a rim that rode onto one would arrive at the face pass
321/// with a stale pcurve and no way to rebuild it. They refuse by name instead.
322fn carrier_invariant_under(
323 solid: &BrepSolid,
324 face_lookup: &HashMap<u64, (usize, usize)>,
325 face_id: u64,
326 axis_point: Vec3,
327 axis_direction: Vec3,
328 angular_tolerance: f64,
329 distance_tolerance: f64,
330) -> bool {
331 let Some(&(shell, face)) = face_lookup.get(&face_id) else {
332 return false;
333 };
334 let surface = &solid.shells[shell].faces[face].surface;
335 if let Some(AnalyticSurface::Plane { u_dir, v_dir, .. }) = surface.analytic() {
336 let unit = match u_dir.cross(*v_dir).normalized() {
337 Ok(unit) => unit,
338 Err(_) => return false,
339 };
340 return unit.cross(axis_direction).length() <= angular_tolerance;
341 }
342 let Some((frame, _, _, _)) = carrier_ruled(solid, face_lookup, face_id) else {
343 return false;
344 };
345 if frame.axis.cross(axis_direction).length() > angular_tolerance {
346 return false;
347 }
348 // Parallel is not enough: the carrier's axis must be the SAME LINE, or the
349 // rotation carries the whole surface sideways.
350 let offset = frame.origin.sub(axis_point);
351 offset
352 .sub(axis_direction.scale(offset.dot(axis_direction)))
353 .length()
354 <= distance_tolerance
355}
356
357/// The ROTATED ruled carrier of the one moved face at a corner, when that is
358/// what the corner has to be solved against: `Some((face id, the frame `R`
359/// leaves, rho0, rho1, height))` for exactly one moved face at the vertex whose
360/// carrier is a cylinder or cone the axis does NOT leave invariant.
361///
362/// `None` — and so the ordinary three-plane solve — for a planar moved face, for
363/// a moved carrier the rotation maps onto itself (the identity/rigid regimes
364/// above have already claimed those), and for a group that brings TWO faces to
365/// the corner, where "the seam generatrix places it" is no longer a statement
366/// about one carrier. A moved sphere, torus, revolution or free-form patch is
367/// not ruled, so it falls through to the three-plane solve and refuses there by
368/// name, as it did before this route existed.
369fn rotated_moved_ruled_carrier(
370 solid: &BrepSolid,
371 face_lookup: &HashMap<u64, (usize, usize)>,
372 rotation: &AffineTransform,
373 moved: &HashSet<u64>,
374 adjacent: &HashSet<u64>,
375 invariant: &dyn Fn(u64) -> bool,
376) -> Option<(u64, crate::RevolutionFrame, f64, f64, f64)> {
377 let mut here = adjacent.iter().copied().filter(|id| moved.contains(id));
378 let face_id = here.next()?;
379 if here.next().is_some() {
380 return None;
381 }
382 if invariant(face_id) {
383 return None;
384 }
385 let (frame, rho0, rho1, height) = carrier_ruled(solid, face_lookup, face_id)?;
386 Some((
387 face_id,
388 rotate_ruled_frame(rotation, &frame),
389 rho0,
390 rho1,
391 height,
392 ))
393}
394
395/// How far `point` sits off a ruled revolution's carrier: the difference between
396/// its radial distance from the axis and the carrier's own radius at that axial
397/// station (constant for a cylinder, linear for a cone).
398fn ruled_offset(frame: &crate::RevolutionFrame, rho0: f64, rho1: f64, height: f64, point: Vec3) -> f64 {
399 let delta = point.sub(frame.origin);
400 let axial = delta.dot(frame.axis);
401 let radial = delta.sub(frame.axis.scale(axial)).length();
402 let target = if height == 0.0 {
403 rho0
404 } else {
405 rho0 + (rho1 - rho0) * (axial / height)
406 };
407 (radial - target).abs()
408}
409
410/// A STRAIGHT rebuilt edge bordering a curved carrier must still lie ON that
411/// carrier — endpoints and midpoint — or the rebuild has torn the edge off it.
412///
413/// This is the guard that answers the coaxial-cap question the contract asks.
414/// Spinning a cylinder's cap about the cylinder's OWN axis leaves both carriers
415/// invariant, so the rim rides by `R` — but the WALL's seam edge has one
416/// endpoint on that rim and one on the untouched bottom rim, and the straight
417/// chord between them is not a generatrix: it cuts the chord of a 25° arc
418/// through the inside of the cylinder. Measured before this guard existed, the
419/// result reached `validate()` and failed there on a stale pcurve with the
420/// arc's chord length as the deviation. The refusal names the wall.
421///
422/// `carrier_rotation` is `Some(R)` when the face is one the rotation MOVES —
423/// the tilted bore wall carrying its own seam generatrix — in which case the
424/// chord is measured against the carrier `R` leaves, not the one on record.
425/// Measuring against the stored frame there would convict every tilt of a bore
426/// of tearing its own seam off itself.
427fn verify_chord_on_curved(
428 solid: &BrepSolid,
429 face_lookup: &HashMap<u64, (usize, usize)>,
430 face_id: u64,
431 edge_id: u64,
432 start: Vec3,
433 end: Vec3,
434 carrier_rotation: Option<&AffineTransform>,
435 tolerance: f64,
436) -> Result<(), KernelRefusal> {
437 let Some((frame, rho0, rho1, height)) = carrier_ruled(solid, face_lookup, face_id) else {
438 return Ok(());
439 };
440 let frame = match carrier_rotation {
441 Some(rotation) => rotate_ruled_frame(rotation, &frame),
442 None => frame,
443 };
444 let midpoint = start.add(end).scale(0.5);
445 for point in [start, midpoint, end] {
446 let off = ruled_offset(&frame, rho0, rho1, height, point);
447 if off > 10.0 * tolerance {
448 return Err(KernelRefusal::unsupported(KernelStage::Validate, "chord_off_carrier", format!(
449 "rotate_faces: rebuilding edge {edge_id} straight between its re-solved corners would leave its curved neighbour {} (off {off:.3e}); the rotation moves one end of that edge along the carrier and the other not at all, and no straight chord spans that — refusing",
450 face_short(solid, face_lookup, face_id)
451 )));
452 }
453 }
454 Ok(())
455}
456
457/// A point that rode rigidly must genuinely still be ON every fixed carrier it
458/// touches. The invariance predicate proves it in exact arithmetic; this is the
459/// measurement that says so on the actual numbers, and it is what turns "rigid"
460/// into a checked claim rather than an asserted one.
461fn verify_point_on_fixed(
462 solid: &BrepSolid,
463 face_lookup: &HashMap<u64, (usize, usize)>,
464 planes: &mut HashMap<u64, Plane>,
465 face_id: u64,
466 point: Vec3,
467 vertex_id: u64,
468 plane_tolerance: f64,
469 tolerance: f64,
470) -> Result<(), KernelRefusal> {
471 if let Some((frame, rho0, rho1, height)) = carrier_ruled(solid, face_lookup, face_id) {
472 let off = ruled_offset(&frame, rho0, rho1, height, point);
473 if off > 10.0 * tolerance {
474 return Err(KernelRefusal::unsupported(KernelStage::Validate, "corner_off_curved_neighbour", format!(
475 "rotate_faces: the rotated corner at vertex {vertex_id} left its curved \
476 neighbour {} (off {off:.3e}) — refusing",
477 face_short(solid, face_lookup, face_id)
478 )));
479 }
480 return Ok(());
481 }
482 let plane = cached_plane(planes, solid, face_lookup, face_id, plane_tolerance)
483 .map_err(|_| {
484 unsupported_carrier(
485 solid,
486 face_lookup,
487 face_id,
488 &format!("a FIXED neighbour at vertex {vertex_id}"),
489 )
490 })?;
491 let off = point.sub(plane.origin).dot(plane.normal).abs();
492 if off > 10.0 * tolerance {
493 return Err(KernelRefusal::unsupported(KernelStage::Validate, "corner_off_fixed_neighbour", format!(
494 "rotate_faces: the rotated corner at vertex {vertex_id} left its fixed neighbour \
495 {} (off {off:.3e}) — refusing",
496 face_short(solid, face_lookup, face_id)
497 )));
498 }
499 Ok(())
500}
501
502/// Rebuild a straight edge between two re-solved corners, refusing the two ways
503/// an angle can turn a neighbour's trim inside out.
504///
505/// Both refusals name the EDGE and the two faces that carry it, which is the
506/// half of the message a user can act on: "edge 9" alone does not say which wall
507/// stopped the rotation.
508fn plan_straight_rebuild(
509 solid: &BrepSolid,
510 face_lookup: &HashMap<u64, (usize, usize)>,
511 faces_of_edge: &HashMap<u64, Vec<u64>>,
512 edge: &EdgeRecord,
513 start_old: Vec3,
514 end_old: Vec3,
515 start_new: Vec3,
516 end_new: Vec3,
517 tolerance: f64,
518) -> Result<EdgeAction, KernelRefusal> {
519 let between = || -> String {
520 let carriers = faces_of_edge
521 .get(&edge.id)
522 .map(Vec::as_slice)
523 .unwrap_or(&[])
524 .iter()
525 .map(|&face_id| face_short(solid, face_lookup, face_id))
526 .collect::<Vec<_>>();
527 match carriers.len() {
528 2 => format!("{} and {}", carriers[0], carriers[1]),
529 _ => carriers.join(", "),
530 }
531 };
532 if edge.degenerate {
533 return Err(KernelRefusal::unsupported(KernelStage::Refine, "degenerate_edge", format!(
534 "rotate_faces: degenerate edge {} between {} would need re-stretching (deferred) \
535 — refusing",
536 edge.id,
537 between()
538 )));
539 }
540 if edge.curve.straight_segment(tolerance).is_none() {
541 return Err(KernelRefusal::unsupported(KernelStage::Refine, "curved_edge_rebuild", format!(
542 "rotate_faces: edge {} between {} must be re-stretched but is not a straight line; \
543 a rotation rebuilds a curved edge only where it is the rim of the rotated plane \
544 against a cylinder or cone, not between two faces that stay put — refusing",
545 edge.id,
546 between()
547 )));
548 }
549 let new_chord = end_new.sub(start_new);
550 if new_chord.length() <= tolerance {
551 return Err(KernelRefusal::unsupported(KernelStage::Refine, "edge_collapse", format!(
552 "rotate_faces: the rotation collapses edge {} between {} to zero length — refusing",
553 edge.id,
554 between()
555 )));
556 }
557 if end_old.sub(start_old).dot(new_chord) <= 0.0 {
558 return Err(KernelRefusal::unsupported(KernelStage::Refine, EDGE_INVERSION, format!(
559 "rotate_faces: the rotation inverts edge {} between {} (the trim is turned inside \
560 out) — refusing",
561 edge.id,
562 between()
563 )));
564 }
565 Ok(EdgeAction::Rebuild {
566 start: start_new,
567 end: end_new,
568 })
569}
570
571/// A corner solve shared with [`super::face_move`] words its refusals for a
572/// PUSH ("the pushed cap plane … the push drives the corner off the carrier").
573/// Swapping only the prefix left a rotation telling the user it had pushed
574/// something, so the vocabulary is translated and the vertex named.
575fn translate_corner_refusal(error: &str, vertex_id: u64) -> String {
576 let body = error
577 .trim_start_matches("move_faces: ")
578 .replace("pushed cap plane", "rotated plane")
579 .replace("the push drives", "the rotation drives")
580 .replace("the push tears", "the rotation tears")
581 .replace("pushed cap", "rotated face");
582 format!("rotate_faces: at vertex {vertex_id}, {body}")
583}
584
585/// The SIGNED AREA VECTOR of a CLOSED curve over `[t0, t1]`: `½ Σ pᵢ × pᵢ₊₁`
586/// around it. Dotted with a direction it gives the sense the curve winds about
587/// that direction — positive for counter-clockwise looking down it — and the
588/// magnitude is the projected area, so a rim seen edge-on reports ~0 rather
589/// than a sign read off noise.
590fn closed_curve_area_vector(curve: &NurbsCurve, t0: f64, t1: f64) -> Result<Vec3, String> {
591 const STEPS: usize = 64;
592 let mut points = Vec::with_capacity(STEPS);
593 for step in 0..STEPS {
594 let t = t0 + (t1 - t0) * (step as f64 / STEPS as f64);
595 points.push(curve.evaluate(t)?);
596 }
597 let mut total = Vec3::default();
598 for index in 0..points.len() {
599 total = total.add(points[index].cross(points[(index + 1) % points.len()]));
600 }
601 Ok(total.scale(0.5))
602}
603
604/// The sense a rim runs in about the carrier's axis: `true` when its azimuth
605/// INCREASES along its own parameterisation.
606///
607/// [`super::face_move::arc_sweeps_forward`] answers this from the midpoint's
608/// azimuth against the endpoints', which is undecidable for a CLOSED rim (the
609/// start and end azimuths are the same one). This sums the wrapped azimuth
610/// steps instead, so it reads a closed rim and an open arc the same way.
611fn rim_runs_forward(
612 frame: &crate::RevolutionFrame,
613 curve: &NurbsCurve,
614 t0: f64,
615 t1: f64,
616) -> Result<bool, String> {
617 let azimuth = |point: Vec3| {
618 let delta = point.sub(frame.origin);
619 delta.dot(frame.y_axis).atan2(delta.dot(frame.x_axis))
620 };
621 const STEPS: usize = 32;
622 let mut total = 0.0;
623 let mut previous = azimuth(curve.evaluate(t0)?);
624 for step in 1..=STEPS {
625 let t = t0 + (t1 - t0) * (step as f64 / STEPS as f64);
626 let current = azimuth(curve.evaluate(t)?);
627 let mut delta = current - previous;
628 while delta > std::f64::consts::PI {
629 delta -= std::f64::consts::TAU;
630 }
631 while delta < -std::f64::consts::PI {
632 delta += std::f64::consts::TAU;
633 }
634 total += delta;
635 previous = current;
636 }
637 Ok(total >= 0.0)
638}
639
640/// The new rim where a ROTATED planar carrier meets a fixed OBLIQUE cylinder or
641/// cone: the EXACT conic section, anchored on the re-solved corners.
642///
643/// Two shapes of rim, one rule:
644///
645/// * a **CLOSED** rim — the hole mouth an oblique bore opens through the face —
646/// is the WHOLE section, and the one thing that is free about it is where its
647/// single vertex sits. That vertex is the face's own SEAM vertex, and the
648/// section must start there, not at the carrier's `u = 0`: a face's seam EDGE
649/// need not sit at the generatrix (the push-face seam fix, `becc64a98`), and a
650/// rim anchored anywhere else would relocate the seam vertex and strand the
651/// wall's seam edge. So the arc is built from the re-solved seam vertex's own
652/// azimuth through a full turn.
653/// * an **OPEN** rim is the sub-arc between the two re-solved corners' azimuths.
654///
655/// Both run in the OLD rim's own sense, so every coedge's `forward` flag — and
656/// therefore every trim loop's orientation — survives the rebuild untouched.
657///
658/// The section is `plane ∩ frame`, and which of the two the rotation moved is
659/// not this function's business: a rotated plane against a fixed cylinder and a
660/// fixed plane against a ROTATED cylinder are the same closed form with the
661/// roles swapped. `sense_frame` is the frame the OLD rim curve was measured in —
662/// the carrier as it stands in `solid` — because that is the only frame
663/// `edge.curve` is consistent with; `frame` is where the carrier ENDS UP, and it
664/// is what the anchor azimuth and the section itself are built in.
665#[allow(clippy::too_many_arguments)]
666fn rotated_rim_section(
667 edge: &EdgeRecord,
668 frame: &crate::RevolutionFrame,
669 sense_frame: &crate::RevolutionFrame,
670 rho0: f64,
671 rho1: f64,
672 height: f64,
673 plane: &Plane,
674 fixed_reference: Vec3,
675 start_new: Vec3,
676 end_new: Vec3,
677 tolerance: f64,
678) -> Result<NurbsCurve, KernelRefusal> {
679 let azimuth = |point: Vec3| {
680 let delta = point.sub(frame.origin);
681 delta.dot(frame.y_axis).atan2(delta.dot(frame.x_axis))
682 };
683 let tau = std::f64::consts::TAU;
684 let wrap = |angle: f64| {
685 let value = angle % tau;
686 if value < 0.0 {
687 value + tau
688 } else {
689 value
690 }
691 };
692 let forward = rim_runs_forward(sense_frame, &edge.curve, edge.t0, edge.t1).or_refuse(KernelStage::Refine, "rim_runs_forward")?;
693 let closed = edge.start_vertex_id == edge.end_vertex_id;
694 let (anchor, sweep) = if closed {
695 (azimuth(start_new), tau)
696 } else if forward {
697 (azimuth(start_new), wrap(azimuth(end_new) - azimuth(start_new)))
698 } else {
699 (azimuth(end_new), wrap(azimuth(start_new) - azimuth(end_new)))
700 };
701 if !closed && (sweep <= 1e-9 || sweep >= tau - 1e-9) {
702 return Err(KernelRefusal::unsupported(KernelStage::Refine, "rim_degenerate", format!(
703 "rotate_faces: the re-intersected rim of edge {} degenerates to a point or a \
704 full turn — refusing",
705 edge.id
706 )));
707 }
708 let mut curve = plane_ruled_section_arc(
709 plane.origin,
710 plane.normal,
711 frame,
712 rho0,
713 rho1,
714 height,
715 anchor,
716 sweep,
717 tolerance,
718 )
719 .map_err(|error| {
720 KernelRefusal::unsupported(KernelStage::Refine, "ruled_section", format!(
721 "rotate_faces: rebuilding the rim of edge {} as the section of the rotated \
722 carrier with its fixed neighbour — {}",
723 edge.id,
724 error.trim_start_matches("plane_ruled_section_arc: ")
725 ))
726 })?;
727 if !forward {
728 curve = curve.reversed().or_refuse(KernelStage::Refine, "reversed")?;
729 }
730 if closed {
731 // THE TURNOVER. A closed rim's direction is genuinely FREE — reversing a
732 // closed edge's curve leaves its single vertex where it is, so both
733 // faces it bounds simply traverse it the other way and no topology
734 // moves — and the sense above transports it in the carrier that TURNED.
735 // Once that carrier's axis has swung past the fixed neighbour's own
736 // direction, a rim that keeps its sense about the turning axis reverses
737 // its sense about the FIXED one, and the fixed face — whose chart the
738 // rotation never touched — is handed a hole that winds backwards.
739 //
740 // So the sense is kept in the carrier that did NOT move: the cylinder's
741 // axis when the plane is the side that turned, the plane's normal when
742 // it is the bore. That face has no freedom left — its carrier, its
743 // `same_sense` and its other loops all stand — so it is the one the rim
744 // must agree with, and agreeing with it puts the TURNED face right too,
745 // because the two traverse the one rim in opposite directions.
746 //
747 // Below the turnover this changes nothing: the transported sense and the
748 // fixed carrier's already agree, and the branch is a measured no-op.
749 let before = closed_curve_area_vector(&edge.curve, edge.t0, edge.t1).or_refuse(KernelStage::Refine, "closed_curve_area_vector")?.dot(fixed_reference);
750 let [d0, d1] = curve.domain().or_refuse(KernelStage::Refine, "domain")?;
751 let after = closed_curve_area_vector(&curve, d0, d1).or_refuse(KernelStage::Refine, "closed_curve_area_vector")?.dot(fixed_reference);
752 if before * after < 0.0 {
753 curve = curve.reversed().or_refuse(KernelStage::Refine, "reversed")?;
754 }
755 }
756 // The same "reapply the trimming" contract every rebuilt section is held to:
757 // it runs between the re-solved corners and every sample sits on BOTH
758 // carriers. For a closed rim both corners are the one seam vertex.
759 verify_section_between(
760 &curve,
761 frame,
762 rho0,
763 rho1,
764 height,
765 plane.normal,
766 plane.normal.dot(plane.origin),
767 start_new,
768 end_new,
769 tolerance,
770 )
771 .map_err(|error| error.with_message(|error| error.replace("move_faces:", "rotate_faces:")))?;
772 Ok(curve)
773}
774
775/// ROTATE the faces `face_ids` of `solid` by `angle_radians` about the axis
776/// through `axis_point` along `axis_direction` (right-handed), re-intersecting
777/// the neighbours the rotation moves the selection relative to.
778///
779/// `solid` is never touched: every refusal below happens before a clone is even
780/// made, or on the clone before it is returned, so a refused rotation leaves the
781/// caller's solid exactly as it was.
782///
783/// # Regimes
784///
785/// **Rigid** — every fixed carrier a boundary vertex touches is invariant under
786/// the rotation ([`carrier_invariant_under`]). The corner rides by `R`, the
787/// edges map by `R` exactly, and the fixed faces re-trim around them. This is
788/// what makes a cylindrical wall spun about its own axis a no-op that BUILDS.
789///
790/// **Re-intersection** — otherwise the corner is the common point of the
791/// carriers meeting there, with the moved ones ROTATED, and every affected edge
792/// is rebuilt between the new corners — straight between planes, and as the
793/// EXACT conic wherever a plane meets an oblique cylinder or cone, whichever of
794/// the two the rotation turns. The TURNED side of such a pair is the tilted
795/// bore: its mouth is re-intersected in the frame `R` leaves, and its seam
796/// corner rides its own generatrix down to the fixed flat. A quadric meeting a
797/// quadric, or a sphere, torus, general revolution or free-form neighbour,
798/// refuses by name.
799pub fn rotate_faces(
800 solid: &BrepSolid,
801 face_ids: &[u64],
802 axis_point: Vec3,
803 axis_direction: Vec3,
804 angle_radians: f64,
805) -> Result<BrepSolid, KernelRefusal> {
806 if !(axis_point.x.is_finite() && axis_point.y.is_finite() && axis_point.z.is_finite()) {
807 return Err(KernelRefusal::input(KernelStage::Collect, "axis_point", "rotate_faces: the axis point must be finite"));
808 }
809 if !(axis_direction.x.is_finite()
810 && axis_direction.y.is_finite()
811 && axis_direction.z.is_finite())
812 {
813 return Err(KernelRefusal::input(KernelStage::Collect, "axis_direction", "rotate_faces: the axis direction must be finite"));
814 }
815 if !angle_radians.is_finite() {
816 return Err(KernelRefusal::input(KernelStage::Collect, "angle", "rotate_faces: the angle must be finite"));
817 }
818 let axis = axis_direction.normalized().map_err(|_| {
819 KernelRefusal::input(
820 KernelStage::Collect,
821 "axis_length",
822 "rotate_faces: the axis direction has zero length",
823 )
824 })?;
825 if face_ids.is_empty() {
826 return Err(KernelRefusal::input(KernelStage::Collect, "selection", "rotate_faces: no faces selected"));
827 }
828 let moved: HashSet<u64> = face_ids.iter().copied().collect();
829 let mut face_lookup: HashMap<u64, (usize, usize)> = HashMap::default();
830 for (shell_index, shell) in solid.shells.iter().enumerate() {
831 for (face_index, face) in shell.faces.iter().enumerate() {
832 face_lookup
833 .entry(face.id)
834 .or_insert((shell_index, face_index));
835 }
836 }
837 for &face_id in face_ids {
838 if !face_lookup.contains_key(&face_id) {
839 return Err(KernelRefusal::input(KernelStage::Collect, "face_id", format!("rotate_faces: no face with id {face_id}")));
840 }
841 }
842
843 let scale = solid_model_scale(solid);
844 let tolerance = (scale * 1e-7).max(1e-9);
845 let plane_tolerance = (scale * 1e-6).max(1e-7);
846 // Direction comparisons are between UNIT vectors, so the band is
847 // dimensionless — the sine of the angle two carriers' directions differ by.
848 let angular_tolerance = 1e-9;
849 // "The corner landed exactly where R put it": the rigid endpoints are
850 // assigned `R(point)` verbatim, so this only absorbs rounding noise.
851 let rigid_tolerance = (scale * 1e-9).max(1e-12);
852 let rotation = rotation_about(axis_point, axis, angle_radians).or_refuse(KernelStage::Collect, "rotation")?;
853
854 // --- Classify every edge by how the group uses it ----------------------
855 let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
856 for shell in &solid.shells {
857 for face in &shell.faces {
858 for loop_record in &face.loops {
859 for coedge in &loop_record.coedges {
860 faces_of_edge
861 .entry(coedge.edge_id)
862 .or_default()
863 .push(face.id);
864 }
865 }
866 }
867 }
868 let invariant = |face_id: u64| -> bool {
869 carrier_invariant_under(
870 solid,
871 &face_lookup,
872 face_id,
873 axis_point,
874 axis,
875 angular_tolerance,
876 plane_tolerance,
877 )
878 };
879
880 let mut classes: HashMap<u64, EdgeClass> = HashMap::default();
881 let mut planes: HashMap<u64, Plane> = HashMap::default();
882 for edge in &solid.edges {
883 let uses = faces_of_edge
884 .get(&edge.id)
885 .map(Vec::as_slice)
886 .unwrap_or(&[]);
887 let expected = if edge.degenerate { 1 } else { 2 };
888 if uses.len() != expected {
889 return Err(KernelRefusal::input(KernelStage::Collect, "non_manifold", format!(
890 "rotate_faces: edge {} is used {} times (non-manifold input)",
891 edge.id,
892 uses.len()
893 )));
894 }
895 let moved_uses = uses.iter().filter(|face_id| moved.contains(*face_id)).count();
896 let class = if moved_uses == 0 {
897 EdgeClass::Fixed
898 } else if moved_uses == uses.len() {
899 EdgeClass::Interior
900 } else {
901 let moved_face = *uses.iter().find(|face_id| moved.contains(*face_id)).unwrap();
902 let fixed_face = *uses.iter().find(|face_id| !moved.contains(*face_id)).unwrap();
903 // The face left behind across a boundary edge is what the rotated
904 // carrier is re-intersected with. Planar → cache its plane. An
905 // INVARIANT curved carrier → allowed: the rim rides it by `R`.
906 // Anything else refuses HERE, before a single number is computed,
907 // which is what makes "the refusal names the pair" true of the
908 // curved cases as well as the planar ones.
909 if carrier_is_planar(solid, &face_lookup, fixed_face) {
910 cached_plane(&mut planes, solid, &face_lookup, fixed_face, plane_tolerance)?;
911 } else if !invariant(fixed_face)
912 && carrier_ruled(solid, &face_lookup, fixed_face).is_some()
913 && !edge.degenerate
914 && edge.curve.straight_segment(tolerance).is_some()
915 {
916 // A STRAIGHT boundary edge against a cylinder or cone is one of
917 // the carrier's own rulings: the face was TANGENT to it (a fillet
918 // band beside the face) or runs along its axis. Rotating such a
919 // plane either separates it from the carrier or cuts it in a
920 // different pair of rulings — a topology change, not a conic rim
921 // — so there is nothing for the oblique route to re-intersect.
922 // Refused here, before any corner is solved, so the message says
923 // that instead of whichever corner solve would have failed first.
924 return Err(KernelRefusal::unsupported(KernelStage::Classify, "ruling_edge", format!(
925 "rotate_faces: boundary edge {} of {} is a straight ruling of its neighbour {} \
926 — the face is tangent to that carrier or runs along its axis, as beside a \
927 fillet band — and a rotation re-intersects a cylinder or cone only where the \
928 face cuts it in a conic — refusing",
929 edge.id,
930 face_short(solid, &face_lookup, moved_face),
931 face_label(solid, &face_lookup, fixed_face)
932 )));
933 } else if !invariant(fixed_face)
934 && carrier_ruled(solid, &face_lookup, fixed_face).is_none()
935 {
936 // An OBLIQUE cylinder or cone is admitted here: the rotated
937 // planar carrier meets it in an exact ELLIPSE (a conic on a
938 // cone), which `rotated_rim_section` builds and which the wall
939 // is then re-trimmed around. A sphere, a torus, a general
940 // revolution and a free-form patch have no such route and still
941 // refuse, by name, before a single number is computed.
942 return Err(unsupported_carrier(
943 solid,
944 &face_lookup,
945 fixed_face,
946 &format!(
947 "the fixed neighbour across boundary edge {} of {}",
948 edge.id,
949 face_short(solid, &face_lookup, moved_face)
950 ),
951 ));
952 }
953 EdgeClass::Boundary {
954 moved_face,
955 fixed_face,
956 }
957 };
958 classes.insert(edge.id, class);
959 }
960
961 // --- THE PARALLEL LIMIT, before anything is solved ---------------------
962 // A boundary pair that must be re-intersected needs the rotated moved plane
963 // and the fixed plane to MEET. `solve_corner` would report the same failure
964 // later as "parallel or under-constrained planes" at a vertex, which names
965 // neither face and cannot distinguish this from a tearing corner. The angle
966 // is in the message because the angle is the input that caused it.
967 for edge in &solid.edges {
968 let EdgeClass::Boundary {
969 moved_face,
970 fixed_face,
971 } = classes[&edge.id]
972 else {
973 continue;
974 };
975 if invariant(fixed_face) {
976 continue; // rides rigidly; the two carriers never have to meet anew
977 }
978 if carrier_ruled(solid, &face_lookup, fixed_face).is_some() {
979 // A plane and a quadric are never PARALLEL — they meet in a conic
980 // unless the plane misses the carrier entirely, which the section
981 // construction itself reports (and names) at the rim rebuild.
982 continue;
983 }
984 if let Some((frame, rho0, rho1, _)) = carrier_ruled(solid, &face_lookup, moved_face)
985 .filter(|_| !invariant(moved_face))
986 {
987 // THE MIRROR LIMIT. A turned CYLINDER meets the flat in an ellipse
988 // until its axis runs PARALLEL to that flat: there the section is a
989 // pair of rulings, and there is no rim to build.
990 // `plane_ruled_section_arc` reports it too, but only once a corner
991 // has been solved on a carrier the rotation has already ruined, so
992 // the angle that caused it is named here instead.
993 //
994 // A CONE is deliberately not tested here: an axis parallel to the
995 // flat cuts it in a HYPERBOLA, not a ruling pair, and the limits
996 // that actually stop a cone — the apex plane, and a section whose
997 // rational weights change sign — are the ones the section
998 // construction names for itself, on the fixed-cone row's wording.
999 let radius_scale = rho0.abs().max(rho1.abs()).max(1.0);
1000 if (rho1 - rho0).abs() <= 1e-9 * radius_scale {
1001 let turned_axis = rotate_direction(&rotation, frame.axis);
1002 let fixed_plane = planes[&fixed_face];
1003 if turned_axis.dot(fixed_plane.normal).abs() <= PARALLEL_EPS {
1004 return Err(KernelRefusal::unsupported(KernelStage::Classify, AXIS_PARALLEL_LIMIT, format!(
1005 "rotate_faces: {:.6}° lays the axis of {} parallel to its neighbour {} \
1006 across edge {}; the two carriers then meet in a pair of rulings rather \
1007 than a conic, so there is no rim to rebuild — refusing",
1008 angle_radians.to_degrees(),
1009 face_short(solid, &face_lookup, moved_face),
1010 face_short(solid, &face_lookup, fixed_face),
1011 edge.id
1012 )));
1013 }
1014 }
1015 continue;
1016 }
1017 if !carrier_is_planar(solid, &face_lookup, moved_face) {
1018 continue; // refused below, by the carrier gate, with a better message
1019 }
1020 let moved_plane =
1021 cached_plane(&mut planes, solid, &face_lookup, moved_face, plane_tolerance)?;
1022 let fixed_plane = planes[&fixed_face];
1023 let rotated_normal = rotate_direction(&rotation, moved_plane.normal);
1024 if rotated_normal.cross(fixed_plane.normal).length() <= PARALLEL_EPS {
1025 return Err(KernelRefusal::unsupported(KernelStage::Classify, PARALLEL_LIMIT, format!(
1026 "rotate_faces: {:.6}° turns {} parallel to its neighbour {} across edge {}; \
1027 the two carriers no longer meet, so there is no edge to rebuild — refusing",
1028 angle_radians.to_degrees(),
1029 face_short(solid, &face_lookup, moved_face),
1030 face_short(solid, &face_lookup, fixed_face),
1031 edge.id
1032 )));
1033 }
1034 }
1035
1036 // --- Relocate every vertex the group touches ---------------------------
1037 let mut vertex_faces: HashMap<u64, HashSet<u64>> = HashMap::default();
1038 for edge in &solid.edges {
1039 if let Some(uses) = faces_of_edge.get(&edge.id) {
1040 for vertex_id in [edge.start_vertex_id, edge.end_vertex_id] {
1041 vertex_faces
1042 .entry(vertex_id)
1043 .or_default()
1044 .extend(uses.iter().copied());
1045 }
1046 }
1047 }
1048 let mut new_vertex: HashMap<u64, Vec3> = HashMap::default();
1049 for vertex in &solid.vertices {
1050 let Some(adjacent) = vertex_faces.get(&vertex.id) else {
1051 continue;
1052 };
1053 if !adjacent.iter().any(|face_id| moved.contains(face_id)) {
1054 continue;
1055 }
1056 // THE IDENTITY. If EVERY carrier at this vertex — the rotated ones as
1057 // well as the fixed ones — is invariant under `R`, the rotation
1058 // constrains the vertex to nothing: its old position already satisfies
1059 // every (unchanged) carrier equation. Leaving it is not an optimisation,
1060 // it is the right answer, and it is what makes a cap spun about its own
1061 // cylinder's axis return the input rather than dragging the rim's seam
1062 // vertex around a circle the wall's own seam edge cannot follow.
1063 if adjacent.iter().all(|&face_id| invariant(face_id)) {
1064 continue;
1065 }
1066 let fixed_at: Vec<u64> = adjacent
1067 .iter()
1068 .copied()
1069 .filter(|face_id| !moved.contains(face_id))
1070 .collect();
1071 if fixed_at.is_empty() {
1072 // Interior vertex: carried rigidly with the group.
1073 new_vertex.insert(vertex.id, rotation.point(vertex.point));
1074 continue;
1075 }
1076 // RIGID: every fixed carrier here is invariant, so `R(vertex)` is on all
1077 // of them. Verified rather than asserted.
1078 if fixed_at.iter().all(|&face_id| invariant(face_id)) {
1079 let point = rotation.point(vertex.point);
1080 for &face_id in &fixed_at {
1081 verify_point_on_fixed(
1082 solid,
1083 &face_lookup,
1084 &mut planes,
1085 face_id,
1086 point,
1087 vertex.id,
1088 plane_tolerance,
1089 tolerance,
1090 )?;
1091 }
1092 new_vertex.insert(vertex.id, point);
1093 continue;
1094 }
1095 // A corner against a NON-INVARIANT ruled carrier — the oblique bore's
1096 // seam vertex. Its carrier is not a plane, so the three-plane solve
1097 // below cannot place it; it rides the ONE fixed edge it shares with the
1098 // body (the wall's seam generatrix, or a cone's straight meridian) to
1099 // where the ROTATED cap plane crosses it. Move Face solves the same
1100 // corner the same way under a translation; only the cap plane differs.
1101 if fixed_at
1102 .iter()
1103 .any(|&face_id| carrier_ruled(solid, &face_lookup, face_id).is_some() && !invariant(face_id))
1104 {
1105 let moved_here: Vec<u64> = adjacent
1106 .iter()
1107 .copied()
1108 .filter(|face_id| moved.contains(face_id))
1109 .collect();
1110 if moved_here.len() != 1 {
1111 return Err(KernelRefusal::unsupported(KernelStage::Classify, "single_cap_only", format!(
1112 "rotate_faces: the corner at vertex {} touches {} rotated faces against a \
1113 ruled neighbour (a single rotated cap only) — refusing",
1114 vertex.id,
1115 moved_here.len()
1116 )));
1117 }
1118 let cap_plane =
1119 cached_plane(&mut planes, solid, &face_lookup, moved_here[0], plane_tolerance)
1120 .map_err(|_| {
1121 unsupported_carrier(
1122 solid,
1123 &face_lookup,
1124 moved_here[0],
1125 &format!("the ROTATED cap at vertex {}", vertex.id),
1126 )
1127 })?;
1128 let rotated_cap = rotate_plane(&rotation, &cap_plane);
1129 let plane_c = rotated_cap.normal.dot(rotated_cap.origin);
1130 let fixed_edges: Vec<&EdgeRecord> = solid
1131 .edges
1132 .iter()
1133 .filter(|edge| {
1134 (edge.start_vertex_id == vertex.id || edge.end_vertex_id == vertex.id)
1135 && matches!(classes.get(&edge.id), Some(EdgeClass::Fixed))
1136 })
1137 .collect();
1138 if fixed_edges.len() != 1 {
1139 return Err(KernelRefusal::unsupported(KernelStage::Classify, "single_fixed_edge_only", format!(
1140 "rotate_faces: the corner at vertex {} rides {} fixed edges against a ruled \
1141 neighbour (exactly one required) — refusing",
1142 vertex.id,
1143 fixed_edges.len()
1144 )));
1145 }
1146 let fixed_edge = fixed_edges[0];
1147 let seed = if fixed_edge.start_vertex_id == vertex.id {
1148 fixed_edge.t0
1149 } else {
1150 fixed_edge.t1
1151 };
1152 // A STRAIGHT fixed edge rides its own line exactly, and a crossing
1153 // OUTSIDE its current span is still exact (a line is its own
1154 // extension) — which is what lets the rotation lengthen the wall.
1155 // A CURVED one is solved on the CARRIERS instead: the boolean split
1156 // it exactly at the corner, so riding it could only move the corner
1157 // inward. Straightness is geometry, not pole count.
1158 let carriers = if fixed_edge.curve.straight_segment(tolerance).is_some() {
1159 None
1160 } else {
1161 plane_and_ruled_carriers(
1162 solid,
1163 &face_lookup,
1164 &mut planes,
1165 &fixed_at,
1166 plane_tolerance,
1167 )
1168 };
1169 let corner = match carriers {
1170 Some((fixed_plane, (frame, rho0, rho1, height))) => corner_on_plane_and_ruled(
1171 &fixed_plane,
1172 rotated_cap.normal,
1173 plane_c,
1174 &frame,
1175 rho0,
1176 rho1,
1177 height,
1178 vertex.point,
1179 tolerance,
1180 )
1181 .map_err(|error| error.with_message(|error| translate_corner_refusal(error, vertex.id)))?,
1182 None => resolve_corner_on_fixed_edge(
1183 fixed_edge,
1184 seed,
1185 rotated_cap.normal,
1186 plane_c,
1187 tolerance,
1188 )
1189 .map_err(|error| error.with_message(|error| translate_corner_refusal(error, vertex.id)))?,
1190 };
1191 // Checked, not asserted: the corner must sit on EVERY fixed carrier
1192 // at the vertex and on the rotated cap, or the group tore off.
1193 for &face_id in &fixed_at {
1194 verify_point_on_fixed(
1195 solid,
1196 &face_lookup,
1197 &mut planes,
1198 face_id,
1199 corner,
1200 vertex.id,
1201 plane_tolerance,
1202 tolerance,
1203 )?;
1204 }
1205 let off_cap = corner.sub(rotated_cap.origin).dot(rotated_cap.normal).abs();
1206 if off_cap > 10.0 * tolerance {
1207 return Err(KernelRefusal::internal(KernelStage::Validate, "corner_on_cap", format!(
1208 "rotate_faces: the re-solved corner at vertex {} left the ROTATED cap \
1209 (off {off_cap:.3e}) — refusing",
1210 vertex.id
1211 )));
1212 }
1213 new_vertex.insert(vertex.id, corner);
1214 continue;
1215 }
1216 // THE MIRROR CORNER — a ROTATED ruled carrier against a fixed FLAT.
1217 // This is the bore wall the user tilts: the moved face is the cylinder
1218 // (or cone), the fixed neighbour is the flat it opens through, and the
1219 // rim they meet in is an ELLIPSE on that flat. The three-plane solve
1220 // below cannot place the corner (the moved carrier is not a plane), and
1221 // the branch above cannot either (it is the FIXED side that is curved
1222 // there). One point on the rim is free — the ellipse is a whole
1223 // one-parameter family — and the thing that fixes it is the face's own
1224 // SEAM: the corner is where the seam generatrix, carried by `R` with the
1225 // rest of the wall, crosses the flat. Riding the rotated edge is exact
1226 // for a straight generatrix, and it keeps the seam vertex on the seam,
1227 // which is what the rim section is then anchored at.
1228 if let Some(moved_ruled) = rotated_moved_ruled_carrier(
1229 solid,
1230 &face_lookup,
1231 &rotation,
1232 &moved,
1233 &adjacent,
1234 &invariant,
1235 ) {
1236 let (moved_face, turned_frame, rho0, rho1, height) = moved_ruled;
1237 if fixed_at.len() != 1 {
1238 return Err(KernelRefusal::unsupported(KernelStage::Classify, "mirror_corner_flats", format!(
1239 "rotate_faces: the corner at vertex {} where the rotated {} meets its \
1240 neighbours touches {} fixed faces (a rotated cylinder or cone is \
1241 re-intersected against exactly one flat there) — refusing",
1242 vertex.id,
1243 face_short(solid, &face_lookup, moved_face),
1244 fixed_at.len()
1245 )));
1246 }
1247 let fixed_face = fixed_at[0];
1248 let flat = cached_plane(&mut planes, solid, &face_lookup, fixed_face, plane_tolerance)
1249 .map_err(|_| {
1250 unsupported_carrier(
1251 solid,
1252 &face_lookup,
1253 fixed_face,
1254 &format!(
1255 "the fixed neighbour of the rotated {} at vertex {}",
1256 face_short(solid, &face_lookup, moved_face),
1257 vertex.id
1258 ),
1259 )
1260 })?;
1261 let seam: Vec<&EdgeRecord> = solid
1262 .edges
1263 .iter()
1264 .filter(|edge| {
1265 (edge.start_vertex_id == vertex.id || edge.end_vertex_id == vertex.id)
1266 && matches!(classes.get(&edge.id), Some(EdgeClass::Interior))
1267 && !edge.degenerate
1268 })
1269 .collect();
1270 // A rim vertex that is NOT the seam vertex — the bookkeeping split an
1271 // import leaves at a carrier's parameter origin — has no edge of the
1272 // group's own at it, and nothing then says where on the new ellipse
1273 // it belongs. It refuses here rather than being placed by guesswork.
1274 if seam.len() != 1 {
1275 return Err(KernelRefusal::unsupported(KernelStage::Classify, "seam_edge_count", format!(
1276 "rotate_faces: the corner at vertex {} on the rotated {} rides {} of the \
1277 group's own edges (the seam generatrix, exactly one, is what places it) \
1278 — refusing",
1279 vertex.id,
1280 face_short(solid, &face_lookup, moved_face),
1281 seam.len()
1282 )));
1283 }
1284 let seam = seam[0];
1285 let seed = if seam.start_vertex_id == vertex.id {
1286 seam.t0
1287 } else {
1288 seam.t1
1289 };
1290 let turned_seam = EdgeRecord {
1291 curve: transform_curve(&seam.curve, rotation).or_refuse(KernelStage::Refine, "transform_curve")?,
1292 ..seam.clone()
1293 };
1294 let corner = resolve_corner_on_fixed_edge(
1295 &turned_seam,
1296 seed,
1297 flat.normal,
1298 flat.normal.dot(flat.origin),
1299 tolerance,
1300 )
1301 .map_err(|error| error.with_message(|error| translate_corner_refusal(error, vertex.id)))?;
1302 // Checked, not asserted, exactly as every other corner here is: on
1303 // the flat it must stay on, and on the carrier the rotation left.
1304 verify_point_on_fixed(
1305 solid,
1306 &face_lookup,
1307 &mut planes,
1308 fixed_face,
1309 corner,
1310 vertex.id,
1311 plane_tolerance,
1312 tolerance,
1313 )?;
1314 let off = ruled_offset(&turned_frame, rho0, rho1, height, corner);
1315 if off > 10.0 * tolerance {
1316 return Err(KernelRefusal::internal(KernelStage::Validate, "mirror_corner_on_carrier", format!(
1317 "rotate_faces: the re-solved corner at vertex {} left the ROTATED {} \
1318 (off {off:.3e}) — refusing",
1319 vertex.id,
1320 face_short(solid, &face_lookup, moved_face)
1321 )));
1322 }
1323 new_vertex.insert(vertex.id, corner);
1324 continue;
1325 }
1326 // RE-INTERSECTION: the corner is the common point of the carriers
1327 // meeting here, with the moved ones ROTATED. Every one of them must be
1328 // planar to solve it in closed form.
1329 let mut corner_planes = Vec::with_capacity(adjacent.len());
1330 for &face_id in &fixed_at {
1331 corner_planes.push(
1332 cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance).map_err(
1333 |_| {
1334 unsupported_carrier(
1335 solid,
1336 &face_lookup,
1337 face_id,
1338 &format!(
1339 "a FIXED carrier meeting the rotated group at vertex {}",
1340 vertex.id
1341 ),
1342 )
1343 },
1344 )?,
1345 );
1346 }
1347 for face_id in adjacent.iter().copied().filter(|id| moved.contains(id)) {
1348 let plane = cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance)
1349 .map_err(|_| {
1350 unsupported_carrier(
1351 solid,
1352 &face_lookup,
1353 face_id,
1354 &format!(
1355 "a ROTATED carrier meeting a fixed neighbour at vertex {}",
1356 vertex.id
1357 ),
1358 )
1359 })?;
1360 corner_planes.push(rotate_plane(&rotation, &plane));
1361 }
1362 let corner = solve_corner(&corner_planes).ok_or_else(|| {
1363 KernelRefusal::ill_posed(KernelStage::Refine, "corner_underconstrained", format!(
1364 "rotate_faces: cannot re-intersect the carriers meeting at vertex {} \
1365 (parallel or under-constrained planes) — refusing",
1366 vertex.id
1367 ))
1368 })?;
1369 // The corner must genuinely sit on EVERY carrier; otherwise the group
1370 // tears away from its fixed neighbours and no manifold heal exists.
1371 for plane in &corner_planes {
1372 if corner.sub(plane.origin).dot(plane.normal).abs() > tolerance {
1373 return Err(KernelRefusal::unsupported(KernelStage::Refine, "corner_tear", format!(
1374 "rotate_faces: the rotated group tears away from its neighbours at \
1375 vertex {} — refusing rather than emitting an invalid solid",
1376 vertex.id
1377 )));
1378 }
1379 }
1380 new_vertex.insert(vertex.id, corner);
1381 }
1382
1383 // --- Plan every edge update -------------------------------------------
1384 let vertex_position: HashMap<u64, Vec3> = solid
1385 .vertices
1386 .iter()
1387 .map(|vertex| (vertex.id, vertex.point))
1388 .collect();
1389 let mut actions: HashMap<u64, EdgeAction> = HashMap::default();
1390 for edge in &solid.edges {
1391 let position = |vertex_id: u64| -> Result<Vec3, KernelRefusal> {
1392 vertex_position.get(&vertex_id).copied().ok_or_else(|| {
1393 KernelRefusal::internal(
1394 KernelStage::Collect,
1395 "vertex_lookup",
1396 format!("rotate_faces: missing vertex {vertex_id}"),
1397 )
1398 })
1399 };
1400 let start_old = position(edge.start_vertex_id)?;
1401 let end_old = position(edge.end_vertex_id)?;
1402 let start_new = new_vertex
1403 .get(&edge.start_vertex_id)
1404 .copied()
1405 .unwrap_or(start_old);
1406 let end_new = new_vertex
1407 .get(&edge.end_vertex_id)
1408 .copied()
1409 .unwrap_or(end_old);
1410 // Neither endpoint relocated, and every ROTATED carrier the edge lies on
1411 // maps onto itself: the edge is where it belongs already. (For a Fixed
1412 // edge the second clause is vacuous — no face using it is moved.)
1413 if start_new.sub(start_old).length() == 0.0
1414 && end_new.sub(end_old).length() == 0.0
1415 && faces_of_edge[&edge.id]
1416 .iter()
1417 .all(|face_id| !moved.contains(face_id) || invariant(*face_id))
1418 {
1419 continue;
1420 }
1421 let rigid = start_new.sub(rotation.point(start_old)).length() <= rigid_tolerance
1422 && end_new.sub(rotation.point(end_old)).length() <= rigid_tolerance;
1423 let rebuild = |planes: &mut HashMap<u64, Plane>| -> Result<EdgeAction, KernelRefusal> {
1424 // Every carrier of an edge that is REBUILT straight has to be
1425 // re-trimmed around it afterwards, and the only re-trims here are the
1426 // planar one and the invariant-ruled one.
1427 for &face_id in &faces_of_edge[&edge.id] {
1428 if invariant(face_id) {
1429 continue;
1430 }
1431 // A NON-invariant RULED carrier re-trims too, on its own grown
1432 // carrier (`retrim_ruled_face`) — this is the arm the oblique
1433 // bore's seam generatrix takes, and `verify_chord_on_curved`
1434 // below is what proves the straight chord stayed on it.
1435 if carrier_ruled(solid, &face_lookup, face_id).is_some() {
1436 continue;
1437 }
1438 cached_plane(planes, solid, &face_lookup, face_id, plane_tolerance).map_err(
1439 |_| {
1440 unsupported_carrier(
1441 solid,
1442 &face_lookup,
1443 face_id,
1444 &format!("a carrier of edge {}, whose trim the rotation moves", edge.id),
1445 )
1446 },
1447 )?;
1448 }
1449 let action = plan_straight_rebuild(
1450 solid,
1451 &face_lookup,
1452 &faces_of_edge,
1453 edge,
1454 start_old,
1455 end_old,
1456 start_new,
1457 end_new,
1458 tolerance,
1459 )?;
1460 if let EdgeAction::Rebuild { start, end } = &action {
1461 for &face_id in &faces_of_edge[&edge.id] {
1462 verify_chord_on_curved(
1463 solid,
1464 &face_lookup,
1465 face_id,
1466 edge.id,
1467 *start,
1468 *end,
1469 moved.contains(&face_id).then_some(&rotation),
1470 tolerance,
1471 )?;
1472 }
1473 }
1474 Ok(action)
1475 };
1476 match classes[&edge.id] {
1477 EdgeClass::Fixed => {
1478 // A fixed side edge follows its re-solved endpoint. Both of its
1479 // own carriers stayed put, so it cannot ride `R`: it is rebuilt
1480 // between the endpoints, straight.
1481 actions.insert(edge.id, rebuild(&mut planes)?);
1482 }
1483 EdgeClass::Interior => {
1484 if rigid {
1485 actions.insert(edge.id, EdgeAction::Rotate);
1486 } else {
1487 actions.insert(edge.id, rebuild(&mut planes)?);
1488 }
1489 }
1490 EdgeClass::Boundary {
1491 moved_face,
1492 fixed_face,
1493 } => {
1494 if rigid && invariant(fixed_face) {
1495 // The edge maps by `R` and lands on the (unchanged) fixed
1496 // carrier as well as on the rotated moved one — exact for any
1497 // curve type, no re-intersection needed. This is the arm a
1498 // cap spun about its own cylinder's axis takes.
1499 actions.insert(edge.id, EdgeAction::Rotate);
1500 } else if let Some((frame, rho0, rho1, height)) =
1501 carrier_ruled(solid, &face_lookup, fixed_face).filter(|_| !invariant(fixed_face))
1502 {
1503 // THE OBLIQUE QUADRIC RIM. The rotated planar carrier meets
1504 // the fixed cylinder in an ELLIPSE and the fixed cone in a
1505 // conic, and no affine carries the old rim to it — a
1506 // rotation moves the carrier's NORMAL, which neither the
1507 // cylinder's axial shift nor the cone's homothety about its
1508 // apex can follow. So the rim is RE-INTERSECTED, exactly,
1509 // and anchored on the corners already re-solved above.
1510 let moved_plane =
1511 cached_plane(&mut planes, solid, &face_lookup, moved_face, plane_tolerance)
1512 .map_err(|_| {
1513 unsupported_carrier(
1514 solid,
1515 &face_lookup,
1516 moved_face,
1517 &format!(
1518 "the ROTATED side of boundary edge {} against {}",
1519 edge.id,
1520 face_short(solid, &face_lookup, fixed_face)
1521 ),
1522 )
1523 })?;
1524 let curve = rotated_rim_section(
1525 edge,
1526 &frame,
1527 &frame,
1528 rho0,
1529 rho1,
1530 height,
1531 &rotate_plane(&rotation, &moved_plane),
1532 frame.axis,
1533 start_new,
1534 end_new,
1535 tolerance,
1536 )?;
1537 actions.insert(edge.id, EdgeAction::Replace { curve });
1538 } else if let Some((frame, rho0, rho1, height)) =
1539 carrier_ruled(solid, &face_lookup, moved_face)
1540 .filter(|_| !invariant(moved_face))
1541 {
1542 // THE MIRROR RIM — the tilted bore's mouth. The same
1543 // `plane ∩ quadric` section as the arm above, with the roles
1544 // swapped: here it is the CYLINDER (or cone) that turned and
1545 // the FLAT that stayed, so the section is built in the frame
1546 // `R` leaves and against the fixed plane. The sense is still
1547 // read in the carrier's OLD frame, the only one the rim
1548 // curve on record is consistent with, and the anchor is the
1549 // seam corner the vertex pass just rode down the generatrix.
1550 let fixed_plane =
1551 cached_plane(&mut planes, solid, &face_lookup, fixed_face, plane_tolerance)
1552 .map_err(|_| {
1553 unsupported_carrier(
1554 solid,
1555 &face_lookup,
1556 fixed_face,
1557 &format!(
1558 "the FIXED side of boundary edge {} against the rotated {}",
1559 edge.id,
1560 face_short(solid, &face_lookup, moved_face)
1561 ),
1562 )
1563 })?;
1564 let curve = rotated_rim_section(
1565 edge,
1566 &rotate_ruled_frame(&rotation, &frame),
1567 &frame,
1568 rho0,
1569 rho1,
1570 height,
1571 &fixed_plane,
1572 fixed_plane.normal,
1573 start_new,
1574 end_new,
1575 tolerance,
1576 )?;
1577 actions.insert(edge.id, EdgeAction::Replace { curve });
1578 } else {
1579 // Real re-intersection: the line where the ROTATED moved
1580 // plane meets the fixed plane, delimited by the re-solved
1581 // corners. The moved side must be planar for the chord to
1582 // stay on it.
1583 cached_plane(&mut planes, solid, &face_lookup, moved_face, plane_tolerance)
1584 .map_err(|_| {
1585 unsupported_carrier(
1586 solid,
1587 &face_lookup,
1588 moved_face,
1589 &format!(
1590 "the ROTATED side of boundary edge {} against {}",
1591 edge.id,
1592 face_short(solid, &face_lookup, fixed_face)
1593 ),
1594 )
1595 })?;
1596 actions.insert(edge.id, rebuild(&mut planes)?);
1597 }
1598 }
1599 }
1600 }
1601
1602 // --- Plan face updates -------------------------------------------------
1603 // Edges whose SHAPE changed. A face bounding one of these must be RE-TRIMMED
1604 // (its rim moved to a new curve), not merely surface-transformed, else its
1605 // pcurve goes stale. A rigidly ROTATED edge on a rigidly rotated face keeps
1606 // its pcurve exactly, which is what the rigid regime is worth.
1607 let reshaped: HashSet<u64> = actions
1608 .iter()
1609 .filter(|(_, action)| {
1610 matches!(
1611 action,
1612 EdgeAction::Rebuild { .. } | EdgeAction::Replace { .. }
1613 )
1614 })
1615 .map(|(edge_id, _)| *edge_id)
1616 .collect();
1617 let dirty: HashSet<u64> = actions.keys().copied().collect();
1618 let mut face_actions: Vec<(usize, usize, FaceAction)> = Vec::new();
1619 // Ruled faces re-trim in a post-pass (they need `&mut solid`): the fixed
1620 // neighbours a rim rode onto, and the ROTATED wall whose own rims moved.
1621 let mut ruled_retrim_faces: Vec<u64> = Vec::new();
1622 for (shell_index, shell) in solid.shells.iter().enumerate() {
1623 for (face_index, face) in shell.faces.iter().enumerate() {
1624 let edge_ids = || {
1625 face.loops
1626 .iter()
1627 .flat_map(|loop_record| &loop_record.coedges)
1628 .map(|coedge| coedge.edge_id)
1629 };
1630 if moved.contains(&face.id) {
1631 if edge_ids().any(|edge_id| reshaped.contains(&edge_id))
1632 && carrier_ruled(solid, &face_lookup, face.id).is_some()
1633 && !invariant(face.id)
1634 {
1635 // THE TILTED WALL ITSELF. Its rims were re-intersected, so
1636 // the pcurves on record are stale — a bore that met its
1637 // flats in circles meets them in ellipses once it leans, and
1638 // in the wall's own parameters that is a sine, not a line.
1639 // Turning the control net is exact (a rotated cylinder is a
1640 // cylinder of the same radius), and the re-trim afterwards
1641 // rebuilds the loops on THAT carrier, grown along its axis
1642 // to cover the new rims — the tilt lengthens the wall.
1643 face_actions.push((shell_index, face_index, FaceAction::RotateSurface));
1644 ruled_retrim_faces.push(face.id);
1645 } else if edge_ids().any(|edge_id| reshaped.contains(&edge_id)) {
1646 // A boundary edge changed shape, so the patch is re-trimmed
1647 // around it — on the ROTATED plane, frame and all.
1648 let plane =
1649 cached_plane(&mut planes, solid, &face_lookup, face.id, plane_tolerance)
1650 .map_err(|_| {
1651 unsupported_carrier(
1652 solid,
1653 &face_lookup,
1654 face.id,
1655 "the ROTATED face whose rim the neighbours reshaped",
1656 )
1657 })?;
1658 face_actions.push((
1659 shell_index,
1660 face_index,
1661 FaceAction::Retrim(rotate_plane(&rotation, &plane)),
1662 ));
1663 } else if edge_ids().any(|edge_id| dirty.contains(&edge_id)) {
1664 // Every edge of the face rode along rigidly: rotating the
1665 // control net keeps surface, curves and pcurves in exact
1666 // agreement for ANY carrier type.
1667 face_actions.push((shell_index, face_index, FaceAction::RotateSurface));
1668 } else if !invariant(face.id) {
1669 // Nothing moved and the carrier does not map onto itself —
1670 // which the vertex rule above makes unreachable, since a face
1671 // whose boundary stayed put had every carrier at every one of
1672 // its corners invariant, this one included. Rotate the net
1673 // rather than silently emit a face off its own boundary.
1674 face_actions.push((shell_index, face_index, FaceAction::RotateSurface));
1675 }
1676 // ...and an invariant carrier nothing touched is left ALONE: the
1677 // rotation maps it onto itself, so turning the control net would
1678 // only re-parameterise the surface under pcurves that were never
1679 // rebuilt. This is the arm the identity rotations exit through.
1680 } else if edge_ids().any(|edge_id| dirty.contains(&edge_id)) {
1681 if carrier_is_planar(solid, &face_lookup, face.id) {
1682 // A FIXED plane keeps its carrier; only its trim moved.
1683 let plane =
1684 cached_plane(&mut planes, solid, &face_lookup, face.id, plane_tolerance)?;
1685 face_actions.push((shell_index, face_index, FaceAction::Retrim(plane)));
1686 } else if carrier_ruled(solid, &face_lookup, face.id).is_some() {
1687 // Both the INVARIANT coaxial band (whose rim rode by `R`)
1688 // and the OBLIQUE cylinder or cone (whose rim was
1689 // re-intersected) re-trim on their own carrier, grown along
1690 // its axis to cover the new rim.
1691 ruled_retrim_faces.push(face.id);
1692 } else {
1693 return Err(unsupported_carrier(
1694 solid,
1695 &face_lookup,
1696 face.id,
1697 "a FIXED face the rotation must re-trim",
1698 ));
1699 }
1700 }
1701 }
1702 }
1703
1704 // --- Apply to a fresh clone (the input is never touched) ---------------
1705 let mut result = solid.clone();
1706 for edge in &mut result.edges {
1707 match actions.get(&edge.id) {
1708 Some(EdgeAction::Rotate) => {
1709 edge.curve = transform_curve(&edge.curve, rotation).or_refuse(KernelStage::Sew, "transform_curve")?;
1710 }
1711 Some(EdgeAction::Rebuild { start, end }) => {
1712 edge.curve = make_line(*start, *end).or_refuse(KernelStage::Sew, "make_line")?;
1713 edge.t0 = 0.0;
1714 edge.t1 = 1.0;
1715 }
1716 Some(EdgeAction::Replace { curve }) => {
1717 // An exactly re-intersected section spans its whole domain by
1718 // construction, so the trim IS the domain.
1719 let [d0, d1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
1720 edge.curve = curve.clone();
1721 edge.t0 = d0;
1722 edge.t1 = d1;
1723 }
1724 None => {}
1725 }
1726 }
1727 for vertex in &mut result.vertices {
1728 if let Some(point) = new_vertex.get(&vertex.id) {
1729 vertex.point = *point;
1730 }
1731 }
1732 let final_edges: HashMap<u64, EdgeRecord> = result
1733 .edges
1734 .iter()
1735 .map(|edge| (edge.id, edge.clone()))
1736 .collect();
1737 for (shell_index, face_index, action) in face_actions {
1738 let face = &mut result.shells[shell_index].faces[face_index];
1739 match action {
1740 FaceAction::RotateSurface => {
1741 face.surface = transform_surface(&face.surface, rotation).or_refuse(KernelStage::Sew, "transform_surface")?;
1742 }
1743 FaceAction::Retrim(plane) => {
1744 // A MOVED face's carrier frame is this operation's to choose,
1745 // and `R` is not always the choice that leaves the chart legal;
1746 // a FIXED neighbour's frame is not ours to touch, and its
1747 // outward normal must survive the re-trim exactly.
1748 let plane = if moved.contains(&face.id) {
1749 orient_plane_to_loop(face, &plane, &final_edges)?
1750 } else {
1751 plane
1752 };
1753 retrim_planar_face(face, &plane, &final_edges, scale, "rotate_faces")?;
1754 // `retrim_planar_face` maps each edge's WHOLE curve onto the
1755 // rebuilt plane, so an edge that represents a strict SUBRANGE of
1756 // its curve needs the range fitter to span exactly [t0, t1];
1757 // full-domain edges keep the exact affine pcurve just built.
1758 let face_edges: HashSet<u64> = face
1759 .loops
1760 .iter()
1761 .flat_map(|loop_record| loop_record.coedges.iter().map(|c| c.edge_id))
1762 .collect();
1763 refit_touched_pcurves(
1764 face,
1765 &final_edges,
1766 &face_edges,
1767 true,
1768 tolerance,
1769 "rotate_faces",
1770 )?;
1771 }
1772 }
1773 }
1774 for face_id in ruled_retrim_faces {
1775 retrim_ruled_face(&mut result, face_id, &final_edges, tolerance, "rotate_faces")?;
1776 }
1777
1778 // Topology (and therefore genus) is untouched — only geometry moved — so
1779 // validate() re-checks Euler, loop closure and pcurve agreement.
1780 //
1781 // Taken through `validate_detailed` in ONE pass, because the floor below
1782 // needs the `wire_warnings` half of the same report and a second validation
1783 // of the same solid would buy nothing.
1784 let report = result.validate_detailed(&crate::KernelTolerances::for_solid(&result, 1e-7));
1785 if !report.issues.is_empty() {
1786 return Err(KernelRefusal::internal(KernelStage::Validate, "validate", format!(
1787 "rotate_faces: rotated solid failed validation: {:?}",
1788 report.issues
1789 )));
1790 }
1791 // THE ACCEPTANCE FLOOR. `validate()` tests INCIDENCE: it would pass a face
1792 // whose own trim loop crosses itself in its own parameter domain, because no
1793 // incidence check can see a bowtie — and that is exactly what a neighbour's
1794 // re-trim produces when the rotation folds it. Read `unreadable` as well as
1795 // the verdict: an empty crossing list beside a loop the scan could not read
1796 // is no answer, not a clean one.
1797 let crossings = crate::loop_self_crossings(&result);
1798 if !crossings.unreadable.is_empty() {
1799 return Err(KernelRefusal::internal(KernelStage::Validate, "crossing_scan_unreadable", format!(
1800 "rotate_faces: the loop self-crossing scan could not read {:?}; refusing rather \
1801 than shipping an unchecked result",
1802 crossings.unreadable
1803 )));
1804 }
1805 if crossings.loops == 0 {
1806 return Err(KernelRefusal::internal(KernelStage::Validate, "crossing_scan_empty",
1807 "rotate_faces: the loop self-crossing scan read no loops at all; refusing rather than shipping an unchecked result"));
1808 }
1809 if crossings.is_flagged() {
1810 return Err(KernelRefusal::internal(KernelStage::Validate, "loop_self_crossing", format!(
1811 "rotate_faces: the rotation makes a re-trimmed neighbour's own loop cross itself \
1812 ({}) — refusing",
1813 crossings.summary().unwrap_or_default()
1814 )));
1815 }
1816 // Belt and braces on top of the per-edge inversion guard: a global inversion
1817 // flips the signed volume even if every edge kept its direction.
1818 if let (Ok(before), Ok(after)) = (solid_signed_volume(solid), solid_signed_volume(&result)) {
1819 if before * after <= 0.0 {
1820 return Err(KernelRefusal::unsupported(KernelStage::Validate, SOLID_INVERSION,
1821 "rotate_faces: the rotation inverts the solid (signed volume changed sign) \
1822 — refusing"));
1823 }
1824 }
1825 // ...and a guard on a quantity the signed volume CANNOT carry.
1826 //
1827 // The volume is `⅓ ∮ x·n dA`, and on a trimmed face that integral reads the
1828 // carrier normal and the trim loop's winding TOGETHER — Green's theorem
1829 // turns the loop into the sign of the area element. Flip both at once, as a
1830 // carrier frame carried past its own turnover does, and the two negatives
1831 // cancel: the number comes back unchanged. That is not a weak signal, it is
1832 // a BLIND one, and no threshold on it can ever be made to see this. The
1833 // measured proof is the half-turn, where the body agreed with its input to
1834 // 3.6e-15 and the volume to 3.0e-16 with a face inside out.
1835 //
1836 // So the floor also reads the winding DIRECTLY, against the same
1837 // `validate_uv_wire` convention the rest of the kernel cites as law.
1838 // `validate()` drops that verdict — it returns `issues` and this lives under
1839 // `wire_warnings` — which is why a mis-wound result walked out past a
1840 // validation the operation already ran.
1841 //
1842 // The comparison is with what the INPUT carried, not with zero: a body that
1843 // arrives already mis-wound (an import, or an older edit) is not this
1844 // rotation's doing, and refusing it here would strand a user on a file they
1845 // cannot edit. Count, not identity — a rotation that repaired one face and
1846 // broke another would read as even here, and that is a narrower lie than
1847 // the blindness this replaces.
1848 //
1849 // The input is only validated when the result actually carries a warning,
1850 // so a rotation that leaves a clean body pays nothing for this at all.
1851 if !report.wire_warnings.is_empty() {
1852 let inherited = solid
1853 .validate_detailed(&crate::KernelTolerances::for_solid(solid, 1e-7))
1854 .wire_warnings;
1855 if report.wire_warnings.len() > inherited.len() {
1856 return Err(KernelRefusal::internal(KernelStage::Validate, "chart_winding", format!(
1857 "rotate_faces: the rotation leaves a face whose (u,v) chart is left-handed \
1858 against its own normal ({}) — refusing",
1859 report
1860 .wire_warnings
1861 .iter()
1862 .map(|warning| warning.message.as_str())
1863 .collect::<Vec<_>>()
1864 .join("; ")
1865 )));
1866 }
1867 }
1868 Ok(result)
1869}