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 crate::face_refusal_slugs::{PIVOT_GEOMETRY, RIGID_SCALE};
116use std::collections::HashSet;
117
118use crate::feature_pipeline::features::common;
119use crate::feature_pipeline::{AddedSolid, FeatureContext, FeatureRefusal, FeatureResult, SceneMap};
120use crate::{move_faces, rotate_faces, BrepSolid, KernelRefusal, KernelStage, Vec3};
121
122/// Stations per boundary edge in [`selection_centre`]. A power of two, so a
123/// rational circle split into quarter spans is sampled at its span ends — the
124/// points where an axis-aligned circle reaches its bounds.
125/// [`FeatureResult::refused_step`] of a Transform Face whose ROTATION refused
126/// (a turn alone, or the first half of rotate-then-translate).
127pub const REFUSED_STEP_ROTATION: &str = "rotation";
128/// … whose TRANSLATION refused (a move alone, or the second half).
129pub const REFUSED_STEP_TRANSLATION: &str = "translation";
130
131pub const CENTRE_SAMPLES: usize = 64;
132
133pub fn execute(ctx: &FeatureContext) -> FeatureResult {
134    match build(ctx) {
135        Ok(result) => result,
136        Err(error) => ctx.fail(error),
137    }
138}
139
140/// The selection resolved against a scene: the one target solid's handle and
141/// the deduplicated face ids on it, or `None` for the soft no-ops (nothing
142/// resolved, or faces on more than one solid). Misses land in `unresolved`.
143fn resolve_selection(
144    scene: &SceneMap,
145    names: &[String],
146    unresolved: &mut Vec<String>,
147) -> Option<(u32, Vec<u64>)> {
148    let mut resolved: Vec<(u32, u64)> = Vec::new();
149    for name in names {
150        match scene.resolve_face(name) {
151            Some(face_ref) => resolved.push((face_ref.handle, face_ref.face_id)),
152            None => unresolved.push(name.clone()),
153        }
154    }
155    let &(target, _) = resolved.first()?;
156    if resolved.iter().any(|(handle, _)| *handle != target) {
157        return None;
158    }
159    let mut seen: HashSet<u64> = HashSet::new();
160    let face_ids = resolved
161        .iter()
162        .map(|(_, face_id)| *face_id)
163        .filter(|face_id| seen.insert(*face_id))
164        .collect();
165    Some((target, face_ids))
166}
167
168/// The centre of the selection's boundary bounds — the default pivot. See the
169/// module doc for exactly what is bounded. `None` only when the faces carry no
170/// geometry at all.
171pub fn selection_centre(solid: &BrepSolid, face_ids: &[u64]) -> Option<Vec3> {
172    let selected: HashSet<u64> = face_ids.iter().copied().collect();
173    let mut low = [f64::INFINITY; 3];
174    let mut high = [f64::NEG_INFINITY; 3];
175    let mut include = |point: Vec3| {
176        let point = [point.x, point.y, point.z];
177        if point.iter().all(|c| c.is_finite()) {
178            for axis in 0..3 {
179                low[axis] = low[axis].min(point[axis]);
180                high[axis] = high[axis].max(point[axis]);
181            }
182        }
183    };
184    let mut edge_ids: HashSet<u64> = HashSet::new();
185    let mut edgeless = Vec::new();
186    for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
187        if !selected.contains(&face.id) {
188            continue;
189        }
190        let before = edge_ids.len();
191        for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
192            edge_ids.insert(coedge.edge_id);
193        }
194        if edge_ids.len() == before && face.loops.iter().all(|l| l.coedges.is_empty()) {
195            edgeless.push(face);
196        }
197    }
198    let vertex_ids: HashSet<u64> = solid
199        .edges
200        .iter()
201        .filter(|edge| edge_ids.contains(&edge.id))
202        .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
203        .collect();
204    for vertex in &solid.vertices {
205        if vertex_ids.contains(&vertex.id) {
206            include(vertex.point);
207        }
208    }
209    for edge in solid.edges.iter().filter(|edge| edge_ids.contains(&edge.id)) {
210        if edge.degenerate {
211            continue;
212        }
213        for station in 0..=CENTRE_SAMPLES {
214            let t = edge.t0 + (edge.t1 - edge.t0) * station as f64 / CENTRE_SAMPLES as f64;
215            if let Ok(point) = edge.curve.evaluate(t) {
216                include(point);
217            }
218        }
219    }
220    for face in edgeless {
221        for control in face.surface.control_points.iter().flatten() {
222            if let Ok(point) = control.point() {
223                include(point);
224            }
225        }
226    }
227    if !low[0].is_finite() {
228        return None;
229    }
230    Some(Vec3::new(
231        0.5 * (low[0] + high[0]),
232        0.5 * (low[1] + high[1]),
233        0.5 * (low[2] + high[2]),
234    ))
235}
236
237/// The pivot a Transform Face whose `faces` are `names` gets by default, read
238/// off the scene the results BEFORE it build — the value the app stores into
239/// `pivot` when the selection is committed, and the value `build` falls back to
240/// for a null `pivot`, because both go through [`resolve_selection`] and
241/// [`selection_centre`]. `None` when the selection does not resolve to faces on
242/// one solid.
243pub fn face_transform_pivot(prefix: &[FeatureResult], names: &[String]) -> Option<[f64; 3]> {
244    let mut scene = SceneMap::default();
245    for result in prefix {
246        scene.apply(result);
247    }
248    let (handle, face_ids) = resolve_selection(&scene, names, &mut Vec::new())?;
249    let centre =
250        crate::with_registered_solid_str(handle, |solid| Ok(selection_centre(solid, &face_ids)))
251            .ok()??;
252    Some([centre.x, centre.y, centre.z])
253}
254
255/// The rotation part of the motion as the axis and angle `rotate_faces` takes:
256/// `R` is built by the shared compose (so the Euler convention is single-sourced)
257/// and converted through its quaternion, choosing the numerically largest
258/// component first so a half turn keeps its axis. `None` for the identity.
259pub fn rotation_axis_angle(rotation_deg: [f64; 3]) -> Option<(Vec3, f64)> {
260    if rotation_deg == [0.0; 3] {
261        return None;
262    }
263    let radians = rotation_deg.map(f64::to_radians);
264    let m = common::compose_trs_matrix([0.0; 3], radians, [1.0; 3], [0.0; 3]);
265    let at = |row: usize, col: usize| m[row * 4 + col];
266    let (m00, m11, m22) = (at(0, 0), at(1, 1), at(2, 2));
267    let trace = m00 + m11 + m22;
268    // [x, y, z, w]
269    let q = if trace >= m00 && trace >= m11 && trace >= m22 {
270        let w = 0.5 * (1.0 + trace).max(0.0).sqrt();
271        let k = 0.25 / w;
272        [(at(2, 1) - at(1, 2)) * k, (at(0, 2) - at(2, 0)) * k, (at(1, 0) - at(0, 1)) * k, w]
273    } else if m00 >= m11 && m00 >= m22 {
274        let x = 0.5 * (1.0 + m00 - m11 - m22).max(0.0).sqrt();
275        let k = 0.25 / x;
276        [x, (at(0, 1) + at(1, 0)) * k, (at(0, 2) + at(2, 0)) * k, (at(2, 1) - at(1, 2)) * k]
277    } else if m11 >= m22 {
278        let y = 0.5 * (1.0 - m00 + m11 - m22).max(0.0).sqrt();
279        let k = 0.25 / y;
280        [(at(0, 1) + at(1, 0)) * k, y, (at(1, 2) + at(2, 1)) * k, (at(0, 2) - at(2, 0)) * k]
281    } else {
282        let z = 0.5 * (1.0 - m00 - m11 + m22).max(0.0).sqrt();
283        let k = 0.25 / z;
284        [(at(0, 2) + at(2, 0)) * k, (at(1, 2) + at(2, 1)) * k, z, (at(1, 0) - at(0, 1)) * k]
285    };
286    // One sign for the quaternion, so the angle lands in [0, π].
287    let sign = if q[3] < 0.0 { -1.0 } else { 1.0 };
288    let axis = Vec3::new(q[0] * sign, q[1] * sign, q[2] * sign);
289    let half_sine = axis.length();
290    let angle = 2.0 * half_sine.atan2(q[3] * sign);
291    if angle <= 1e-12 {
292        return None;
293    }
294    Some((axis.scale(1.0 / half_sine), angle))
295}
296
297/// A `transform` component, read through the shared vec3 reader under its
298/// dotted name so an expression error names `transform.position` and not a bare
299/// key.
300fn transform_vec3(ctx: &FeatureContext, key: &str, default: [f64; 3]) -> Result<[f64; 3], String> {
301    common::vec3_from_value(
302        ctx.env,
303        ctx.param("transform").and_then(|transform| transform.get(key)),
304        &format!("transform.{key}"),
305        default,
306    )
307}
308
309fn build(ctx: &FeatureContext) -> Result<FeatureResult, FeatureRefusal> {
310    let mut result = FeatureResult::empty(ctx.id.clone(), ctx.feature_type.clone());
311
312    let names = common::reference_name_array(ctx.param("faces"));
313    if names.is_empty() {
314        return Ok(result); // Soft no-op — nothing selected.
315    }
316    let Some((target_handle, face_ids)) =
317        resolve_selection(ctx.scene, &names, &mut result.unresolved)
318    else {
319        return Ok(result); // Only unresolved names, or faces on several solids.
320    };
321    // The feature proceeds on the faces that resolved: a miss is reported as
322    // a typed partial fulfilment beside `unresolved`, never silently.
323    result.note_partial_resolution(&names);
324
325    // The motion. Scale first: a document that scales is refused before
326    // anything else is read, because no reading of the rest makes it rigid.
327    let scale = transform_vec3(ctx, "scale", [1.0; 3])?;
328    if scale != [1.0; 3] {
329        return Err(KernelRefusal::input(
330            KernelStage::Collect,
331            RIGID_SCALE,
332            format!(
333                "transformFace: `transform.scale` is {scale:?}, but a face transform is a RIGID \
334                 motion — a scaled carrier is a different surface, not a moved one — so scale must \
335                 be [1, 1, 1]; refusing"
336            ),
337        )
338        .into());
339    }
340    let position = transform_vec3(ctx, "position", [0.0; 3])?;
341    let rotation_deg = transform_vec3(ctx, "rotationEuler", [0.0; 3])?;
342    let translation = Vec3::new(position[0], position[1], position[2]);
343    let translates = translation.length() > 1e-12;
344    let rotation = rotation_axis_angle(rotation_deg);
345    if !translates && rotation.is_none() {
346        return Ok(result); // Soft no-op — the identity motion.
347    }
348
349    let target_name = ctx
350        .scene
351        .solids
352        .iter()
353        .find(|(_, handle)| **handle == target_handle)
354        .map(|(name, _)| name.clone())
355        .ok_or("transformFace: target solid has no scene-map name")?;
356    let solid = crate::with_registered_solid_str(target_handle, |solid| Ok(solid.clone()))?;
357
358    // The rotation as the re-cut road needs it too: axis point, axis, angle.
359    let turn = match rotation {
360        None => None,
361        Some((axis, angle)) => {
362            let pivot = match ctx.param("pivot") {
363                None | Some(serde_json::Value::Null) => selection_centre(&solid, &face_ids)
364                    .ok_or_else(|| {
365                        KernelRefusal::input(
366                            KernelStage::Collect,
367                            PIVOT_GEOMETRY,
368                            "transformFace: the selected faces carry no geometry to centre a pivot on",
369                        )
370                    })?,
371                Some(value) => {
372                    let [x, y, z] = common::vec3_from_value(ctx.env, Some(value), "pivot", [0.0; 3])?;
373                    Vec3::new(x, y, z)
374                }
375            };
376            Some((pivot, axis, angle))
377        }
378    };
379    // Every motion step records itself on refusal (`refused_step`), so the
380    // app names the motion the user asked for without reading this text.
381    let refused_at = |error: KernelRefusal, step: &str| {
382        let mut refused = ctx.fail(error);
383        refused.refused_step = Some(step.to_string());
384        refused
385    };
386    let moved = match turn {
387        None => match move_faces(&solid, &face_ids, translation) {
388            Ok(moved) => moved,
389            Err(error) => return Ok(refused_at(error, REFUSED_STEP_TRANSLATION)),
390        },
391        Some((pivot, axis, angle)) => {
392            if !translates {
393                match rotate_faces(&solid, &face_ids, pivot, axis, angle) {
394                    Ok(turned) => turned,
395                    Err(error) => return Ok(refused_at(error, REFUSED_STEP_ROTATION)),
396                }
397            } else {
398                // ROTATE-THEN-TRANSLATE, each step named on refusal (module doc).
399                // The primitive's class rides through the step's wrap.
400                // The refused step is also RECORDED on the result
401                // (`FeatureResult::refused_step`), so a reader need not parse it
402                // out of this text.
403                let turned = match rotate_faces(&solid, &face_ids, pivot, axis, angle) {
404                    Ok(turned) => turned,
405                    Err(error) => {
406                        let error = error.with_message(|error| {
407                            format!(
408                                "transformFace: the ROTATION step of this rotate-then-translate motion \
409                                 refused — {error}"
410                            )
411                        });
412                        return Ok(refused_at(error, REFUSED_STEP_ROTATION));
413                    }
414                };
415                match move_faces(&turned, &face_ids, translation) {
416                    Ok(moved) => moved,
417                    Err(error) => {
418                        let error = error.with_message(|error| {
419                            format!(
420                                "transformFace: the TRANSLATION step of this rotate-then-translate \
421                                 motion, applied to the faces as the rotation left them, refused — {error}"
422                            )
423                        });
424                        return Ok(refused_at(error, REFUSED_STEP_TRANSLATION));
425                    }
426                }
427            }
428        }
429    };
430    // Carrying a face past a neighbour's own extent leaves it crossing one it
431    // should have stopped at, and both roads are pure geometry edits that create
432    // no cell for `validate()` to catch. The acceptance Push Face takes: split and
433    // trim where that can be proved, refuse by name where it cannot. Where the
434    // motion carried the selection clean OUT of the faces it opened through —
435    // the breakout — that split also re-trims the neighbour left holding an
436    // unanswered boundary, which is the only way such a body closes.
437    //
438    // And where the acceptance refuses, the RE-CUT road is offered the same
439    // edit from the other end — the original body, the selection and the
440    // motion, cut as a boolean rather than re-solved — because a motion whose
441    // answer needs a face to STOP EXISTING has no body for a repair to reach.
442    // A plane moved rigidly is still a plane, so the road takes a
443    // rotate-then-translate as well as a translation (the 2026-09-22 slot
444    // document with its wall also TURNED 16.9° refused here while the same
445    // drag without the turn built). See `edit/direct_edit/face_move_recut.rs`.
446    // It runs only after a refusal, so no motion that builds today changes by a
447    // bit, and a motion `move_faces` / `rotate_faces` itself refused never
448    // reaches it.
449    let moved = match crate::accept_sound(moved, "transformFace") {
450        Ok(body) => body,
451        Err(refusal) => {
452            match crate::recut_moved_planes_rigid(&solid, &face_ids, turn, translation)
453                // The re-cut body passes the SAME acceptance the primitives'
454                // body did: a boolean's output is sound by construction, so
455                // this is a check and not a repair, and a body it refuses is
456                // refused here by name rather than returned.
457                .and_then(|body| {
458                    crate::accept_sound(body, "transformFace").map_err(|reason| {
459                        reason.with_message(|reason| {
460                            format!("the re-cut built a body the acceptance refused — {reason}")
461                        })
462                    })
463                })
464            {
465                Ok(body) => {
466                    result.notes.push(format!(
467                        "transformFace: the moved carriers could not be re-solved onto the body \
468                         they left ({refusal}), so the selection was RE-CUT from the original \
469                         body instead — the result is the boolean this motion defines, and a \
470                         face whose carrier left the body is no longer on it"
471                    ));
472                    body
473                }
474                // The acceptance's refusal stays the primary one — its class
475                // is what the boundary reports — and the re-cut's decline is
476                // appended as context.
477                Err(reason) => {
478                    return Err(refusal
479                        .with_message(|refusal| {
480                            format!(
481                                "{refusal}. The re-cut road was offered it too and declined: {reason}"
482                            )
483                        })
484                        .into())
485                }
486            }
487        }
488    };
489
490    // Both roads preserve every name — collect, never re-stamp.
491    let face_names = common::collect_face_names(&moved);
492    let edge_names = common::collect_edge_names(&moved);
493    let handle = crate::register_solid_value(moved);
494    result.added.push(AddedSolid {
495        handle,
496        name: target_name.clone(),
497        face_names,
498        edge_names,
499        ..AddedSolid::default()
500    });
501    result.removed.push(target_name);
502    Ok(result)
503}
504
505/// Context-bar applicability ([`crate::feature_pipeline::context_offer`]):
506/// selected faces drive `faces`.
507pub fn context_applicable(probe: &crate::feature_pipeline::SelectionProbe) -> bool {
508    probe.faces > 0
509}
510
511pub fn schema() -> serde_json::Value {
512    serde_json::json!({
513    "type": "TF",
514    "shortName": "TF",
515    "longName": "Transform Face",
516    "ribbonPath": "Home/Synchronous Modeling/Transform Face",
517    "commandSize": "Compact",
518    "displayBuilder": false,
519    "inputParamsSchema": {
520        "id": {
521            "type": "string",
522            "default_value": null,
523            "hint": "Optional identifier for the transform face feature"
524        },
525        "faces": {
526            "type": "reference_selection",
527            "selectionFilter": [
528                "FACE"
529            ],
530            "timestampDependency": "parentSolid",
531            "multiple": true,
532            "default_value": [],
533            "hint": "Select one or more faces on a single solid to move and turn"
534        },
535        "pivot": {
536            "type": "vec3",
537            "label": "Pivot",
538            "default_value": null,
539            "hint": "The point the rotation turns about, in world millimetres; set to the centre of the selection when the faces are picked"
540        },
541        "transform": {
542            "type": "transform",
543            "default_value": {
544                "position": [0, 0, 0],
545                "rotationEuler": [0, 0, 0]
546            },
547            "hint": "Rotate the faces about the pivot, then move them; drag the gizmo or type the values"
548        }
549    }
550})
551}
552