Skip to main content

brep_app/panels/
component_actions.rs

1//! The shared COMPONENT action set — the ONE entry point for per-component
2//! interactions, consumed by the selection context bar (this lane) and,
3//! through the same enum, by the assembly structure tree's per-node action
4//! hooks (lane F seam: the tree calls [`run_component_action`] with the
5//! node's owning ACOMP feature id — actions always ROUTE TO the owning
6//! 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.
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    /// Add a SECOND instance of the same part (a new ACOMP on the source's
22    /// pose, free even when the source is fixed) and arm the Move gizmo on it,
23    /// so the user drags the copy into place. Engine-side.
24    MoveCopy,
25    /// Open the part's SOURCE document in its own document tab (shell-side).
26    /// Editing a component IS opening its part: there is no separate
27    /// edit-in-place session any more — the part is a document like any other,
28    /// and the assembly picks the change up through the outdated badge /
29    /// `panels::update_components` once the part is saved.
30    OpenPart,
31    /// Make the component's part the Work part, in place in the assembly tab
32    /// (shell-side, like the BOM row's double click).
33    WorkPart,
34    /// Fix ⇄ Unfix (writes `isFixed` on the owning ACOMP; re-runs + re-solves).
35    ToggleFixed,
36    /// Replace the part this occurrence shows with another (shell-side: the
37    /// chooser lives in the shell). Mates survive by face name when the parts
38    /// share lineage; the rest are held and listed.
39    Replace,
40    /// Delete the owning ACOMP feature (the library entry GC's kernel-side when
41    /// its last instance goes).
42    Delete,
43}
44
45impl ComponentAction {
46    /// Every action, in bar order.
47    pub const ALL: [ComponentAction; 7] = [
48        ComponentAction::Move,
49        ComponentAction::MoveCopy,
50        ComponentAction::OpenPart,
51        ComponentAction::WorkPart,
52        ComponentAction::Replace,
53        ComponentAction::ToggleFixed,
54        ComponentAction::Delete,
55    ];
56
57    /// Stable id (widget keys / verifier state).
58    pub fn id(self) -> &'static str {
59        match self {
60            ComponentAction::Move => "move",
61            ComponentAction::MoveCopy => "move-copy",
62            ComponentAction::OpenPart => "open-part",
63            ComponentAction::WorkPart => "work-part",
64            ComponentAction::Replace => "replace",
65            ComponentAction::ToggleFixed => "toggle-fixed",
66            ComponentAction::Delete => "delete",
67        }
68    }
69
70    /// The id in reverse (`None` for an unknown id).
71    pub fn from_id(id: &str) -> Option<Self> {
72        Self::ALL.into_iter().find(|action| action.id() == id)
73    }
74
75    /// The button label. `fixed` flips the Fix/Unfix wording.
76    pub fn label(self, fixed: bool) -> &'static str {
77        match self {
78            ComponentAction::Move => "\u{2725} Move",
79            ComponentAction::MoveCopy => "\u{29C9} Move Copy",
80            ComponentAction::OpenPart => "\u{270E} Edit Part",
81            ComponentAction::WorkPart => "\u{25CE} Make Work Part",
82            ComponentAction::Replace => "\u{21C4} Replace\u{2026}",
83            ComponentAction::ToggleFixed => {
84                if fixed {
85                    "\u{1F513} Unfix"
86                } else {
87                    "\u{1F512} Fix"
88                }
89            }
90            ComponentAction::Delete => "\u{2716} Delete",
91        }
92    }
93
94    /// Hover tooltip.
95    pub fn tooltip(self) -> &'static str {
96        match self {
97            ComponentAction::Move => "Move/rotate gizmo on-off (arrows + arcs together)",
98            ComponentAction::MoveCopy => {
99                "Add another instance of this part and drag it into place with the move gizmo"
100            }
101            ComponentAction::OpenPart => "Open the part's source document in its own tab",
102            ComponentAction::WorkPart => "Edit this part here, in the context of the assembly",
103            ComponentAction::Replace => {
104                "Show another part here (a family member, a variation, any part); mates keep their faces by name"
105            }
106            ComponentAction::ToggleFixed => "Ground / free this instance for the solver",
107            ComponentAction::Delete => "Delete this component instance",
108        }
109    }
110}
111
112/// A document-level flow the SHELL must run (it owns the open documents + the
113/// file dialog); engine-mutating actions never produce one.
114#[derive(Clone, PartialEq, Eq, Debug)]
115pub enum ComponentActionRequest {
116    OpenPart { component_id: String },
117    /// Open the Replace component chooser for this occurrence.
118    Replace { component_id: String },
119    /// Make `component_path` the Work part — in place inside the assembly
120    /// tab, or (`in_place: false`) in a tab of its own.
121    WorkPart { component_path: String, in_place: bool },
122}
123
124/// Run `action` on the component owned by ACOMP feature `component_id`.
125/// Engine-mutating actions apply immediately; Edit Part returns the request the
126/// shell dispatches. Unknown component ids toast.
127pub fn run_component_action(
128    state: &mut EngineState,
129    action: ComponentAction,
130    component_id: &str,
131) -> Option<ComponentActionRequest> {
132    match action {
133        ComponentAction::Move => {
134            // Toggle the full gizmo (arrows + arcs together); a FIXED component
135            // refuses with a toast inside the engine.
136            state.component_move_toggle(component_id);
137            None
138        }
139        ComponentAction::MoveCopy => {
140            // The copy is free even off a fixed source; the engine toasts an
141            // unknown id itself.
142            let _ = state.component_move_copy(component_id);
143            None
144        }
145        ComponentAction::ToggleFixed if component_id.contains(':') => {
146            // A nested occurrence: fixed or freed on the instance that lends
147            // it; a rigid level above it says why it cannot be.
148            let toggled = state
149                .occurrence_fixed(component_id)
150                .and_then(|fixed| state.set_component_fixed(component_id, !fixed));
151            if let Err(error) = toggled {
152                state.push_notice(error);
153            }
154            None
155        }
156        ComponentAction::ToggleFixed => {
157            let Some(info) = state.component_info(component_id) else {
158                state.push_notice(format!("'{component_id}' is not an assembly component"));
159                return None;
160            };
161            let mut params = serde_json::from_str::<serde_json::Value>(
162                &state.feature_params_json(feature_index(state, component_id)?),
163            )
164            .unwrap_or_else(|_| serde_json::json!({}));
165            if let Some(object) = params.as_object_mut() {
166                // An EXPLICIT boolean either way — the kernel honors explicit
167                // `false` (un-fixing the sole component stays possible; the
168                // absent-auto-grounds rule keys on absence only).
169                object.insert("isFixed".into(), serde_json::Value::Bool(!info.fixed));
170            }
171            let _ = state.update_feature_params(component_id, &params.to_string());
172            None
173        }
174        ComponentAction::Delete => {
175            // Deleting the feature is the ONE truth (tree/bar both route here);
176            // the parts-library entry GC's at the kernel's next rebuild when its
177            // last instance goes.
178            let _ = state.delete_feature(component_id);
179            None
180        }
181        ComponentAction::OpenPart => Some(ComponentActionRequest::OpenPart {
182            component_id: component_id.to_string(),
183        }),
184        ComponentAction::Replace => Some(ComponentActionRequest::Replace {
185            component_id: component_id.to_string(),
186        }),
187        ComponentAction::WorkPart => Some(ComponentActionRequest::WorkPart {
188            component_path: component_id.to_string(),
189            in_place: true,
190        }),
191    }
192}
193
194/// The feature index carrying id `id` (the engine exposes index→id, so scan).
195fn feature_index(state: &EngineState, id: &str) -> Option<usize> {
196    (0..state.history_len()).find(|&i| state.feature_id_at(i).as_deref() == Some(id))
197}
198
199/// The component's parts-library `sourceKey` (the ModelStore document name the
200/// part was inserted from), read off the document's `partsLibrary` block —
201/// `None` when the component / entry / key is absent (an imported or
202/// embedded-only part). The Edit-Part flow offers to create a file from the
203/// embedded document when a source is unavailable, and
204/// keys its store lookup on this.
205pub fn part_source_key(state: &EngineState, component_id: &str) -> Option<String> {
206    let info = state.component_info(component_id)?;
207    let document: serde_json::Value =
208        serde_json::from_str(&state.history_request_json()).ok()?;
209    document["partsLibrary"][&info.part_name]["sourceKey"]
210        .as_str()
211        .filter(|key| !key.is_empty())
212        .map(str::to_string)
213}
214