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