Skip to main content

brep_app/workbench/
commands.rs

1//! Renderer-neutral command snapshots. Availability and dispatch belong to their owners.
2use super::{ButtonState, WorkbenchButton};
3use brep_render::engine_state::EngineState;
4
5pub const RIBBON_TABS: [&str; 3] = ["Home", "View", "Help"];
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8pub enum CommandSize {
9    Large,
10    Compact,
11}
12
13/// Built-ins borrow static strings; runtime adapters borrow their owned metadata.
14#[derive(Clone, Copy, Debug)]
15pub struct CommandPresentation<'a> {
16    pub id: &'a str,
17    pub glyph: &'a str,
18    pub ribbon_path: &'a str,
19    pub size: CommandSize,
20}
21impl<'a> CommandPresentation<'a> {
22    pub fn label(&self) -> &'a str {
23        self.ribbon_path.rsplit('/').next().unwrap_or("")
24    }
25}
26
27pub fn ribbon_segments(path: &str) -> Result<[&str; 3], String> {
28    let parts: Vec<_> = path.split('/').collect();
29    if parts.len() != 3 || parts.iter().any(|p| p.trim().is_empty() || p.trim() != *p) {
30        return Err(format!("invalid ribbon path: {path:?}"));
31    }
32    if !RIBBON_TABS.contains(&parts[0]) {
33        return Err(format!("unknown ribbon tab: {}", parts[0]));
34    }
35    Ok([parts[0], parts[1], parts[2]])
36}
37
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub enum CommandTarget {
40    Shell(&'static str),
41    Workbench(&'static str),
42    Plugin(String),
43    Feature(String),
44    Constraint(String),
45    Annotation(String),
46}
47
48#[derive(Clone, Debug)]
49pub struct OfferedCommand {
50    pub id: String,
51    pub glyph: String,
52    pub ribbon_path: String,
53    pub size: CommandSize,
54    pub target: CommandTarget,
55    pub pressed: bool,
56    pub toggle: bool,
57    pub disabled_reason: Option<String>,
58    pub detail: String,
59    pub menu: Vec<OfferedCommand>,
60}
61impl OfferedCommand {
62    pub fn presentation(&self) -> CommandPresentation<'_> {
63        CommandPresentation {
64            id: &self.id,
65            glyph: &self.glyph,
66            ribbon_path: &self.ribbon_path,
67            size: self.size,
68        }
69    }
70    pub fn segments(&self) -> [&str; 3] {
71        ribbon_segments(self.presentation().ribbon_path).expect("validated command path")
72    }
73    pub fn label(&self) -> &str {
74        self.presentation().label()
75    }
76    pub fn is_home(&self) -> bool {
77        self.segments()[0] == "Home"
78    }
79    /// A creation command: Classic draws these on its creation strip, and
80    /// everything else (File, Undo, actions, tools) on its primary row.
81    pub fn is_creation(&self) -> bool {
82        matches!(
83            self.target,
84            CommandTarget::Feature(_) | CommandTarget::Constraint(_) | CommandTarget::Annotation(_)
85        )
86    }
87}
88
89pub fn validate_commands(commands: &[OfferedCommand]) -> Result<(), String> {
90    fn visit(
91        commands: &[OfferedCommand],
92        ids: &mut std::collections::HashSet<String>,
93    ) -> Result<(), String> {
94        for command in commands {
95            ribbon_segments(&command.ribbon_path)?;
96            if command.id.is_empty() || !ids.insert(command.id.clone()) {
97                return Err(format!("duplicate or empty command id: {}", command.id));
98            }
99            visit(&command.menu, ids)?;
100        }
101        Ok(())
102    }
103    visit(commands, &mut Default::default())
104}
105
106fn static_command(p: CommandPresentation<'static>, target: CommandTarget) -> OfferedCommand {
107    OfferedCommand {
108        id: p.id.into(),
109        glyph: p.glyph.into(),
110        ribbon_path: p.ribbon_path.into(),
111        size: p.size,
112        target,
113        pressed: false,
114        toggle: false,
115        disabled_reason: None,
116        detail: p.label().into(),
117        menu: vec![],
118    }
119}
120
121/// File commands share metadata; Properties and Settings are offered only in File.
122pub const FILE_COMMANDS: &[CommandPresentation<'static>] = &[
123    CommandPresentation {
124        id: "file:new",
125        glyph: "\u{E010}",
126        ribbon_path: "Home/File/New",
127        size: CommandSize::Large,
128    },
129    CommandPresentation {
130        id: "file:open",
131        glyph: "\u{E011}",
132        ribbon_path: "Home/File/Open",
133        size: CommandSize::Large,
134    },
135    CommandPresentation {
136        id: "file:save",
137        glyph: "\u{E012}",
138        ribbon_path: "Home/File/Save",
139        size: CommandSize::Large,
140    },
141    CommandPresentation {
142        id: "file:saveas",
143        glyph: "\u{E013}",
144        ribbon_path: "Home/File/Save As",
145        size: CommandSize::Compact,
146    },
147    CommandPresentation {
148        id: "file:import",
149        glyph: "\u{E014}",
150        ribbon_path: "Home/File/Import",
151        size: CommandSize::Compact,
152    },
153    CommandPresentation {
154        id: "file:export",
155        glyph: "\u{E015}",
156        ribbon_path: "Home/File/Export",
157        size: CommandSize::Compact,
158    },
159    CommandPresentation {
160        id: "properties",
161        glyph: "\u{E066}",
162        ribbon_path: "Home/File/Document Properties",
163        size: CommandSize::Compact,
164    },
165    CommandPresentation {
166        id: "settings",
167        glyph: "\u{2699}",
168        ribbon_path: "Home/File/Settings",
169        size: CommandSize::Compact,
170    },
171    // Plugin management lives under Settings in the File menu, in both styles.
172    CommandPresentation {
173        id: "workbench:manage:plugins",
174        glyph: "\u{2699}",
175        ribbon_path: "Home/File/Plugins",
176        size: CommandSize::Compact,
177    },
178    CommandPresentation {
179        id: "workbench:manage:javascript",
180        glyph: "\u{270E}",
181        ribbon_path: "Home/File/JavaScript",
182        size: CommandSize::Compact,
183    },
184];
185
186/// Flat New rows in DocumentClass::ALL order.
187pub const NEW_DOCUMENT_COMMANDS: &[CommandPresentation<'static>] = &[
188    CommandPresentation {
189        id: "file:newclass:normal",
190        glyph: "\u{E010}",
191        ribbon_path: "Home/File/New part",
192        size: CommandSize::Compact,
193    },
194    CommandPresentation {
195        id: "file:newclass:family",
196        glyph: "\u{E010}",
197        ribbon_path: "Home/File/New family",
198        size: CommandSize::Compact,
199    },
200    CommandPresentation {
201        id: "file:newclass:template",
202        glyph: "\u{E010}",
203        ribbon_path: "Home/File/New template",
204        size: CommandSize::Compact,
205    },
206];
207
208pub fn file_presentation(id: &str) -> CommandPresentation<'static> {
209    *FILE_COMMANDS
210        .iter()
211        .find(|c| c.id == id)
212        .expect("File command is declared")
213}
214
215pub const SHELL_COMMANDS: &[CommandPresentation<'static>] = &[
216    CommandPresentation {
217        id: "undo",
218        glyph: "\u{E016}",
219        ribbon_path: "Home/Undo/Undo",
220        size: CommandSize::Compact,
221    },
222    CommandPresentation {
223        id: "redo",
224        glyph: "\u{E017}",
225        ribbon_path: "Home/Undo/Redo",
226        size: CommandSize::Compact,
227    },
228    CommandPresentation {
229        id: "wireframe",
230        glyph: "\u{1F578}",
231        ribbon_path: "View/Display/Wireframe",
232        size: CommandSize::Compact,
233    },
234    CommandPresentation {
235        id: "projection",
236        glyph: "\u{E018}",
237        ribbon_path: "View/Display/Projection",
238        size: CommandSize::Compact,
239    },
240    CommandPresentation {
241        id: "show:faces",
242        glyph: "\u{E028}",
243        ribbon_path: "View/Display/Show faces",
244        size: CommandSize::Compact,
245    },
246    CommandPresentation {
247        id: "show:edges",
248        glyph: "\u{E029}",
249        ribbon_path: "View/Display/Show edges",
250        size: CommandSize::Compact,
251    },
252    CommandPresentation {
253        id: "show:vertices",
254        glyph: "\u{E02A}",
255        ribbon_path: "View/Display/Show vertices",
256        size: CommandSize::Compact,
257    },
258    CommandPresentation {
259        id: "fit",
260        glyph: "\u{26F6}",
261        ribbon_path: "View/Navigation/Zoom to fit",
262        size: CommandSize::Large,
263    },
264    CommandPresentation {
265        id: "help",
266        glyph: "\u{2753}",
267        ribbon_path: "Help/Support/Help",
268        size: CommandSize::Large,
269    },
270    CommandPresentation {
271        id: "info",
272        glyph: "\u{2139}",
273        ribbon_path: "Help/Support/Info",
274        size: CommandSize::Compact,
275    },
276    CommandPresentation {
277        id: "bug",
278        glyph: "\u{1F41E}",
279        ribbon_path: "Help/Support/Submit Bug",
280        size: CommandSize::Compact,
281    },
282];
283
284pub(crate) fn adapt_button(button: &WorkbenchButton, state: &ButtonState) -> OfferedCommand {
285    let mut c = static_command(button.presentation(), CommandTarget::Workbench(button.id));
286    c.pressed = button.is_pressed(state);
287    c.toggle = button.pressed.is_some();
288    c.disabled_reason = button.disabled_reason(state).map(str::to_owned);
289    c.detail = button.detail(state);
290    c.menu = button
291        .menu
292        .iter()
293        .filter(|b| b.offered(state))
294        .map(|b| adapt_button(b, state))
295        .collect();
296    c
297}
298
299/// Canonical feature command text for text-and-icon surfaces such as palettes.
300pub fn feature_command_label(feature: &serde_json::Value) -> String {
301    let ty = feature["type"].as_str().unwrap_or("");
302    let label = feature["ribbonPath"]
303        .as_str()
304        .and_then(|p| p.rsplit('/').next())
305        .or(feature["longName"].as_str())
306        .or(feature["label"].as_str())
307        .unwrap_or(ty);
308    let glyph = brep_render::features::feature_icon(ty)
309        .map(|g| g.to_string())
310        .or_else(|| feature["icon"].as_str().map(str::to_owned));
311    glyph.map_or_else(|| label.to_owned(), |g| format!("{g} {label}"))
312}
313
314/// The same Home descriptors feed the Classic strip and Ribbon. Order is catalogue order.
315pub fn offered_home_commands(engine: &EngineState) -> Vec<OfferedCommand> {
316    if !engine.settings.show_workbench_toolbar || engine.any_sketch_mode() || engine.ref_select_modal()
317    {
318        return vec![];
319    }
320    // A FORM FIELD's reference pick keeps the strip drawn (the dock stays, so
321    // the layout must not jump) but nothing on it may run: a new feature's
322    // form would replace the one being picked into.
323    let picking = engine.ref_select_active();
324    let mut commands = offered_home_commands_unchecked(engine);
325    if picking {
326        for command in &mut commands {
327            command.disabled_reason = Some(PICKING_DISABLED.into());
328        }
329    }
330    commands
331}
332
333/// Why every creation command is disabled while a form field's reference
334/// pick runs.
335pub const PICKING_DISABLED: &str = "Finish picking the reference first (Done on its field, or Escape)";
336
337/// [`offered_home_commands`] before its mode gate: the catalogue's offer for
338/// the active workbench.
339fn offered_home_commands_unchecked(engine: &EngineState) -> Vec<OfferedCommand> {
340    let active = &engine.settings.workbench;
341    let dynamic = super::owned_workbenches(engine);
342    let active_owned = dynamic.iter().find(|w| w.id == *active);
343    let mut commands = vec![];
344    let catalogue = engine.feature_catalogue();
345    for f in catalogue["features"].as_array().into_iter().flatten() {
346        let Some(ty) = f["type"].as_str() else {
347            continue;
348        };
349        if !active_owned.map_or_else(
350            || super::includes_feature(active, ty),
351            |w| w.feature_types.iter().any(|f| f == ty),
352        ) {
353            continue;
354        }
355        // Older runtime plugins are adapted at this boundary; built-ins declare paths in their schemas.
356        let label = f["label"].as_str().or(f["longName"].as_str()).unwrap_or(ty);
357        let path = f["ribbonPath"]
358            .as_str()
359            .map(str::to_owned)
360            .unwrap_or_else(|| format!("Home/Extensions/{}", label.replace('/', " ")));
361        commands.push(OfferedCommand {
362            id: format!("feature:{ty}"),
363            glyph: brep_render::features::feature_icon(ty)
364                .map(|glyph| glyph.to_string())
365                .or_else(|| f["icon"].as_str().map(str::to_owned))
366                .unwrap_or_else(|| ty.into()),
367            ribbon_path: path,
368            size: if f["commandSize"] == "Compact" {
369                CommandSize::Compact
370            } else {
371                CommandSize::Large
372            },
373            target: CommandTarget::Feature(ty.into()),
374            pressed: false,
375            toggle: false,
376            disabled_reason: None,
377            detail: format!("Add {label}"),
378            menu: vec![],
379        });
380    }
381    let state = ButtonState::of(engine);
382    if super::panel_visible(active, super::assembly::CONSTRAINTS_PANEL_ID, &state) {
383        for d in brep_render::brep_kernel::CONSTRAINT_TYPES {
384            commands.push(creation(
385                format!("constraint:{}", d.type_id),
386                d.icon,
387                format!("Home/Constraints/{}", d.label),
388                CommandTarget::Constraint(d.type_id.into()),
389            ));
390        }
391    }
392    if super::panel_visible(active, super::pmi::PANEL_ID, &state) {
393        for d in brep_render::brep_kernel::PMI_TYPES {
394            commands.push(creation(
395                format!("annotation:{}", d.type_id),
396                d.icon,
397                format!("Home/Annotations/{}", d.label),
398                CommandTarget::Annotation(d.type_id.into()),
399            ));
400        }
401    }
402    // The Reference group leads the home commands: the catalogue lists its
403    // features in two runs (Datium and Plane, then Sketch, Spline, Waypoint
404    // and Helix after the Primitives), and the group is shown as one block
405    // directly after Undo (`place_reference_after_undo`), so the creation
406    // order here is the order the toolbar draws.
407    let (reference, rest): (Vec<_>, Vec<_>) = commands.into_iter().partition(|c| {
408        let s = c.segments();
409        s[0] == "Home" && s[1] == "Reference"
410    });
411    reference.into_iter().chain(rest).collect()
412}
413fn creation(id: String, glyph: &str, path: String, target: CommandTarget) -> OfferedCommand {
414    let label = path.rsplit('/').next().unwrap_or("");
415    let detail = match &target {
416        CommandTarget::Constraint(_) => format!("Add {label} constraint from the selection"),
417        CommandTarget::Annotation(_) => format!(
418            "Add a {} to the active view, from the selection",
419            label.to_lowercase()
420        ),
421        _ => String::new(),
422    };
423    OfferedCommand {
424        id,
425        glyph: glyph.into(),
426        ribbon_path: path,
427        size: CommandSize::Compact,
428        target,
429        pressed: false,
430        toggle: false,
431        disabled_reason: None,
432        detail,
433        menu: vec![],
434    }
435}
436
437pub fn offered_commands(state: &ButtonState, info_open: bool) -> Vec<OfferedCommand> {
438    #[cfg(debug_assertions)]
439    {
440        static VALIDATED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
441        VALIDATED.get_or_init(|| {
442            validate_builtin_presentations().expect("invalid built-in command registry")
443        });
444    }
445    let engine = state.engine;
446    let mut commands: Vec<_> = FILE_COMMANDS
447        .iter()
448        .filter(|p| p.id.starts_with("file:"))
449        .chain(SHELL_COMMANDS)
450        .map(|p| {
451            let mut c = static_command(*p, CommandTarget::Shell(p.id));
452            c.toggle = matches!(
453                p.id,
454                "wireframe" | "projection" | "show:faces" | "show:edges" | "show:vertices" | "info"
455            );
456            c.pressed = match p.id {
457                "wireframe" => engine.settings.wireframe,
458                "projection" => matches!(
459                    engine.camera.projection,
460                    brep_render::view::Projection::Perspective { .. }
461                ),
462                "show:faces" => engine.settings.show_faces,
463                "show:edges" => engine.settings.show_edges,
464                "show:vertices" => engine.settings.show_vertices,
465                "info" => info_open,
466                _ => false,
467            };
468            let unavailable = match p.id {
469                "undo" => {
470                    !(if engine.sketch_mode() {
471                        engine.sketch_can_undo()
472                    } else if engine.sketch3d_mode() {
473                        engine.sketch3d_can_undo()
474                    } else {
475                        engine.can_undo()
476                    })
477                }
478                "redo" => {
479                    !(if engine.sketch_mode() {
480                        engine.sketch_can_redo()
481                    } else if engine.sketch3d_mode() {
482                        engine.sketch3d_can_redo()
483                    } else {
484                        engine.can_redo()
485                    })
486                }
487                _ => false,
488            };
489            if unavailable {
490                c.disabled_reason = Some("No history step available".into());
491            }
492            match p.id {
493                "undo" if engine.any_sketch_mode() => c.detail = "Undo sketch edit (Ctrl+Z)".into(),
494                "redo" if engine.any_sketch_mode() => c.detail = "Redo sketch edit (Ctrl+Y)".into(),
495                "fit" if engine.sheet_open().is_some() => {
496                    c.detail = "Zoom to fit (the open sheet's paper)".into()
497                }
498                "info" => c.detail = "Info (licences and diagnostics)".into(),
499                _ => {}
500            }
501            if p.id == "projection" {
502                c.detail = if c.pressed {
503                    "Perspective projection"
504                } else {
505                    "Orthographic projection"
506                }
507                .into();
508            }
509            c
510        })
511        .collect();
512    commands.extend(
513        super::offered_buttons(&engine.settings.workbench, state)
514            .into_iter()
515            .map(|b| adapt_button(b, state)),
516    );
517    for a in super::plugin_actions(engine, &engine.settings.workbench) {
518        let Some(id) = a["id"].as_str() else { continue };
519        let label = a["label"].as_str().unwrap_or(id);
520        commands.push(OfferedCommand {
521            id: id.into(),
522            glyph: a["glyph"].as_str().unwrap_or("\u{2699}").into(),
523            ribbon_path: a["ribbonPath"]
524                .as_str()
525                .map(str::to_owned)
526                .unwrap_or_else(|| format!("Home/Extensions/{}", label.replace('/', " "))),
527            size: if a["commandSize"] == "Large" {
528                CommandSize::Large
529            } else {
530                CommandSize::Compact
531            },
532            target: CommandTarget::Plugin(id.into()),
533            pressed: false,
534            toggle: false,
535            disabled_reason: None,
536            detail: label.into(),
537            menu: vec![],
538        });
539    }
540    commands.extend(offered_home_commands(engine));
541    place_reference_after_undo(&mut commands);
542    debug_assert!(
543        validate_commands(&commands).is_ok(),
544        "invalid command registry: {:?}",
545        validate_commands(&commands)
546    );
547    commands
548}
549
550/// The Reference group sits directly after Undo in every workbench. A
551/// workbench's own buttons (the Sheet Metal and Assembly groups) are declared
552/// before the feature catalogue, which is where Reference comes from, so
553/// without this they would push Reference behind them. Commands keep their
554/// relative order; only the Reference ones move.
555fn place_reference_after_undo(commands: &mut Vec<OfferedCommand>) {
556    let in_group = |c: &OfferedCommand, group: &str| {
557        let s = c.segments();
558        s[0] == "Home" && s[1] == group
559    };
560    if !commands.iter().any(|c| in_group(c, "Undo")) {
561        return;
562    }
563    let (reference, mut rest): (Vec<_>, Vec<_>) = std::mem::take(commands)
564        .into_iter()
565        .partition(|c| in_group(c, "Reference"));
566    let insert = rest
567        .iter()
568        .rposition(|c| in_group(c, "Undo"))
569        .map_or(rest.len(), |i| i + 1);
570    rest.splice(insert..insert, reference);
571    *commands = rest;
572}
573
574/// Groups are derived in first-declaration order; commands retain source order within them.
575pub fn command_groups<'a>(
576    commands: &'a [OfferedCommand],
577    tab: &str,
578) -> Vec<(&'a str, Vec<&'a OfferedCommand>)> {
579    let mut groups: Vec<(&str, Vec<&OfferedCommand>)> = vec![];
580    for c in commands.iter().filter(|c| c.segments()[0] == tab) {
581        let group = c.segments()[1];
582        if let Some((_, members)) = groups.iter_mut().find(|(name, _)| *name == group) {
583            members.push(c);
584        } else {
585            groups.push((group, vec![c]));
586        }
587    }
588    groups
589}
590
591/// Validate all declarations, including commands not currently offered.
592pub fn validate_builtin_presentations() -> Result<(), String> {
593    let mut seen = std::collections::HashSet::new();
594    let catalogue = brep_render::features::feature_catalogue();
595    for p in FILE_COMMANDS
596        .iter()
597        .chain(NEW_DOCUMENT_COMMANDS)
598        .chain(SHELL_COMMANDS)
599        .copied()
600        .chain(super::every_button_in(super::WORKBENCHES).map(|b| b.presentation()))
601    {
602        ribbon_segments(p.ribbon_path)?;
603        if !seen.insert(p.id.to_owned()) {
604            return Err(format!("duplicate command id: {}", p.id));
605        }
606    }
607    for feature in catalogue["features"].as_array().into_iter().flatten() {
608        let path = feature["ribbonPath"]
609            .as_str()
610            .ok_or_else(|| format!("feature has no ribbonPath: {}", feature["type"]))?;
611        if ribbon_segments(path)?[0] != "Home" {
612            return Err(format!("feature outside Home: {path}"));
613        }
614        if !matches!(feature["commandSize"].as_str(), Some("Large" | "Compact")) {
615            return Err(format!("feature has no commandSize: {path}"));
616        }
617        let ty = feature["type"].as_str().ok_or("feature has no type")?;
618        if !seen.insert(format!("feature:{ty}")) {
619            return Err(format!("duplicate feature type: {ty}"));
620        }
621    }
622    Ok(())
623}
624