Skip to main content

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 a plane meets a cylinder or cone \
544             — 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, or neither, for a fixed edge whose corner rides
795/// it. The TURNED side of such a pair is the tilted
796/// bore: its mouth is re-intersected in the frame `R` leaves, and its seam
797/// corner rides its own generatrix down to the fixed flat. A quadric meeting a
798/// quadric, or a sphere, torus, general revolution or free-form neighbour,
799/// refuses by name.
800pub fn rotate_faces(
801    solid: &BrepSolid,
802    face_ids: &[u64],
803    axis_point: Vec3,
804    axis_direction: Vec3,
805    angle_radians: f64,
806) -> Result<BrepSolid, KernelRefusal> {
807    rotate_faces_rigid(
808        solid, face_ids, axis_point, axis_direction, angle_radians, Vec3::new(0.0, 0.0, 0.0),
809    )
810}
811
812/// Re-intersect against the final rigidly moved carriers. The translation is
813/// applied to the rotation's affine before solving any corner, so a combined
814/// face edit never has to build or validate a rotation-only intermediate body.
815/// The public rotation primitive uses exactly this solver with zero translation.
816pub(crate) fn rotate_faces_rigid(
817    solid: &BrepSolid,
818    face_ids: &[u64],
819    axis_point: Vec3,
820    axis_direction: Vec3,
821    angle_radians: f64,
822    translation: Vec3,
823) -> Result<BrepSolid, KernelRefusal> {
824    if !(translation.x.is_finite() && translation.y.is_finite() && translation.z.is_finite()) {
825        return Err(KernelRefusal::input(KernelStage::Collect, "translation", "rotate_faces: the translation must be finite"));
826    }
827    if !(axis_point.x.is_finite() && axis_point.y.is_finite() && axis_point.z.is_finite()) {
828        return Err(KernelRefusal::input(KernelStage::Collect, "axis_point", "rotate_faces: the axis point must be finite"));
829    }
830    if !(axis_direction.x.is_finite()
831        && axis_direction.y.is_finite()
832        && axis_direction.z.is_finite())
833    {
834        return Err(KernelRefusal::input(KernelStage::Collect, "axis_direction", "rotate_faces: the axis direction must be finite"));
835    }
836    if !angle_radians.is_finite() {
837        return Err(KernelRefusal::input(KernelStage::Collect, "angle", "rotate_faces: the angle must be finite"));
838    }
839    let axis = axis_direction.normalized().map_err(|_| {
840        KernelRefusal::input(
841            KernelStage::Collect,
842            "axis_length",
843            "rotate_faces: the axis direction has zero length",
844        )
845    })?;
846    if face_ids.is_empty() {
847        return Err(KernelRefusal::input(KernelStage::Collect, "selection", "rotate_faces: no faces selected"));
848    }
849    let moved: HashSet<u64> = face_ids.iter().copied().collect();
850    let mut face_lookup: HashMap<u64, (usize, usize)> = HashMap::default();
851    for (shell_index, shell) in solid.shells.iter().enumerate() {
852        for (face_index, face) in shell.faces.iter().enumerate() {
853            face_lookup
854                .entry(face.id)
855                .or_insert((shell_index, face_index));
856        }
857    }
858    for &face_id in face_ids {
859        if !face_lookup.contains_key(&face_id) {
860            return Err(KernelRefusal::input(KernelStage::Collect, "face_id", format!("rotate_faces: no face with id {face_id}")));
861        }
862    }
863
864    let scale = solid_model_scale(solid);
865    let tolerance = (scale * 1e-7).max(1e-9);
866    let plane_tolerance = (scale * 1e-6).max(1e-7);
867    // Direction comparisons are between UNIT vectors, so the band is
868    // dimensionless — the sine of the angle two carriers' directions differ by.
869    let angular_tolerance = 1e-9;
870    // "The corner landed exactly where R put it": the rigid endpoints are
871    // assigned `R(point)` verbatim, so this only absorbs rounding noise.
872    let rigid_tolerance = (scale * 1e-9).max(1e-12);
873    let mut rotation = rotation_about(axis_point, axis, angle_radians).or_refuse(KernelStage::Collect, "rotation")?;
874    rotation.elements[3] += translation.x;
875    rotation.elements[7] += translation.y;
876    rotation.elements[11] += translation.z;
877
878    // --- Classify every edge by how the group uses it ----------------------
879    let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
880    for shell in &solid.shells {
881        for face in &shell.faces {
882            for loop_record in &face.loops {
883                for coedge in &loop_record.coedges {
884                    faces_of_edge
885                        .entry(coedge.edge_id)
886                        .or_default()
887                        .push(face.id);
888                }
889            }
890        }
891    }
892    let invariant = |face_id: u64| -> bool {
893        let rotation_invariant = carrier_invariant_under(
894            solid,
895            &face_lookup,
896            face_id,
897            axis_point,
898            axis,
899            angular_tolerance,
900            plane_tolerance,
901        );
902        if !rotation_invariant || translation.length() == 0.0 {
903            return rotation_invariant;
904        }
905        // A rigid corner may ride only when BOTH parts leave its carrier
906        // invariant. In particular, a plane spun within itself is no longer
907        // invariant when translated along its normal, nor is an axially shifted
908        // cone. A cylinder still admits a slide along its own axis.
909        let (shell, face) = face_lookup[&face_id];
910        let surface = &solid.shells[shell].faces[face].surface;
911        if let Some(AnalyticSurface::Plane { u_dir, v_dir, .. }) = surface.analytic() {
912            return u_dir.cross(*v_dir).normalized()
913                .map(|normal| normal.dot(translation).abs() <= rigid_tolerance)
914                .unwrap_or(false);
915        }
916        let Some((frame, rho0, rho1, _)) = carrier_ruled(solid, &face_lookup, face_id) else {
917            return false;
918        };
919        if (rho1 - rho0).abs() > 1e-9 * rho0.abs().max(rho1.abs()).max(1.0) {
920            return translation.length() <= rigid_tolerance;
921        }
922        translation.sub(frame.axis.scale(translation.dot(frame.axis))).length() <= rigid_tolerance
923    };
924
925    let mut classes: HashMap<u64, EdgeClass> = HashMap::default();
926    let mut planes: HashMap<u64, Plane> = HashMap::default();
927    for edge in &solid.edges {
928        let uses = faces_of_edge
929            .get(&edge.id)
930            .map(Vec::as_slice)
931            .unwrap_or(&[]);
932        let expected = if edge.degenerate { 1 } else { 2 };
933        if uses.len() != expected {
934            return Err(KernelRefusal::input(KernelStage::Collect, "non_manifold", format!(
935                "rotate_faces: edge {} is used {} times (non-manifold input)",
936                edge.id,
937                uses.len()
938            )));
939        }
940        let moved_uses = uses.iter().filter(|face_id| moved.contains(*face_id)).count();
941        let class = if moved_uses == 0 {
942            EdgeClass::Fixed
943        } else if moved_uses == uses.len() {
944            EdgeClass::Interior
945        } else {
946            let moved_face = *uses.iter().find(|face_id| moved.contains(*face_id)).unwrap();
947            let fixed_face = *uses.iter().find(|face_id| !moved.contains(*face_id)).unwrap();
948            // The face left behind across a boundary edge is what the rotated
949            // carrier is re-intersected with. Planar → cache its plane. An
950            // INVARIANT curved carrier → allowed: the rim rides it by `R`.
951            // Anything else refuses HERE, before a single number is computed,
952            // which is what makes "the refusal names the pair" true of the
953            // curved cases as well as the planar ones.
954            if carrier_is_planar(solid, &face_lookup, fixed_face) {
955                cached_plane(&mut planes, solid, &face_lookup, fixed_face, plane_tolerance)?;
956            } else if !invariant(fixed_face)
957                && carrier_ruled(solid, &face_lookup, fixed_face).is_some()
958                && !edge.degenerate
959                && edge.curve.straight_segment(tolerance).is_some()
960            {
961                // A STRAIGHT boundary edge against a cylinder or cone is one of
962                // the carrier's own rulings: the face was TANGENT to it (a fillet
963                // band beside the face) or runs along its axis. Rotating such a
964                // plane either separates it from the carrier or cuts it in a
965                // different pair of rulings — a topology change, not a conic rim
966                // — so there is nothing for the oblique route to re-intersect.
967                // Refused here, before any corner is solved, so the message says
968                // that instead of whichever corner solve would have failed first.
969                return Err(KernelRefusal::unsupported(KernelStage::Classify, "ruling_edge", format!(
970                    "rotate_faces: boundary edge {} of {} is a straight ruling of its neighbour {} \
971                     — the face is tangent to that carrier or runs along its axis, as beside a \
972                     fillet band — and a rotation re-intersects a cylinder or cone only where the \
973                     face cuts it in a conic — refusing",
974                    edge.id,
975                    face_short(solid, &face_lookup, moved_face),
976                    face_label(solid, &face_lookup, fixed_face)
977                )));
978            } else if !invariant(fixed_face)
979                && carrier_ruled(solid, &face_lookup, fixed_face).is_none()
980            {
981                // An OBLIQUE cylinder or cone is admitted here: the rotated
982                // planar carrier meets it in an exact ELLIPSE (a conic on a
983                // cone), which `rotated_rim_section` builds and which the wall
984                // is then re-trimmed around. A sphere, a torus, a general
985                // revolution and a free-form patch have no such route and still
986                // refuse, by name, before a single number is computed.
987                return Err(unsupported_carrier(
988                    solid,
989                    &face_lookup,
990                    fixed_face,
991                    &format!(
992                        "the fixed neighbour across boundary edge {} of {}",
993                        edge.id,
994                        face_short(solid, &face_lookup, moved_face)
995                    ),
996                ));
997            }
998            EdgeClass::Boundary {
999                moved_face,
1000                fixed_face,
1001            }
1002        };
1003        classes.insert(edge.id, class);
1004    }
1005
1006    // --- THE PARALLEL LIMIT, before anything is solved ---------------------
1007    // A boundary pair that must be re-intersected needs the rotated moved plane
1008    // and the fixed plane to MEET. `solve_corner` would report the same failure
1009    // later as "parallel or under-constrained planes" at a vertex, which names
1010    // neither face and cannot distinguish this from a tearing corner. The angle
1011    // is in the message because the angle is the input that caused it.
1012    for edge in &solid.edges {
1013        let EdgeClass::Boundary {
1014            moved_face,
1015            fixed_face,
1016        } = classes[&edge.id]
1017        else {
1018            continue;
1019        };
1020        if invariant(fixed_face) {
1021            continue; // rides rigidly; the two carriers never have to meet anew
1022        }
1023        if carrier_ruled(solid, &face_lookup, fixed_face).is_some() {
1024            // A plane and a quadric are never PARALLEL — they meet in a conic
1025            // unless the plane misses the carrier entirely, which the section
1026            // construction itself reports (and names) at the rim rebuild.
1027            continue;
1028        }
1029        if let Some((frame, rho0, rho1, _)) = carrier_ruled(solid, &face_lookup, moved_face)
1030            .filter(|_| !invariant(moved_face))
1031        {
1032            // THE MIRROR LIMIT. A turned CYLINDER meets the flat in an ellipse
1033            // until its axis runs PARALLEL to that flat: there the section is a
1034            // pair of rulings, and there is no rim to build.
1035            // `plane_ruled_section_arc` reports it too, but only once a corner
1036            // has been solved on a carrier the rotation has already ruined, so
1037            // the angle that caused it is named here instead.
1038            //
1039            // A CONE is deliberately not tested here: an axis parallel to the
1040            // flat cuts it in a HYPERBOLA, not a ruling pair, and the limits
1041            // that actually stop a cone — the apex plane, and a section whose
1042            // rational weights change sign — are the ones the section
1043            // construction names for itself, on the fixed-cone row's wording.
1044            let radius_scale = rho0.abs().max(rho1.abs()).max(1.0);
1045            if (rho1 - rho0).abs() <= 1e-9 * radius_scale {
1046                let turned_axis = rotate_direction(&rotation, frame.axis);
1047                let fixed_plane = planes[&fixed_face];
1048                if turned_axis.dot(fixed_plane.normal).abs() <= PARALLEL_EPS {
1049                    return Err(KernelRefusal::unsupported(KernelStage::Classify, AXIS_PARALLEL_LIMIT, format!(
1050                        "rotate_faces: {:.6}° lays the axis of {} parallel to its neighbour {} \
1051                         across edge {}; the two carriers then meet in a pair of rulings rather \
1052                         than a conic, so there is no rim to rebuild — refusing",
1053                        angle_radians.to_degrees(),
1054                        face_short(solid, &face_lookup, moved_face),
1055                        face_short(solid, &face_lookup, fixed_face),
1056                        edge.id
1057                    )));
1058                }
1059            }
1060            continue;
1061        }
1062        if !carrier_is_planar(solid, &face_lookup, moved_face) {
1063            continue; // refused below, by the carrier gate, with a better message
1064        }
1065        let moved_plane =
1066            cached_plane(&mut planes, solid, &face_lookup, moved_face, plane_tolerance)?;
1067        let fixed_plane = planes[&fixed_face];
1068        let rotated_normal = rotate_direction(&rotation, moved_plane.normal);
1069        if rotated_normal.cross(fixed_plane.normal).length() <= PARALLEL_EPS {
1070            return Err(KernelRefusal::unsupported(KernelStage::Classify, PARALLEL_LIMIT, format!(
1071                "rotate_faces: {:.6}° turns {} parallel to its neighbour {} across edge {}; \
1072                 the two carriers no longer meet, so there is no edge to rebuild — refusing",
1073                angle_radians.to_degrees(),
1074                face_short(solid, &face_lookup, moved_face),
1075                face_short(solid, &face_lookup, fixed_face),
1076                edge.id
1077            )));
1078        }
1079    }
1080
1081    // --- Relocate every vertex the group touches ---------------------------
1082    let mut vertex_faces: HashMap<u64, HashSet<u64>> = HashMap::default();
1083    for edge in &solid.edges {
1084        if let Some(uses) = faces_of_edge.get(&edge.id) {
1085            for vertex_id in [edge.start_vertex_id, edge.end_vertex_id] {
1086                vertex_faces
1087                    .entry(vertex_id)
1088                    .or_default()
1089                    .extend(uses.iter().copied());
1090            }
1091        }
1092    }
1093    let mut new_vertex: HashMap<u64, Vec3> = HashMap::default();
1094    for vertex in &solid.vertices {
1095        let Some(adjacent) = vertex_faces.get(&vertex.id) else {
1096            continue;
1097        };
1098        if !adjacent.iter().any(|face_id| moved.contains(face_id)) {
1099            continue;
1100        }
1101        // THE IDENTITY. If EVERY carrier at this vertex — the rotated ones as
1102        // well as the fixed ones — is invariant under `R`, the rotation
1103        // constrains the vertex to nothing: its old position already satisfies
1104        // every (unchanged) carrier equation. Leaving it is not an optimisation,
1105        // it is the right answer, and it is what makes a cap spun about its own
1106        // cylinder's axis return the input rather than dragging the rim's seam
1107        // vertex around a circle the wall's own seam edge cannot follow.
1108        if adjacent.iter().all(|&face_id| invariant(face_id))
1109            || (translation.length() > 0.0
1110                && adjacent.iter().filter(|face_id| moved.contains(face_id))
1111                    .all(|&face_id| invariant(face_id)))
1112        {
1113            // In a combined motion an axially spun/slid cylinder can stay
1114            // invariant while its fixed cap would not. Only the selected
1115            // carriers actually move: when they all stay put, so does this
1116            // vertex, regardless of how the affine would move a fixed face.
1117            continue;
1118        }
1119        let fixed_at: Vec<u64> = adjacent
1120            .iter()
1121            .copied()
1122            .filter(|face_id| !moved.contains(face_id))
1123            .collect();
1124        if fixed_at.is_empty() {
1125            // Interior vertex: carried rigidly with the group.
1126            new_vertex.insert(vertex.id, rotation.point(vertex.point));
1127            continue;
1128        }
1129        // RIGID: every fixed carrier here is invariant, so `R(vertex)` is on all
1130        // of them. Verified rather than asserted.
1131        if fixed_at.iter().all(|&face_id| invariant(face_id)) {
1132            let point = rotation.point(vertex.point);
1133            for &face_id in &fixed_at {
1134                verify_point_on_fixed(
1135                    solid,
1136                    &face_lookup,
1137                    &mut planes,
1138                    face_id,
1139                    point,
1140                    vertex.id,
1141                    plane_tolerance,
1142                    tolerance,
1143                )?;
1144            }
1145            new_vertex.insert(vertex.id, point);
1146            continue;
1147        }
1148        // A corner against a NON-INVARIANT ruled carrier — the oblique bore's
1149        // seam vertex. Its carrier is not a plane, so the three-plane solve
1150        // below cannot place it; it rides the ONE fixed edge it shares with the
1151        // body (the wall's seam generatrix, or a cone's straight meridian) to
1152        // where the ROTATED cap plane crosses it. Move Face solves the same
1153        // corner the same way under a translation; only the cap plane differs.
1154        if fixed_at
1155            .iter()
1156            .any(|&face_id| carrier_ruled(solid, &face_lookup, face_id).is_some() && !invariant(face_id))
1157        {
1158            let moved_here: Vec<u64> = adjacent
1159                .iter()
1160                .copied()
1161                .filter(|face_id| moved.contains(face_id))
1162                .collect();
1163            if moved_here.len() != 1 {
1164                return Err(KernelRefusal::unsupported(KernelStage::Classify, "single_cap_only", format!(
1165                    "rotate_faces: the corner at vertex {} touches {} rotated faces against a \
1166                     ruled neighbour (a single rotated cap only) — refusing",
1167                    vertex.id,
1168                    moved_here.len()
1169                )));
1170            }
1171            let cap_plane =
1172                cached_plane(&mut planes, solid, &face_lookup, moved_here[0], plane_tolerance)
1173                    .map_err(|_| {
1174                        unsupported_carrier(
1175                            solid,
1176                            &face_lookup,
1177                            moved_here[0],
1178                            &format!("the ROTATED cap at vertex {}", vertex.id),
1179                        )
1180                    })?;
1181            let rotated_cap = rotate_plane(&rotation, &cap_plane);
1182            let plane_c = rotated_cap.normal.dot(rotated_cap.origin);
1183            let fixed_edges: Vec<&EdgeRecord> = solid
1184                .edges
1185                .iter()
1186                .filter(|edge| {
1187                    (edge.start_vertex_id == vertex.id || edge.end_vertex_id == vertex.id)
1188                        && matches!(classes.get(&edge.id), Some(EdgeClass::Fixed))
1189                })
1190                .collect();
1191            if fixed_edges.len() != 1 {
1192                return Err(KernelRefusal::unsupported(KernelStage::Classify, "single_fixed_edge_only", format!(
1193                    "rotate_faces: the corner at vertex {} rides {} fixed edges against a ruled \
1194                     neighbour (exactly one required) — refusing",
1195                    vertex.id,
1196                    fixed_edges.len()
1197                )));
1198            }
1199            let fixed_edge = fixed_edges[0];
1200            let seed = if fixed_edge.start_vertex_id == vertex.id {
1201                fixed_edge.t0
1202            } else {
1203                fixed_edge.t1
1204            };
1205            // A STRAIGHT fixed edge rides its own line exactly, and a crossing
1206            // OUTSIDE its current span is still exact (a line is its own
1207            // extension) — which is what lets the rotation lengthen the wall.
1208            // A CURVED one is solved on the CARRIERS instead: the boolean split
1209            // it exactly at the corner, so riding it could only move the corner
1210            // inward. Straightness is geometry, not pole count.
1211            let carriers = if fixed_edge.curve.straight_segment(tolerance).is_some() {
1212                None
1213            } else {
1214                plane_and_ruled_carriers(
1215                    solid,
1216                    &face_lookup,
1217                    &mut planes,
1218                    &fixed_at,
1219                    plane_tolerance,
1220                )
1221            };
1222            let corner = match carriers {
1223                Some((fixed_plane, (frame, rho0, rho1, height))) => corner_on_plane_and_ruled(
1224                    &fixed_plane,
1225                    rotated_cap.normal,
1226                    plane_c,
1227                    &frame,
1228                    rho0,
1229                    rho1,
1230                    height,
1231                    vertex.point,
1232                    tolerance,
1233                )
1234                .map_err(|error| error.with_message(|error| translate_corner_refusal(error, vertex.id)))?,
1235                None => resolve_corner_on_fixed_edge(
1236                    fixed_edge,
1237                    seed,
1238                    rotated_cap.normal,
1239                    plane_c,
1240                    tolerance,
1241                )
1242                .map_err(|error| error.with_message(|error| translate_corner_refusal(error, vertex.id)))?,
1243            };
1244            // Checked, not asserted: the corner must sit on EVERY fixed carrier
1245            // at the vertex and on the rotated cap, or the group tore off.
1246            for &face_id in &fixed_at {
1247                verify_point_on_fixed(
1248                    solid,
1249                    &face_lookup,
1250                    &mut planes,
1251                    face_id,
1252                    corner,
1253                    vertex.id,
1254                    plane_tolerance,
1255                    tolerance,
1256                )?;
1257            }
1258            let off_cap = corner.sub(rotated_cap.origin).dot(rotated_cap.normal).abs();
1259            if off_cap > 10.0 * tolerance {
1260                return Err(KernelRefusal::internal(KernelStage::Validate, "corner_on_cap", format!(
1261                    "rotate_faces: the re-solved corner at vertex {} left the ROTATED cap \
1262                     (off {off_cap:.3e}) — refusing",
1263                    vertex.id
1264                )));
1265            }
1266            new_vertex.insert(vertex.id, corner);
1267            continue;
1268        }
1269        // THE MIRROR CORNER — a ROTATED ruled carrier against a fixed FLAT.
1270        // This is the bore wall the user tilts: the moved face is the cylinder
1271        // (or cone), the fixed neighbour is the flat it opens through, and the
1272        // rim they meet in is an ELLIPSE on that flat. The three-plane solve
1273        // below cannot place the corner (the moved carrier is not a plane), and
1274        // the branch above cannot either (it is the FIXED side that is curved
1275        // there). One point on the rim is free — the ellipse is a whole
1276        // one-parameter family — and the thing that fixes it is the face's own
1277        // SEAM: the corner is where the seam generatrix, carried by `R` with the
1278        // rest of the wall, crosses the flat. Riding the rotated edge is exact
1279        // for a straight generatrix, and it keeps the seam vertex on the seam,
1280        // which is what the rim section is then anchored at.
1281        if let Some(moved_ruled) = rotated_moved_ruled_carrier(
1282            solid,
1283            &face_lookup,
1284            &rotation,
1285            &moved,
1286            &adjacent,
1287            &invariant,
1288        ) {
1289            let (moved_face, turned_frame, rho0, rho1, height) = moved_ruled;
1290            if fixed_at.len() != 1 {
1291                return Err(KernelRefusal::unsupported(KernelStage::Classify, "mirror_corner_flats", format!(
1292                    "rotate_faces: the corner at vertex {} where the rotated {} meets its \
1293                     neighbours touches {} fixed faces (a rotated cylinder or cone is \
1294                     re-intersected against exactly one flat there) — refusing",
1295                    vertex.id,
1296                    face_short(solid, &face_lookup, moved_face),
1297                    fixed_at.len()
1298                )));
1299            }
1300            let fixed_face = fixed_at[0];
1301            let flat = cached_plane(&mut planes, solid, &face_lookup, fixed_face, plane_tolerance)
1302                .map_err(|_| {
1303                    unsupported_carrier(
1304                        solid,
1305                        &face_lookup,
1306                        fixed_face,
1307                        &format!(
1308                            "the fixed neighbour of the rotated {} at vertex {}",
1309                            face_short(solid, &face_lookup, moved_face),
1310                            vertex.id
1311                        ),
1312                    )
1313                })?;
1314            let seam: Vec<&EdgeRecord> = solid
1315                .edges
1316                .iter()
1317                .filter(|edge| {
1318                    (edge.start_vertex_id == vertex.id || edge.end_vertex_id == vertex.id)
1319                        && matches!(classes.get(&edge.id), Some(EdgeClass::Interior))
1320                        && !edge.degenerate
1321                })
1322                .collect();
1323            // A rim vertex that is NOT the seam vertex — the bookkeeping split an
1324            // import leaves at a carrier's parameter origin — has no edge of the
1325            // group's own at it, and nothing then says where on the new ellipse
1326            // it belongs. It refuses here rather than being placed by guesswork.
1327            if seam.len() != 1 {
1328                return Err(KernelRefusal::unsupported(KernelStage::Classify, "seam_edge_count", format!(
1329                    "rotate_faces: the corner at vertex {} on the rotated {} rides {} of the \
1330                     group's own edges (the seam generatrix, exactly one, is what places it) \
1331                     — refusing",
1332                    vertex.id,
1333                    face_short(solid, &face_lookup, moved_face),
1334                    seam.len()
1335                )));
1336            }
1337            let seam = seam[0];
1338            let seed = if seam.start_vertex_id == vertex.id {
1339                seam.t0
1340            } else {
1341                seam.t1
1342            };
1343            let turned_seam = EdgeRecord {
1344                curve: transform_curve(&seam.curve, rotation).or_refuse(KernelStage::Refine, "transform_curve")?,
1345                ..seam.clone()
1346            };
1347            let corner = resolve_corner_on_fixed_edge(
1348                &turned_seam,
1349                seed,
1350                flat.normal,
1351                flat.normal.dot(flat.origin),
1352                tolerance,
1353            )
1354            .map_err(|error| error.with_message(|error| translate_corner_refusal(error, vertex.id)))?;
1355            // Checked, not asserted, exactly as every other corner here is: on
1356            // the flat it must stay on, and on the carrier the rotation left.
1357            verify_point_on_fixed(
1358                solid,
1359                &face_lookup,
1360                &mut planes,
1361                fixed_face,
1362                corner,
1363                vertex.id,
1364                plane_tolerance,
1365                tolerance,
1366            )?;
1367            let off = ruled_offset(&turned_frame, rho0, rho1, height, corner);
1368            if off > 10.0 * tolerance {
1369                return Err(KernelRefusal::internal(KernelStage::Validate, "mirror_corner_on_carrier", format!(
1370                    "rotate_faces: the re-solved corner at vertex {} left the ROTATED {} \
1371                     (off {off:.3e}) — refusing",
1372                    vertex.id,
1373                    face_short(solid, &face_lookup, moved_face)
1374                )));
1375            }
1376            new_vertex.insert(vertex.id, corner);
1377            continue;
1378        }
1379        // RE-INTERSECTION: the corner is the common point of the carriers
1380        // meeting here, with the moved ones ROTATED. Every one of them must be
1381        // planar to solve it in closed form.
1382        let mut corner_planes = Vec::with_capacity(adjacent.len());
1383        for &face_id in &fixed_at {
1384            corner_planes.push(
1385                cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance).map_err(
1386                    |_| {
1387                        unsupported_carrier(
1388                            solid,
1389                            &face_lookup,
1390                            face_id,
1391                            &format!(
1392                                "a FIXED carrier meeting the rotated group at vertex {}",
1393                                vertex.id
1394                            ),
1395                        )
1396                    },
1397                )?,
1398            );
1399        }
1400        for face_id in adjacent.iter().copied().filter(|id| moved.contains(id)) {
1401            let plane = cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance)
1402                .map_err(|_| {
1403                    unsupported_carrier(
1404                        solid,
1405                        &face_lookup,
1406                        face_id,
1407                        &format!(
1408                            "a ROTATED carrier meeting a fixed neighbour at vertex {}",
1409                            vertex.id
1410                        ),
1411                    )
1412                })?;
1413            corner_planes.push(rotate_plane(&rotation, &plane));
1414        }
1415        let corner = solve_corner(&corner_planes).ok_or_else(|| {
1416            KernelRefusal::ill_posed(KernelStage::Refine, "corner_underconstrained", format!(
1417                "rotate_faces: cannot re-intersect the carriers meeting at vertex {} \
1418                 (parallel or under-constrained planes) — refusing",
1419                vertex.id
1420            ))
1421        })?;
1422        // The corner must genuinely sit on EVERY carrier; otherwise the group
1423        // tears away from its fixed neighbours and no manifold heal exists.
1424        for plane in &corner_planes {
1425            if corner.sub(plane.origin).dot(plane.normal).abs() > tolerance {
1426                return Err(KernelRefusal::unsupported(KernelStage::Refine, "corner_tear", format!(
1427                    "rotate_faces: the rotated group tears away from its neighbours at \
1428                     vertex {} — refusing rather than emitting an invalid solid",
1429                    vertex.id
1430                )));
1431            }
1432        }
1433        new_vertex.insert(vertex.id, corner);
1434    }
1435
1436    // --- Plan every edge update -------------------------------------------
1437    let vertex_position: HashMap<u64, Vec3> = solid
1438        .vertices
1439        .iter()
1440        .map(|vertex| (vertex.id, vertex.point))
1441        .collect();
1442    let mut actions: HashMap<u64, EdgeAction> = HashMap::default();
1443    for edge in &solid.edges {
1444        let position = |vertex_id: u64| -> Result<Vec3, KernelRefusal> {
1445            vertex_position.get(&vertex_id).copied().ok_or_else(|| {
1446                KernelRefusal::internal(
1447                    KernelStage::Collect,
1448                    "vertex_lookup",
1449                    format!("rotate_faces: missing vertex {vertex_id}"),
1450                )
1451            })
1452        };
1453        let start_old = position(edge.start_vertex_id)?;
1454        let end_old = position(edge.end_vertex_id)?;
1455        let start_new = new_vertex
1456            .get(&edge.start_vertex_id)
1457            .copied()
1458            .unwrap_or(start_old);
1459        let end_new = new_vertex
1460            .get(&edge.end_vertex_id)
1461            .copied()
1462            .unwrap_or(end_old);
1463        // Neither endpoint relocated, and every ROTATED carrier the edge lies on
1464        // maps onto itself: the edge is where it belongs already. (For a Fixed
1465        // edge the second clause is vacuous — no face using it is moved.)
1466        if start_new.sub(start_old).length() == 0.0
1467            && end_new.sub(end_old).length() == 0.0
1468            && faces_of_edge[&edge.id]
1469                .iter()
1470                .all(|face_id| !moved.contains(face_id) || invariant(*face_id))
1471        {
1472            continue;
1473        }
1474        let rigid = start_new.sub(rotation.point(start_old)).length() <= rigid_tolerance
1475            && end_new.sub(rotation.point(end_old)).length() <= rigid_tolerance;
1476        let rebuild = |planes: &mut HashMap<u64, Plane>| -> Result<EdgeAction, KernelRefusal> {
1477            // Every carrier of an edge that is REBUILT straight has to be
1478            // re-trimmed around it afterwards, and the only re-trims here are the
1479            // planar one and the invariant-ruled one.
1480            for &face_id in &faces_of_edge[&edge.id] {
1481                if invariant(face_id) {
1482                    continue;
1483                }
1484                // A NON-invariant RULED carrier re-trims too, on its own grown
1485                // carrier (`retrim_ruled_face`) — this is the arm the oblique
1486                // bore's seam generatrix takes, and `verify_chord_on_curved`
1487                // below is what proves the straight chord stayed on it.
1488                if carrier_ruled(solid, &face_lookup, face_id).is_some() {
1489                    continue;
1490                }
1491                cached_plane(planes, solid, &face_lookup, face_id, plane_tolerance).map_err(
1492                    |_| {
1493                        unsupported_carrier(
1494                            solid,
1495                            &face_lookup,
1496                            face_id,
1497                            &format!("a carrier of edge {}, whose trim the rotation moves", edge.id),
1498                        )
1499                    },
1500                )?;
1501            }
1502            let action = plan_straight_rebuild(
1503                solid,
1504                &face_lookup,
1505                &faces_of_edge,
1506                edge,
1507                start_old,
1508                end_old,
1509                start_new,
1510                end_new,
1511                tolerance,
1512            )?;
1513            if let EdgeAction::Rebuild { start, end } = &action {
1514                for &face_id in &faces_of_edge[&edge.id] {
1515                    verify_chord_on_curved(
1516                        solid,
1517                        &face_lookup,
1518                        face_id,
1519                        edge.id,
1520                        *start,
1521                        *end,
1522                        moved.contains(&face_id).then_some(&rotation),
1523                        tolerance,
1524                    )?;
1525                }
1526            }
1527            Ok(action)
1528        };
1529        match classes[&edge.id] {
1530            EdgeClass::Fixed => {
1531                // A CURVED fixed edge between a flat and a cylinder or cone: an
1532                // Offset Shell's enlarged bore cutting a tilted inner floor runs
1533                // such an ellipse arc out to the rotated face. Both carriers
1534                // stayed put, so the section is unchanged as a SET; only the end
1535                // on the rotated face moved, along it (the vertex pass solved
1536                // that corner on these two carriers). So it is re-cut between
1537                // its corners, under the guards a straight rebuild gets.
1538                if !edge.degenerate
1539                    && edge.start_vertex_id != edge.end_vertex_id
1540                    && edge.curve.straight_segment(tolerance).is_none()
1541                {
1542                    if let Some((fixed_plane, (frame, rho0, rho1, height))) = plane_and_ruled_carriers(
1543                        solid,
1544                        &face_lookup,
1545                        &mut planes,
1546                        &faces_of_edge[&edge.id],
1547                        plane_tolerance,
1548                    ) {
1549                        let new_chord = end_new.sub(start_new);
1550                        if new_chord.length() <= tolerance {
1551                            return Err(KernelRefusal::unsupported(KernelStage::Refine, "edge_collapse", format!(
1552                                "rotate_faces: the rotation collapses curved edge {} to zero length — refusing",
1553                                edge.id
1554                            )));
1555                        }
1556                        if end_old.sub(start_old).dot(new_chord) <= 0.0 {
1557                            return Err(KernelRefusal::unsupported(KernelStage::Refine, EDGE_INVERSION, format!(
1558                                "rotate_faces: the rotation inverts curved edge {} (the trim is turned \
1559                                 inside out) — refusing",
1560                                edge.id
1561                            )));
1562                        }
1563                        let curve = rotated_rim_section(
1564                            edge,
1565                            &frame,
1566                            &frame,
1567                            rho0,
1568                            rho1,
1569                            height,
1570                            &fixed_plane,
1571                            fixed_plane.normal,
1572                            start_new,
1573                            end_new,
1574                            tolerance,
1575                        )?;
1576                        actions.insert(edge.id, EdgeAction::Replace { curve });
1577                        continue;
1578                    }
1579                }
1580                // A fixed side edge follows its re-solved endpoint. Both of its
1581                // own carriers stayed put, so it cannot ride `R`: it is rebuilt
1582                // between the endpoints, straight.
1583                actions.insert(edge.id, rebuild(&mut planes)?);
1584            }
1585            EdgeClass::Interior => {
1586                if rigid {
1587                    actions.insert(edge.id, EdgeAction::Rotate);
1588                } else {
1589                    actions.insert(edge.id, rebuild(&mut planes)?);
1590                }
1591            }
1592            EdgeClass::Boundary {
1593                moved_face,
1594                fixed_face,
1595            } => {
1596                if rigid && invariant(fixed_face) {
1597                    // The edge maps by `R` and lands on the (unchanged) fixed
1598                    // carrier as well as on the rotated moved one — exact for any
1599                    // curve type, no re-intersection needed. This is the arm a
1600                    // cap spun about its own cylinder's axis takes.
1601                    actions.insert(edge.id, EdgeAction::Rotate);
1602                } else if let Some((frame, rho0, rho1, height)) =
1603                    carrier_ruled(solid, &face_lookup, fixed_face).filter(|_| !invariant(fixed_face))
1604                {
1605                    // THE OBLIQUE QUADRIC RIM. The rotated planar carrier meets
1606                    // the fixed cylinder in an ELLIPSE and the fixed cone in a
1607                    // conic, and no affine carries the old rim to it — a
1608                    // rotation moves the carrier's NORMAL, which neither the
1609                    // cylinder's axial shift nor the cone's homothety about its
1610                    // apex can follow. So the rim is RE-INTERSECTED, exactly,
1611                    // and anchored on the corners already re-solved above.
1612                    let moved_plane =
1613                        cached_plane(&mut planes, solid, &face_lookup, moved_face, plane_tolerance)
1614                            .map_err(|_| {
1615                                unsupported_carrier(
1616                                    solid,
1617                                    &face_lookup,
1618                                    moved_face,
1619                                    &format!(
1620                                        "the ROTATED side of boundary edge {} against {}",
1621                                        edge.id,
1622                                        face_short(solid, &face_lookup, fixed_face)
1623                                    ),
1624                                )
1625                            })?;
1626                    let curve = rotated_rim_section(
1627                        edge,
1628                        &frame,
1629                        &frame,
1630                        rho0,
1631                        rho1,
1632                        height,
1633                        &rotate_plane(&rotation, &moved_plane),
1634                        frame.axis,
1635                        start_new,
1636                        end_new,
1637                        tolerance,
1638                    )?;
1639                    actions.insert(edge.id, EdgeAction::Replace { curve });
1640                } else if let Some((frame, rho0, rho1, height)) =
1641                    carrier_ruled(solid, &face_lookup, moved_face)
1642                        .filter(|_| !invariant(moved_face))
1643                {
1644                    // THE MIRROR RIM — the tilted bore's mouth. The same
1645                    // `plane ∩ quadric` section as the arm above, with the roles
1646                    // swapped: here it is the CYLINDER (or cone) that turned and
1647                    // the FLAT that stayed, so the section is built in the frame
1648                    // `R` leaves and against the fixed plane. The sense is still
1649                    // read in the carrier's OLD frame, the only one the rim
1650                    // curve on record is consistent with, and the anchor is the
1651                    // seam corner the vertex pass just rode down the generatrix.
1652                    let fixed_plane =
1653                        cached_plane(&mut planes, solid, &face_lookup, fixed_face, plane_tolerance)
1654                            .map_err(|_| {
1655                                unsupported_carrier(
1656                                    solid,
1657                                    &face_lookup,
1658                                    fixed_face,
1659                                    &format!(
1660                                        "the FIXED side of boundary edge {} against the rotated {}",
1661                                        edge.id,
1662                                        face_short(solid, &face_lookup, moved_face)
1663                                    ),
1664                                )
1665                            })?;
1666                    let curve = rotated_rim_section(
1667                        edge,
1668                        &rotate_ruled_frame(&rotation, &frame),
1669                        &frame,
1670                        rho0,
1671                        rho1,
1672                        height,
1673                        &fixed_plane,
1674                        fixed_plane.normal,
1675                        start_new,
1676                        end_new,
1677                        tolerance,
1678                    )?;
1679                    actions.insert(edge.id, EdgeAction::Replace { curve });
1680                } else {
1681                    // Real re-intersection: the line where the ROTATED moved
1682                    // plane meets the fixed plane, delimited by the re-solved
1683                    // corners. The moved side must be planar for the chord to
1684                    // stay on it.
1685                    cached_plane(&mut planes, solid, &face_lookup, moved_face, plane_tolerance)
1686                        .map_err(|_| {
1687                            unsupported_carrier(
1688                                solid,
1689                                &face_lookup,
1690                                moved_face,
1691                                &format!(
1692                                    "the ROTATED side of boundary edge {} against {}",
1693                                    edge.id,
1694                                    face_short(solid, &face_lookup, fixed_face)
1695                                ),
1696                            )
1697                        })?;
1698                    actions.insert(edge.id, rebuild(&mut planes)?);
1699                }
1700            }
1701        }
1702    }
1703
1704    // --- Plan face updates -------------------------------------------------
1705    // Edges whose SHAPE changed. A face bounding one of these must be RE-TRIMMED
1706    // (its rim moved to a new curve), not merely surface-transformed, else its
1707    // pcurve goes stale. A rigidly ROTATED edge on a rigidly rotated face keeps
1708    // its pcurve exactly, which is what the rigid regime is worth.
1709    let reshaped: HashSet<u64> = actions
1710        .iter()
1711        .filter(|(_, action)| {
1712            matches!(
1713                action,
1714                EdgeAction::Rebuild { .. } | EdgeAction::Replace { .. }
1715            )
1716        })
1717        .map(|(edge_id, _)| *edge_id)
1718        .collect();
1719    let dirty: HashSet<u64> = actions.keys().copied().collect();
1720    let mut face_actions: Vec<(usize, usize, FaceAction)> = Vec::new();
1721    // Ruled faces re-trim in a post-pass (they need `&mut solid`): the fixed
1722    // neighbours a rim rode onto, and the ROTATED wall whose own rims moved.
1723    let mut ruled_retrim_faces: Vec<u64> = Vec::new();
1724    for (shell_index, shell) in solid.shells.iter().enumerate() {
1725        for (face_index, face) in shell.faces.iter().enumerate() {
1726            let edge_ids = || {
1727                face.loops
1728                    .iter()
1729                    .flat_map(|loop_record| &loop_record.coedges)
1730                    .map(|coedge| coedge.edge_id)
1731            };
1732            if moved.contains(&face.id) {
1733                if edge_ids().any(|edge_id| reshaped.contains(&edge_id))
1734                    && carrier_ruled(solid, &face_lookup, face.id).is_some()
1735                    && !invariant(face.id)
1736                {
1737                    // THE TILTED WALL ITSELF. Its rims were re-intersected, so
1738                    // the pcurves on record are stale — a bore that met its
1739                    // flats in circles meets them in ellipses once it leans, and
1740                    // in the wall's own parameters that is a sine, not a line.
1741                    // Turning the control net is exact (a rotated cylinder is a
1742                    // cylinder of the same radius), and the re-trim afterwards
1743                    // rebuilds the loops on THAT carrier, grown along its axis
1744                    // to cover the new rims — the tilt lengthens the wall.
1745                    face_actions.push((shell_index, face_index, FaceAction::RotateSurface));
1746                    ruled_retrim_faces.push(face.id);
1747                } else if edge_ids().any(|edge_id| reshaped.contains(&edge_id)) {
1748                    // A boundary edge changed shape, so the patch is re-trimmed
1749                    // around it — on the ROTATED plane, frame and all.
1750                    let plane =
1751                        cached_plane(&mut planes, solid, &face_lookup, face.id, plane_tolerance)
1752                            .map_err(|_| {
1753                                unsupported_carrier(
1754                                    solid,
1755                                    &face_lookup,
1756                                    face.id,
1757                                    "the ROTATED face whose rim the neighbours reshaped",
1758                                )
1759                            })?;
1760                    face_actions.push((
1761                        shell_index,
1762                        face_index,
1763                        FaceAction::Retrim(rotate_plane(&rotation, &plane)),
1764                    ));
1765                } else if edge_ids().any(|edge_id| dirty.contains(&edge_id)) {
1766                    // Every edge of the face rode along rigidly: rotating the
1767                    // control net keeps surface, curves and pcurves in exact
1768                    // agreement for ANY carrier type.
1769                    face_actions.push((shell_index, face_index, FaceAction::RotateSurface));
1770                } else if !invariant(face.id) {
1771                    // Nothing moved and the carrier does not map onto itself —
1772                    // which the vertex rule above makes unreachable, since a face
1773                    // whose boundary stayed put had every carrier at every one of
1774                    // its corners invariant, this one included. Rotate the net
1775                    // rather than silently emit a face off its own boundary.
1776                    face_actions.push((shell_index, face_index, FaceAction::RotateSurface));
1777                }
1778                // ...and an invariant carrier nothing touched is left ALONE: the
1779                // rotation maps it onto itself, so turning the control net would
1780                // only re-parameterise the surface under pcurves that were never
1781                // rebuilt. This is the arm the identity rotations exit through.
1782            } else if edge_ids().any(|edge_id| dirty.contains(&edge_id)) {
1783                if carrier_is_planar(solid, &face_lookup, face.id) {
1784                    // A FIXED plane keeps its carrier; only its trim moved.
1785                    let plane =
1786                        cached_plane(&mut planes, solid, &face_lookup, face.id, plane_tolerance)?;
1787                    face_actions.push((shell_index, face_index, FaceAction::Retrim(plane)));
1788                } else if carrier_ruled(solid, &face_lookup, face.id).is_some() {
1789                    // Both the INVARIANT coaxial band (whose rim rode by `R`)
1790                    // and the OBLIQUE cylinder or cone (whose rim was
1791                    // re-intersected) re-trim on their own carrier, grown along
1792                    // its axis to cover the new rim.
1793                    ruled_retrim_faces.push(face.id);
1794                } else {
1795                    return Err(unsupported_carrier(
1796                        solid,
1797                        &face_lookup,
1798                        face.id,
1799                        "a FIXED face the rotation must re-trim",
1800                    ));
1801                }
1802            }
1803        }
1804    }
1805
1806    // --- Apply to a fresh clone (the input is never touched) ---------------
1807    let mut result = solid.clone();
1808    for edge in &mut result.edges {
1809        match actions.get(&edge.id) {
1810            Some(EdgeAction::Rotate) => {
1811                edge.curve = transform_curve(&edge.curve, rotation).or_refuse(KernelStage::Sew, "transform_curve")?;
1812            }
1813            Some(EdgeAction::Rebuild { start, end }) => {
1814                edge.curve = make_line(*start, *end).or_refuse(KernelStage::Sew, "make_line")?;
1815                edge.t0 = 0.0;
1816                edge.t1 = 1.0;
1817            }
1818            Some(EdgeAction::Replace { curve }) => {
1819                // An exactly re-intersected section spans its whole domain by
1820                // construction, so the trim IS the domain.
1821                let [d0, d1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
1822                edge.curve = curve.clone();
1823                edge.t0 = d0;
1824                edge.t1 = d1;
1825            }
1826            None => {}
1827        }
1828    }
1829    for vertex in &mut result.vertices {
1830        if let Some(point) = new_vertex.get(&vertex.id) {
1831            vertex.point = *point;
1832        }
1833    }
1834    let final_edges: HashMap<u64, EdgeRecord> = result
1835        .edges
1836        .iter()
1837        .map(|edge| (edge.id, edge.clone()))
1838        .collect();
1839    for (shell_index, face_index, action) in face_actions {
1840        let face = &mut result.shells[shell_index].faces[face_index];
1841        match action {
1842            FaceAction::RotateSurface => {
1843                face.surface = transform_surface(&face.surface, rotation).or_refuse(KernelStage::Sew, "transform_surface")?;
1844            }
1845            FaceAction::Retrim(plane) => {
1846                // A MOVED face's carrier frame is this operation's to choose,
1847                // and `R` is not always the choice that leaves the chart legal;
1848                // a FIXED neighbour's frame is not ours to touch, and its
1849                // outward normal must survive the re-trim exactly.
1850                let plane = if moved.contains(&face.id) {
1851                    orient_plane_to_loop(face, &plane, &final_edges)?
1852                } else {
1853                    plane
1854                };
1855                retrim_planar_face(face, &plane, &final_edges, scale, "rotate_faces")?;
1856                // `retrim_planar_face` maps each edge's WHOLE curve onto the
1857                // rebuilt plane, so an edge that represents a strict SUBRANGE of
1858                // its curve needs the range fitter to span exactly [t0, t1];
1859                // full-domain edges keep the exact affine pcurve just built.
1860                let face_edges: HashSet<u64> = face
1861                    .loops
1862                    .iter()
1863                    .flat_map(|loop_record| loop_record.coedges.iter().map(|c| c.edge_id))
1864                    .collect();
1865                refit_touched_pcurves(
1866                    face,
1867                    &final_edges,
1868                    &face_edges,
1869                    true,
1870                    tolerance,
1871                    "rotate_faces",
1872                )?;
1873            }
1874        }
1875    }
1876    for face_id in ruled_retrim_faces {
1877        retrim_ruled_face(&mut result, face_id, &final_edges, tolerance, "rotate_faces")?;
1878    }
1879
1880    // Topology (and therefore genus) is untouched — only geometry moved — so
1881    // validate() re-checks Euler, loop closure and pcurve agreement.
1882    //
1883    // Taken through `validate_detailed` in ONE pass, because the floor below
1884    // needs the `wire_warnings` half of the same report and a second validation
1885    // of the same solid would buy nothing.
1886    let report = result.validate_detailed(&crate::KernelTolerances::for_solid(&result, 1e-7));
1887    if !report.issues.is_empty() {
1888        return Err(KernelRefusal::internal(KernelStage::Validate, "validate", format!(
1889            "rotate_faces: rotated solid failed validation: {:?}",
1890            report.issues
1891        )));
1892    }
1893    // THE ACCEPTANCE FLOOR. `validate()` tests INCIDENCE: it would pass a face
1894    // whose own trim loop crosses itself in its own parameter domain, because no
1895    // incidence check can see a bowtie — and that is exactly what a neighbour's
1896    // re-trim produces when the rotation folds it. Read `unreadable` as well as
1897    // the verdict: an empty crossing list beside a loop the scan could not read
1898    // is no answer, not a clean one.
1899    let crossings = crate::loop_self_crossings(&result);
1900    if !crossings.unreadable.is_empty() {
1901        return Err(KernelRefusal::internal(KernelStage::Validate, "crossing_scan_unreadable", format!(
1902            "rotate_faces: the loop self-crossing scan could not read {:?}; refusing rather \
1903             than shipping an unchecked result",
1904            crossings.unreadable
1905        )));
1906    }
1907    if crossings.loops == 0 {
1908        return Err(KernelRefusal::internal(KernelStage::Validate, "crossing_scan_empty",
1909            "rotate_faces: the loop self-crossing scan read no loops at all; refusing rather              than shipping an unchecked result"));
1910    }
1911    if crossings.is_flagged() {
1912        return Err(KernelRefusal::internal(KernelStage::Validate, "loop_self_crossing", format!(
1913            "rotate_faces: the rotation makes a re-trimmed neighbour's own loop cross itself \
1914             ({}) — refusing",
1915            crossings.summary().unwrap_or_default()
1916        )));
1917    }
1918    // Belt and braces on top of the per-edge inversion guard: a global inversion
1919    // flips the signed volume even if every edge kept its direction.
1920    // Sign only, through the per-face term memo (`solid_signed_volume_for_sign`:
1921    // the verdict against the bar is the exact integral's; a face read about
1922    // the same reference before is served its term). 2026-10-17, the
1923    // shelled-box report: the exact reads cost 0.26 s a body here.
1924    if let (Ok(before), Ok(after)) =
1925        (crate::signed_volume_against(solid, 0.0), crate::signed_volume_against(&result, 0.0))
1926    {
1927        if before * after <= 0.0 {
1928            return Err(KernelRefusal::unsupported(KernelStage::Validate, SOLID_INVERSION,
1929                "rotate_faces: the rotation inverts the solid (signed volume changed sign) \
1930                 — refusing"));
1931        }
1932    }
1933    // ...and a guard on a quantity the signed volume CANNOT carry.
1934    //
1935    // The volume is `⅓ ∮ x·n dA`, and on a trimmed face that integral reads the
1936    // carrier normal and the trim loop's winding TOGETHER — Green's theorem
1937    // turns the loop into the sign of the area element. Flip both at once, as a
1938    // carrier frame carried past its own turnover does, and the two negatives
1939    // cancel: the number comes back unchanged. That is not a weak signal, it is
1940    // a BLIND one, and no threshold on it can ever be made to see this. The
1941    // measured proof is the half-turn, where the body agreed with its input to
1942    // 3.6e-15 and the volume to 3.0e-16 with a face inside out.
1943    //
1944    // So the floor also reads the winding DIRECTLY, against the same
1945    // `validate_uv_wire` convention the rest of the kernel cites as law.
1946    // `validate()` drops that verdict — it returns `issues` and this lives under
1947    // `wire_warnings` — which is why a mis-wound result walked out past a
1948    // validation the operation already ran.
1949    //
1950    // The comparison is with what the INPUT carried, not with zero: a body that
1951    // arrives already mis-wound (an import, or an older edit) is not this
1952    // rotation's doing, and refusing it here would strand a user on a file they
1953    // cannot edit. Count, not identity — a rotation that repaired one face and
1954    // broke another would read as even here, and that is a narrower lie than
1955    // the blindness this replaces.
1956    //
1957    // The input is only validated when the result actually carries a warning,
1958    // so a rotation that leaves a clean body pays nothing for this at all.
1959    if !report.wire_warnings.is_empty() {
1960        let inherited = solid
1961            .validate_detailed(&crate::KernelTolerances::for_solid(solid, 1e-7))
1962            .wire_warnings;
1963        if report.wire_warnings.len() > inherited.len() {
1964            return Err(KernelRefusal::internal(KernelStage::Validate, "chart_winding", format!(
1965                "rotate_faces: the rotation leaves a face whose (u,v) chart is left-handed \
1966                 against its own normal ({}) — refusing",
1967                report
1968                    .wire_warnings
1969                    .iter()
1970                    .map(|warning| warning.message.as_str())
1971                    .collect::<Vec<_>>()
1972                    .join("; ")
1973            )));
1974        }
1975    }
1976    Ok(result)
1977}