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