Skip to main content

brep_app/panels/
component_actions.rs

1//! The shared COMPONENT action set: the ONE list of what can be done to a
2//! component, with each entry's availability and the reason when it is
3//! refused, drawn by every surface a component is acted on from: the
4//! selection context bar, the viewport's right-click menu and the BOM row
5//! menu ([`component_menu`]); and the ONE dispatcher those surfaces call
6//! ([`run_component_actions`]). Actions always route to the owning feature:
7//! one truth, one undo step.
8//!
9//! Engine-mutating actions (Move, Unfix and Move, Move Copy, Fix/Unfix, Hide,
10//! Delete) run HERE against [`EngineState`]; the document-level flows (Make
11//! Work Part, Open part, Save part as, Replace, Info) return a
12//! [`ComponentActionRequest`] for the SHELL, which owns the open documents,
13//! the file dialog and the Info windows.
14
15use brep_render::engine_state::EngineState;
16
17use crate::column_tree::RowAction;
18
19/// One per-component action.
20#[derive(Clone, Copy, PartialEq, Eq, Debug)]
21pub enum ComponentAction {
22    /// Make the component's part the Work part, in place in the assembly tab
23    /// (shell-side, like the BOM row's double click).
24    WorkPart,
25    /// Open the part in a tab of its own: the live Work part session, so the
26    /// assembly updates as the part is edited (shell-side). The ONE "Open
27    /// part" flow every menu offers (owner 2026-11-05).
28    OpenPart,
29    /// A part with no file of its own (embedded): save it to a new part file
30    /// (shell-side: the file dialog).
31    SavePartAs,
32    /// Roll the history to the component's ACOMP feature and open it there
33    /// (shell-side: the history panel's focus).
34    EditFeature,
35    /// Toggle the Move gizmo on/off — arrows + rotation arcs together.
36    /// Refused, with the reason, on a fixed component. Engine-side.
37    Move,
38    /// Unfix the fixed component and move it: unfixed only when the drag
39    /// commits, in the move's own undo step (the same `isFixed`). Engine-side.
40    UnfixMove,
41    /// Add a SECOND instance of the same part (a new ACOMP on the source's
42    /// pose, free even when the source is fixed) and arm the Move gizmo on it,
43    /// so the user drags the copy into place. Engine-side.
44    MoveCopy,
45    /// Replace the part this occurrence shows with another (shell-side: the
46    /// chooser lives in the shell). Mates survive by face name when the parts
47    /// share lineage; the rest are held and listed.
48    Replace,
49    /// Fix ⇄ Unfix: the component's ONE fixed setting (`isFixed`, or
50    /// `flexibleFixed` for a part inside a flexible sub-assembly), one undo
51    /// step, the same command as `component_set_fixed`.
52    ToggleFixed,
53    /// Hide ⇄ Show the component's member solids. Engine-side.
54    ToggleVisible,
55    /// Open a pinned Info window per member solid (shell-side).
56    Info,
57    /// Delete the owning ACOMP feature (the library entry GC's kernel-side when
58    /// its last instance goes).
59    Delete,
60}
61
62impl ComponentAction {
63    /// Every action, in menu order.
64    pub const ALL: [ComponentAction; 12] = [
65        ComponentAction::WorkPart,
66        ComponentAction::OpenPart,
67        ComponentAction::SavePartAs,
68        ComponentAction::EditFeature,
69        ComponentAction::Move,
70        ComponentAction::UnfixMove,
71        ComponentAction::MoveCopy,
72        ComponentAction::Replace,
73        ComponentAction::ToggleFixed,
74        ComponentAction::ToggleVisible,
75        ComponentAction::Info,
76        ComponentAction::Delete,
77    ];
78
79    /// Stable id (widget keys `menuitem:<row>:<id>` / `component:<id>`).
80    pub fn id(self) -> &'static str {
81        match self {
82            ComponentAction::WorkPart => "make-work-part",
83            ComponentAction::OpenPart => "open-part",
84            ComponentAction::SavePartAs => "save-part-as",
85            ComponentAction::EditFeature => "edit-feature",
86            ComponentAction::Move => "move",
87            ComponentAction::UnfixMove => "unfix-move",
88            ComponentAction::MoveCopy => "move-copy",
89            ComponentAction::Replace => "replace",
90            ComponentAction::ToggleFixed => "toggle-fixed",
91            ComponentAction::ToggleVisible => "toggle-visible",
92            ComponentAction::Info => "info",
93            ComponentAction::Delete => "delete",
94        }
95    }
96
97    /// The id in reverse (`None` for an unknown id).
98    pub fn from_id(id: &str) -> Option<Self> {
99        Self::ALL.into_iter().find(|action| action.id() == id)
100    }
101
102    /// The label. `on` flips the two-state ones: Fix/Unfix (`on` = fixed) and
103    /// Hide/Show (`on` = visible).
104    pub fn label(self, on: bool) -> &'static str {
105        match self {
106            ComponentAction::WorkPart => "\u{25CE} Make Work Part",
107            ComponentAction::OpenPart => "Open part",
108            ComponentAction::SavePartAs => "Save part as\u{2026}",
109            ComponentAction::EditFeature => "\u{270E} Edit feature",
110            ComponentAction::Move => "\u{2725} Move",
111            ComponentAction::UnfixMove => "\u{1F513} Unfix and Move",
112            ComponentAction::MoveCopy => "\u{29C9} Move Copy",
113            ComponentAction::Replace => "\u{21C4} Replace\u{2026}",
114            ComponentAction::ToggleFixed if on => "\u{1F513} Unfix",
115            ComponentAction::ToggleFixed => "\u{1F512} Fix",
116            ComponentAction::ToggleVisible if on => "\u{1f441} Hide",
117            ComponentAction::ToggleVisible => "\u{1f441} Show",
118            ComponentAction::Info => "\u{1f575} Info",
119            ComponentAction::Delete => "\u{2716} Delete",
120        }
121    }
122
123    /// Hover tooltip.
124    pub fn tooltip(self) -> &'static str {
125        match self {
126            ComponentAction::WorkPart => "Edit this part here, in the context of the assembly (double click)",
127            ComponentAction::OpenPart => "Open this part in a tab of its own; the assembly updates as you edit",
128            ComponentAction::SavePartAs => "This part has no file of its own: save it to a new part file you name",
129            ComponentAction::EditFeature => "Roll to this component's feature and open it in the history",
130            ComponentAction::Move => "Move/rotate gizmo on-off (arrows + arcs together)",
131            ComponentAction::UnfixMove => {
132                "Unfix this component and move it (unfixed only when you drag; one undo step)"
133            }
134            ComponentAction::MoveCopy => {
135                "Add another instance of this part and drag it into place with the move gizmo"
136            }
137            ComponentAction::Replace => {
138                "Show another part here (a family member, a variation, any part); mates keep their faces by name"
139            }
140            ComponentAction::ToggleFixed => "Fix this part in place, or free it, for the solver (its Fixed setting)",
141            ComponentAction::ToggleVisible => "Hide or show this component",
142            ComponentAction::Info => "Open a pinned Info window for this component",
143            ComponentAction::Delete => "Delete this component instance",
144        }
145    }
146}
147
148/// A document-level flow the SHELL must run (it owns the open documents, the
149/// file dialog and the Info windows); engine-mutating actions never produce
150/// one.
151#[derive(Clone, PartialEq, Eq, Debug)]
152pub enum ComponentActionRequest {
153    /// Save an embedded part to a new part file (the shell's part flow).
154    SavePartAs { component_id: String },
155    /// Open the Replace component chooser for this occurrence.
156    Replace { component_id: String },
157    /// Make `component_path` the Work part — in place inside the assembly
158    /// tab, or (`in_place: false`) in a tab of its own.
159    WorkPart { component_path: String, in_place: bool },
160    /// Open one pinned Info window per entity name.
161    Info { targets: Vec<String> },
162    /// Focus the history on this feature (the menu already rolled to it).
163    EditFeature { feature_id: String },
164}
165
166/// The Pattern Component instance menu's first, disabled line.
167pub const PATTERN_INFO: &str = "pattern-info";
168
169/// What a component menu stands for: the placements it acts on (one ACOMP
170/// id, several for a packed BOM row, a nested occurrence path, or a Pattern
171/// Component instance) and the facts its entries are decided on.
172pub struct MenuSubject {
173    pub ids: Vec<String>,
174    /// The part has no file of its own (an embedded-only part): Save part as
175    /// is offered.
176    pub embedded: bool,
177    /// Each placement's Fixed setting, or why it cannot be changed here (a
178    /// part inside a rigid sub-assembly).
179    pub fixed: Vec<Result<bool, String>>,
180    /// Every member solid of every placement is visible.
181    pub visible: bool,
182}
183
184impl MenuSubject {
185    /// The subject for `ids`, read off the engine (a nested BOM line builds
186    /// its own from the facts it already has, per frame, without a scan).
187    pub fn of(state: &EngineState, ids: &[String]) -> Self {
188        let instance = ids.first().and_then(|id| brep_render::brep_kernel::pattern_component_instance(id));
189        let owner = |id: &String| match &instance {
190            Some((_, seed, _)) => seed.clone(),
191            None => id.clone(),
192        };
193        let embedded = ids.first().and_then(|id| state.component_info(&owner(id))).is_some_and(|info| {
194            !state.part_source(&info.part_name).is_some_and(|(key, _)| !key.is_empty())
195        });
196        let fixed = ids
197            .iter()
198            .map(|id| {
199                if instance.is_none() && id.contains(':') {
200                    state.occurrence_fixed(id)
201                } else {
202                    state
203                        .component_info(&owner(id))
204                        .map(|info| info.fixed)
205                        .ok_or_else(|| format!("'{id}' is not an assembly component"))
206                }
207            })
208            .collect();
209        Self { ids: ids.to_vec(), embedded, fixed, visible: all_visible(state, ids) }
210    }
211}
212
213/// Every solid of the scene under `id` (`{id}:…`).
214fn member_solids(state: &EngineState, id: &str) -> Vec<String> {
215    let prefix = format!("{id}:");
216    state
217        .scene
218        .solids()
219        .iter()
220        .filter(|solid| solid.name.starts_with(&prefix))
221        .map(|solid| solid.name.clone())
222        .collect()
223}
224
225/// Whether every member solid of every placement is visible.
226fn all_visible(state: &EngineState, ids: &[String]) -> bool {
227    ids.iter().all(|id| {
228        let prefix = format!("{id}:");
229        state.scene.solids().iter().filter(|solid| solid.name.starts_with(&prefix)).all(|solid| solid.visible)
230    })
231}
232
233/// THE component menu: every [`ComponentAction`] in menu order, each enabled
234/// or refused with its reason, for `subject`. The context bar, the viewport's
235/// right-click menu and the BOM row menu all draw exactly this list.
236pub fn component_menu(subject: &MenuSubject) -> Vec<RowAction> {
237    let ids = &subject.ids;
238    let Some(first) = ids.first() else { return Vec::new() };
239    let instance = brep_render::brep_kernel::pattern_component_instance(first);
240    let nested = instance.is_none() && first.contains(':');
241    let rolled_up = ids.len() > 1;
242    let n = ids.len();
243    let fixed_states = &subject.fixed;
244    let any_fixed = fixed_states.iter().any(|state| matches!(state, Ok(true)));
245    let all_fixed = fixed_states.iter().all(|state| matches!(state, Ok(true)));
246    let visible = subject.visible;
247    let unpack = |verb: &str| format!("{n} placements on this row \u{2014} switch to Unpacked to {verb} one");
248    let inside = "A part inside a sub-assembly: make the sub-assembly the Work part to do this";
249    let copy = "A pattern copy: it follows its seed \u{2014} act on the seed";
250    let mut menu = Vec::new();
251    if let Some((pattern, seed, number)) = &instance {
252        menu.push(
253            RowAction::new(PATTERN_INFO, format!("Instance {number} of {pattern}: follows {seed}"))
254                .disabled("A pattern copy: these act on its seed, and every copy follows"),
255        );
256    }
257    for action in ComponentAction::ALL {
258        let label = match action {
259            ComponentAction::ToggleFixed => action.label(any_fixed),
260            ComponentAction::ToggleVisible => action.label(visible),
261            _ => action.label(false),
262        };
263        let entry = RowAction::new(action.id(), label).tooltip(action.tooltip());
264        let entry = match action {
265            ComponentAction::SavePartAs if !subject.embedded => continue,
266            ComponentAction::UnfixMove if !any_fixed || nested || instance.is_some() => continue,
267            ComponentAction::Move | ComponentAction::UnfixMove | ComponentAction::MoveCopy | ComponentAction::Delete
268                if instance.is_some() =>
269            {
270                entry.disabled(copy)
271            }
272            ComponentAction::ToggleFixed if instance.is_some() => entry.disabled(copy),
273            ComponentAction::Move
274            | ComponentAction::UnfixMove
275            | ComponentAction::MoveCopy
276            | ComponentAction::Delete
277            | ComponentAction::EditFeature
278                if nested =>
279            {
280                entry.disabled(inside)
281            }
282            ComponentAction::Move if rolled_up => entry.disabled(unpack("move")),
283            ComponentAction::UnfixMove if rolled_up => entry.disabled(unpack("move")),
284            ComponentAction::MoveCopy if rolled_up => entry.disabled(unpack("copy")),
285            ComponentAction::Delete if rolled_up => entry.disabled(unpack("delete")),
286            ComponentAction::Move if all_fixed => {
287                entry.disabled("This component is fixed \u{2014} Unfix it, or use Unfix and Move")
288            }
289            // A packed row's Fix/Unfix acts on EVERY placement it stands for,
290            // as one undo step: Unfix frees the fixed ones (it reads Unfix when
291            // ANY is), Fix fixes them all. A nested line standing for several
292            // placements cannot (they live on different instances).
293            ComponentAction::ToggleFixed if rolled_up && nested => entry.disabled(unpack("fix or unfix")),
294            ComponentAction::ToggleFixed if rolled_up => entry.tooltip(if any_fixed {
295                format!("Unfix the fixed placements among the {n} on this row (one undo step)")
296            } else {
297                format!("Fix all {n} placements on this row in place (one undo step)")
298            }),
299            ComponentAction::ToggleFixed => match fixed_states.first() {
300                Some(Err(reason)) => entry.disabled(reason.clone()),
301                _ => entry,
302            },
303            _ => entry,
304        };
305        menu.push(match action {
306            // The destructive tail, fenced off from the rest.
307            ComponentAction::Delete => entry.separator_above().destructive(),
308            ComponentAction::EditFeature | ComponentAction::Move => entry.separator_above(),
309            _ => entry,
310        });
311    }
312    menu
313}
314
315/// Run `action` on one component (see [`run_component_actions`]).
316pub fn run_component_action(
317    state: &mut EngineState,
318    action: ComponentAction,
319    component_id: &str,
320) -> Option<ComponentActionRequest> {
321    run_component_actions(state, action, &[component_id.to_string()])
322}
323
324/// Run `action` on the placements a menu stands for (`ids`: one ACOMP id,
325/// several for a packed BOM row, a nested path, or a pattern instance, which
326/// acts on its seed). Engine-mutating actions apply immediately, one undo step;
327/// a document-level flow comes back as the request the shell dispatches.
328/// Refusals toast.
329pub fn run_component_actions(
330    state: &mut EngineState,
331    action: ComponentAction,
332    ids: &[String],
333) -> Option<ComponentActionRequest> {
334    let first = ids.first()?.clone();
335    // On a pattern instance everything but Replace acts on its seed (Replace
336    // keeps the instance id: the chooser opens on the seed and says why).
337    let seed = brep_render::brep_kernel::pattern_component_instance(&first).map(|(_, seed, _)| seed);
338    let target = match (&seed, action) {
339        (Some(_), ComponentAction::Replace) | (None, _) => first.clone(),
340        (Some(seed), _) => seed.clone(),
341    };
342    match action {
343        ComponentAction::Move => {
344            // Toggle the full gizmo (arrows + arcs together); a FIXED component
345            // refuses with a toast inside the engine.
346            state.component_move_toggle(&target);
347            None
348        }
349        ComponentAction::UnfixMove => {
350            state.component_move_unfix_toggle(&[target]);
351            None
352        }
353        ComponentAction::MoveCopy => {
354            // The copy is free even off a fixed source; the engine toasts an
355            // unknown id itself.
356            let _ = state.component_move_copy(&target);
357            None
358        }
359        ComponentAction::ToggleFixed if ids.len() > 1 => {
360            // A packed row: the same `isFixed` write across every placement,
361            // one undo step. Unfix when any is fixed, else Fix them all.
362            let any_fixed = ids.iter().any(|id| state.component_info(id).is_some_and(|info| info.fixed));
363            if let Err(error) = state.set_components_fixed(ids, !any_fixed) {
364                state.push_notice(error);
365            }
366            None
367        }
368        ComponentAction::ToggleFixed if target.contains(':') => {
369            // A nested occurrence: fixed or freed on the instance that lends
370            // it; a rigid level above it says why it cannot be.
371            let toggled = state
372                .occurrence_fixed(&target)
373                .and_then(|fixed| state.set_component_fixed(&target, !fixed));
374            if let Err(error) = toggled {
375                state.push_notice(error);
376            }
377            None
378        }
379        ComponentAction::ToggleFixed => {
380            let Some(info) = state.component_info(&target) else {
381                state.push_notice(format!("'{target}' is not an assembly component"));
382                return None;
383            };
384            // The ONE Fix/Unfix command (`component_set_fixed`): an explicit
385            // `isFixed` boolean either way on the owning ACOMP, one undo step.
386            if let Err(error) = state.set_component_fixed(&target, !info.fixed) {
387                state.push_notice(error);
388            }
389            None
390        }
391        ComponentAction::ToggleVisible => {
392            let ids: Vec<String> = if seed.is_some() { vec![first] } else { ids.to_vec() };
393            let visible = !all_visible(state, &ids);
394            let solids: Vec<String> = ids.iter().flat_map(|id| member_solids(state, id)).collect();
395            state.set_visible_many(&solids, visible);
396            None
397        }
398        ComponentAction::Info => {
399            let ids: Vec<String> = if seed.is_some() { vec![first] } else { ids.to_vec() };
400            let targets: Vec<String> = ids.iter().flat_map(|id| member_solids(state, id)).collect();
401            (!targets.is_empty()).then_some(ComponentActionRequest::Info { targets })
402        }
403        ComponentAction::Delete => {
404            // Deleting the feature is the ONE truth (every menu routes here);
405            // the parts-library entry GC's at the kernel's next rebuild when its
406            // last instance goes.
407            let _ = state.delete_feature(&target);
408            None
409        }
410        ComponentAction::SavePartAs => Some(ComponentActionRequest::SavePartAs { component_id: target }),
411        ComponentAction::EditFeature => {
412            let index = state.history.index_of(&target);
413            match index {
414                Some(index) => {
415                    state.roll_to(index);
416                    Some(ComponentActionRequest::EditFeature { feature_id: target })
417                }
418                None => {
419                    state.push_notice(format!("'{target}' has no feature of its own"));
420                    None
421                }
422            }
423        }
424        ComponentAction::Replace => Some(ComponentActionRequest::Replace { component_id: target }),
425        ComponentAction::WorkPart | ComponentAction::OpenPart => Some(ComponentActionRequest::WorkPart {
426            component_path: target,
427            in_place: action == ComponentAction::WorkPart,
428        }),
429    }
430}
431
432/// The component's parts-library `sourceKey` (the ModelStore document name the
433/// part was inserted from), read off the document's `partsLibrary` block —
434/// `None` when the component / entry / key is absent (an imported or
435/// embedded-only part). The Edit-Part flow offers to create a file from the
436/// embedded document when a source is unavailable, and
437/// keys its store lookup on this.
438pub fn part_source_key(state: &EngineState, component_id: &str) -> Option<String> {
439    let info = state.component_info(component_id)?;
440    let document: serde_json::Value =
441        serde_json::from_str(&state.history_request_json()).ok()?;
442    document["partsLibrary"][&info.part_name]["sourceKey"]
443        .as_str()
444        .filter(|key| !key.is_empty())
445        .map(str::to_string)
446}
447