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    /// The gizmo pivot — the component's member-bbox center, re-synced after
20    /// every applied run so the gizmo follows a re-solved (snapped) component.
21    pub(super) anchor: [f64; 3],
22    /// The in-flight handle drag (grab snapshot + the pending composed pose).
23    pub(super) drag: Option<ComponentMoveDrag>,
24}
25
26/// A grab snapshot: the handle, the grab screen point, the pose AT GRAB (the
27/// frozen frame every drag move resolves against — no error accumulation), and
28/// the PENDING composed pose the release commits.
29#[derive(Clone)]
30pub(super) struct ComponentMoveDrag {
31    handle: u32,
32    sx: f32,
33    sy: f32,
34    start: ComponentPose,
35    pending: Option<ComponentPose>,
36}
37
38/// A component pose as the gizmo tracks it: the ACOMP `transform` pair plus the
39/// gizmo anchor (bbox center) it pivots about.
40#[derive(Clone, Copy, PartialEq, Debug)]
41pub(super) struct ComponentPose {
42    pub translate: [f64; 3],
43    pub rotate_deg: [f64; 3],
44    pub anchor: [f64; 3],
45}
46
47impl EngineState {
48    /// Whether the component Move gizmo is armed (for any component).
49    pub fn component_move_armed(&self) -> bool {
50        self.component_move.feature_id.is_some()
51    }
52
53    /// The armed component feature id (empty when disarmed).
54    pub fn component_move_armed_feature(&self) -> String {
55        self.component_move.feature_id.clone().unwrap_or_default()
56    }
57
58    /// The Move toggle (context bar / tree action): ARMS the full gizmo (all
59    /// handle sets) for `feature_id`, or DISARMS when it is already armed
60    /// (arming fresh replaces any other armed component). A FIXED component
61    /// refuses with a toast and never arms (spec §8.5).
62    pub fn component_move_toggle(&mut self, feature_id: &str) {
63        let Some(info) = self.component_info(feature_id) else {
64            self.push_notice(format!("'{feature_id}' is not an assembly component"));
65            return;
66        };
67        if info.fixed {
68            self.push_notice(format!(
69                "{} ({feature_id}) is fixed — unfix it to move",
70                info.part_name
71            ));
72            return;
73        }
74        if self.component_move.feature_id.as_deref() == Some(feature_id) {
75            self.disarm_transform();
76        } else {
77            self.component_move_arm_widget(feature_id);
78        }
79    }
80
81    /// The `arm_transform` ROUTE for ACOMP features (history-panel expand):
82    /// a FIXED component silently stays armless (the explicit Move action is
83    /// the one that toasts).
84    pub(super) fn component_move_arm(&mut self, feature_id: &str) {
85        match self.component_info(feature_id) {
86            Some(info) if !info.fixed => self.component_move_arm_widget(feature_id),
87            _ => {}
88        }
89    }
90
91    /// Drop the component arm STATE only (the caller owns the widget slot) —
92    /// the exclusivity hook `arm_transform` / `arm_dimension` / `disarm_transform`
93    /// call before taking the slot for themselves.
94    pub(super) fn component_move_reset(&mut self) {
95        self.component_move = ComponentMoveArm::default();
96    }
97
98    /// Arm for `feature_id`: claim the shared widget slot (clearing the
99    /// feature gizmo + dimension overlay), pin the anchor at the member-bbox
100    /// center, and feed the full handle set.
101    fn component_move_arm_widget(&mut self, feature_id: &str) {
102        // Claim the shared slot WITHOUT disarm_transform (which would also reset
103        // the component state we are about to set).
104        self.transform_gizmo.feature_id = None;
105        self.transform_gizmo.mode = GizmoMode::None;
106        self.transform_gizmo.drag = None;
107        self.clear_feature_dimension_overlay();
108
109        let anchor = self
110            .component_bbox_center(feature_id)
111            .or_else(|| self.component_info(feature_id).map(|info| info.translate))
112            .unwrap_or([0.0; 3]);
113        self.component_move.feature_id = Some(feature_id.to_string());
114        self.component_move.anchor = anchor;
115        self.component_move.drag = None;
116        self.feed_component_widget();
117        self.dirty = true;
118    }
119
120    /// (Re)feed the widget gizmo at the armed component's current pose+anchor.
121    fn feed_component_widget(&mut self) {
122        let Some(id) = self.component_move.feature_id.clone() else {
123            return;
124        };
125        let Some(info) = self.component_info(&id) else {
126            return;
127        };
128        let pose = ComponentPose {
129            translate: info.translate,
130            rotate_deg: info.rotate_deg,
131            anchor: self.component_move.anchor,
132        };
133        let json = component_frame_json(&pose);
134        let _ = self.widgets.set_transform_json(&json);
135    }
136
137    /// Post-run re-sync (the [`finish_apply`] hook, mirroring
138    /// `sync_transform_gizmo`): re-anchor at the possibly re-solved member bbox
139    /// and re-feed; auto-disarm when the component vanished or became fixed.
140    pub(super) fn component_move_sync(&mut self) {
141        let Some(id) = self.component_move.feature_id.clone() else {
142            return;
143        };
144        match self.component_info(&id) {
145            Some(info) if !info.fixed => {
146                self.component_move.anchor =
147                    self.component_bbox_center(&id).unwrap_or(info.translate);
148                self.feed_component_widget();
149                self.dirty = true;
150            }
151            _ => self.disarm_transform(),
152        }
153    }
154
155    /// Begin a component-gizmo drag at viewport px `(x, y)`; `true` when a
156    /// handle was grabbed (the viewport routes the drag here, not the camera).
157    pub fn component_press(&mut self, x: f64, y: f64) -> bool {
158        let Some(id) = self.component_move.feature_id.clone() else {
159            return false;
160        };
161        let handle = self.transform_pick(x, y);
162        if handle == 0 {
163            return false;
164        }
165        let Some(info) = self.component_info(&id) else {
166            return false;
167        };
168        self.widgets.set_transform_active(handle);
169        self.component_move.drag = Some(ComponentMoveDrag {
170            handle,
171            sx: x as f32,
172            sy: y as f32,
173            start: ComponentPose {
174                translate: info.translate,
175                rotate_deg: info.rotate_deg,
176                anchor: self.component_move.anchor,
177            },
178            pending: None,
179        });
180        self.dirty = true;
181        true
182    }
183
184    /// Whether a component-gizmo drag is in flight.
185    pub fn component_move_dragging(&self) -> bool {
186        self.component_move.drag.is_some()
187    }
188
189    /// Continue the drag: resolve the world delta against the FROZEN grab frame,
190    /// compose the pending pose, and move ONLY the visible gizmo (free move —
191    /// the mesh follows on release, when the commit re-runs + re-solves).
192    pub fn component_drag_to(&mut self, cx: f64, cy: f64) {
193        let Some(drag) = self.component_move.drag.clone() else {
194            return;
195        };
196        let cam = gizmo_camera(&self.camera);
197        let frame = component_frame_json(&drag.start);
198        let json = self.widgets.transform_drag_json_with_frame(
199            &cam,
200            &frame,
201            drag.handle,
202            drag.sx,
203            drag.sy,
204            cx as f32,
205            cy as f32,
206        );
207        let Some(delta) = parse_drag_delta(&json) else {
208            return;
209        };
210        let pending = compose_component_delta(&drag.start, &delta);
211        // Live-follow the WIDGET at the pending pose; the gold active-handle
212        // highlight survives (only a null feed clears it).
213        let json = component_frame_json(&pending);
214        let _ = self.widgets.set_transform_json(&json);
215        if let Some(live) = self.component_move.drag.as_mut() {
216            live.pending = Some(pending);
217        }
218        self.dirty = true;
219    }
220
221    /// End the drag: COMMIT the pending pose into the ACOMP's
222    /// `inputParams.transform` (one param write → one undo entry → one rerun
223    /// whose constraint tail re-solves; the post-run sync then re-glues the
224    /// gizmo to wherever the solve left the component). A grab that never moved
225    /// commits nothing.
226    pub fn component_release(&mut self) {
227        let Some(drag) = self.component_move.drag.take() else {
228            return;
229        };
230        self.widgets.set_transform_active(0);
231        self.dirty = true;
232        let Some(pending) = drag.pending else {
233            return;
234        };
235        let Some(id) = self.component_move.feature_id.clone() else {
236            return;
237        };
238        self.component_move.anchor = pending.anchor;
239        let Some(index) = self.history.index_of(&id) else {
240            return;
241        };
242        let mut params = self
243            .history
244            .feature_params(index)
245            .unwrap_or_else(|| serde_json::json!({}));
246        if !params.get("transform").map(|t| t.is_object()).unwrap_or(false) {
247            if let Some(object) = params.as_object_mut() {
248                object.insert("transform".into(), serde_json::json!({}));
249            }
250        }
251        if let Some(transform) = params.get_mut("transform").and_then(|t| t.as_object_mut()) {
252            transform.insert("translate".into(), serde_json::json!(pending.translate));
253            transform.insert("rotateEulerDeg".into(), serde_json::json!(pending.rotate_deg));
254        }
255        let _ = self.update_feature_params(&id, &params.to_string());
256    }
257
258    /// The armed component gizmo's logical state for the verifier:
259    /// `{armed, feature, anchor}`.
260    pub fn component_move_json(&self) -> String {
261        serde_json::json!({
262            "armed": self.component_move_armed(),
263            "feature": self.component_move_armed_feature(),
264            "anchor": self.component_move.anchor,
265        })
266        .to_string()
267    }
268}
269
270/// The widget frame feed for a component pose: origin = the ANCHOR (bbox
271/// center), axes = the pose's rotated basis (intrinsic XYZ, the kernel bake).
272/// EVERY handle set is shown (center free-move ball + axis arrows + rotation
273/// arcs) — move and rotate coexist, no mode switch.
274fn component_frame_json(pose: &ComponentPose) -> String {
275    let euler = [
276        pose.rotate_deg[0].to_radians(),
277        pose.rotate_deg[1].to_radians(),
278        pose.rotate_deg[2].to_radians(),
279    ];
280    let x = normalize3(rotate_euler_xyz_f64([1.0, 0.0, 0.0], euler));
281    let y = normalize3(rotate_euler_xyz_f64([0.0, 1.0, 0.0], euler));
282    let z = normalize3(rotate_euler_xyz_f64([0.0, 0.0, 1.0], euler));
283    serde_json::json!({
284        "origin": pose.anchor,
285        "x": x,
286        "y": y,
287        "z": z,
288        "showCenter": true,
289        "showAxes": true,
290        "showRings": true,
291    })
292    .to_string()
293}
294
295/// Translation shifts pose and anchor together. Rotation pivots about anchor C:
296/// `R' = dR·R`, `translate' = C + dR·(translate − C)`.
297pub(super) fn compose_component_delta(
298    start: &ComponentPose,
299    delta: &TransformDelta,
300) -> ComponentPose {
301    match delta {
302        TransformDelta::Translate(d) => ComponentPose {
303            translate: add3(start.translate, *d),
304            rotate_deg: start.rotate_deg,
305            anchor: add3(start.anchor, *d),
306        },
307        TransformDelta::Rotate { axis, radians } => {
308            let dq = quat_from_axis_angle(*axis, *radians);
309            let q0 = quat_from_euler_xyz_deg(start.rotate_deg);
310            let rotate_deg = euler_xyz_deg_from_quat(quat_mul(dq, q0));
311            let offset = sub3(start.translate, start.anchor);
312            ComponentPose {
313                translate: add3(start.anchor, quat_rotate(dq, offset)),
314                rotate_deg,
315                anchor: start.anchor,
316            }
317        }
318    }
319}
320
321/// Rotate `v` by quaternion `q`: `v + 2·(q.xyz × (q.xyz × v + w·v))`.
322fn quat_rotate(q: Quat, v: [f64; 3]) -> [f64; 3] {
323    let u = [q[0], q[1], q[2]];
324    let w = q[3];
325    let t = cross(u, add3(cross(u, v), [w * v[0], w * v[1], w * v[2]]));
326    [v[0] + 2.0 * t[0], v[1] + 2.0 * t[1], v[2] + 2.0 * t[2]]
327}
328
329// BREP private tests: 0d959ee3a6638a39