Skip to main content

brep_app/panels/
component_actions.rs

1//! The shared COMPONENT action set (assemblies build-spec §8.5) — the ONE
2//! entry point for per-component interactions, consumed by the selection
3//! context bar (this lane) and, through the same enum, by the assembly
4//! structure tree's per-node action hooks (lane F seam: the tree calls
5//! [`run_component_action`] with the node's owning ACOMP feature id — actions
6//! always ROUTE TO the owning feature, one truth, one undo lane).
7//!
8//! Engine-mutating actions (Move / Fix-Unfix / Delete) run HERE against
9//! [`EngineState`]; the document-level flow (Edit Part) returns a
10//! [`ComponentActionRequest`] for the SHELL, which owns the open documents and
11//! the file dialog.
12
13use brep_render::engine_state::EngineState;
14
15/// One per-component action (spec §8.5 / §8.2 tree actions).
16#[derive(Clone, Copy, PartialEq, Eq, Debug)]
17pub enum ComponentAction {
18    /// Toggle the Move gizmo on/off — arrows + rotation arcs together (fixed refuses
19    /// with a toast). Engine-side.
20    Move,
21    /// Open the part's SOURCE document in its own document tab (shell-side).
22    /// Editing a component IS opening its part: there is no separate
23    /// edit-in-place session any more — the part is a document like any other,
24    /// and the assembly picks the change up through the outdated badge /
25    /// `panels::update_components` once the part is saved.
26    OpenPart,
27    /// Fix ⇄ Unfix (writes `isFixed` on the owning ACOMP; re-runs + re-solves).
28    ToggleFixed,
29    /// Delete the owning ACOMP feature (the library entry GC's kernel-side when
30    /// its last instance goes).
31    Delete,
32}
33
34impl ComponentAction {
35    /// Every action, in bar order.
36    pub const ALL: [ComponentAction; 4] = [
37        ComponentAction::Move,
38        ComponentAction::OpenPart,
39        ComponentAction::ToggleFixed,
40        ComponentAction::Delete,
41    ];
42
43    /// Stable id (widget keys / verifier state).
44    pub fn id(self) -> &'static str {
45        match self {
46            ComponentAction::Move => "move",
47            ComponentAction::OpenPart => "open-part",
48            ComponentAction::ToggleFixed => "toggle-fixed",
49            ComponentAction::Delete => "delete",
50        }
51    }
52
53    /// The id in reverse (`None` for an unknown id).
54    pub fn from_id(id: &str) -> Option<Self> {
55        Self::ALL.into_iter().find(|action| action.id() == id)
56    }
57
58    /// The button label. `fixed` flips the Fix/Unfix wording.
59    pub fn label(self, fixed: bool) -> &'static str {
60        match self {
61            ComponentAction::Move => "\u{2725} Move",
62            ComponentAction::OpenPart => "\u{270E} Edit Part",
63            ComponentAction::ToggleFixed => {
64                if fixed {
65                    "\u{1F513} Unfix"
66                } else {
67                    "\u{1F512} Fix"
68                }
69            }
70            ComponentAction::Delete => "\u{2716} Delete",
71        }
72    }
73
74    /// Hover tooltip.
75    pub fn tooltip(self) -> &'static str {
76        match self {
77            ComponentAction::Move => "Move/rotate gizmo on-off (arrows + arcs together)",
78            ComponentAction::OpenPart => "Open the part's source document in its own tab",
79            ComponentAction::ToggleFixed => "Ground / free this instance for the solver",
80            ComponentAction::Delete => "Delete this component instance",
81        }
82    }
83}
84
85/// A document-level flow the SHELL must run (it owns the open documents + the
86/// file dialog); engine-mutating actions never produce one.
87#[derive(Clone, PartialEq, Eq, Debug)]
88pub enum ComponentActionRequest {
89    OpenPart { component_id: String },
90}
91
92/// Run `action` on the component owned by ACOMP feature `component_id`.
93/// Engine-mutating actions apply immediately; Edit Part returns the request the
94/// shell dispatches. Unknown component ids toast.
95pub fn run_component_action(
96    state: &mut EngineState,
97    action: ComponentAction,
98    component_id: &str,
99) -> Option<ComponentActionRequest> {
100    match action {
101        ComponentAction::Move => {
102            // Toggle the full gizmo (arrows + arcs together); a FIXED component
103            // refuses with a toast inside the engine (spec §8.5).
104            state.component_move_toggle(component_id);
105            None
106        }
107        ComponentAction::ToggleFixed => {
108            let Some(info) = state.component_info(component_id) else {
109                state.push_notice(format!("'{component_id}' is not an assembly component"));
110                return None;
111            };
112            let mut params = serde_json::from_str::<serde_json::Value>(
113                &state.feature_params_json(feature_index(state, component_id)?),
114            )
115            .unwrap_or_else(|_| serde_json::json!({}));
116            if let Some(object) = params.as_object_mut() {
117                // An EXPLICIT boolean either way — the kernel honors explicit
118                // `false` (un-fixing the sole component stays possible; the
119                // absent-auto-grounds rule keys on absence only).
120                object.insert("isFixed".into(), serde_json::Value::Bool(!info.fixed));
121            }
122            let _ = state.update_feature_params(component_id, &params.to_string());
123            None
124        }
125        ComponentAction::Delete => {
126            // Deleting the feature is the ONE truth (tree/bar both route here);
127            // the parts-library entry GC's at the kernel's next rebuild when its
128            // last instance goes.
129            let _ = state.delete_feature(component_id);
130            None
131        }
132        ComponentAction::OpenPart => Some(ComponentActionRequest::OpenPart {
133            component_id: component_id.to_string(),
134        }),
135    }
136}
137
138/// The feature index carrying id `id` (the engine exposes index→id, so scan).
139fn feature_index(state: &EngineState, id: &str) -> Option<usize> {
140    (0..state.history_len()).find(|&i| state.feature_id_at(i).as_deref() == Some(id))
141}
142
143/// The component's parts-library `sourceKey` (the ModelStore document name the
144/// part was inserted from), read off the document's `partsLibrary` block —
145/// `None` when the component / entry / key is absent (an imported or
146/// embedded-only part, which therefore has no file to open). The Edit-Part flow
147/// keys its store lookup on this.
148pub fn part_source_key(state: &EngineState, component_id: &str) -> Option<String> {
149    let info = state.component_info(component_id)?;
150    let document: serde_json::Value =
151        serde_json::from_str(&state.history_request_json()).ok()?;
152    document["partsLibrary"][&info.part_name]["sourceKey"]
153        .as_str()
154        .filter(|key| !key.is_empty())
155        .map(str::to_string)
156}
157
158#[cfg(test)]
159pub(crate) mod tests {
160    use super::*;
161
162    /// The two-instance assembly fixture (mirrors the engine-state one: ACOMP1
163    /// fixed at the origin, ACOMP2 free at +20 X, one embedded cube part with
164    /// an empty snapshot so the kernel self-heal lane builds it).
165    pub(crate) fn two_instance_assembly_json() -> String {
166        serde_json::json!({
167            "expressions": "",
168            "configurator": {},
169            "features": [
170                {
171                    "type": "ACOMP",
172                    "inputParams": {
173                        "id": "ACOMP1",
174                        "partName": "widget",
175                        "transform": { "translate": [0.0, 0.0, 0.0], "rotateEulerDeg": [0.0, 0.0, 0.0] },
176                        "isFixed": true
177                    },
178                    "persistentData": {}
179                },
180                {
181                    "type": "ACOMP",
182                    "inputParams": {
183                        "id": "ACOMP2",
184                        "partName": "widget",
185                        "transform": { "translate": [20.0, 0.0, 0.0], "rotateEulerDeg": [0.0, 0.0, 0.0] }
186                    },
187                    "persistentData": {}
188                }
189            ],
190            "partsLibrary": {
191                "widget": {
192                    "sourceKey": "widget",
193                    "sourceSignature": "sig-1",
194                    "document": {
195                        "expressions": "",
196                        "configurator": {},
197                        "features": [{
198                            "type": "P.CU",
199                            "inputParams": {
200                                "id": "Part",
201                                "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
202                                "transform": {
203                                    "position": [0.0, 0.0, 0.0],
204                                    "rotationEuler": [0.0, 0.0, 0.0],
205                                    "scale": [1.0, 1.0, 1.0]
206                                },
207                                "boolean": { "targets": [], "operation": "NONE" }
208                            },
209                            "persistentData": {}
210                        }]
211                    },
212                    "snapshot": ""
213                }
214            }
215        })
216        .to_string()
217    }
218
219    pub(crate) fn assembly_engine() -> EngineState {
220        let mut engine = EngineState::new();
221        engine
222            .set_history_json(&two_instance_assembly_json())
223            .expect("assembly loads");
224        engine
225    }
226
227    #[test]
228    fn toggle_fixed_writes_an_explicit_boolean_both_ways() {
229        let mut engine = assembly_engine();
230        assert!(!engine.component_info("ACOMP2").unwrap().fixed);
231
232        // Fix the free instance…
233        run_component_action(&mut engine, ComponentAction::ToggleFixed, "ACOMP2");
234        assert!(engine.component_info("ACOMP2").unwrap().fixed);
235
236        // …and UNFIX the first (explicitly-fixed) one: the explicit `false`
237        // must be honored (the auto-ground rule keys on ABSENCE only).
238        run_component_action(&mut engine, ComponentAction::ToggleFixed, "ACOMP1");
239        assert!(!engine.component_info("ACOMP1").unwrap().fixed);
240    }
241
242    #[test]
243    fn delete_removes_the_instance_and_its_members() {
244        let mut engine = assembly_engine();
245        assert_eq!(engine.scene.solids().len(), 2);
246        run_component_action(&mut engine, ComponentAction::Delete, "ACOMP2");
247        assert_eq!(engine.history_len(), 1, "the ACOMP feature is gone");
248        let names: Vec<&str> = engine.scene.solids().iter().map(|s| s.name.as_str()).collect();
249        assert_eq!(names, ["ACOMP1:Part"], "only the surviving instance renders");
250    }
251
252    #[test]
253    fn move_action_arms_free_and_toasts_fixed() {
254        let mut engine = assembly_engine();
255        run_component_action(&mut engine, ComponentAction::Move, "ACOMP2");
256        assert!(engine.component_move_armed());
257        assert_eq!(engine.component_move_armed_feature(), "ACOMP2");
258
259        run_component_action(&mut engine, ComponentAction::Move, "ACOMP1");
260        assert_eq!(
261            engine.component_move_armed_feature(),
262            "ACOMP2",
263            "the fixed instance never arms (the free one stays armed)"
264        );
265        let notices = engine.take_notices();
266        assert!(notices.iter().any(|n| n.contains("fixed")), "{notices:?}");
267    }
268
269    #[test]
270    fn document_flows_return_shell_requests() {
271        let mut engine = assembly_engine();
272        assert_eq!(
273            run_component_action(&mut engine, ComponentAction::OpenPart, "ACOMP1"),
274            Some(ComponentActionRequest::OpenPart { component_id: "ACOMP1".into() })
275        );
276    }
277
278    #[test]
279    fn part_source_key_reads_the_library_entry() {
280        let engine = assembly_engine();
281        assert_eq!(
282            part_source_key(&engine, "ACOMP1").as_deref(),
283            Some("widget"),
284            "the entry's sourceKey"
285        );
286        assert_eq!(part_source_key(&engine, "ACOMP9"), None, "unknown component");
287
288        // An EMPTY sourceKey (embedded-only part) reads as None → the Edit-Part
289        // flow has no file to open and says so.
290        let mut doc: serde_json::Value =
291            serde_json::from_str(&two_instance_assembly_json()).unwrap();
292        doc["partsLibrary"]["widget"]["sourceKey"] = serde_json::json!("");
293        let mut engine = EngineState::new();
294        engine.set_history_json(&doc.to_string()).unwrap();
295        assert_eq!(part_source_key(&engine, "ACOMP1"), None);
296    }
297
298    #[test]
299    fn action_ids_round_trip() {
300        for action in ComponentAction::ALL {
301            assert_eq!(ComponentAction::from_id(action.id()), Some(action));
302        }
303        assert_eq!(ComponentAction::from_id("bogus"), None);
304        // Fix/Unfix wording flips on the fixed flag.
305        assert!(ComponentAction::ToggleFixed.label(false).contains("Fix"));
306        assert!(ComponentAction::ToggleFixed.label(true).contains("Unfix"));
307    }
308}