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}