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 if !translates {
377 match rotate_faces(&solid, &face_ids, pivot, axis, angle) {
378 Ok(turned) => turned,
379 Err(error) => return Ok(refused_at(error, REFUSED_STEP_ROTATION)),
380 }
381 } else {
382 match crate::direct_edit::rotate_faces_rigid(
383 &solid, &face_ids, pivot, axis, angle, translation,
384 ) {
385 Ok(moved) => moved,
386 Err(error) => {
387 let error = error.with_message(|error| {
388 format!("transformFace: the combined rigid motion refused — {error}")
389 });
390 return Ok(refused_at(error, REFUSED_STEP_ROTATION));
391 }
392 }
393 }
394 }
395 };
396 // Carrying a face past a neighbour's own extent leaves it crossing one it
397 // should have stopped at, and both roads are pure geometry edits that create
398 // no cell for `validate()` to catch. The acceptance Push Face takes: split and
399 // trim where that can be proved, refuse by name where it cannot. Where the
400 // motion carried the selection clean OUT of the faces it opened through —
401 // the breakout — that split also re-trims the neighbour left holding an
402 // unanswered boundary, which is the only way such a body closes.
403 //
404 // And where the acceptance refuses, the RE-CUT road is offered the same
405 // edit from the other end — the original body, the selection and the
406 // motion, cut as a boolean rather than re-solved — because a motion whose
407 // answer needs a face to STOP EXISTING has no body for a repair to reach.
408 // A plane moved rigidly is still a plane, so the road takes a
409 // rotate-then-translate as well as a translation (the 2026-09-22 slot
410 // document with its wall also TURNED 16.9° refused here while the same
411 // drag without the turn built). See `edit/direct_edit/face_move_recut.rs`.
412 // It runs only after an acceptance refusal; a motion refused by the
413 // direct re-intersection solver never reaches it.
414 let moved = match crate::accept_sound(moved, "transformFace") {
415 Ok(body) => body,
416 Err(refusal) => {
417 match crate::recut_moved_planes_rigid(&solid, &face_ids, turn, translation)
418 // The re-cut body passes the SAME acceptance the primitives'
419 // body did: a boolean's output is sound by construction, so
420 // this is a check and not a repair, and a body it refuses is
421 // refused here by name rather than returned.
422 .and_then(|body| {
423 crate::accept_sound(body, "transformFace").map_err(|reason| {
424 reason.with_message(|reason| {
425 format!("the re-cut built a body the acceptance refused — {reason}")
426 })
427 })
428 })
429 {
430 Ok(body) => {
431 result.notes.push(format!(
432 "transformFace: the moved carriers could not be re-solved onto the body \
433 they left ({refusal}), so the selection was RE-CUT from the original \
434 body instead — the result is the boolean this motion defines, and a \
435 face whose carrier left the body is no longer on it"
436 ));
437 body
438 }
439 // The acceptance's refusal stays the primary one — its class
440 // is what the boundary reports — and the re-cut's decline is
441 // appended as context.
442 Err(reason) => {
443 return Err(refusal
444 .with_message(|refusal| {
445 format!(
446 "{refusal}. The re-cut road was offered it too and declined: {reason}"
447 )
448 })
449 .into())
450 }
451 }
452 }
453 };
454
455 // Both roads preserve every name — collect, never re-stamp.
456 let face_names = common::collect_face_names(&moved);
457 let edge_names = common::collect_edge_names(&moved);
458 let handle = crate::register_solid_value(moved);
459 result.added.push(AddedSolid {
460 handle,
461 name: target_name.clone(),
462 face_names,
463 edge_names,
464 ..AddedSolid::default()
465 });
466 result.removed.push(target_name);
467 Ok(result)
468}
469
470/// Context-bar applicability ([`crate::feature_pipeline::context_offer`]):
471/// selected faces drive `faces`.
472pub fn context_applicable(probe: &crate::feature_pipeline::SelectionProbe) -> bool {
473 probe.faces > 0
474}
475
476pub fn schema() -> serde_json::Value {
477 serde_json::json!({
478 "type": "TF",
479 "shortName": "TF",
480 "longName": "Transform Face",
481 "ribbonPath": "Home/Synchronous Modeling/Transform Face",
482 "commandSize": "Compact",
483 "displayBuilder": false,
484 "inputParamsSchema": {
485 "id": {
486 "type": "string",
487 "default_value": null,
488 "hint": "Optional identifier for the transform face feature"
489 },
490 "faces": {
491 "type": "reference_selection",
492 "selectionFilter": [
493 "FACE"
494 ],
495 "timestampDependency": "parentSolid",
496 "multiple": true,
497 "default_value": [],
498 "hint": "Select one or more faces on a single solid to move and turn"
499 },
500 "pivot": {
501 "type": "vec3",
502 "label": "Pivot",
503 "default_value": null,
504 "hint": "The point the rotation turns about, in world millimetres; set to the centre of the selection when the faces are picked"
505 },
506 "transform": {
507 "type": "transform",
508 "default_value": {
509 "position": [0, 0, 0],
510 "rotationEuler": [0, 0, 0]
511 },
512 "hint": "Rotate the faces about the pivot, then move them; drag the gizmo or type the values"
513 }
514 }
515})
516}
517