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//! Built-in workbenches are extended with 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//! Plugin workbenches use the document-scoped [`OwnedWorkbench`] overlay;
16//! their owned strings and schemas never enter the static built-in registry.
17//!
18//! `"All"` is special: it accepts every feature and its buttons are the DERIVED
19//! union of every other workbench's buttons ([`workbench_buttons`]) — never
20//! hand-maintained — so adding a workbench automatically grows it.
21//!
22//! # Shared buttons: a shell MODE's tools belong to no workbench
23//!
24//! A workbench filters feature CREATION. A shell MODE — sketch editing — is
25//! entered from a surface no workbench filters, so its tools cannot be any one
26//! workbench's without vanishing under the others. [`shared_buttons`] is the
27//! second list every row carries: declared once ([`sketch::BUTTONS`]),
28//! conditional on the mode, appended to every workbench's own buttons. That is
29//! what makes the row the SINGLE home for "this mode's tools" — there is no
30//! strip of its own to keep in step, and switching workbench mid-sketch keeps
31//! the tools.
32
33use brep_render::engine_state::EngineState;
34mod commands;
35pub use commands::*;
36
37pub mod all;
38pub mod assembly;
39pub mod cam;
40pub mod diagram;
41pub mod drawing;
42pub mod ecad;
43pub mod instructions;
44pub mod modeling;
45pub mod pads;
46pub mod pcb;
47pub mod pmi;
48pub mod sheet_metal;
49pub mod simulation;
50pub mod sketch;
51pub mod sketch3d;
52pub mod symbol;
53pub mod wire_harness;
54
55/// The minimal view of one catalogue entry a workbench predicate consumes: the
56/// feature `type` CODE (e.g. `"E"`, `"S"`, `"SM.F"`), read from the existing
57/// app-side catalogue accessor ([`brep_render::features::feature_catalogue`]).
58/// Each workbench file classifies DIRECTLY off this code (no kernel-stamped
59/// category — the kernel stays untouched). Borrows from the runtime catalogue
60/// JSON, hence the lifetime — the registry itself stays `'static` because the
61/// fn-pointer predicate is higher-ranked over the borrow.
62pub struct FeatureInfo<'a> {
63    pub type_code: &'a str,
64}
65
66/// What a button's predicates read: the document's engine and, for the eCAD
67/// workbenches, the document's eCAD editors.
68///
69/// The editors are not engine state — they are egui widgets the app keeps per
70/// document ([`ecad::Editors`]) — so a predicate that only took the engine
71/// could not say whether eCAD's wire tool is armed, and an eCAD button would
72/// have to be offered unconditionally and refused at dispatch instead: a row
73/// drawing unavailable buttons as available. `ecad` is `None` where a document
74/// has no editors, and then every eCAD button reads NOT offered.
75#[derive(Clone, Copy)]
76pub struct ButtonState<'a> {
77    pub engine: &'a EngineState,
78    pub ecad: Option<&'a ecad::Editors>,
79}
80
81impl<'a> ButtonState<'a> {
82    /// The engine alone: every button but the eCAD workbenches' can answer.
83    pub fn of(engine: &'a EngineState) -> Self {
84        Self { engine, ecad: None }
85    }
86
87    /// The engine and the document's eCAD editors.
88    pub fn with_ecad(engine: &'a EngineState, ecad: &'a ecad::Editors) -> Self {
89        Self { engine, ecad: Some(ecad) }
90    }
91}
92
93/// A workbench-specific toolbar button, DATA ONLY (no egui, no behavior): the
94/// shared toolbar-button helper renders it and a click surfaces `id` out of the
95/// toolbar to the shell, which dispatches on `id`.
96///
97/// A button may be CONDITIONAL on document state ([`Self::when`]) and may read
98/// as PRESSED from it ([`Self::pressed`]). Both are predicates over the
99/// document ([`ButtonState`]) rather than flags the toolbar sets, so the
100/// declaration still owns the whole answer and the row stays a renderer:
101/// Drawing's sheet buttons are offered only while a sheet is OPEN, and the armed
102/// sketch draw tool is the lit button. A `None` predicate is the plain case —
103/// always offered, never pressed — which is most buttons.
104///
105/// Three more readings came with the eCAD editors, whose actions carry them:
106/// an offered button can be DISABLED with a reason ([`Self::disabled`]), its
107/// tooltip can have LIVE detail ([`Self::caption`]), and it can be a MENU of other buttons
108/// ([`Self::menu`]).
109pub struct WorkbenchButton {
110    /// Stable id the shell matches on when the button is clicked.
111    pub id: &'static str,
112    /// The single unicode glyph shown on the square button.
113    pub glyph: &'static str,
114    /// Required tab/group/canonical-label path, validated with the registry.
115    pub ribbon_path: &'static str,
116    /// Ribbon sizing; Classic always uses the compact presentation.
117    pub size: CommandSize,
118    /// Hover tooltip / human label.
119    pub tooltip: &'static str,
120    /// When this button is OFFERED at all. `None` = always.
121    pub when: Option<fn(&ButtonState) -> bool>,
122    /// When this button draws PRESSED (a toggle reflecting live state, the way
123    /// the toolbar's wireframe and projection buttons do). `None` = a plain
124    /// momentary button.
125    pub pressed: Option<fn(&ButtonState) -> bool>,
126    /// Why an OFFERED button cannot act right now, or `None` when it can. A
127    /// disabled button is drawn greyed with the reason on hover, and the
128    /// `workbench_button` command refuses it with the same reason — never a
129    /// silent no-op. `None` = never disabled.
130    ///
131    /// This is eCAD's `enabled`, kept apart from `offered` the way eCAD keeps
132    /// it: Finish wire is on the row whenever the wire tool is, and greyed
133    /// until there is a wire to finish, rather than blinking in and out.
134    pub disabled: Option<fn(&ButtonState) -> Option<&'static str>>,
135    /// Supplementary live tooltip detail for a
136    /// button whose words describe state (`Bend: vertical first`) or depend on
137    /// the document (a copper layer's name). `None` = the tooltip.
138    pub caption: Option<fn(&ButtonState) -> String>,
139    /// A MENU button's entries: clicking it opens a menu of these, each with
140    /// its canonical label, live tooltip detail and pressed state, instead of surfacing its own id.
141    /// The entries are buttons in every other respect — ids the lookup finds
142    /// and the `workbench_button` command presses. A menu is offered while any
143    /// entry is. Empty = an ordinary button.
144    pub menu: &'static [WorkbenchButton],
145}
146
147impl WorkbenchButton {
148    /// Whether `state` offers this button. The ONE reading of [`Self::when`] —
149    /// the toolbar row, the `workbench_button` command and `describe_workbenches`
150    /// all route through it, so a button an agent can call is exactly a button
151    /// the user can see.
152    pub fn offered(&self, state: &ButtonState) -> bool {
153        self.when.is_none_or(|when| when(state))
154            && (self.menu.is_empty() || self.menu.iter().any(|entry| entry.offered(state)))
155    }
156
157    /// Whether this button draws pressed in `state`.
158    pub fn is_pressed(&self, state: &ButtonState) -> bool {
159        self.pressed.is_some_and(|pressed| pressed(state))
160    }
161
162    /// Why this button cannot act in `state`, or `None` when it can.
163    pub fn disabled_reason(&self, state: &ButtonState) -> Option<&'static str> {
164        self.disabled.and_then(|disabled| disabled(state))
165    }
166
167    /// Canonical command label; state-specific wording remains in `detail`.
168    pub fn label(&self, _state: &ButtonState) -> String {
169        self.presentation().label().to_owned()
170    }
171
172    pub fn presentation(&self) -> CommandPresentation<'static> {
173        CommandPresentation { id: self.id, glyph: self.glyph, ribbon_path: self.ribbon_path, size: self.size }
174    }
175
176    pub fn detail(&self, state: &ButtonState) -> String {
177        self.caption.map_or_else(|| self.tooltip.to_owned(), |caption| caption(state))
178    }
179}
180
181/// One workbench: a UI filter + optional toolbar buttons + claimed panels. Fully
182/// `'static` — fn-pointer predicate and `&'static` slices, no `OnceLock`.
183pub struct Workbench {
184    /// Stable id, e.g. `"all"` / `"modeling"` / `"sheetMetal"` — the value stored
185    /// in `RenderSettings.workbench` and published to the verifier.
186    pub id: &'static str,
187    /// Human label for the dropdown, e.g. `"Sheet Metal"`.
188    pub label: &'static str,
189    /// SVG catalog key used by the workbench switcher.
190    pub glyph: &'static str,
191    /// This workbench OWNS its inclusion decision: does a catalogue entry belong
192    /// in this workbench's feature-creation UI?
193    pub includes: fn(&FeatureInfo<'_>) -> bool,
194    /// Extra toolbar buttons this workbench adds (data only). EMPTY in v1.
195    pub buttons: &'static [WorkbenchButton],
196    /// Ids of EXISTING panels this workbench CLAIMS. Claim-based, not hide-based:
197    /// a panel is visible unless it is claimed by ≥1 workbench and the active one
198    /// does not list it (see [`panel_visible`]). EMPTY in v1 (nothing claimed →
199    /// every panel visible everywhere). Phase 2 adds claims here, editing NO other
200    /// file — the same anti-rot property the button union gives.
201    pub panels: &'static [&'static str],
202}
203
204/// The fallback / default workbench id. Boot-read validation is implicit:
205/// [`resolve`] maps any unknown stored id to this, so there is no separate boot
206/// step — every consumer routes through `resolve`.
207pub const DEFAULT_WORKBENCH_ID: &str = "modeling";
208
209/// Every workbench, in DROPDOWN ORDER: All, Modeling, Sheet Metal. The dropdown
210/// iterates THIS — labels are never hardcoded. A new workbench is appended here.
211pub static WORKBENCHES: &[&Workbench] = &[
212    &all::ALL,
213    &modeling::MODELING,
214    &sheet_metal::SHEET_METAL,
215    // Placeholders — established for the dropdown, fleshed out later.
216    &wire_harness::WIRE_HARNESS,
217    &assembly::ASSEMBLY,
218    &pmi::PMI,
219    &drawing::DRAWING,
220    // The eCAD four: their editors are drawn in place of the 3D view.
221    &diagram::DIAGRAM,
222    &pcb::PCB,
223    &symbol::SYMBOL,
224    &pads::PADS,
225    // Manufacturing: setups, tools, operations and toolpaths over the model.
226    &cam::CAM,
227    // Manufacturing instructions: an assembly, step by step.
228    &instructions::INSTRUCTIONS,
229    // Motion studies: an assembly's mates driven over time.
230    &simulation::SIMULATION,
231];
232
233/// Workbenches DECLARED but not yet in the dropdown — none today. A
234/// workbench whose records and buttons land before anything draws it waits
235/// here, held to every law below, and registering it is a move from this
236/// list to [`WORKBENCHES`], which `every_declared_workbench_is_in_exactly_one_list`
237/// keeps honest in both directions. The eCAD four waited here until their
238/// editors were drawn: a workbench whose buttons act on an editor nobody can
239/// see is worse than one that does nothing.
240pub static UNREGISTERED: &[&Workbench] = &[];
241
242/// Pure lookup by id — `None` if there is no such workbench.
243pub fn workbench_by_id(id: &str) -> Option<&'static Workbench> {
244    WORKBENCHES.iter().copied().find(|w| w.id == id)
245}
246
247/// Resolve a (possibly stale / unknown) stored id to a live workbench, falling
248/// back to the default. This IS the boot/read validation — route ALL consumers
249/// (dropdown display, palette/offer filters, buttons, panels) through here.
250pub fn resolve(id: &str) -> &'static Workbench {
251    workbench_by_id(id).unwrap_or_else(|| {
252        workbench_by_id(DEFAULT_WORKBENCH_ID).expect("default workbench must be registered")
253    })
254}
255
256/// The buttons EVERY workbench carries, whatever the active one is — the tools
257/// of a SHELL MODE. A workbench is a filter over feature CREATION; a shell mode
258/// is entered from a surface no workbench filters (the sketch tools' own
259/// `editSketch`, on a History panel no workbench claims), so no workbench owns
260/// its tools and every row has to offer them. Declared ONCE — [`sketch::BUTTONS`]
261/// is the only entry today — and conditional on the mode, so out of it the rows
262/// are exactly what each workbench declares.
263///
264/// This is the ONE reading: [`workbench_buttons`] appends it and
265/// [`button_by_id`] searches it, so the row, `describe_workbenches` and the
266/// `workbench_button` command agree about what exists.
267pub fn shared_buttons() -> &'static [WorkbenchButton] {
268    /// The two shared sets in one slice, built once: the planar sketch's
269    /// tools, then the 3D sketch's. Each set is conditional on its own mode,
270    /// so a row never shows both.
271    static SHARED: std::sync::LazyLock<Vec<WorkbenchButton>> = std::sync::LazyLock::new(|| {
272        sketch::BUTTONS
273            .iter()
274            .chain(sketch3d::BUTTONS.iter())
275            .map(|b| WorkbenchButton {
276                id: b.id,
277                glyph: b.glyph,
278                ribbon_path: b.ribbon_path,
279                size: b.size,
280                tooltip: b.tooltip,
281                when: b.when,
282                pressed: b.pressed,
283                disabled: b.disabled,
284                caption: b.caption,
285                menu: b.menu,
286            })
287            .collect()
288    });
289    &SHARED
290}
291
292/// Buttons one workbench LENDS to another: `(borrower id, the lent buttons)`.
293/// The borrower's row shows the lender's own declaration — the same id, the
294/// same picture, the same tooltip, dispatched by the same shell arm — so the
295/// two rows cannot drift apart the way two declarations would.
296///
297/// A lent button is NOT one of the borrower's own: it is declared once, by its
298/// owner, which is what keeps every law above intact. The prefix law
299/// ([`button_prefix`]) reads a workbench's OWN buttons, so PCB showing
300/// `assembly.add_component` breaks nothing; the artwork law sees one button
301/// with one picture; [`button_by_id`] finds the one declaration; and All's
302/// deduped union is built from OWN lists, so a lent button is in it exactly
303/// once — via its owner. What a borrow changes is only [`workbench_buttons`]:
304/// the borrower's row, and therefore [`offered_buttons`], `describe_workbenches`
305/// and what `workbench_button` will press while it is active.
306///
307/// PCB borrows Assembly's ADD COMPONENT because a board is an assembly of
308/// placed parts and the user asked for "the same one": inserting a component
309/// is one action wherever it is reached from, and a second declaration would
310/// be two ids for it.
311pub static BORROWED: &[(&str, &[&WorkbenchButton])] =
312    &[("pcb", &[assembly::ADD_COMPONENT])];
313
314/// The buttons workbench `id` BORROWS ([`BORROWED`]) — empty for a workbench
315/// that borrows none, and for `"all"`, whose union already carries every
316/// lender's own declaration.
317pub fn borrowed_buttons(id: &str) -> &'static [&'static WorkbenchButton] {
318    BORROWED
319        .iter()
320        .find(|(borrower, _)| *borrower == id)
321        .map_or(&[], |(_, buttons)| *buttons)
322}
323
324/// The active workbench's toolbar buttons: its OWN declared buttons — for
325/// `"all"` the DEDUPED UNION of every workbench's, so "All" shows every icon
326/// without a hand-maintained list — then the ones it BORROWS ([`BORROWED`]),
327/// then the [`shared_buttons`] every workbench carries. The borrowed ones trail
328/// the workbench's own, exactly as the shared tail trails both, so a row's own
329/// declared order (eCAD's toolbar order, in PCB's case) is untouched by a
330/// borrow.
331pub fn workbench_buttons(id: &str) -> Vec<&'static WorkbenchButton> {
332    let wb = resolve(id);
333    let own = if wb.id == "all" {
334        union_in(WORKBENCHES)
335    } else {
336        wb.buttons.iter().collect::<Vec<_>>()
337    };
338    own.into_iter()
339        .chain(borrowed_buttons(wb.id).iter().copied())
340        .chain(shared_buttons())
341        .collect()
342}
343
344/// The active workbench's buttons that `state` actually OFFERS — [`workbench_buttons`]
345/// filtered by each button's [`WorkbenchButton::when`]. This is what the toolbar row
346/// draws and what `describe_workbenches` reports as active, so the two can never
347/// disagree about which buttons exist right now.
348pub fn offered_buttons(id: &str, state: &ButtonState) -> Vec<&'static WorkbenchButton> {
349    if owned_workbenches(state.engine).iter().any(|w| w.id == id) {
350        return shared_buttons().iter().filter(|b| b.offered(state)).collect();
351    }
352    workbench_buttons(id).into_iter().filter(|button| button.offered(state)).collect()
353}
354
355/// Look one button up by id across every workbench AND the shared list — the
356/// dispatch side, which must answer for a button the active workbench does not
357/// carry (the `workbench_button` command names an id, not a workbench) and for
358/// one no workbench declares at all (the sketch tools).
359pub fn button_by_id(id: &str) -> Option<&'static WorkbenchButton> {
360    button_in(WORKBENCHES, id)
361}
362
363/// What every one of a workbench's OWN button ids leads with: its id,
364/// lowercased, and a dot — `drawing.dim.radius`, `sheetmetal.flat_pattern`.
365///
366/// A button id is all the toolbar's return path and the `workbench_button`
367/// command carry, so it must name ONE button across every workbench: with an
368/// id declared twice, [`button_by_id`] returns the first declaration and asks
369/// ITS `when`, and All's union keeps the first and drops the rest. The prefix
370/// makes two workbenches' ids disjoint. It matters where two workbenches
371/// take their buttons from one table: eCAD's editor actions (`view.*`,
372/// `edit.*`, `sheet.*`, `board.*`) are Diagram's and PCB's alike, so each
373/// leads them with its own — `diagram.sheet.tool.wire`,
374/// `pcb.sheet.tool.wire` — while the symbol and pads editors' ids already
375/// lead with `symbol.` and `pads.` and need nothing added. The shared sketch
376/// tools belong to no workbench and lead with none of theirs (`sketch.`).
377pub fn button_prefix(wb: &Workbench) -> String {
378    format!("{}.", wb.id.to_ascii_lowercase())
379}
380
381/// [`button_by_id`] over any registry.
382fn button_in(registry: &[&'static Workbench], id: &str) -> Option<&'static WorkbenchButton> {
383    every_button_in(registry).find(|button| button.id == id)
384}
385
386/// Why the `workbench_button` command must NOT press `id` in `state`, or
387/// `None` when it may. The command's whole gate, here rather than in the
388/// command so it is tested against every kind of button: an unknown id, one
389/// the row is not offering, a MENU (its entries are pressed, not it), and an
390/// offered button that is DISABLED, which is refused with its reason — never
391/// run as a silent no-op.
392pub fn press_refusal(id: &str, state: &ButtonState) -> Option<String> {
393    press_refusal_in(WORKBENCHES, id, state)
394}
395
396/// [`press_refusal`] over any registry.
397fn press_refusal_in(registry: &[&'static Workbench], id: &str, state: &ButtonState) -> Option<String> {
398    let Some(button) = button_in(registry, id) else {
399        return Some(format!("no workbench button `{id}` (see describe_workbenches)"));
400    };
401    if !button.offered(state) {
402        return Some(format!(
403            "the `{id}` button is not offered right now (see describe_workbenches `activeButtons`)"
404        ));
405    }
406    if !button.menu.is_empty() {
407        let entries: Vec<&str> =
408            button.menu.iter().filter(|e| e.offered(state)).map(|e| e.id).collect();
409        return Some(format!("`{id}` opens a menu; press one of its entries: {}", entries.join(", ")));
410    }
411    button
412        .disabled_reason(state)
413        .map(|why| format!("the `{id}` button is disabled right now: {why}"))
414}
415
416/// Buttons that draw ONE picture although their ids differ, because they are
417/// one user-facing action in different places — each group with the reason.
418/// The artwork law (`every_button_draws_catalogued_artwork_shared_only_by_one_action`)
419/// lets two buttons share a picture only when they are the same action behind
420/// their workbench prefixes (Diagram's and PCB's Rotate) or are listed here.
421/// A share between unrelated buttons still fails, which is what the law is for;
422/// a deliberate reuse is a reviewable line here instead of a duplicated icon.
423///
424/// Two reuses were REFUSED rather than listed, because the picture already
425/// means something else in this app: U+21BB ↻ is the BOM's outdated-vs-source
426/// badge (`panels::assembly_components::OUTDATED_GLYPH`), not "rotate", and
427/// U+2713 ✓ is a status tick (a resolved annotation, the switcher's active
428/// entry), not "finish". Rotate and finish have pictures of their own.
429pub const SYNONYMS: &[(&[&str], &str)] = &[
430    (&["sketch.tool.text", "symbol.tool.text"], "places text in the active editor"),
431    (
432        &[
433            "sketch.tool.select",
434            "sketch3d.tool.select",
435            "diagram.sheet.tool.select",
436            "pcb.sheet.tool.select",
437            "pcb.board.tool.select",
438            "symbol.tool.select",
439            "pads.tool.select",
440        ],
441        "select and drag, in every editor",
442    ),
443    (&["sketch.tool.line", "sketch3d.tool.line", "symbol.tool.line", "pads.tool.line"], "draws a straight line"),
444    (&["sketch.tool.point", "sketch3d.tool.point"], "places a point"),
445    (&["sketch.tool.arc", "sketch3d.tool.arc"], "draws an arc from centre, start and end"),
446    (&["sketch.tool.bezier", "sketch3d.tool.bezier"], "draws a cubic bezier"),
447    (&["sketch.tool.pickEdges", "sketch3d.tool.pickRef"], "links model geometry into the sketch as a fixed reference"),
448    (&["sketch.tool.rect", "symbol.tool.rectangle", "pads.tool.rectangle"], "draws a rectangle"),
449    (&["sketch.tool.circle", "symbol.tool.circle", "pads.tool.circle"], "draws a circle"),
450    (&["pcb.sheet.wire.finish", "pcb.board.route.finish"], "ends the path being drawn"),
451];
452
453/// Every DECLARED workbench: the dropdown's ([`WORKBENCHES`]), then the ones
454/// not in it yet ([`UNREGISTERED`]). What the laws hold, so a workbench is
455/// covered before it is offered.
456pub fn declared_workbenches() -> Vec<&'static Workbench> {
457    WORKBENCHES.iter().chain(UNREGISTERED).copied().collect()
458}
459
460/// [`button_by_id`] over every DECLARED workbench, registered or not — for the
461/// eCAD module, whose buttons exist before their workbenches are offered.
462pub(crate) fn button_by_declared_id(id: &str) -> Option<&'static WorkbenchButton> {
463    button_in(&declared_workbenches(), id)
464}
465
466/// Every DECLARED button in the app: each workbench's own, then the shared
467/// ones. The lookup + audit side ([`button_by_id`], the artwork and distinctness
468/// tests) — NOT what any row draws, which is [`offered_buttons`].
469pub fn every_button() -> impl Iterator<Item = &'static WorkbenchButton> {
470    every_button_in(WORKBENCHES)
471}
472
473/// [`every_button`] over any registry.
474///
475/// A MENU button's entries are buttons too — the command presses them by id —
476/// so each follows the menu that holds it.
477fn every_button_in<'r>(
478    registry: &'r [&'static Workbench],
479) -> impl Iterator<Item = &'static WorkbenchButton> + 'r {
480    registry
481        .iter()
482        .flat_map(|w| w.buttons.iter())
483        .chain(shared_buttons())
484        .flat_map(|button| std::iter::once(button).chain(button.menu.iter()))
485}
486
487/// All's own buttons over any registry: the union of every workbench's.
488fn union_in(registry: &[&'static Workbench]) -> Vec<&'static WorkbenchButton> {
489    dedupe_buttons(registry.iter().map(|w| w.buttons))
490}
491
492/// Collect buttons across lists, keeping FIRST occurrence per `id` and preserving
493/// order. The union logic behind [`workbench_buttons`] for `"all"`.
494fn dedupe_buttons<'a>(
495    lists: impl Iterator<Item = &'a [WorkbenchButton]>,
496) -> Vec<&'a WorkbenchButton> {
497    let mut seen = std::collections::HashSet::new();
498    let mut out = Vec::new();
499    for list in lists {
500        for button in list {
501            if seen.insert(button.id) {
502                out.push(button);
503            }
504        }
505    }
506    out
507}
508
509/// Whether a catalogue entry belongs in workbench `active_id`'s feature-creation
510/// UI. The ONE entry point the palette + context-offer filters call: builds a
511/// [`FeatureInfo`] from the feature `type_code` and runs the resolved workbench's
512/// predicate (each workbench file owns its classification off the code).
513pub fn includes_feature(active_id: &str, type_code: &str) -> bool {
514    let info = FeatureInfo { type_code };
515    (resolve(active_id).includes)(&info)
516}
517
518/// The Qualify pane's registration id: a part's declared connection points —
519/// its port groups, their purpose, and the consistency reports the model
520/// already produces.
521///
522/// Claimed by NO workbench, and deliberately: a connection point is part DATA
523/// and belongs to whichever surface the user is on — the schematic symbol, the
524/// pads, or the 3D model — so a claim list would have to name most of the
525/// registry and would still hide the pane from the one workbench it was
526/// forgotten in. What gates it is the DOCUMENT ([`PANEL_CONDITIONS`]), which is
527/// the honest condition: a part that declares no connection points has nothing
528/// to qualify.
529pub const QUALIFY_PANEL_ID: &str = "qualify";
530
531/// A panel whose visibility ALSO depends on the document, beside the claim.
532///
533/// The claim system answers "which workbench is this panel's". It cannot answer
534/// "does this document have anything for it", because a [`Workbench`]'s
535/// `panels` is a list of ids with no room for a predicate. This is that second
536/// half, in the shape [`WorkbenchButton::when`] already uses — a predicate over
537/// [`ButtonState`], which carries the whole engine — so a conditional PANEL is
538/// declared exactly like a conditional BUTTON and read in exactly one place
539/// ([`panel_visible`]).
540///
541/// Keyed by PANEL rather than by workbench because the condition is the
542/// panel's own: Qualify wants a part with connection points wherever it is
543/// drawn, not a different answer per workbench.
544pub struct PanelCondition {
545    pub panel: &'static str,
546    /// Whether the document has anything for this panel. `false` hides it
547    /// exactly as an unclaimed workbench does — in place, keeping its position.
548    pub when: fn(&ButtonState) -> bool,
549}
550
551/// Every conditional panel.
552pub static PANEL_CONDITIONS: &[PanelCondition] = &[
553    PanelCondition { panel: QUALIFY_PANEL_ID, when: declares_connection_points },
554    PanelCondition { panel: FAMILY_TABLE_PANEL_ID, when: is_family_document },
555    PanelCondition { panel: PLM_PANEL_ID, when: crate::panels::plm_host::in_plm_session },
556];
557
558/// The PLM pane's registration id (`panels::plm_host`): every PLM panel, as
559/// sections of one pane. Claimed by no workbench; on screen exactly while the
560/// session's store is a PLM.
561pub const PLM_PANEL_ID: &str = "plm";
562
563/// The Family table pane's registration id: a family seed's member table
564/// (`panels::family_table_editor`). Claimed by no workbench. It is on screen
565/// exactly while the document being edited is a family seed (`.fbrep`),
566/// whatever workbench the user is in.
567pub const FAMILY_TABLE_PANEL_ID: &str = "familyTable";
568
569/// Whether the active document is a family seed: its own `documentClass`
570/// field, so a family that has never been saved answers too.
571pub fn is_family_document(state: &ButtonState) -> bool {
572    crate::document_class::is_family(state.engine)
573}
574
575/// Whether the document declares any connection point: a `ports` block with at
576/// least one group in it. An EMPTY block reads as none — the block is omitted
577/// on re-serialize when empty, so an empty array is a transient the panel's own
578/// "remove the last group" leaves behind, and it must not keep the pane up.
579pub fn declares_connection_points(state: &ButtonState) -> bool {
580    state
581        .engine
582        .history
583        .ports_block()
584        .and_then(serde_json::Value::as_array)
585        .is_some_and(|groups| !groups.is_empty())
586}
587
588/// Whether panel `panel_id` is visible in workbench `active_id`, for document
589/// `state`. TWO halves, and this is the one reading of both.
590///
591/// Claim-based: a panel is visible UNLESS it is claimed by ≥1 workbench and the
592/// active workbench does not list it. `"all"` sees every panel (mirrors the
593/// button union).
594///
595/// Then CONDITIONAL: a panel [`PANEL_CONDITIONS`] names is visible only while
596/// its predicate holds for this document. A panel with no condition — every one
597/// but Qualify — is unaffected, so `state` costs the other callers nothing but
598/// the argument.
599pub fn panel_visible(active_id: &str, panel_id: &str, state: &ButtonState) -> bool {
600    if !PANEL_CONDITIONS
601        .iter()
602        .filter(|condition| condition.panel == panel_id)
603        .all(|condition| (condition.when)(state))
604    {
605        return false;
606    }
607    let dynamic = owned_workbenches(state.engine);
608    let plugin = dynamic.iter().find(|w| w.id == active_id);
609    let claimed = WORKBENCHES.iter().any(|w| w.panels.contains(&panel_id))
610        || dynamic.iter().any(|w| w.panels.iter().any(|p| p == panel_id));
611    if let Some(wb) = plugin {
612        return !claimed || wb.panels.iter().any(|p| p == panel_id);
613    }
614    if !claimed {
615        return true;
616    }
617    let wb = resolve(active_id);
618    if wb.id == "all" {
619        return true;
620    }
621    wb.panels.contains(&panel_id)
622}
623
624/// The workbench list as `{id, label}` JSON plus the resolved current id — the
625/// `__brepWorkbench` logical-state global the headed verifier reads to drive the
626/// dropdown and confirm the active workbench. `current` is the RESOLVED id (an
627/// unknown stored id reads back as the default).
628pub fn workbench_state_json(current_stored: &str) -> String {
629    let available: Vec<serde_json::Value> = WORKBENCHES
630        .iter()
631        .map(|w| serde_json::json!({ "id": w.id, "label": w.label }))
632        .collect();
633    serde_json::json!({
634        "current": resolve(current_stored).id,
635        "available": available,
636    })
637    .to_string()
638}
639
640
641/// Document-scoped, owned overlay. Static built-ins retain their predicates and callbacks.
642#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
643#[serde(rename_all = "camelCase")]
644pub struct OwnedWorkbench {
645    pub id: String,
646    pub label: String,
647    #[serde(default)] pub feature_types: Vec<String>,
648    #[serde(default)] pub actions: Vec<String>,
649    #[serde(default)] pub panels: Vec<String>,
650}
651
652pub fn owned_workbenches(engine: &EngineState) -> Vec<OwnedWorkbench> {
653    serde_json::from_value(engine.plugin_workbenches()).unwrap_or_default()
654}
655
656pub fn resolved_id(engine: &EngineState, id: &str) -> String {
657    if workbench_by_id(id).is_some() || owned_workbenches(engine).iter().any(|w| w.id == id) {
658        id.to_owned()
659    } else { DEFAULT_WORKBENCH_ID.to_owned() }
660}
661
662pub fn includes_feature_scoped(engine: &EngineState, active: &str, ty: &str) -> bool {
663    if let Some(wb) = owned_workbenches(engine).iter().find(|w| w.id == active) {
664        wb.feature_types.iter().any(|f| f == ty)
665    } else { includes_feature(active, ty) }
666}
667
668pub fn plugin_actions(engine: &EngineState, active: &str) -> Vec<serde_json::Value> {
669    let workbenches = owned_workbenches(engine);
670    let ids = workbenches.iter().find(|w| w.id == active).map(|w| &w.actions);
671    engine.plugin_catalogue()["actions"].as_array().into_iter().flatten()
672        .filter(|a| active == "all" || ids.is_some_and(|ids| a["id"].as_str().is_some_and(|id| ids.iter().any(|i| i == id))))
673        .cloned().collect()
674}
675
676
677pub fn workbench_state_scoped(engine: &EngineState) -> serde_json::Value {
678    let mut state: serde_json::Value = serde_json::from_str(&workbench_state_json(&engine.settings.workbench)).unwrap();
679    state["saved"] = serde_json::json!(engine.settings.workbench);
680    state["current"] = serde_json::json!(resolved_id(engine, &engine.settings.workbench));
681    if let Some(available) = state["available"].as_array_mut() {
682        available.extend(owned_workbenches(engine).iter().map(|w| serde_json::json!({"id":w.id,"label":w.label})));
683    }
684    state
685}
686
687/// Declarative panels claimed by the active document's workbench.
688pub fn plugin_panels(engine: &EngineState, active: &str) -> Vec<serde_json::Value> {
689    let workbenches = owned_workbenches(engine);
690    let ids = workbenches.iter().find(|w| w.id == active).map(|w| &w.panels);
691    engine.plugin_catalogue()["panels"].as_array().into_iter().flatten()
692        .filter(|p| active == "all" || ids.is_some_and(|ids| p["id"].as_str().is_some_and(|id| ids.iter().any(|i| i == id))))
693        .cloned().collect()
694}