1use 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#[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 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
121pub 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 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
186pub 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
299pub 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
314pub 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 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
333pub const PICKING_DISABLED: &str = "Finish picking the reference first (Done on its field, or Escape)";
336
337fn 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 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 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
550fn 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
574pub 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
591pub 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