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 /// Fix ⇄ Unfix (writes `isFixed` on the owning ACOMP; re-runs + re-solves).
32 ToggleFixed,
33 /// Delete the owning ACOMP feature (the library entry GC's kernel-side when
34 /// its last instance goes).
35 Delete,
36}
37
38impl ComponentAction {
39 /// Every action, in bar order.
40 pub const ALL: [ComponentAction; 5] = [
41 ComponentAction::Move,
42 ComponentAction::MoveCopy,
43 ComponentAction::OpenPart,
44 ComponentAction::ToggleFixed,
45 ComponentAction::Delete,
46 ];
47
48 /// Stable id (widget keys / verifier state).
49 pub fn id(self) -> &'static str {
50 match self {
51 ComponentAction::Move => "move",
52 ComponentAction::MoveCopy => "move-copy",
53 ComponentAction::OpenPart => "open-part",
54 ComponentAction::ToggleFixed => "toggle-fixed",
55 ComponentAction::Delete => "delete",
56 }
57 }
58
59 /// The id in reverse (`None` for an unknown id).
60 pub fn from_id(id: &str) -> Option<Self> {
61 Self::ALL.into_iter().find(|action| action.id() == id)
62 }
63
64 /// The button label. `fixed` flips the Fix/Unfix wording.
65 pub fn label(self, fixed: bool) -> &'static str {
66 match self {
67 ComponentAction::Move => "\u{2725} Move",
68 ComponentAction::MoveCopy => "\u{29C9} Move Copy",
69 ComponentAction::OpenPart => "\u{270E} Edit Part",
70 ComponentAction::ToggleFixed => {
71 if fixed {
72 "\u{1F513} Unfix"
73 } else {
74 "\u{1F512} Fix"
75 }
76 }
77 ComponentAction::Delete => "\u{2716} Delete",
78 }
79 }
80
81 /// Hover tooltip.
82 pub fn tooltip(self) -> &'static str {
83 match self {
84 ComponentAction::Move => "Move/rotate gizmo on-off (arrows + arcs together)",
85 ComponentAction::MoveCopy => {
86 "Add another instance of this part and drag it into place with the move gizmo"
87 }
88 ComponentAction::OpenPart => "Open the part's source document in its own tab",
89 ComponentAction::ToggleFixed => "Ground / free this instance for the solver",
90 ComponentAction::Delete => "Delete this component instance",
91 }
92 }
93}
94
95/// A document-level flow the SHELL must run (it owns the open documents + the
96/// file dialog); engine-mutating actions never produce one.
97#[derive(Clone, PartialEq, Eq, Debug)]
98pub enum ComponentActionRequest {
99 OpenPart { component_id: String },
100}
101
102/// Run `action` on the component owned by ACOMP feature `component_id`.
103/// Engine-mutating actions apply immediately; Edit Part returns the request the
104/// shell dispatches. Unknown component ids toast.
105pub fn run_component_action(
106 state: &mut EngineState,
107 action: ComponentAction,
108 component_id: &str,
109) -> Option<ComponentActionRequest> {
110 match action {
111 ComponentAction::Move => {
112 // Toggle the full gizmo (arrows + arcs together); a FIXED component
113 // refuses with a toast inside the engine.
114 state.component_move_toggle(component_id);
115 None
116 }
117 ComponentAction::MoveCopy => {
118 // The copy is free even off a fixed source; the engine toasts an
119 // unknown id itself.
120 let _ = state.component_move_copy(component_id);
121 None
122 }
123 ComponentAction::ToggleFixed => {
124 let Some(info) = state.component_info(component_id) else {
125 state.push_notice(format!("'{component_id}' is not an assembly component"));
126 return None;
127 };
128 let mut params = serde_json::from_str::<serde_json::Value>(
129 &state.feature_params_json(feature_index(state, component_id)?),
130 )
131 .unwrap_or_else(|_| serde_json::json!({}));
132 if let Some(object) = params.as_object_mut() {
133 // An EXPLICIT boolean either way — the kernel honors explicit
134 // `false` (un-fixing the sole component stays possible; the
135 // absent-auto-grounds rule keys on absence only).
136 object.insert("isFixed".into(), serde_json::Value::Bool(!info.fixed));
137 }
138 let _ = state.update_feature_params(component_id, ¶ms.to_string());
139 None
140 }
141 ComponentAction::Delete => {
142 // Deleting the feature is the ONE truth (tree/bar both route here);
143 // the parts-library entry GC's at the kernel's next rebuild when its
144 // last instance goes.
145 let _ = state.delete_feature(component_id);
146 None
147 }
148 ComponentAction::OpenPart => Some(ComponentActionRequest::OpenPart {
149 component_id: component_id.to_string(),
150 }),
151 }
152}
153
154/// The feature index carrying id `id` (the engine exposes index→id, so scan).
155fn feature_index(state: &EngineState, id: &str) -> Option<usize> {
156 (0..state.history_len()).find(|&i| state.feature_id_at(i).as_deref() == Some(id))
157}
158
159/// The component's parts-library `sourceKey` (the ModelStore document name the
160/// part was inserted from), read off the document's `partsLibrary` block —
161/// `None` when the component / entry / key is absent (an imported or
162/// embedded-only part). The Edit-Part flow offers to create a file from the
163/// embedded document when a source is unavailable, and
164/// keys its store lookup on this.
165pub fn part_source_key(state: &EngineState, component_id: &str) -> Option<String> {
166 let info = state.component_info(component_id)?;
167 let document: serde_json::Value =
168 serde_json::from_str(&state.history_request_json()).ok()?;
169 document["partsLibrary"][&info.part_name]["sourceKey"]
170 .as_str()
171 .filter(|key| !key.is_empty())
172 .map(str::to_string)
173}
174