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