Skip to main content

brep_app/panels/
workbench_toolbar.rs

1//! Workbench actions toolbar — a second top strip drawn UNDER the primary
2//! toolbar, listing the ACTIVE WORKBENCH's creatable features as one square
3//! icon button each, and (in a workbench that shows the Constraints panel —
4//! Assembly, and All as its union) a second group with one button per
5//! assembly-constraint type.
6//!
7//! It is a shortcut surface over creation, nothing more: a feature button does
8//! exactly what picking that entry in the **Add new feature** palette does
9//! (`HistoryPanel::add_feature_of_type`, so ACOMP still routes to the
10//! component selector), and a constraint button does what the context bar's
11//! constraint offer does (`context_bar::add_constraint_from_selection`, seeding
12//! `elements` from the current selection). Which features appear is the SAME
13//! workbench filter the palette applies ([`workbench::includes_feature`]); the
14//! constraints group is gated on the SAME panel claim the dock and the context
15//! bar use ([`workbench::panel_visible`]). Nothing here decides anything on
16//! its own.
17//!
18//! Shown only while `settings.show_workbench_toolbar` is on (the Settings
19//! "Show workbench actions toolbar" checkbox), and NEVER in a special mode: a
20//! sketch takes over the shell with its own tool strip, and reference
21//! selection bypasses the dock the new feature's form would open in. Follows
22//! the toolbar pattern: a small state struct owning only the per-frame `hits`
23//! map (widget rects) the headed verifier reads, a `show(...)` the shell calls
24//! right after the primary toolbar so the strip lands below it, and clicks
25//! flow OUT through a returned [`WorkbenchToolbarOutcome`] — the shell
26//! dispatches, because adding a feature touches panels this strip cannot
27//! borrow.
28
29use 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/// What one frame of the strip produced for the shell to act on.
38#[derive(Default)]
39pub struct WorkbenchToolbarOutcome {
40    /// A feature button click — the feature TYPE CODE to add (`"E"`, `"ACOMP"`).
41    pub feature: Option<String>,
42    /// A constraint button click — the constraint type id to add (`"fixed"`).
43    pub constraint: Option<String>,
44}
45
46/// One drawn button: its stable id, the glyph on the square, and the tooltip.
47struct ActionButton {
48    id: String,
49    glyph: String,
50    tooltip: String,
51}
52
53/// The strip's own state: the per-frame hit-rects, plus the button lists,
54/// which are derived from the kernel catalogues and so are rebuilt only when
55/// the workbench changes rather than re-walked every frame.
56#[derive(Default)]
57pub struct WorkbenchToolbarPanel {
58    hits: HashMap<String, egui::Rect>,
59    /// The feature buttons for `cached_workbench` (the catalogue filtered by
60    /// that workbench, in catalogue order).
61    features: Vec<ActionButton>,
62    cached_workbench: Option<String>,
63    /// The constraint buttons — the constraint catalogue is static, so this is
64    /// built once.
65    constraints: Vec<ActionButton>,
66}
67
68impl WorkbenchToolbarPanel {
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Whether the strip is drawn this frame: the setting is on AND no special
74    /// mode owns the shell (a sketch has its own tool strip; reference
75    /// selection hides the dock the new feature's form would open in).
76    pub fn visible(state: &EngineState) -> bool {
77        state.settings.show_workbench_toolbar && !state.sketch_mode() && !state.ref_select_active()
78    }
79
80    /// Draw the strip as a top panel — called right AFTER the primary toolbar
81    /// so egui stacks it directly underneath. Draws nothing (and publishes no
82    /// hit-rects) when [`Self::visible`] is false, or when the active
83    /// workbench offers nothing to put on it (the placeholder workbenches).
84    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    /// Rebuild the button lists when the workbench changed (or on first use).
121    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    /// Draw one group of square buttons through the shared toolbar-button
132    /// helper, publishing each hit-rect under `wbtb:<id>`; returns the clicked
133    /// button's payload (the part of the id after the group prefix).
134    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    /// The published widget hit-rects (egui points) for the headed verifier —
151    /// `wbtb:feature:<type>` / `wbtb:constraint:<type>`; empty while hidden.
152    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
167/// A small, dim group caption ahead of its buttons.
168fn caption(ui: &mut egui::Ui, text: &str) {
169    ui.label(egui::RichText::new(text).weak().small());
170}
171
172/// One button per catalogue feature the workbench INCLUDES, in catalogue
173/// order — the same list, same filter, as the Add-feature palette. The glyph is
174/// the feature's icon (the catalogued artwork paints it); a feature with no icon
175/// falls back to its short name as text.
176fn 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
208/// One button per assembly-constraint type, spec §4 order (the same order as
209/// the constraint catalogue and the panel's `+` dropdown). The glyph is the
210/// type's icon (`ConstraintTypeDef::icon` — the picture the context bar's
211/// offer and the viewport chip show), drawn as catalogued artwork; the plain
212/// name rides in the tooltip.
213fn 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    /// One headless egui frame of the strip with `events` as input, on a
229    /// screen wide enough that the wrapped row keeps every button on screen.
230    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    /// The setting defaults ON and the strip publishes the Modeling feature
256    /// set: Extrude yes, sheet metal and the assembly component no, and no
257    /// constraints group outside a workbench that shows the panel.
258    #[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    /// The strip is the SAME filter as the palette: every type the strip
278    /// offers is one `includes_feature` accepts, and vice versa, for each
279    /// workbench.
280    #[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    /// Assembly (and All, its union) add the constraints group — one button per
309    /// catalogue constraint type — and Assembly drops the modeling features.
310    #[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        // Assembly's feature group is the four building blocks only.
338        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        // Sheet Metal shows no constraints group (the panel is not shown there).
344        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    /// The Settings checkbox hides the strip: nothing drawn, no hit-rects.
352    #[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        // Round trip: the setting survives to_json → apply_json.
362        let json = state.settings_json();
363        assert!(json.contains(r#""showWorkbenchToolbar":false"#), "{json}");
364    }
365
366    /// While a sketch is being edited the strip is gone regardless of the
367    /// setting — the sketch's own tool strip owns that slot.
368    #[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    /// A real press + release on a feature button surfaces its type code
408    /// through the outcome (the return path the shell dispatches on).
409    #[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    /// The strip's constraint click on a PLAIN part (no components, nothing
450    /// selected) is the shell's `add_constraint_from_selection` — it must
451    /// either add an (element-less) row for the user to fill in, or refuse
452    /// with an error the shell can toast; never a silent no-op.
453    #[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    /// Same for a constraint button under the Assembly workbench.
474    #[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    /// Every constraint button's glyph is the type's catalogued icon — the
516    /// same picture the context bar offer and the viewport chip show — never
517    /// the `FIXD`/`DIST` short name. Coincident included (it had no glyph
518    /// anywhere until 2026-09-06).
519    #[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}