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