1use crate::panels::toolbar_button;
30use crate::workbench;
31use brep_render::engine_state::EngineState;
32use brep_render::features;
33use eframe::egui;
34use serde_json::Value;
35use std::collections::HashMap;
36
37#[derive(Default)]
39pub struct WorkbenchToolbarOutcome {
40 pub feature: Option<String>,
42 pub constraint: Option<String>,
44}
45
46struct ActionButton {
48 id: String,
49 glyph: String,
50 tooltip: String,
51}
52
53#[derive(Default)]
57pub struct WorkbenchToolbarPanel {
58 hits: HashMap<String, egui::Rect>,
59 features: Vec<ActionButton>,
62 cached_workbench: Option<String>,
63 constraints: Vec<ActionButton>,
66}
67
68impl WorkbenchToolbarPanel {
69 pub fn new() -> Self {
70 Self::default()
71 }
72
73 pub fn visible(state: &EngineState) -> bool {
77 state.settings.show_workbench_toolbar && !state.sketch_mode() && !state.ref_select_active()
78 }
79
80 pub fn show(&mut self, ui: &mut egui::Ui, state: &EngineState) -> WorkbenchToolbarOutcome {
85 self.hits.clear();
86 let mut outcome = WorkbenchToolbarOutcome::default();
87 if !Self::visible(state) {
88 return outcome;
89 }
90 let active = state.settings.workbench.clone();
91 self.refresh_buttons(&active);
92 let with_constraints =
93 workbench::panel_visible(&active, workbench::assembly::CONSTRAINTS_PANEL_ID);
94 if self.features.is_empty() && !with_constraints {
95 return outcome;
96 }
97 egui::containers::panel::Panel::top("brep-workbench-toolbar")
98 .resizable(false)
99 .show(ui, |ui| {
100 ui.add_space(2.0);
101 ui.horizontal_wrapped(|ui| {
102 if !self.features.is_empty() {
103 caption(ui, "Features");
104 outcome.feature = Self::draw_group(ui, &self.features, &mut self.hits);
105 }
106 if with_constraints {
107 if !self.features.is_empty() {
108 ui.separator();
109 }
110 caption(ui, "Constraints");
111 outcome.constraint =
112 Self::draw_group(ui, &self.constraints, &mut self.hits);
113 }
114 });
115 ui.add_space(2.0);
116 });
117 outcome
118 }
119
120 fn refresh_buttons(&mut self, active: &str) {
122 if self.cached_workbench.as_deref() != Some(active) {
123 self.features = feature_buttons(active);
124 self.cached_workbench = Some(active.to_string());
125 }
126 if self.constraints.is_empty() {
127 self.constraints = constraint_buttons();
128 }
129 }
130
131 fn draw_group(
135 ui: &mut egui::Ui,
136 buttons: &[ActionButton],
137 hits: &mut HashMap<String, egui::Rect>,
138 ) -> Option<String> {
139 let mut clicked = None;
140 for button in buttons {
141 let resp = toolbar_button::button(ui, &button.glyph, &button.tooltip);
142 hits.insert(format!("wbtb:{}", button.id), resp.rect);
143 if resp.clicked() {
144 clicked = button.id.split_once(':').map(|(_, payload)| payload.to_string());
145 }
146 }
147 clicked
148 }
149
150 pub fn hits_json(&self) -> String {
153 let map: serde_json::Map<String, Value> = self
154 .hits
155 .iter()
156 .map(|(k, r)| {
157 (
158 k.clone(),
159 serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
160 )
161 })
162 .collect();
163 Value::Object(map).to_string()
164 }
165}
166
167fn caption(ui: &mut egui::Ui, text: &str) {
169 ui.label(egui::RichText::new(text).weak().small());
170}
171
172fn feature_buttons(active: &str) -> Vec<ActionButton> {
177 let catalogue = features::feature_catalogue();
178 let mut buttons = Vec::new();
179 if let Some(list) = catalogue.get("features").and_then(Value::as_array) {
180 for feature in list {
181 let ty = feature.get("type").and_then(Value::as_str).unwrap_or("");
182 if ty.is_empty() || !workbench::includes_feature(active, ty) {
183 continue;
184 }
185 let name = feature
186 .get("longName")
187 .and_then(Value::as_str)
188 .unwrap_or(ty)
189 .to_string();
190 let glyph = match features::feature_icon(ty) {
191 Some(icon) => icon.to_string(),
192 None => feature
193 .get("shortName")
194 .and_then(Value::as_str)
195 .unwrap_or(ty)
196 .to_string(),
197 };
198 buttons.push(ActionButton {
199 id: format!("feature:{ty}"),
200 glyph,
201 tooltip: format!("Add {name}"),
202 });
203 }
204 }
205 buttons
206}
207
208fn constraint_buttons() -> Vec<ActionButton> {
214 brep_render::brep_kernel::CONSTRAINT_TYPES
215 .iter()
216 .map(|def| ActionButton {
217 id: format!("constraint:{}", def.type_id),
218 glyph: def.icon.to_string(),
219 tooltip: format!("Add {} constraint from the selection", def.label),
220 })
221 .collect()
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 fn frame(
231 ctx: &egui::Context,
232 panel: &mut WorkbenchToolbarPanel,
233 state: &EngineState,
234 events: Vec<egui::Event>,
235 ) -> WorkbenchToolbarOutcome {
236 let raw = egui::RawInput {
237 screen_rect: Some(egui::Rect::from_min_size(
238 egui::pos2(0.0, 0.0),
239 egui::vec2(1600.0, 300.0),
240 )),
241 events,
242 ..Default::default()
243 };
244 let mut out = WorkbenchToolbarOutcome::default();
245 let _ = ctx.run_ui(raw, |ui| out = panel.show(ui, state));
246 out
247 }
248
249 fn keys(panel: &WorkbenchToolbarPanel) -> Vec<String> {
250 let mut keys: Vec<_> = panel.hits.keys().cloned().collect();
251 keys.sort();
252 keys
253 }
254
255 #[test]
259 fn modeling_lists_its_features_and_no_constraints() {
260 let ctx = egui::Context::default();
261 let state = EngineState::new();
262 assert!(state.settings.show_workbench_toolbar, "the strip is on by default");
263 let mut panel = WorkbenchToolbarPanel::new();
264 let out = frame(&ctx, &mut panel, &state, vec![]);
265 assert!(out.feature.is_none() && out.constraint.is_none(), "a passive render is not a click");
266 let keys = keys(&panel);
267 assert!(keys.contains(&"wbtb:feature:E".to_string()), "extrude offered: {keys:?}");
268 assert!(keys.contains(&"wbtb:feature:S".to_string()), "sketch offered");
269 assert!(!keys.contains(&"wbtb:feature:SM.F".to_string()), "sheet metal filtered");
270 assert!(!keys.contains(&"wbtb:feature:ACOMP".to_string()), "assembly component filtered");
271 assert!(
272 !keys.iter().any(|k| k.starts_with("wbtb:constraint:")),
273 "no constraints group in Modeling: {keys:?}"
274 );
275 }
276
277 #[test]
281 fn strip_matches_the_palette_filter_per_workbench() {
282 let ctx = egui::Context::default();
283 let catalogue = features::feature_catalogue();
284 let all_types: Vec<String> = catalogue["features"]
285 .as_array()
286 .unwrap()
287 .iter()
288 .filter_map(|f| f.get("type").and_then(Value::as_str).map(String::from))
289 .collect();
290 for wb in ["all", "modeling", "sheetMetal", "assembly"] {
291 let mut state = EngineState::new();
292 state
293 .apply_settings_json(&format!(r#"{{"workbench":"{wb}"}}"#))
294 .unwrap();
295 let mut panel = WorkbenchToolbarPanel::new();
296 frame(&ctx, &mut panel, &state, vec![]);
297 for ty in &all_types {
298 let shown = panel.hits.contains_key(&format!("wbtb:feature:{ty}"));
299 assert_eq!(
300 shown,
301 workbench::includes_feature(wb, ty),
302 "{wb}: strip and palette disagree on {ty}"
303 );
304 }
305 }
306 }
307
308 #[test]
311 fn assembly_and_all_show_the_constraints_group() {
312 let ctx = egui::Context::default();
313 let constraint_types: Vec<String> =
314 brep_render::brep_kernel::constraint_schema_catalogue()
315 .as_array()
316 .unwrap()
317 .iter()
318 .filter_map(|s| s.get("type").and_then(Value::as_str).map(String::from))
319 .collect();
320 assert!(constraint_types.contains(&"fixed".to_string()));
321 for wb in ["assembly", "all"] {
322 let mut state = EngineState::new();
323 state
324 .apply_settings_json(&format!(r#"{{"workbench":"{wb}"}}"#))
325 .unwrap();
326 let mut panel = WorkbenchToolbarPanel::new();
327 frame(&ctx, &mut panel, &state, vec![]);
328 for ty in &constraint_types {
329 assert!(
330 panel.hits.contains_key(&format!("wbtb:constraint:{ty}")),
331 "{wb} offers the {ty} constraint: {:?}",
332 keys(&panel)
333 );
334 }
335 assert!(panel.hits.contains_key("wbtb:feature:ACOMP"), "{wb} offers ACOMP");
336 }
337 let mut state = EngineState::new();
339 state.apply_settings_json(r#"{"workbench":"assembly"}"#).unwrap();
340 let mut panel = WorkbenchToolbarPanel::new();
341 frame(&ctx, &mut panel, &state, vec![]);
342 assert!(!panel.hits.contains_key("wbtb:feature:E"), "assembly hides Extrude");
343 let mut state = EngineState::new();
345 state.apply_settings_json(r#"{"workbench":"sheetMetal"}"#).unwrap();
346 let mut panel = WorkbenchToolbarPanel::new();
347 frame(&ctx, &mut panel, &state, vec![]);
348 assert!(!keys(&panel).iter().any(|k| k.starts_with("wbtb:constraint:")));
349 }
350
351 #[test]
353 fn setting_off_hides_the_strip() {
354 let ctx = egui::Context::default();
355 let mut state = EngineState::new();
356 state.apply_settings_json(r#"{"showWorkbenchToolbar": false}"#).unwrap();
357 assert!(!WorkbenchToolbarPanel::visible(&state));
358 let mut panel = WorkbenchToolbarPanel::new();
359 frame(&ctx, &mut panel, &state, vec![]);
360 assert!(panel.hits.is_empty(), "hidden strip publishes nothing: {:?}", keys(&panel));
361 let json = state.settings_json();
363 assert!(json.contains(r#""showWorkbenchToolbar":false"#), "{json}");
364 }
365
366 #[test]
369 fn sketch_mode_hides_the_strip() {
370 let ctx = egui::Context::default();
371 let mut state = EngineState::new();
372 let history = serde_json::json!({
373 "features": [{
374 "type": "S",
375 "inputParams": { "id": "Sk" },
376 "persistentData": {
377 "basis": { "origin": [0, 0, 0], "x": [1, 0, 0], "y": [0, 1, 0], "z": [0, 0, 1] },
378 "sketch": {
379 "points": [
380 { "id": 0, "x": 0.0, "y": 0.0 },
381 { "id": 1, "x": 10.0, "y": 0.0 }
382 ],
383 "geometries": [
384 { "id": 10, "type": "line", "points": [0, 1], "construction": false }
385 ],
386 "constraints": []
387 }
388 }
389 }]
390 });
391 state.set_history_json(&history.to_string()).expect("sketch history loads");
392 let mut panel = WorkbenchToolbarPanel::new();
393 frame(&ctx, &mut panel, &state, vec![]);
394 assert!(panel.hits.contains_key("wbtb:feature:E"), "visible before the sketch opens");
395
396 state.enter_sketch_mode("Sk").expect("enter sketch mode");
397 assert!(state.sketch_mode());
398 assert!(!WorkbenchToolbarPanel::visible(&state));
399 frame(&ctx, &mut panel, &state, vec![]);
400 assert!(panel.hits.is_empty(), "hidden in sketch mode: {:?}", keys(&panel));
401
402 let _ = state.exit_sketch_mode(false);
403 frame(&ctx, &mut panel, &state, vec![]);
404 assert!(panel.hits.contains_key("wbtb:feature:E"), "back after the sketch closes");
405 }
406
407 #[test]
410 fn click_surfaces_the_feature_type() {
411 let ctx = egui::Context::default();
412 let state = EngineState::new();
413 let mut panel = WorkbenchToolbarPanel::new();
414 frame(&ctx, &mut panel, &state, vec![]);
415 let pos = panel
416 .hits
417 .get("wbtb:feature:P.CU")
418 .expect("the cube button publishes a hit-rect")
419 .center();
420 frame(
421 &ctx,
422 &mut panel,
423 &state,
424 vec![
425 egui::Event::PointerMoved(pos),
426 egui::Event::PointerButton {
427 pos,
428 button: egui::PointerButton::Primary,
429 pressed: true,
430 modifiers: egui::Modifiers::default(),
431 },
432 ],
433 );
434 let out = frame(
435 &ctx,
436 &mut panel,
437 &state,
438 vec![egui::Event::PointerButton {
439 pos,
440 button: egui::PointerButton::Primary,
441 pressed: false,
442 modifiers: egui::Modifiers::default(),
443 }],
444 );
445 assert_eq!(out.feature.as_deref(), Some("P.CU"));
446 assert!(out.constraint.is_none());
447 }
448
449 #[test]
454 fn constraint_click_on_a_plain_part_adds_or_refuses_loudly() {
455 let mut state = EngineState::new();
456 state
457 .set_history_json(&crate::app::seed_history_json())
458 .expect("seed history loads");
459 let before = state.assembly_state_value()["constraints"]
460 .as_array()
461 .map_or(0, Vec::len);
462 let outcome = crate::panels::context_bar::add_constraint_from_selection(&mut state, "fixed");
463 let after = state.assembly_state_value()["constraints"]
464 .as_array()
465 .map_or(0, Vec::len);
466 eprintln!("plain-part fixed constraint: before={before} after={after} outcome={outcome:?}");
467 match &outcome {
468 Ok(id) => assert_eq!(after, before + 1, "row {id} added"),
469 Err(error) => assert!(!error.is_empty(), "a refusal names its reason"),
470 }
471 }
472
473 #[test]
475 fn click_surfaces_the_constraint_type() {
476 let ctx = egui::Context::default();
477 let mut state = EngineState::new();
478 state.apply_settings_json(r#"{"workbench":"assembly"}"#).unwrap();
479 let mut panel = WorkbenchToolbarPanel::new();
480 frame(&ctx, &mut panel, &state, vec![]);
481 let pos = panel
482 .hits
483 .get("wbtb:constraint:fixed")
484 .expect("the fixed-constraint button publishes a hit-rect")
485 .center();
486 frame(
487 &ctx,
488 &mut panel,
489 &state,
490 vec![
491 egui::Event::PointerMoved(pos),
492 egui::Event::PointerButton {
493 pos,
494 button: egui::PointerButton::Primary,
495 pressed: true,
496 modifiers: egui::Modifiers::default(),
497 },
498 ],
499 );
500 let out = frame(
501 &ctx,
502 &mut panel,
503 &state,
504 vec![egui::Event::PointerButton {
505 pos,
506 button: egui::PointerButton::Primary,
507 pressed: false,
508 modifiers: egui::Modifiers::default(),
509 }],
510 );
511 assert_eq!(out.constraint.as_deref(), Some("fixed"));
512 assert!(out.feature.is_none());
513 }
514
515 #[test]
520 fn constraint_buttons_carry_the_type_icon_as_artwork() {
521 let buttons = constraint_buttons();
522 assert_eq!(buttons.len(), 10);
523 for (button, def) in buttons.iter().zip(brep_render::brep_kernel::CONSTRAINT_TYPES.iter()) {
524 assert_eq!(button.glyph, def.icon, "{}", def.type_id);
525 assert!(
526 crate::icons::artwork(&button.glyph).is_some(),
527 "{}: the icon {:?} must be catalogued artwork, not a font character",
528 def.type_id,
529 button.glyph
530 );
531 assert!(button.tooltip.contains(def.label), "{}: {}", def.type_id, button.tooltip);
532 assert!(!button.glyph.contains(def.short_name), "{}: no short names on the strip", def.type_id);
533 }
534 assert_eq!(buttons[1].glyph, "\u{2261}", "coincident is \u{2261}, as in the sketch solver");
535 }
536}