Skip to main content

brep_app/workbench/
mod.rs

1//! Per-file WORKBENCH registry — a UI FILTER over feature-CREATION.
2//!
3//! A "workbench" trims the feature-creation UI (the "Add new feature" palette and
4//! the selection context bar) and can gate workbench-specific toolbar buttons /
5//! panels. It NEVER changes what the feature history executes, renders, or lets
6//! the user edit: a document full of sheet-metal features opens and works
7//! identically under Modeling. The ONLY things a workbench touches are the palette
8//! (see [`includes_feature`]) and the context offers.
9//!
10//! EXTENSIBILITY is the whole point: a new workbench = one new file here + one
11//! entry in [`WORKBENCHES`]. No enums to edit. Each file exposes a single
12//! `'static` [`Workbench`] that OWNS its own inclusion decision (`includes`), its
13//! extra toolbar buttons (data only), and the ids of existing panels it claims.
14//!
15//! `"All"` is special: it accepts every feature and its buttons are the DERIVED
16//! union of every other workbench's buttons ([`workbench_buttons`]) — never
17//! hand-maintained — so adding a workbench automatically grows it.
18
19pub mod all;
20pub mod assembly;
21pub mod modeling;
22pub mod pmi;
23pub mod sheet_metal;
24pub mod wire_harness;
25
26/// The minimal view of one catalogue entry a workbench predicate consumes: the
27/// feature `type` CODE (e.g. `"E"`, `"S"`, `"SM.F"`), read from the existing
28/// app-side catalogue accessor ([`brep_render::features::feature_catalogue`]).
29/// Each workbench file classifies DIRECTLY off this code (no kernel-stamped
30/// category — the kernel stays untouched). Borrows from the runtime catalogue
31/// JSON, hence the lifetime — the registry itself stays `'static` because the
32/// fn-pointer predicate is higher-ranked over the borrow.
33pub struct FeatureInfo<'a> {
34    pub type_code: &'a str,
35}
36
37/// A workbench-specific toolbar button, DATA ONLY (no egui, no behavior): the
38/// shared toolbar-button helper renders it and a click surfaces `id` out of the
39/// toolbar to the shell, which dispatches on `id`. Phase 1 ships none.
40pub struct WorkbenchButton {
41    /// Stable id the shell matches on when the button is clicked.
42    pub id: &'static str,
43    /// The single unicode glyph shown on the square button.
44    pub glyph: &'static str,
45    /// Hover tooltip / human label.
46    pub tooltip: &'static str,
47}
48
49/// One workbench: a UI filter + optional toolbar buttons + claimed panels. Fully
50/// `'static` — fn-pointer predicate and `&'static` slices, no `OnceLock`.
51pub struct Workbench {
52    /// Stable id, e.g. `"all"` / `"modeling"` / `"sheetMetal"` — the value stored
53    /// in `RenderSettings.workbench` and published to the verifier.
54    pub id: &'static str,
55    /// Human label for the dropdown, e.g. `"Sheet Metal"`.
56    pub label: &'static str,
57    /// This workbench OWNS its inclusion decision: does a catalogue entry belong
58    /// in this workbench's feature-creation UI?
59    pub includes: fn(&FeatureInfo<'_>) -> bool,
60    /// Extra toolbar buttons this workbench adds (data only). EMPTY in v1.
61    pub buttons: &'static [WorkbenchButton],
62    /// Ids of EXISTING panels this workbench CLAIMS. Claim-based, not hide-based:
63    /// a panel is visible unless it is claimed by ≥1 workbench and the active one
64    /// does not list it (see [`panel_visible`]). EMPTY in v1 (nothing claimed →
65    /// every panel visible everywhere). Phase 2 adds claims here, editing NO other
66    /// file — the same anti-rot property the button union gives.
67    pub panels: &'static [&'static str],
68}
69
70/// The fallback / default workbench id. Boot-read validation is implicit:
71/// [`resolve`] maps any unknown stored id to this, so there is no separate boot
72/// step — every consumer routes through `resolve`.
73pub const DEFAULT_WORKBENCH_ID: &str = "modeling";
74
75/// Every workbench, in DROPDOWN ORDER: All, Modeling, Sheet Metal. The dropdown
76/// iterates THIS — labels are never hardcoded. A new workbench is appended here.
77pub static WORKBENCHES: &[&Workbench] = &[
78    &all::ALL,
79    &modeling::MODELING,
80    &sheet_metal::SHEET_METAL,
81    // Placeholders — established for the dropdown, fleshed out later.
82    &wire_harness::WIRE_HARNESS,
83    &assembly::ASSEMBLY,
84    &pmi::PMI,
85];
86
87/// Pure lookup by id — `None` if there is no such workbench.
88pub fn workbench_by_id(id: &str) -> Option<&'static Workbench> {
89    WORKBENCHES.iter().copied().find(|w| w.id == id)
90}
91
92/// Resolve a (possibly stale / unknown) stored id to a live workbench, falling
93/// back to the default. This IS the boot/read validation — route ALL consumers
94/// (dropdown display, palette/offer filters, buttons, panels) through here.
95pub fn resolve(id: &str) -> &'static Workbench {
96    workbench_by_id(id).unwrap_or_else(|| {
97        workbench_by_id(DEFAULT_WORKBENCH_ID).expect("default workbench must be registered")
98    })
99}
100
101/// The active workbench's toolbar buttons. For `"all"` this is the DEDUPED UNION
102/// of every workbench's buttons (so "All" shows every icon without a
103/// hand-maintained list); for any other workbench it is that workbench's own
104/// buttons.
105pub fn workbench_buttons(id: &str) -> Vec<&'static WorkbenchButton> {
106    let wb = resolve(id);
107    if wb.id == "all" {
108        dedupe_buttons(WORKBENCHES.iter().map(|w| w.buttons))
109    } else {
110        wb.buttons.iter().collect()
111    }
112}
113
114/// Collect buttons across lists, keeping FIRST occurrence per `id` and preserving
115/// order. The union logic behind [`workbench_buttons`] for `"all"`.
116fn dedupe_buttons<'a>(
117    lists: impl Iterator<Item = &'a [WorkbenchButton]>,
118) -> Vec<&'a WorkbenchButton> {
119    let mut seen = std::collections::HashSet::new();
120    let mut out = Vec::new();
121    for list in lists {
122        for button in list {
123            if seen.insert(button.id) {
124                out.push(button);
125            }
126        }
127    }
128    out
129}
130
131/// Whether a catalogue entry belongs in workbench `active_id`'s feature-creation
132/// UI. The ONE entry point the palette + context-offer filters call: builds a
133/// [`FeatureInfo`] from the feature `type_code` and runs the resolved workbench's
134/// predicate (each workbench file owns its classification off the code).
135pub fn includes_feature(active_id: &str, type_code: &str) -> bool {
136    let info = FeatureInfo { type_code };
137    (resolve(active_id).includes)(&info)
138}
139
140/// Whether panel `panel_id` is visible in workbench `active_id`. Claim-based: a
141/// panel is visible UNLESS it is claimed by ≥1 workbench and the active workbench
142/// does not list it. `"all"` sees every panel (mirrors the button union). With
143/// v1's empty `panels` slices nothing is claimed, so every panel is visible in
144/// every workbench — the scaffolding is wired but hides nothing.
145pub fn panel_visible(active_id: &str, panel_id: &str) -> bool {
146    let claimed = WORKBENCHES.iter().any(|w| w.panels.contains(&panel_id));
147    if !claimed {
148        return true;
149    }
150    let wb = resolve(active_id);
151    if wb.id == "all" {
152        return true;
153    }
154    wb.panels.contains(&panel_id)
155}
156
157/// The workbench list as `{id, label}` JSON plus the resolved current id — the
158/// `__brepWorkbench` logical-state global the headed verifier reads to drive the
159/// dropdown and confirm the active workbench. `current` is the RESOLVED id (an
160/// unknown stored id reads back as the default).
161pub fn workbench_state_json(current_stored: &str) -> String {
162    let available: Vec<serde_json::Value> = WORKBENCHES
163        .iter()
164        .map(|w| serde_json::json!({ "id": w.id, "label": w.label }))
165        .collect();
166    serde_json::json!({
167        "current": resolve(current_stored).id,
168        "available": available,
169    })
170    .to_string()
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn registry_order_and_labels() {
179        // The dropdown iterates WORKBENCHES; the order (and labels) live here.
180        let ids: Vec<_> = WORKBENCHES.iter().map(|w| w.id).collect();
181        assert_eq!(
182            ids,
183            ["all", "modeling", "sheetMetal", "wireHarness", "assembly", "pmi"]
184        );
185        let labels: Vec<_> = WORKBENCHES.iter().map(|w| w.label).collect();
186        assert_eq!(
187            labels,
188            ["All", "Modeling", "Sheet Metal", "Wire harness", "Assembly", "PMI"]
189        );
190    }
191
192    /// The placeholder workbenches are established (resolvable) but expose no
193    /// features yet — their `includes` predicate rejects everything. (Assembly
194    /// is no longer a placeholder — see `assembly_workbench_is_defined`.)
195    #[test]
196    fn placeholder_workbenches_are_empty_shells() {
197        let info = |code| FeatureInfo { type_code: code };
198        for id in ["wireHarness", "pmi"] {
199            let wb = resolve(id);
200            assert_eq!(wb.id, id, "placeholder `{id}` resolves");
201            for code in ["S", "D", "P", "E", "SM.F", "B"] {
202                assert!(
203                    !(wb.includes)(&info(code)),
204                    "placeholder `{id}` includes no features yet ({code})"
205                );
206            }
207            assert!(wb.buttons.is_empty() && wb.panels.is_empty());
208        }
209    }
210
211    /// The Assembly workbench (spec §8.1): creatable = Datum / Plane / Sketch /
212    /// Component (ACOMP); everything else filtered; claims the two assembly
213    /// panels — which therefore hide outside Assembly (and All), while an
214    /// UNCLAIMED panel (history) stays visible everywhere.
215    #[test]
216    fn assembly_workbench_is_defined() {
217        let info = |code| FeatureInfo { type_code: code };
218        let wb = resolve("assembly");
219        for code in ["S", "D", "P", "ACOMP"] {
220            assert!((wb.includes)(&info(code)), "assembly includes {code}");
221        }
222        for code in ["E", "B", "SM.F", "SM.TAB"] {
223            assert!(!(wb.includes)(&info(code)), "assembly excludes {code}");
224        }
225        // ACOMP is Assembly's creatable: Modeling and Sheet Metal hide it,
226        // All (the superset) still offers it.
227        assert!(!includes_feature("modeling", "ACOMP"));
228        assert!(!includes_feature("sheetMetal", "ACOMP"));
229        assert!(includes_feature("all", "ACOMP"));
230        assert!(includes_feature("assembly", "ACOMP"));
231
232        // Panel claims: the assembly panels show under Assembly + All only.
233        for panel in [assembly::BOM_PANEL_ID, assembly::CONSTRAINTS_PANEL_ID] {
234            assert!(panel_visible("assembly", panel), "{panel} visible in assembly");
235            assert!(panel_visible("all", panel), "{panel} visible in All");
236            assert!(!panel_visible("modeling", panel), "{panel} hidden in modeling");
237            assert!(!panel_visible("sheetMetal", panel), "{panel} hidden in sheet metal");
238        }
239        // Unclaimed panels stay visible in Assembly (history is never filtered).
240        assert!(panel_visible("assembly", "history"));
241        assert!(panel_visible("assembly", "scene"));
242    }
243
244    #[test]
245    fn unknown_id_falls_back_to_default() {
246        // Pure lookup is `None`; the resolver falls back to the default.
247        assert!(workbench_by_id("bogus").is_none());
248        assert!(workbench_by_id("").is_none());
249        assert_eq!(resolve("bogus").id, DEFAULT_WORKBENCH_ID);
250        assert_eq!(resolve("").id, "modeling");
251        // A known id resolves to itself.
252        assert_eq!(resolve("sheetMetal").id, "sheetMetal");
253    }
254
255    #[test]
256    fn predicates_classify_by_type_code() {
257        // The three predicates classify DIRECTLY off the feature type code: a
258        // sheet-metal code (SM.F), a shared building block (S/D/P), and a plain
259        // modeling code (E).
260        let info = |code| FeatureInfo { type_code: code };
261
262        // All accepts everything.
263        let all = resolve("all");
264        for code in ["SM.F", "S", "D", "P", "E"] {
265            assert!((all.includes)(&info(code)), "All includes {code}");
266        }
267
268        // Modeling excludes sheet metal (SM.*), keeps modeling + the common S/D/P.
269        let m = resolve("modeling");
270        assert!(!(m.includes)(&info("SM.F")), "modeling excludes SM.F");
271        assert!(!(m.includes)(&info("SM.TAB")), "modeling excludes SM.TAB");
272        for code in ["S", "D", "P", "E"] {
273            assert!((m.includes)(&info(code)), "modeling includes {code}");
274        }
275
276        // Sheet Metal keeps SM.* + the common S/D/P, excludes pure modeling (E).
277        let s = resolve("sheetMetal");
278        assert!((s.includes)(&info("SM.F")), "sheet metal includes SM.F");
279        for code in ["S", "D", "P"] {
280            assert!((s.includes)(&info(code)), "sheet metal includes common {code}");
281        }
282        assert!(!(s.includes)(&info("E")), "sheet metal excludes Extrude");
283    }
284
285    #[test]
286    fn includes_feature_entry_point() {
287        assert!(!includes_feature("modeling", "SM.TAB"));
288        assert!(includes_feature("modeling", "E"));
289        assert!(includes_feature("modeling", "S"));
290        assert!(includes_feature("sheetMetal", "SM.TAB"));
291        assert!(!includes_feature("sheetMetal", "E"));
292        assert!(includes_feature("all", "E"));
293        assert!(includes_feature("all", "SM.TAB"));
294        // Unknown workbench id falls back to Modeling's filtering.
295        assert!(!includes_feature("bogus", "SM.TAB"));
296    }
297
298    #[test]
299    fn all_buttons_is_the_deduped_union() {
300        // Sheet Metal owns the flat-pattern export button (Phase 2), Assembly
301        // Add-Component + the interference check; Modeling still declares none.
302        // "All" is the DEDUPED UNION, so it carries all without a hand-maintained
303        // list.
304        let ids = |wb: &str| -> Vec<&str> {
305            workbench_buttons(wb).iter().map(|b| b.id).collect()
306        };
307        assert_eq!(ids("sheetMetal"), ["sheetmetal.flat_pattern"]);
308        assert_eq!(
309            ids("assembly"),
310            [
311                "assembly.add_component",
312                "assembly.step_parts_library",
313                "assembly.interference"
314            ]
315        );
316        assert!(workbench_buttons("modeling").is_empty());
317        assert_eq!(
318            ids("all"),
319            [
320                "sheetmetal.flat_pattern",
321                "assembly.add_component",
322                "assembly.step_parts_library",
323                "assembly.interference"
324            ]
325        );
326    }
327
328    #[test]
329    fn dedupe_preserves_order_and_drops_duplicate_ids() {
330        // Exercise the union logic directly with synthetic overlapping lists (the
331        // real slices are empty in v1). `y` appears in both lists; the FIRST wins
332        // and order is preserved.
333        let a = [
334            WorkbenchButton { id: "x", glyph: "1", tooltip: "X" },
335            WorkbenchButton { id: "y", glyph: "2", tooltip: "Y" },
336        ];
337        let b = [
338            WorkbenchButton { id: "y", glyph: "3", tooltip: "Y-dup" },
339            WorkbenchButton { id: "z", glyph: "4", tooltip: "Z" },
340        ];
341        let out = dedupe_buttons([a.as_slice(), b.as_slice()].into_iter());
342        let ids: Vec<_> = out.iter().map(|button| button.id).collect();
343        assert_eq!(ids, ["x", "y", "z"]);
344        // The kept `y` is the first list's (glyph "2"), not the duplicate.
345        assert_eq!(out[1].glyph, "2");
346    }
347
348    #[test]
349    fn panels_default_visible_when_unclaimed() {
350        // v1: no workbench claims any panel → every panel is visible everywhere.
351        for wb in ["all", "modeling", "sheetMetal", "bogus"] {
352            assert!(panel_visible(wb, "history"));
353            assert!(panel_visible(wb, "scene"));
354            assert!(panel_visible(wb, "expressions"));
355        }
356    }
357
358    #[test]
359    fn state_json_reports_current_and_available() {
360        let json: serde_json::Value =
361            serde_json::from_str(&workbench_state_json("sheetMetal")).unwrap();
362        assert_eq!(json["current"], "sheetMetal");
363        // An unknown stored id reads back as the resolved default.
364        let fallback: serde_json::Value =
365            serde_json::from_str(&workbench_state_json("bogus")).unwrap();
366        assert_eq!(fallback["current"], "modeling");
367        let available = json["available"].as_array().unwrap();
368        assert_eq!(available.len(), WORKBENCHES.len());
369        assert_eq!(available[0]["id"], "all");
370        assert_eq!(available[2]["label"], "Sheet Metal");
371        assert_eq!(available[5]["id"], "pmi");
372    }
373}