Skip to main content

brep_render/engine_state/
component_move.rs

1use crate::geometry3d::{add3, cross3 as cross, sub3};
2use super::transform_gizmo::{
3    euler_xyz_deg_from_quat, normalize3, quat_from_axis_angle, quat_from_euler_xyz_deg, quat_mul,
4    parse_drag_delta, Quat, TransformDelta,
5};
6use super::*;
7
8// Component movement uses the shared gizmo at the member-bbox center. Fixed
9// components cannot arm it. During a drag only the gizmo moves; release writes
10// the pose once and reruns the history so assembly constraints can re-solve.
11// Arming a component resets feature-transform and dimension gizmos because they
12// share one widget slot. All translation and rotation handles are shown together.
13
14/// The component-Move controller state (one per engine, like [`TransformArm`]).
15#[derive(Default)]
16pub struct ComponentMoveArm {
17    /// The ACOMP feature id the gizmo is armed for (`None` = disarmed).
18    pub(super) feature_id: Option<String>,
19    /// Move Copy selects its members once the background scene rebuild lands.
20    select_after_run: bool,
21    /// The gizmo pivot — the component's member-bbox center, re-synced after
22    /// every applied run so the gizmo follows a re-solved (snapped) component.
23    pub(super) anchor: [f64; 3],
24    /// The in-flight handle drag (grab snapshot + the pending composed pose).
25    pub(super) drag: Option<ComponentMoveDrag>,
26}
27
28/// A grab snapshot: the handle, the grab screen point, the pose AT GRAB (the
29/// frozen frame every drag move resolves against — no error accumulation), and
30/// the PENDING composed pose the release commits.
31#[derive(Clone)]
32pub(super) struct ComponentMoveDrag {
33    handle: u32,
34    sx: f32,
35    sy: f32,
36    start: ComponentPose,
37    pending: Option<ComponentPose>,
38}
39
40/// A component pose as the gizmo tracks it: the ACOMP `transform` pair plus the
41/// gizmo anchor (bbox center) it pivots about.
42#[derive(Clone, Copy, PartialEq, Debug)]
43pub(super) struct ComponentPose {
44    pub translate: [f64; 3],
45    pub rotate_deg: [f64; 3],
46    pub anchor: [f64; 3],
47}
48
49impl EngineState {
50    /// Whether the component Move gizmo is armed (for any component).
51    pub fn component_move_armed(&self) -> bool {
52        self.component_move.feature_id.is_some()
53    }
54
55    /// The armed component feature id (empty when disarmed).
56    pub fn component_move_armed_feature(&self) -> String {
57        self.component_move.feature_id.clone().unwrap_or_default()
58    }
59
60    /// The Move toggle (context bar / tree action): ARMS the full gizmo (all
61    /// handle sets) for `feature_id`, or DISARMS when it is already armed
62    /// (arming fresh replaces any other armed component). A FIXED component
63    /// refuses with a toast and never arms.
64    pub fn component_move_toggle(&mut self, feature_id: &str) {
65        let Some(info) = self.component_info(feature_id) else {
66            self.push_notice(format!("'{feature_id}' is not an assembly component"));
67            return;
68        };
69        if info.fixed {
70            self.push_notice(format!(
71                "{} ({feature_id}) is fixed — unfix it to move",
72                info.part_name
73            ));
74            return;
75        }
76        if self.component_move.feature_id.as_deref() == Some(feature_id) {
77            self.disarm_transform();
78        } else {
79            self.component_move_arm_widget(feature_id);
80        }
81    }
82
83    /// The Move Copy action (context bar / tree action): append a SECOND
84    /// instance of `source_id`'s part — its `inputParams` cloned (same
85    /// `partName`, same pose, any other placement keys riding along) under a
86    /// fresh `ACOMP<n>` id and an explicit `isFixed: false` — then select it
87    /// and arm the full Move gizmo on it, so the user drags the copy off the
88    /// original. No new part is made: both instances reference one
89    /// parts-library entry. A FIXED source copies fine (the copy is free).
90    /// Returns the new feature id.
91    pub fn component_move_copy(&mut self, source_id: &str) -> Result<String, String> {
92        let Some(index) = self.history.index_of(source_id).filter(|&index| {
93            matches!(
94                self.history.feature_type(index).as_deref(),
95                Some("ACOMP") | Some("ASSEMBLY COMPONENT")
96            )
97        }) else {
98            let error = format!("'{source_id}' is not an assembly component");
99            self.push_notice(error.clone());
100            return Err(error);
101        };
102        let mut params = self
103            .history
104            .feature_params(index)
105            .unwrap_or_else(|| serde_json::json!({}));
106        let id = self.history.next_feature_id("ACOMP");
107        let Some(map) = params.as_object_mut() else {
108            return Err(format!("component '{source_id}': malformed inputParams"));
109        };
110        map.insert("id".into(), serde_json::Value::String(id.clone()));
111        map.insert("isFixed".into(), serde_json::Value::Bool(false));
112        let feature = serde_json::json!({
113            "type": "ACOMP",
114            "inputParams": params,
115            "persistentData": {}
116        });
117        self.add_feature(&feature.to_string())?;
118        // Select the copy so the context bar's actions (Move again, Delete, …)
119        // target the instance the gizmo sits on, not the original.
120        self.component_move_arm_widget(&id);
121        if self.run_pending() {
122            self.component_move.select_after_run = true;
123        } else {
124            self.select_component(&id);
125        }
126        Ok(id)
127    }
128
129    /// The `arm_transform` ROUTE for ACOMP features (history-panel expand):
130    /// a FIXED component silently stays armless (the explicit Move action is
131    /// the one that toasts).
132    pub(super) fn component_move_arm(&mut self, feature_id: &str) {
133        match self.component_info(feature_id) {
134            Some(info) if !info.fixed => self.component_move_arm_widget(feature_id),
135            _ => {}
136        }
137    }
138
139    /// Drop the component arm STATE only (the caller owns the widget slot) —
140    /// the exclusivity hook `arm_transform` / `arm_dimension` / `disarm_transform`
141    /// call before taking the slot for themselves.
142    pub(super) fn component_move_reset(&mut self) {
143        self.component_move = ComponentMoveArm::default();
144    }
145
146    /// Arm for `feature_id`: claim the shared widget slot (clearing the
147    /// feature gizmo + dimension overlay), pin the anchor at the member-bbox
148    /// center, and feed the full handle set.
149    fn component_move_arm_widget(&mut self, feature_id: &str) {
150        // Claim the shared slot WITHOUT disarm_transform (which would also reset
151        // the component state we are about to set).
152        self.transform_gizmo.feature_id = None;
153        self.transform_gizmo.mode = GizmoMode::None;
154        self.transform_gizmo.drag = None;
155        self.clear_feature_dimension_overlay();
156
157        let anchor = self
158            .component_bbox_center(feature_id)
159            .or_else(|| self.component_info(feature_id).map(|info| info.translate))
160            .unwrap_or([0.0; 3]);
161        self.component_move.feature_id = Some(feature_id.to_string());
162        self.component_move.select_after_run = false;
163        self.component_move.anchor = anchor;
164        self.component_move.drag = None;
165        self.feed_component_widget();
166        self.dirty = true;
167    }
168
169    /// (Re)feed the widget gizmo at the armed component's current pose+anchor.
170    fn feed_component_widget(&mut self) {
171        let Some(id) = self.component_move.feature_id.clone() else {
172            return;
173        };
174        let Some(info) = self.component_info(&id) else {
175            return;
176        };
177        let pose = ComponentPose {
178            translate: info.translate,
179            rotate_deg: info.rotate_deg,
180            anchor: self.component_move.anchor,
181        };
182        let json = component_frame_json(&pose);
183        let _ = self.widgets.set_transform_json(&json);
184    }
185
186    /// Post-run re-sync (the [`finish_apply`] hook, mirroring
187    /// `sync_transform_gizmo`): re-anchor at the possibly re-solved member bbox
188    /// and re-feed; auto-disarm when the component vanished or became fixed.
189    pub(super) fn component_move_sync(&mut self) {
190        let Some(id) = self.component_move.feature_id.clone() else {
191            return;
192        };
193        match self.component_info(&id) {
194            Some(info) if !info.fixed => {
195                // Earlier replies may arrive while a newer run is pending.
196                // Consume this once, only after the current scene is available.
197                if self.component_move.select_after_run && !self.run_pending() {
198                    self.component_move.select_after_run = false;
199                    self.select_component(&id);
200                }
201                self.component_move.anchor =
202                    self.component_bbox_center(&id).unwrap_or(info.translate);
203                self.feed_component_widget();
204                self.dirty = true;
205            }
206            _ => self.disarm_transform(),
207        }
208    }
209
210    /// Begin a component-gizmo drag at viewport px `(x, y)`; `true` when a
211    /// handle was grabbed (the viewport routes the drag here, not the camera).
212    pub fn component_press(&mut self, x: f64, y: f64) -> bool {
213        let Some(id) = self.component_move.feature_id.clone() else {
214            return false;
215        };
216        let handle = self.transform_pick(x, y);
217        if handle == 0 {
218            return false;
219        }
220        let Some(info) = self.component_info(&id) else {
221            return false;
222        };
223        self.widgets.set_transform_active(handle);
224        self.component_move.drag = Some(ComponentMoveDrag {
225            handle,
226            sx: x as f32,
227            sy: y as f32,
228            start: ComponentPose {
229                translate: info.translate,
230                rotate_deg: info.rotate_deg,
231                anchor: self.component_move.anchor,
232            },
233            pending: None,
234        });
235        self.dirty = true;
236        true
237    }
238
239    /// Whether a component-gizmo drag is in flight.
240    pub fn component_move_dragging(&self) -> bool {
241        self.component_move.drag.is_some()
242    }
243
244    /// Continue the drag: resolve the world delta against the FROZEN grab frame,
245    /// compose the pending pose, and move ONLY the visible gizmo (free move —
246    /// the mesh follows on release, when the commit re-runs + re-solves).
247    pub fn component_drag_to(&mut self, cx: f64, cy: f64) {
248        let Some(drag) = self.component_move.drag.clone() else {
249            return;
250        };
251        let cam = gizmo_camera(&self.camera);
252        let frame = component_frame_json(&drag.start);
253        let json = self.widgets.transform_drag_json_with_frame(
254            &cam,
255            &frame,
256            drag.handle,
257            drag.sx,
258            drag.sy,
259            cx as f32,
260            cy as f32,
261        );
262        let Some(delta) = parse_drag_delta(&json) else {
263            return;
264        };
265        let pending = compose_component_delta(&drag.start, &delta);
266        // Live-follow the WIDGET at the pending pose; the gold active-handle
267        // highlight survives (only a null feed clears it).
268        let json = component_frame_json(&pending);
269        let _ = self.widgets.set_transform_json(&json);
270        if let Some(live) = self.component_move.drag.as_mut() {
271            live.pending = Some(pending);
272        }
273        self.dirty = true;
274    }
275
276    /// End the drag: COMMIT the pending pose into the ACOMP's
277    /// `inputParams.transform` (one param write → one undo entry → one rerun
278    /// whose constraint tail re-solves; the post-run sync then re-glues the
279    /// gizmo to wherever the solve left the component). A grab that never moved
280    /// commits nothing.
281    pub fn component_release(&mut self) {
282        let Some(drag) = self.component_move.drag.take() else {
283            return;
284        };
285        self.widgets.set_transform_active(0);
286        self.dirty = true;
287        let Some(pending) = drag.pending else {
288            return;
289        };
290        let Some(id) = self.component_move.feature_id.clone() else {
291            return;
292        };
293        self.component_move.anchor = pending.anchor;
294        let Some(index) = self.history.index_of(&id) else {
295            return;
296        };
297        let mut params = self
298            .history
299            .feature_params(index)
300            .unwrap_or_else(|| serde_json::json!({}));
301        if !params.get("transform").map(|t| t.is_object()).unwrap_or(false) {
302            if let Some(object) = params.as_object_mut() {
303                object.insert("transform".into(), serde_json::json!({}));
304            }
305        }
306        if let Some(transform) = params.get_mut("transform").and_then(|t| t.as_object_mut()) {
307            transform.insert("translate".into(), serde_json::json!(pending.translate));
308            transform.insert("rotateEulerDeg".into(), serde_json::json!(pending.rotate_deg));
309        }
310        let _ = self.update_feature_params(&id, &params.to_string());
311    }
312
313    /// The armed component gizmo's logical state for the verifier:
314    /// `{armed, feature, anchor}`.
315    pub fn component_move_json(&self) -> String {
316        serde_json::json!({
317            "armed": self.component_move_armed(),
318            "feature": self.component_move_armed_feature(),
319            "anchor": self.component_move.anchor,
320        })
321        .to_string()
322    }
323}
324
325/// The widget frame feed for a component pose: origin = the ANCHOR (bbox
326/// center), axes = the pose's rotated basis (intrinsic XYZ, the kernel bake).
327/// EVERY handle set is shown (center free-move ball + axis arrows + rotation
328/// arcs) — move and rotate coexist, no mode switch.
329fn component_frame_json(pose: &ComponentPose) -> String {
330    let euler = [
331        pose.rotate_deg[0].to_radians(),
332        pose.rotate_deg[1].to_radians(),
333        pose.rotate_deg[2].to_radians(),
334    ];
335    let x = normalize3(rotate_euler_xyz_f64([1.0, 0.0, 0.0], euler));
336    let y = normalize3(rotate_euler_xyz_f64([0.0, 1.0, 0.0], euler));
337    let z = normalize3(rotate_euler_xyz_f64([0.0, 0.0, 1.0], euler));
338    serde_json::json!({
339        "origin": pose.anchor,
340        "x": x,
341        "y": y,
342        "z": z,
343        "showCenter": true,
344        "showAxes": true,
345        "showRings": true,
346    })
347    .to_string()
348}
349
350/// Translation shifts pose and anchor together. Rotation pivots about anchor C:
351/// `R' = dR·R`, `translate' = C + dR·(translate − C)`.
352pub(super) fn compose_component_delta(
353    start: &ComponentPose,
354    delta: &TransformDelta,
355) -> ComponentPose {
356    match delta {
357        TransformDelta::Translate(d) => ComponentPose {
358            translate: add3(start.translate, *d),
359            rotate_deg: start.rotate_deg,
360            anchor: add3(start.anchor, *d),
361        },
362        TransformDelta::Rotate { axis, radians } => {
363            let dq = quat_from_axis_angle(*axis, *radians);
364            let q0 = quat_from_euler_xyz_deg(start.rotate_deg);
365            let rotate_deg = euler_xyz_deg_from_quat(quat_mul(dq, q0));
366            let offset = sub3(start.translate, start.anchor);
367            ComponentPose {
368                translate: add3(start.anchor, quat_rotate(dq, offset)),
369                rotate_deg,
370                anchor: start.anchor,
371            }
372        }
373    }
374}
375
376/// Rotate `v` by quaternion `q`: `v + 2·(q.xyz × (q.xyz × v + w·v))`.
377fn quat_rotate(q: Quat, v: [f64; 3]) -> [f64; 3] {
378    let u = [q[0], q[1], q[2]];
379    let w = q[3];
380    let t = cross(u, add3(cross(u, v), [w * v[0], w * v[1], w * v[2]]));
381    [v[0] + 2.0 * t[0], v[1] + 2.0 * t[1], v[2] + 2.0 * t[2]]
382}
383