Skip to main content

brep_kernel/edit/direct_edit/
face_rotate.rs

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