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