Skip to main content

brep_kernel/feature_pipeline/features/
transform_face.rs

1//! TF — Transform Face.
2//!
3//! Carries the selected faces of a single solid by ONE RIGID MOTION — a rotation
4//! about a stated pivot followed by a translation — preserving each carrier's
5//! shape, and re-intersects the neighbours the motion moves the selection
6//! relative to. The motion is the `transform` parameter the transform gizmo
7//! edits: dragging an axis or the centre handle writes `position`, dragging a
8//! ring writes `rotationEuler`.
9//!
10//! # The motion
11//!
12//! `p ↦ pivot + position + R·(p − pivot)`, with `R` the intrinsic XYZ Euler
13//! rotation of `rotationEuler` (degrees) — exactly
14//! [`common::compose_trs_matrix`]`(position, rotationEuler, [1,1,1], pivot)`, the
15//! one feature-transform convention the Transform feature, the assembly pose and
16//! the gizmo already speak. The gizmo is drawn at `pivot + position`, which is
17//! where the pivot point lands, so a ring drag turns the faces about the handle
18//! the user is holding.
19//!
20//! `scale` has no meaning for a face edit — a scaled carrier is a different
21//! surface, not a moved one. The schema's `default_value` omits it, which hides
22//! it in the form and in the tool schema, and a document that carries a
23//! non-unit scale anyway is refused BY NAME rather than silently ignored.
24//!
25//! # A rotation is not a mode of a translation — and this feature does not pretend it is
26//!
27//! [`crate::move_faces`] moves a carrier's POSITION and leaves its normal alone;
28//! [`crate::rotate_faces`] moves the normal too, so the neighbour pairs are
29//! re-intersected differently and the invariants that let a corner ride rigidly
30//! are different invariants (a cone about its own axis is invariant under a
31//! rotation and not under a translation). The two primitives stay two roads, and
32//! this feature is a DISPATCHER over them, not a third solver:
33//!
34//! * a pure TRANSLATION (`rotationEuler` is the identity) goes to `move_faces`
35//!   with `position` — Move Face's road, unchanged;
36//! * a pure ROTATION (`position` is zero) goes to `rotate_faces` about the axis
37//!   LINE through `pivot` along the rotation's own axis, by its own angle —
38//!   Rotate Face's road, unchanged. `R` is converted to that axis and angle from
39//!   the same matrix the compose builds, so the stored Euler angles and the
40//!   kernel's Rodrigues rotation are one rotation;
41//! * a COMBINED motion is rotate-then-translate: `rotate_faces` about the pivot,
42//!   then `move_faces` by `position` on the solid the rotation left. Both are
43//!   pure geometry edits that keep every face id, so the second call takes the
44//!   same ids the first did.
45//!
46//! Why not one rigid re-intersection: neither primitive accepts a general rigid
47//! motion. `move_faces` takes a vector and builds its rim maps from it (the
48//! exact affine for a ruled carrier, the plane × sphere circle); `rotate_faces`
49//! takes an axis and an angle and tests carrier invariance against that axis
50//! line. A third road accepting a matrix would have to redo both invariance
51//! analyses per case, which is precisely the objection the Rotate Face contract
52//! raised against a matrix: a matrix names no invariant. Decomposing keeps the
53//! invariant each road proves.
54//!
55//! Why not the screw decomposition (Chasles: any rigid motion is a rotation
56//! about SOME axis plus a slide along it, which would fold the in-plane part of
57//! `position` into a single `rotate_faces` call): that axis sits at
58//! `|position⊥| / (2·sin(θ/2))` from the pivot — metres away for a degree of turn
59//! — and it is no longer the selection's own axis, so a coaxial cylinder spun
60//! and slid would lose the rigid regime it has about the pivot. The pivot the
61//! user stated is the axis the invariance test must see.
62//!
63//! The price of two calls is an INTERMEDIATE state: the faces rotated but not yet
64//! translated. A motion whose end state is buildable can still refuse there (a
65//! rotation that alone inverts a neighbour the translation would have restored).
66//! Such a refusal is named as the step it happened in — "the rotation step" or
67//! "the translation step" — with the primitive's own message after it, so the
68//! user reads which half of the motion to change.
69//!
70//! # The pivot
71//!
72//! `pivot` is a stored vec3. When it is null the pivot is the SELECTION'S CENTRE,
73//! computed by [`selection_centre`] from the feature's INPUT solid: the centre of
74//! the axis-aligned bounds of every vertex on the selected faces' boundaries and
75//! of each boundary edge sampled at [`CENTRE_SAMPLES`] equal steps across its
76//! active parameter range (a face with no edge at all — a closed sphere —
77//! contributes its surface control points). Exact for straight-edged faces.
78//! The app stores that value into `pivot` the moment the face selection is
79//! committed (through [`face_transform_pivot`], the same computation), so the
80//! rotation centre is a number in the document: re-running the history, dragging
81//! the gizmo, or editing a feature upstream never moves it. A null pivot in a
82//! hand-written document is resolved the same way on every run, from the same
83//! input, so it cannot drift either.
84//!
85//! # Reference resolution and behaviour mapping
86//!
87//! `faces` is a `reference_selection` of FACE names — the declaration Push Face
88//! and Delete Face use. A miss is an `unresolved` entry, never a panic. Every
89//! `transform` component and every `pivot` component may be a number or an
90//! expression.
91//!
92//! SOFT no-op (an empty result, no error) for an empty selection, names that all
93//! fail to resolve, faces spanning multiple solids, and the IDENTITY motion — a
94//! half-filled dialog must not shout. A kernel `Err` → `ctx.fail` (halt): the
95//! refusal names the offending entity, and neither primitive mutates its input,
96//! so the source solid is untouched.
97//!
98//! # Name preservation and the exit
99//!
100//! Both roads are pure geometry edits: no face, edge or vertex is created or
101//! destroyed, so every id and name survives. Names are COLLECTED, never
102//! re-stamped, and the result reuses the TARGET solid's name. The result goes
103//! through [`crate::accept_sound`] once, at the exit, as Move Face and Rotate
104//! Face each did.
105//!
106//! The acceptance is the one step that can change topology, and only on a
107//! BREAKOUT — a motion that carries the selection clean out of the faces it
108//! opened through, which both primitives build as a body passing through
109//! itself. Its crossing repair then splits the pair along their carriers' exact
110//! section, drops what ended up on the wrong side and RE-TRIMS the neighbour
111//! that is left holding an unanswered boundary, so edges and vertices move
112//! while every FACE name still survives. It says so in the result's `notes`
113//! rather than changing the answer silently.
114
115use std::collections::HashSet;
116
117use crate::feature_pipeline::features::common;
118use crate::feature_pipeline::{AddedSolid, FeatureContext, FeatureResult, SceneMap};
119use crate::{move_faces, rotate_faces, BrepSolid, Vec3};
120
121/// Stations per boundary edge in [`selection_centre`]. A power of two, so a
122/// rational circle split into quarter spans is sampled at its span ends — the
123/// points where an axis-aligned circle reaches its bounds.
124pub const CENTRE_SAMPLES: usize = 64;
125
126pub fn execute(ctx: &FeatureContext) -> FeatureResult {
127    match build(ctx) {
128        Ok(result) => result,
129        Err(error) => ctx.fail(error),
130    }
131}
132
133/// The selection resolved against a scene: the one target solid's handle and
134/// the deduplicated face ids on it, or `None` for the soft no-ops (nothing
135/// resolved, or faces on more than one solid). Misses land in `unresolved`.
136fn resolve_selection(
137    scene: &SceneMap,
138    names: &[String],
139    unresolved: &mut Vec<String>,
140) -> Option<(u32, Vec<u64>)> {
141    let mut resolved: Vec<(u32, u64)> = Vec::new();
142    for name in names {
143        match scene.resolve_face(name) {
144            Some(face_ref) => resolved.push((face_ref.handle, face_ref.face_id)),
145            None => unresolved.push(name.clone()),
146        }
147    }
148    let &(target, _) = resolved.first()?;
149    if resolved.iter().any(|(handle, _)| *handle != target) {
150        return None;
151    }
152    let mut seen: HashSet<u64> = HashSet::new();
153    let face_ids = resolved
154        .iter()
155        .map(|(_, face_id)| *face_id)
156        .filter(|face_id| seen.insert(*face_id))
157        .collect();
158    Some((target, face_ids))
159}
160
161/// The centre of the selection's boundary bounds — the default pivot. See the
162/// module doc for exactly what is bounded. `None` only when the faces carry no
163/// geometry at all.
164pub fn selection_centre(solid: &BrepSolid, face_ids: &[u64]) -> Option<Vec3> {
165    let selected: HashSet<u64> = face_ids.iter().copied().collect();
166    let mut low = [f64::INFINITY; 3];
167    let mut high = [f64::NEG_INFINITY; 3];
168    let mut include = |point: Vec3| {
169        let point = [point.x, point.y, point.z];
170        if point.iter().all(|c| c.is_finite()) {
171            for axis in 0..3 {
172                low[axis] = low[axis].min(point[axis]);
173                high[axis] = high[axis].max(point[axis]);
174            }
175        }
176    };
177    let mut edge_ids: HashSet<u64> = HashSet::new();
178    let mut edgeless = Vec::new();
179    for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
180        if !selected.contains(&face.id) {
181            continue;
182        }
183        let before = edge_ids.len();
184        for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
185            edge_ids.insert(coedge.edge_id);
186        }
187        if edge_ids.len() == before && face.loops.iter().all(|l| l.coedges.is_empty()) {
188            edgeless.push(face);
189        }
190    }
191    let vertex_ids: HashSet<u64> = solid
192        .edges
193        .iter()
194        .filter(|edge| edge_ids.contains(&edge.id))
195        .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
196        .collect();
197    for vertex in &solid.vertices {
198        if vertex_ids.contains(&vertex.id) {
199            include(vertex.point);
200        }
201    }
202    for edge in solid.edges.iter().filter(|edge| edge_ids.contains(&edge.id)) {
203        if edge.degenerate {
204            continue;
205        }
206        for station in 0..=CENTRE_SAMPLES {
207            let t = edge.t0 + (edge.t1 - edge.t0) * station as f64 / CENTRE_SAMPLES as f64;
208            if let Ok(point) = edge.curve.evaluate(t) {
209                include(point);
210            }
211        }
212    }
213    for face in edgeless {
214        for control in face.surface.control_points.iter().flatten() {
215            if let Ok(point) = control.point() {
216                include(point);
217            }
218        }
219    }
220    if !low[0].is_finite() {
221        return None;
222    }
223    Some(Vec3::new(
224        0.5 * (low[0] + high[0]),
225        0.5 * (low[1] + high[1]),
226        0.5 * (low[2] + high[2]),
227    ))
228}
229
230/// The pivot a Transform Face whose `faces` are `names` gets by default, read
231/// off the scene the results BEFORE it build — the value the app stores into
232/// `pivot` when the selection is committed, and the value `build` falls back to
233/// for a null `pivot`, because both go through [`resolve_selection`] and
234/// [`selection_centre`]. `None` when the selection does not resolve to faces on
235/// one solid.
236pub fn face_transform_pivot(prefix: &[FeatureResult], names: &[String]) -> Option<[f64; 3]> {
237    let mut scene = SceneMap::default();
238    for result in prefix {
239        scene.apply(result);
240    }
241    let (handle, face_ids) = resolve_selection(&scene, names, &mut Vec::new())?;
242    let centre =
243        crate::with_registered_solid_str(handle, |solid| Ok(selection_centre(solid, &face_ids)))
244            .ok()??;
245    Some([centre.x, centre.y, centre.z])
246}
247
248/// The rotation part of the motion as the axis and angle `rotate_faces` takes:
249/// `R` is built by the shared compose (so the Euler convention is single-sourced)
250/// and converted through its quaternion, choosing the numerically largest
251/// component first so a half turn keeps its axis. `None` for the identity.
252pub fn rotation_axis_angle(rotation_deg: [f64; 3]) -> Option<(Vec3, f64)> {
253    if rotation_deg == [0.0; 3] {
254        return None;
255    }
256    let radians = rotation_deg.map(f64::to_radians);
257    let m = common::compose_trs_matrix([0.0; 3], radians, [1.0; 3], [0.0; 3]);
258    let at = |row: usize, col: usize| m[row * 4 + col];
259    let (m00, m11, m22) = (at(0, 0), at(1, 1), at(2, 2));
260    let trace = m00 + m11 + m22;
261    // [x, y, z, w]
262    let q = if trace >= m00 && trace >= m11 && trace >= m22 {
263        let w = 0.5 * (1.0 + trace).max(0.0).sqrt();
264        let k = 0.25 / w;
265        [(at(2, 1) - at(1, 2)) * k, (at(0, 2) - at(2, 0)) * k, (at(1, 0) - at(0, 1)) * k, w]
266    } else if m00 >= m11 && m00 >= m22 {
267        let x = 0.5 * (1.0 + m00 - m11 - m22).max(0.0).sqrt();
268        let k = 0.25 / x;
269        [x, (at(0, 1) + at(1, 0)) * k, (at(0, 2) + at(2, 0)) * k, (at(2, 1) - at(1, 2)) * k]
270    } else if m11 >= m22 {
271        let y = 0.5 * (1.0 - m00 + m11 - m22).max(0.0).sqrt();
272        let k = 0.25 / y;
273        [(at(0, 1) + at(1, 0)) * k, y, (at(1, 2) + at(2, 1)) * k, (at(0, 2) - at(2, 0)) * k]
274    } else {
275        let z = 0.5 * (1.0 - m00 - m11 + m22).max(0.0).sqrt();
276        let k = 0.25 / z;
277        [(at(0, 2) + at(2, 0)) * k, (at(1, 2) + at(2, 1)) * k, z, (at(1, 0) - at(0, 1)) * k]
278    };
279    // One sign for the quaternion, so the angle lands in [0, π].
280    let sign = if q[3] < 0.0 { -1.0 } else { 1.0 };
281    let axis = Vec3::new(q[0] * sign, q[1] * sign, q[2] * sign);
282    let half_sine = axis.length();
283    let angle = 2.0 * half_sine.atan2(q[3] * sign);
284    if angle <= 1e-12 {
285        return None;
286    }
287    Some((axis.scale(1.0 / half_sine), angle))
288}
289
290/// A `transform` component, read through the shared vec3 reader under its
291/// dotted name so an expression error names `transform.position` and not a bare
292/// key.
293fn transform_vec3(ctx: &FeatureContext, key: &str, default: [f64; 3]) -> Result<[f64; 3], String> {
294    common::vec3_from_value(
295        ctx.env,
296        ctx.param("transform").and_then(|transform| transform.get(key)),
297        &format!("transform.{key}"),
298        default,
299    )
300}
301
302fn build(ctx: &FeatureContext) -> Result<FeatureResult, String> {
303    let mut result = FeatureResult::empty(ctx.id.clone(), ctx.feature_type.clone());
304
305    let names = common::reference_name_array(ctx.param("faces"));
306    if names.is_empty() {
307        return Ok(result); // Soft no-op — nothing selected.
308    }
309    let Some((target_handle, face_ids)) =
310        resolve_selection(ctx.scene, &names, &mut result.unresolved)
311    else {
312        return Ok(result); // Only unresolved names, or faces on several solids.
313    };
314
315    // The motion. Scale first: a document that scales is refused before
316    // anything else is read, because no reading of the rest makes it rigid.
317    let scale = transform_vec3(ctx, "scale", [1.0; 3])?;
318    if scale != [1.0; 3] {
319        return Err(format!(
320            "transformFace: `transform.scale` is {scale:?}, but a face transform is a RIGID \
321             motion — a scaled carrier is a different surface, not a moved one — so scale must \
322             be [1, 1, 1]; refusing"
323        ));
324    }
325    let position = transform_vec3(ctx, "position", [0.0; 3])?;
326    let rotation_deg = transform_vec3(ctx, "rotationEuler", [0.0; 3])?;
327    let translation = Vec3::new(position[0], position[1], position[2]);
328    let translates = translation.length() > 1e-12;
329    let rotation = rotation_axis_angle(rotation_deg);
330    if !translates && rotation.is_none() {
331        return Ok(result); // Soft no-op — the identity motion.
332    }
333
334    let target_name = ctx
335        .scene
336        .solids
337        .iter()
338        .find(|(_, handle)| **handle == target_handle)
339        .map(|(name, _)| name.clone())
340        .ok_or("transformFace: target solid has no scene-map name")?;
341    let solid = crate::with_registered_solid_str(target_handle, |solid| Ok(solid.clone()))?;
342
343    // The rotation as the re-cut road needs it too: axis point, axis, angle.
344    let turn = match rotation {
345        None => None,
346        Some((axis, angle)) => {
347            let pivot = match ctx.param("pivot") {
348                None | Some(serde_json::Value::Null) => selection_centre(&solid, &face_ids)
349                    .ok_or("transformFace: the selected faces carry no geometry to centre a pivot on")?,
350                Some(value) => {
351                    let [x, y, z] = common::vec3_from_value(ctx.env, Some(value), "pivot", [0.0; 3])?;
352                    Vec3::new(x, y, z)
353                }
354            };
355            Some((pivot, axis, angle))
356        }
357    };
358    let moved = match turn {
359        None => move_faces(&solid, &face_ids, translation)?,
360        Some((pivot, axis, angle)) => {
361            if !translates {
362                rotate_faces(&solid, &face_ids, pivot, axis, angle)?
363            } else {
364                // ROTATE-THEN-TRANSLATE, each step named on refusal (module doc).
365                let turned = rotate_faces(&solid, &face_ids, pivot, axis, angle).map_err(|error| {
366                    format!(
367                        "transformFace: the ROTATION step of this rotate-then-translate motion \
368                         refused — {error}"
369                    )
370                })?;
371                move_faces(&turned, &face_ids, translation).map_err(|error| {
372                    format!(
373                        "transformFace: the TRANSLATION step of this rotate-then-translate \
374                         motion, applied to the faces as the rotation left them, refused — {error}"
375                    )
376                })?
377            }
378        }
379    };
380    // Carrying a face past a neighbour's own extent leaves it crossing one it
381    // should have stopped at, and both roads are pure geometry edits that create
382    // no cell for `validate()` to catch. The acceptance Push Face takes: split and
383    // trim where that can be proved, refuse by name where it cannot. Where the
384    // motion carried the selection clean OUT of the faces it opened through —
385    // the breakout — that split also re-trims the neighbour left holding an
386    // unanswered boundary, which is the only way such a body closes.
387    //
388    // And where the acceptance refuses, the RE-CUT road is offered the same
389    // edit from the other end — the original body, the selection and the
390    // motion, cut as a boolean rather than re-solved — because a motion whose
391    // answer needs a face to STOP EXISTING has no body for a repair to reach.
392    // A plane moved rigidly is still a plane, so the road takes a
393    // rotate-then-translate as well as a translation (the 2026-09-22 slot
394    // document with its wall also TURNED 16.9° refused here while the same
395    // drag without the turn built). See `edit/direct_edit/face_move_recut.rs`.
396    // It runs only after a refusal, so no motion that builds today changes by a
397    // bit, and a motion `move_faces` / `rotate_faces` itself refused never
398    // reaches it.
399    let moved = match crate::accept_sound(moved, "transformFace") {
400        Ok(body) => body,
401        Err(refusal) => {
402            match crate::recut_moved_planes_rigid(&solid, &face_ids, turn, translation) {
403                Ok(body) => {
404                    result.notes.push(format!(
405                        "transformFace: the moved carriers could not be re-solved onto the body \
406                         they left ({refusal}), so the selection was RE-CUT from the original \
407                         body instead — the result is the boolean this motion defines, and a \
408                         face whose carrier left the body is no longer on it"
409                    ));
410                    body
411                }
412                Err(reason) => {
413                    return Err(format!(
414                        "{refusal}. The re-cut road was offered it too and declined: {reason}"
415                    ))
416                }
417            }
418        }
419    };
420
421    // Both roads preserve every name — collect, never re-stamp.
422    let face_names = common::collect_face_names(&moved);
423    let edge_names = common::collect_edge_names(&moved);
424    let handle = crate::register_solid_value(moved);
425    result.added.push(AddedSolid {
426        handle,
427        name: target_name.clone(),
428        face_names,
429        edge_names,
430        ..AddedSolid::default()
431    });
432    result.removed.push(target_name);
433    Ok(result)
434}
435
436/// Context-bar applicability ([`crate::feature_pipeline::context_offer`]):
437/// selected faces drive `faces`.
438pub fn context_applicable(probe: &crate::feature_pipeline::SelectionProbe) -> bool {
439    probe.faces > 0
440}
441
442pub fn schema() -> serde_json::Value {
443    serde_json::json!({
444    "type": "TF",
445    "shortName": "TF",
446    "longName": "Transform Face",
447    "displayBuilder": false,
448    "inputParamsSchema": {
449        "id": {
450            "type": "string",
451            "default_value": null,
452            "hint": "Optional identifier for the transform face feature"
453        },
454        "faces": {
455            "type": "reference_selection",
456            "selectionFilter": [
457                "FACE"
458            ],
459            "timestampDependency": "parentSolid",
460            "multiple": true,
461            "default_value": [],
462            "hint": "Select one or more faces on a single solid to move and turn"
463        },
464        "pivot": {
465            "type": "vec3",
466            "label": "Pivot",
467            "default_value": null,
468            "hint": "The point the rotation turns about, in world millimetres; set to the centre of the selection when the faces are picked"
469        },
470        "transform": {
471            "type": "transform",
472            "default_value": {
473                "position": [0, 0, 0],
474                "rotationEuler": [0, 0, 0]
475            },
476            "hint": "Rotate the faces about the pivot, then move them; drag the gizmo or type the values"
477        }
478    }
479})
480}
481