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.sketch_mode() || engine.ref_select_active()
317 {
318 return vec![];
319 }
320 let active = &engine.settings.workbench;
321 let dynamic = super::owned_workbenches(engine);
322 let active_owned = dynamic.iter().find(|w| w.id == *active);
323 let mut commands = vec![];
324 let catalogue = engine.feature_catalogue();
325 for f in catalogue["features"].as_array().into_iter().flatten() {
326 let Some(ty) = f["type"].as_str() else {
327 continue;
328 };
329 if !active_owned.map_or_else(
330 || super::includes_feature(active, ty),
331 |w| w.feature_types.iter().any(|f| f == ty),
332 ) {
333 continue;
334 }
335 let label = f["label"].as_str().or(f["longName"].as_str()).unwrap_or(ty);
337 let path = f["ribbonPath"]
338 .as_str()
339 .map(str::to_owned)
340 .unwrap_or_else(|| format!("Home/Extensions/{}", label.replace('/', " ")));
341 commands.push(OfferedCommand {
342 id: format!("feature:{ty}"),
343 glyph: brep_render::features::feature_icon(ty)
344 .map(|glyph| glyph.to_string())
345 .or_else(|| f["icon"].as_str().map(str::to_owned))
346 .unwrap_or_else(|| ty.into()),
347 ribbon_path: path,
348 size: if f["commandSize"] == "Compact" {
349 CommandSize::Compact
350 } else {
351 CommandSize::Large
352 },
353 target: CommandTarget::Feature(ty.into()),
354 pressed: false,
355 toggle: false,
356 disabled_reason: None,
357 detail: format!("Add {label}"),
358 menu: vec![],
359 });
360 }
361 let state = ButtonState::of(engine);
362 if super::panel_visible(active, super::assembly::CONSTRAINTS_PANEL_ID, &state) {
363 for d in brep_render::brep_kernel::CONSTRAINT_TYPES {
364 commands.push(creation(
365 format!("constraint:{}", d.type_id),
366 d.icon,
367 format!("Home/Constraints/{}", d.label),
368 CommandTarget::Constraint(d.type_id.into()),
369 ));
370 }
371 }
372 if super::panel_visible(active, super::pmi::PANEL_ID, &state) {
373 for d in brep_render::brep_kernel::PMI_TYPES {
374 commands.push(creation(
375 format!("annotation:{}", d.type_id),
376 d.icon,
377 format!("Home/Annotations/{}", d.label),
378 CommandTarget::Annotation(d.type_id.into()),
379 ));
380 }
381 }
382 commands
383}
384fn creation(id: String, glyph: &str, path: String, target: CommandTarget) -> OfferedCommand {
385 let label = path.rsplit('/').next().unwrap_or("");
386 let detail = match &target {
387 CommandTarget::Constraint(_) => format!("Add {label} constraint from the selection"),
388 CommandTarget::Annotation(_) => format!(
389 "Add a {} to the active view, from the selection",
390 label.to_lowercase()
391 ),
392 _ => String::new(),
393 };
394 OfferedCommand {
395 id,
396 glyph: glyph.into(),
397 ribbon_path: path,
398 size: CommandSize::Compact,
399 target,
400 pressed: false,
401 toggle: false,
402 disabled_reason: None,
403 detail,
404 menu: vec![],
405 }
406}
407
408pub fn offered_commands(state: &ButtonState, info_open: bool) -> Vec<OfferedCommand> {
409 #[cfg(debug_assertions)]
410 {
411 static VALIDATED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
412 VALIDATED.get_or_init(|| {
413 validate_builtin_presentations().expect("invalid built-in command registry")
414 });
415 }
416 let engine = state.engine;
417 let mut commands: Vec<_> = FILE_COMMANDS
418 .iter()
419 .filter(|p| p.id.starts_with("file:"))
420 .chain(SHELL_COMMANDS)
421 .map(|p| {
422 let mut c = static_command(*p, CommandTarget::Shell(p.id));
423 c.toggle = matches!(
424 p.id,
425 "wireframe" | "projection" | "show:faces" | "show:edges" | "show:vertices" | "info"
426 );
427 c.pressed = match p.id {
428 "wireframe" => engine.settings.wireframe,
429 "projection" => matches!(
430 engine.camera.projection,
431 brep_render::view::Projection::Perspective { .. }
432 ),
433 "show:faces" => engine.settings.show_faces,
434 "show:edges" => engine.settings.show_edges,
435 "show:vertices" => engine.settings.show_vertices,
436 "info" => info_open,
437 _ => false,
438 };
439 let unavailable = match p.id {
440 "undo" => {
441 !(if engine.sketch_mode() {
442 engine.sketch_can_undo()
443 } else {
444 engine.can_undo()
445 })
446 }
447 "redo" => {
448 !(if engine.sketch_mode() {
449 engine.sketch_can_redo()
450 } else {
451 engine.can_redo()
452 })
453 }
454 _ => false,
455 };
456 if unavailable {
457 c.disabled_reason = Some("No history step available".into());
458 }
459 match p.id {
460 "undo" if engine.sketch_mode() => c.detail = "Undo sketch edit (Ctrl+Z)".into(),
461 "redo" if engine.sketch_mode() => c.detail = "Redo sketch edit (Ctrl+Y)".into(),
462 "fit" if engine.sheet_open().is_some() => {
463 c.detail = "Zoom to fit (the open sheet's paper)".into()
464 }
465 "info" => c.detail = "Info (licences and diagnostics)".into(),
466 _ => {}
467 }
468 if p.id == "projection" {
469 c.detail = if c.pressed {
470 "Perspective projection"
471 } else {
472 "Orthographic projection"
473 }
474 .into();
475 }
476 c
477 })
478 .collect();
479 commands.extend(
480 super::offered_buttons(&engine.settings.workbench, state)
481 .into_iter()
482 .map(|b| adapt_button(b, state)),
483 );
484 for a in super::plugin_actions(engine, &engine.settings.workbench) {
485 let Some(id) = a["id"].as_str() else { continue };
486 let label = a["label"].as_str().unwrap_or(id);
487 commands.push(OfferedCommand {
488 id: id.into(),
489 glyph: a["glyph"].as_str().unwrap_or("\u{2699}").into(),
490 ribbon_path: a["ribbonPath"]
491 .as_str()
492 .map(str::to_owned)
493 .unwrap_or_else(|| format!("Home/Extensions/{}", label.replace('/', " "))),
494 size: if a["commandSize"] == "Large" {
495 CommandSize::Large
496 } else {
497 CommandSize::Compact
498 },
499 target: CommandTarget::Plugin(id.into()),
500 pressed: false,
501 toggle: false,
502 disabled_reason: None,
503 detail: label.into(),
504 menu: vec![],
505 });
506 }
507 commands.extend(offered_home_commands(engine));
508 debug_assert!(
509 validate_commands(&commands).is_ok(),
510 "invalid command registry: {:?}",
511 validate_commands(&commands)
512 );
513 commands
514}
515
516pub fn command_groups<'a>(
518 commands: &'a [OfferedCommand],
519 tab: &str,
520) -> Vec<(&'a str, Vec<&'a OfferedCommand>)> {
521 let mut groups: Vec<(&str, Vec<&OfferedCommand>)> = vec![];
522 for c in commands.iter().filter(|c| c.segments()[0] == tab) {
523 let group = c.segments()[1];
524 if let Some((_, members)) = groups.iter_mut().find(|(name, _)| *name == group) {
525 members.push(c);
526 } else {
527 groups.push((group, vec![c]));
528 }
529 }
530 groups
531}
532
533pub fn validate_builtin_presentations() -> Result<(), String> {
535 let mut seen = std::collections::HashSet::new();
536 let catalogue = brep_render::features::feature_catalogue();
537 for p in FILE_COMMANDS
538 .iter()
539 .chain(NEW_DOCUMENT_COMMANDS)
540 .chain(SHELL_COMMANDS)
541 .copied()
542 .chain(super::every_button_in(super::WORKBENCHES).map(|b| b.presentation()))
543 {
544 ribbon_segments(p.ribbon_path)?;
545 if !seen.insert(p.id.to_owned()) {
546 return Err(format!("duplicate command id: {}", p.id));
547 }
548 }
549 for feature in catalogue["features"].as_array().into_iter().flatten() {
550 let path = feature["ribbonPath"]
551 .as_str()
552 .ok_or_else(|| format!("feature has no ribbonPath: {}", feature["type"]))?;
553 if ribbon_segments(path)?[0] != "Home" {
554 return Err(format!("feature outside Home: {path}"));
555 }
556 if !matches!(feature["commandSize"].as_str(), Some("Large" | "Compact")) {
557 return Err(format!("feature has no commandSize: {path}"));
558 }
559 let ty = feature["type"].as_str().ok_or("feature has no type")?;
560 if !seen.insert(format!("feature:{ty}")) {
561 return Err(format!("duplicate feature type: {ty}"));
562 }
563 }
564 Ok(())
565}
566