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