Skip to main content

brep_app/panels/
sheets.rs

1//! The Sheets panel — the drawing sheets of the document as a TREE on the
2//! shared [`crate::column_tree`] widget: one row per sheet, its placed views
3//! as children, and — when a sheet or a placement is open — that object's
4//! dialog through the SHARED [`crate::form_view`] (the same function the
5//! feature, constraint and PMI annotation dialogs use; the schema comes from
6//! `brep_render::sheets::schema_catalogue`, so neither form is hand-written).
7//!
8//! # Tree mode
9//!
10//! Header: **Add sheet** (a new sheet at the default paper size, opened in the
11//! sheet viewport), **+ Place view** (one entry per saved PMI view — placing
12//! is gated on having a sheet AND a view to place), and a summary.
13//!
14//! Rows: a SHEET row shows its name (editable — rename in place), its paper
15//! size in millimetres, how many views it places and an **open** toggle (the
16//! sheet viewport draws the open sheet; unticking returns to the 3D view); a
17//! click opens it in the sheet viewport — the open sheet is the selected one —
18//! and a double click opens the sheet's dialog; its menu offers Edit sheet and
19//! Delete. A PLACED VIEW row shows its id, the saved view it places with its
20//! scale, its position, whether its text is `flat` on the sheet or
21//! `projected`, and its status — ✓ with the count of drawn edges and
22//! annotations, or ✕ with the reason it did not project (a deleted view, a
23//! camera this slice cannot draw).
24//!
25//! Every row a sheet HOLDS — a placement, a dimension, an ordinate set — is
26//! picked the same way: a click SELECTS it (the row, and the object on the
27//! paper, carry the selection) and a double click opens its form.
28//!
29//! Every row's menu is ALSO the right-click menu of its object on the paper
30//! (`viewport/sheet.rs`), bare paper opening the sheet's: `object_actions`
31//! is the one list and `run_row_action` the one dispatch, so the two cannot
32//! drift. The Delete key removes the selected held object through its menu's
33//! destructive entry (`delete_held_object`, driven by the app shell).
34//!
35//! A SHEET DIMENSION row sits under the placements of the same sheet — a
36//! dimension belongs to the PAPER, not to any one placement, so it is a child
37//! of the sheet — and shows its id, what it measures (its alignment or its
38//! kind), how many anchors it has and the VALUE it read, with ✓ or the reason
39//! it did not resolve. Its form carries the kind, the alignment, the anchors as
40//! reference rows, the offset and the precision; the rows' **Select** opens the
41//! reference picker — the modal every dialog's reference rows open — whose
42//! picks are anchors clicked on the paper, and **Finish** writes them here.
43//! Every reference row of every sheet object works that way: an ordinate
44//! set's datum and members, a section's cutting line, a detail's centre and
45//! rim. A SECTION and a DETAIL have forms of their own for that reason
46//! (`sectionView`, `detailView`): a plain placement has nothing to pick.
47//!
48//! The SHEET's dialog ends with a **Revisions** section: the sheet's revision
49//! rows, each a letter, a date and a description edited in place, with a ✕
50//! to remove a row and **+ Add revision** under them. A list of records is a
51//! surface the schema vocabulary cannot express, so it is the form's one
52//! consumer-drawn section (`FormViewSpec::extra`, the spline editor's anchor
53//! list is the other) — every edit still goes through the engine's revision
54//! doors, and the rows keep the order they were added in.
55//!
56//! **Flatten text** is a checkbox of the placement's own FORM
57//! (`sheets:field:flattenText`, from the schema like every other field)
58//! rather than a column of the tree: the shared column tree gates its editors
59//! on the whole ROW, and a placement row is not editable — its name is its
60//! id. The row says which way the toggle is set; the form is where it moves.
61//!
62//! # Form mode
63//!
64//! Which object is open is the ENGINE's `sheet_open_object` (a mode, not model
65//! state) — a click on an object on the sheet viewport's paper opens one without going
66//! through here, so the mode is read, never owned. Editing is LIVE and
67//! **Return to tree** closes the form.
68//!
69//! Everything the panel knows comes from the engine (`sheet_state`,
70//! `sheet_drawing`); this struct holds the widget's transient state only.
71
72use crate::automation::hit_keys::HitKeyDoc;
73use crate::column_tree::{self, CellKind, ColumnLayout, ColumnSpec, ColumnTreeSpec, RowAction, RowNode};
74use crate::form_view::{form_view, FormBanner, FormViewSpec};
75use brep_render::engine_state::{EngineState, RefSelectTarget};
76use brep_render::features::form_fields_from_schema;
77use brep_render::sheets::SheetState;
78use eframe::egui;
79use serde_json::Value;
80use std::collections::{HashMap, HashSet};
81
82const NAME: &str = "name";
83const KIND: &str = "kind";
84const VALUE: &str = "value";
85const STATUS: &str = "status";
86const ON: &str = "on";
87const ACTIONS: &str = "actions";
88
89const OK_COLOR: &str = "#3fb950";
90const ERROR_COLOR: &str = "#f85149";
91const ACTIVE_COLOR: &str = "#58a6ff";
92const MUTED_COLOR: &str = "#8b949e";
93
94/// Row-menu action ids.
95const ACT_OPEN: &str = "open";
96const ACT_CLOSE: &str = "close";
97const ACT_EDIT: &str = "edit";
98const ACT_DELETE_SHEET: &str = "delete-sheet";
99const ACT_DELETE_VIEW: &str = "delete-view";
100const ACT_DELETE_DIM: &str = "delete-dimension";
101const ACT_DELETE_ORD: &str = "delete-ordinate";
102
103/// A deferred engine mutation (one per frame).
104enum Action {
105    AddSheet,
106    PlaceView(String),
107    Rename(String, String),
108    Open(Option<String>),
109    Edit(Option<String>),
110    /// A single click on a placement, dimension or ordinate row.
111    Select(String),
112    DeleteSheet(String),
113    DeleteView(String),
114    DeleteDimension(String),
115    UpdateSheet(String, Value),
116    UpdateView(String, Value),
117    UpdateDimension(String, Value),
118    DeleteOrdinate(String),
119    UpdateOrdinate(String, Value),
120    /// A reference row's **Select** — a dimension's anchors, an ordinate set's
121    /// datum or members, a section's cutting line, a detail's centre or rim:
122    /// the reference picker, on the paper, the modal every dialog's reference
123    /// rows open.
124    BeginRefSelect {
125        id: String,
126        path: Vec<String>,
127        label: String,
128        filter: Vec<String>,
129        multiple: bool,
130        seed: Vec<String>,
131    },
132    /// The focused reference field's `Done`: commit the running pick.
133    FinishRefSelect,
134    /// An `✕` on the focused reference field: drop the i-th running pick.
135    RefSelectRemove(usize),
136    /// The sheet dialog's Revisions section: append a row…
137    AddRevision(String),
138    /// …edit one field of row `n`…
139    UpdateRevision(String, usize, Value),
140    /// …or remove row `n`.
141    RemoveRevision(String, usize),
142}
143
144/// What the Revisions section asked for this frame. Recorded by the drawer
145/// (which runs inside the form view and holds no engine) and turned into an
146/// [`Action`] once the form has drawn.
147enum RevisionIntent {
148    Add,
149    Update(usize, &'static str, String),
150    Remove(usize),
151}
152
153/// Drive the engine for one deferred [`Action`]. A refusal comes back as the
154/// engine's own message, which the caller turns into a notice.
155fn apply(state: &mut EngineState, action: Action) -> Result<(), String> {
156    match action {
157        Action::AddSheet => {
158            state.sheet_add(None);
159            Ok(())
160        }
161        Action::PlaceView(view) => state.sheet_place_view(None, &view, None, None).map(|_| ()),
162        Action::Rename(id, name) => {
163            state.sheet_update(&id, &serde_json::json!({ "name": name }).to_string())
164        }
165        Action::Open(id) => state.sheet_set_open(id.as_deref()),
166        Action::Edit(id) => {
167            state.sheet_set_object_open(id.as_deref());
168            Ok(())
169        }
170        Action::Select(id) => {
171            state.sheet_select_object(Some(&id));
172            Ok(())
173        }
174        Action::DeleteSheet(id) => state.sheet_delete(&id),
175        Action::DeleteView(id) => state.sheet_remove_view(&id),
176        Action::DeleteDimension(id) => state.sheet_remove_dimension(&id),
177        Action::UpdateSheet(id, params) => state.sheet_update(&id, &params.to_string()),
178        Action::UpdateView(id, params) => state.sheet_update_view(&id, &params.to_string()),
179        Action::UpdateDimension(id, params) => {
180            state.sheet_update_dimension(&id, &params.to_string())
181        }
182        Action::DeleteOrdinate(id) => state.sheet_remove_ordinate(&id),
183        Action::UpdateOrdinate(id, params) => {
184            state.sheet_update_ordinate(&id, &params.to_string())
185        }
186        Action::BeginRefSelect { id, path, label, filter, multiple, seed } => {
187            // Focus moves from one selection box to the next: a pick already
188            // running commits first.
189            if state.ref_select_active() {
190                state.finish_ref_select();
191            }
192            state.begin_ref_select_for_sheet(&id, path, label, filter, multiple, seed)
193        }
194        Action::FinishRefSelect => {
195            state.finish_ref_select();
196            Ok(())
197        }
198        Action::RefSelectRemove(index) => {
199            state.ref_select_remove(index);
200            Ok(())
201        }
202        Action::AddRevision(id) => state.sheet_add_revision(&id, None, None, None).map(|_| ()),
203        Action::UpdateRevision(id, index, params) => {
204            state.sheet_update_revision(&id, index, &params.to_string())
205        }
206        Action::RemoveRevision(id, index) => state.sheet_remove_revision(&id, index),
207    }
208}
209
210/// A SHEET row's menu. `is_open` flips the first entry between Open and
211/// Close.
212fn sheet_actions(is_open: bool) -> Vec<RowAction> {
213    vec![
214        if is_open {
215            RowAction::new(ACT_CLOSE, "Close sheet").tooltip("Return the viewport to the 3D model")
216        } else {
217            RowAction::new(ACT_OPEN, "Open sheet").tooltip("Draw this sheet in the viewport")
218        },
219        RowAction::new(ACT_EDIT, "Edit sheet").tooltip("Name and paper size"),
220        RowAction::new(ACT_DELETE_SHEET, "Delete sheet").separator_above().destructive(),
221    ]
222}
223
224/// A PLACED VIEW row's menu — a plain placement, a section or a detail alike.
225fn placement_actions() -> Vec<RowAction> {
226    vec![
227        RowAction::new(ACT_EDIT, "Edit placement").tooltip("Which view, where, at what scale, whether its text lies flat on the sheet, and — on a section or a detail — its letter"),
228        RowAction::new(ACT_DELETE_VIEW, "Remove from sheet").separator_above().destructive(),
229    ]
230}
231
232/// A SHEET DIMENSION row's menu.
233fn dimension_actions() -> Vec<RowAction> {
234    vec![
235        RowAction::new(ACT_EDIT, "Edit dimension").tooltip("Kind, anchors, offset, precision and tolerance"),
236        RowAction::new(ACT_DELETE_DIM, "Remove dimension").separator_above().destructive(),
237    ]
238}
239
240/// An ORDINATE SET row's menu.
241fn ordinate_actions() -> Vec<RowAction> {
242    vec![
243        RowAction::new(ACT_EDIT, "Edit ordinate set").tooltip("Axis, datum, members, baseline offset and precision"),
244        RowAction::new(ACT_DELETE_ORD, "Remove ordinate set").separator_above().destructive(),
245    ]
246}
247
248/// The menu of the tree row standing for `id` — a sheet, a placement, a
249/// dimension or an ordinate set — or nothing for an id that names none.
250///
251/// This is what the row's `⋯` opens, and what a right-click on the same
252/// object on the PAPER opens (`viewport/sheet.rs`): one list, so the two
253/// cannot offer different things.
254pub(crate) fn object_actions(sheets: &SheetState, open_sheet: Option<&str>, id: &str) -> Vec<RowAction> {
255    if sheets.find_sheet(id).is_some() {
256        sheet_actions(open_sheet == Some(id))
257    } else if sheets.locate_view(id).is_some() {
258        placement_actions()
259    } else if sheets.locate_dimension(id).is_some() {
260        dimension_actions()
261    } else if sheets.locate_ordinate(id).is_some() {
262        ordinate_actions()
263    } else {
264        Vec::new()
265    }
266}
267
268/// What a row-menu entry does: `action` is one of the ids [`object_actions`]
269/// declares, on the row `id`.
270fn menu_action(id: String, action: &str) -> Option<Action> {
271    match action {
272        ACT_OPEN => Some(Action::Open(Some(id))),
273        ACT_CLOSE => Some(Action::Open(None)),
274        ACT_EDIT => Some(Action::Edit(Some(id))),
275        ACT_DELETE_SHEET => Some(Action::DeleteSheet(id)),
276        ACT_DELETE_VIEW => Some(Action::DeleteView(id)),
277        ACT_DELETE_DIM => Some(Action::DeleteDimension(id)),
278        ACT_DELETE_ORD => Some(Action::DeleteOrdinate(id)),
279        _ => None,
280    }
281}
282
283/// Run the menu entry `action` of the row standing for `id` — the dispatch
284/// the tree's menu and the paper's right-click menu share. An unknown entry
285/// does nothing.
286pub(crate) fn run_row_action(state: &mut EngineState, id: &str, action: &str) -> Result<(), String> {
287    menu_action(id.to_string(), action).map_or(Ok(()), |action| apply(state, action))
288}
289
290/// Delete what a sheet HOLDS — a placement, a dimension or an ordinate set —
291/// through its own menu's destructive entry: the Delete key on a selected
292/// object. A SHEET is never deleted this way (it takes everything placed on it
293/// with it), and an id that names nothing a sheet holds refuses.
294pub(crate) fn delete_held_object(state: &mut EngineState, id: &str) -> Result<(), String> {
295    let sheets = state.sheet_state();
296    let entry = if sheets.locate_view(id).is_some() {
297        ACT_DELETE_VIEW
298    } else if sheets.locate_dimension(id).is_some() {
299        ACT_DELETE_DIM
300    } else if sheets.locate_ordinate(id).is_some() {
301        ACT_DELETE_ORD
302    } else {
303        return Err(format!("no placed view, dimension or ordinate set '{id}'"));
304    };
305    run_row_action(state, id, entry)
306}
307
308/// The panel's transient UI state.
309/// The sheet artwork: the Drawing ribbon's Add-sheet glyph (catalogued, so it
310/// draws as an icon). The pane used U+1F5CB, which no font here carries, so
311/// its header and every sheet row showed a box (ui-polish-2026-10-28 row 8).
312const SHEET_GLYPH: &str = "\u{E077}";
313
314pub struct SheetsPanel {
315    hits: HashMap<String, egui::Rect>,
316    layout: ColumnLayout,
317    columns: Vec<ColumnSpec>,
318    /// Sheets collapsed in the tree (every sheet starts expanded).
319    collapsed: HashSet<String>,
320}
321
322impl Default for SheetsPanel {
323    fn default() -> Self {
324        Self::new()
325    }
326}
327
328impl SheetsPanel {
329    pub fn new() -> Self {
330        Self {
331            hits: HashMap::new(),
332            layout: ColumnLayout::default(),
333            columns: vec![
334                ColumnSpec::new(NAME, "Sheet / view", CellKind::Text).width(140.0),
335                ColumnSpec::new(KIND, "", CellKind::Badges).width(28.0),
336                ColumnSpec::new(VALUE, "Paper / placement", CellKind::ReadOnly).width(170.0),
337                ColumnSpec::new(STATUS, "", CellKind::Badges).width(28.0),
338                ColumnSpec::new(ON, "Open", CellKind::Toggle).width(36.0),
339                ColumnSpec::new(ACTIONS, "", CellKind::Actions { label: "\u{22EF}".into() }).width(30.0),
340            ],
341            collapsed: HashSet::new(),
342        }
343    }
344
345    /// Draw the panel: the open object's form, else the sheet tree.
346    pub fn show(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
347        self.hits.clear();
348        self.hits.insert("sheets:panel:clip".into(), ui.clip_rect());
349        let sheets = state.sheet_state();
350        let open_sheet = state.sheet_open().map(String::from);
351        let open_object = state.sheet_open_object().map(String::from);
352        let selected = state.sheet_selected_object().map(String::from);
353        let view_ids = state.sheet_pmi_view_ids();
354
355        let mut action: Option<Action> = None;
356        let mut close = false;
357        match open_object.as_deref() {
358            Some(id) if sheets.find_sheet(id).is_some() => {
359                let sheet = sheets.find_sheet(id).expect("just matched").clone();
360                self.show_form(ui, state, &sheet.id, "Sheet", &sheet.params(), 0, None, &mut action, &mut close);
361            }
362            Some(id) if sheets.locate_view(id).is_some() => {
363                let placed = sheets
364                    .sheets
365                    .iter()
366                    .find_map(|sheet| sheet.find_view(id))
367                    .expect("just located")
368                    .clone();
369                // A section and a detail are placements with forms of their
370                // own: their picked references are rows there, and a plain
371                // placement has none.
372                let (label, index) = match placed.schema_type() {
373                    "sectionView" => ("Section view", 4),
374                    "detailView" => ("Detail view", 5),
375                    _ => ("Placed view", 1),
376                };
377                self.show_form(ui, state, &placed.id, label, &placed.params(), index, None, &mut action, &mut close);
378            }
379            Some(id) if sheets.locate_dimension(id).is_some() => {
380                let dimension = sheets.find_dimension(id).expect("just located").clone();
381                // A dimension that writes a PMI dimension's tolerance block says
382                // so at the top of its form: its own four fields read `none`,
383                // and the value on the paper does not.
384                let inherited = sheets
385                    .locate_dimension(id)
386                    .and_then(|(sheet, _)| state.sheet_drawing_cached(&sheet))
387                    .and_then(|drawing| drawing.dimensions.iter().find(|drawn| drawn.id == dimension.id))
388                    .filter(|drawn| !drawn.tolerance_from.is_empty())
389                    .map(|drawn| {
390                        format!(
391                            "Tolerance from PMI dimension {}: {} \u{2014} set one here to override it",
392                            drawn.tolerance_from, drawn.text
393                        )
394                    });
395                self.show_form(ui, state, &dimension.id, "Sheet dimension", &dimension.params(), 2, inherited.as_deref(), &mut action, &mut close);
396            }
397            Some(id) if sheets.locate_ordinate(id).is_some() => {
398                let set = sheets.find_ordinate(id).expect("just located").clone();
399                self.show_form(ui, state, &set.id, "Ordinate set", &set.params(), 3, None, &mut action, &mut close);
400            }
401            // A stale mode (the object was deleted elsewhere) falls back to
402            // the tree rather than an empty pane.
403            _ => self.show_tree(ui, state, &sheets, open_sheet.as_deref(), selected.as_deref(), &view_ids, &mut action),
404        }
405
406        let result = action.map_or(Ok(()), |action| apply(state, action));
407        if let Err(error) = result {
408            state.push_notice(format!("Sheets: {error}"));
409        }
410        if close {
411            // Leaving the form commits the pick running in it.
412            if state.ref_select_is_sheet() {
413                state.finish_ref_select();
414            }
415            state.sheet_set_object_open(None);
416        }
417        // This pane drew (or opened) the pick: the shell finishes an in-form
418        // pick no form claims, so one never runs on with its field off screen.
419        if let Some(field) = state.ref_select_field() {
420            if field.target == RefSelectTarget::Sheet && state.sheet_open_object() == Some(field.subject.as_str()) {
421                state.ref_select_claim();
422            }
423        }
424    }
425
426    #[allow(clippy::too_many_arguments)]
427    fn show_tree(
428        &mut self,
429        ui: &mut egui::Ui,
430        state: &mut EngineState,
431        sheets: &SheetState,
432        open_sheet: Option<&str>,
433        selected: Option<&str>,
434        view_ids: &[String],
435        action: &mut Option<Action>,
436    ) {
437        // --- header -------------------------------------------------------------
438        ui.horizontal_wrapped(|ui| {
439            let add = ui
440                .add(crate::icon_text::icon_button(ui, &format!("{SHEET_GLYPH} Add sheet")))
441                .on_hover_text("Add an A3 sheet and open it in the sheet viewport");
442            self.hits.insert("sheets:add".into(), add.rect);
443            if add.clicked() {
444                *action = Some(Action::AddSheet);
445            }
446            let can_place = !sheets.sheets.is_empty() && !view_ids.is_empty();
447            let mut place: Option<String> = None;
448            ui.add_enabled_ui(can_place, |ui| {
449                let combo = egui::ComboBox::from_id_salt("sheets-place")
450                    .selected_text("+ Place view")
451                    .show_ui(ui, |ui| {
452                        for id in view_ids {
453                            let item = crate::icon_text::selectable_icon_label(ui, false, id);
454                            self.hits.insert(format!("sheets:place:{id}"), item.rect);
455                            if item.clicked() {
456                                place = Some(id.clone());
457                            }
458                        }
459                    });
460                let response = if can_place {
461                    combo.response
462                } else if sheets.sheets.is_empty() {
463                    combo.response.on_disabled_hover_text("Add a sheet first")
464                } else {
465                    combo.response.on_disabled_hover_text("Capture a PMI view first")
466                };
467                self.hits.insert("sheets:place".into(), response.rect);
468            });
469            if let Some(view) = place {
470                *action = Some(Action::PlaceView(view));
471            }
472        });
473        let placed: usize = sheets.sheets.iter().map(|sheet| sheet.views.len()).sum();
474        let dimensions: usize = sheets.sheets.iter().map(|sheet| sheet.dimensions.len()).sum();
475        ui.label(
476            egui::RichText::new(format!(
477                "{} sheet{} | {placed} placed view{} | {dimensions} dimension{} | {}",
478                sheets.sheets.len(),
479                if sheets.sheets.len() == 1 { "" } else { "s" },
480                if placed == 1 { "" } else { "s" },
481                if dimensions == 1 { "" } else { "s" },
482                match open_sheet.and_then(|id| sheets.find_sheet(id)) {
483                    Some(sheet) => format!("open: {}", sheet.name),
484                    None => "no sheet open".to_string(),
485                }
486            ))
487            .weak(),
488        );
489        if sheets.sheets.is_empty() {
490            ui.label(egui::RichText::new("Add a sheet to place PMI views on paper").weak().italics());
491        }
492        ui.add_space(2.0);
493
494        // --- the tree ---------------------------------------------------------------
495        // The OPEN sheet's projection is already computed for the viewport;
496        // the rows read it for their per-placement status, and nothing else is
497        // projected — a sheet is a hidden-line pass and the tree must not
498        // trigger one per row.
499        let drawing = open_sheet.and_then(|id| state.sheet_drawing(id));
500        let rows: Vec<RowNode> = sheets
501            .sheets
502            .iter()
503            .map(|sheet| {
504                let is_open = open_sheet == Some(sheet.id.as_str());
505                let (w, h) = sheet.millimetres();
506                let count = sheet.views.len();
507                let mut row = RowNode::new(&sheet.id)
508                    .cell(NAME, Value::String(sheet.name.clone()))
509                    .cell(KIND, serde_json::json!([{ "glyph": SHEET_GLYPH, "color": if is_open { ACTIVE_COLOR } else { MUTED_COLOR }, "tooltip": "drawing sheet" }]))
510                    .cell(
511                        VALUE,
512                        Value::String(format!(
513                            "{} · {w:.0} × {h:.0} mm · {count} view{}",
514                            sheet.size,
515                            if count == 1 { "" } else { "s" }
516                        )),
517                    )
518                    .cell(
519                        STATUS,
520                        if is_open {
521                            serde_json::json!([{ "glyph": "\u{25CF}", "color": ACTIVE_COLOR, "tooltip": "shown in the sheet viewport" }])
522                        } else {
523                            serde_json::json!([])
524                        },
525                    )
526                    .cell(ON, Value::Bool(is_open))
527                    .actions(sheet_actions(is_open));
528                row.expanded = !self.collapsed.contains(&sheet.id);
529                row.selected = is_open;
530                row.children = sheet
531                    .views
532                    .iter()
533                    .map(|placed| {
534                        let projected = drawing.and_then(|d| d.views.iter().find(|v| v.id == placed.id));
535                        let is_section = placed.section.as_ref();
536                        let is_detail = placed.detail.as_ref();
537                        let (glyph, color, tooltip, status) = match projected {
538                            Some(view) if view.error.is_empty() => (
539                                "\u{2713}",
540                                OK_COLOR,
541                                format!(
542                                    "{}, {}",
543                                    crate::panels::count(view.edges.len(), "edge", "edges"),
544                                    crate::panels::count(view.annotations.len(), "annotation", "annotations")
545                                ),
546                                crate::panels::count(view.edges.len(), "edge", "edges"),
547                            ),
548                            Some(view) => ("\u{2715}", ERROR_COLOR, view.error.clone(), view.error.clone()),
549                            None => ("\u{2013}", MUTED_COLOR, "open the sheet to draw it".into(), String::new()),
550                        };
551                        let _ = status;
552                        // A placement row carries no editable cell: its name
553                        // IS its id, and the Open toggle belongs to the sheet.
554                        let mut child = RowNode::new(&placed.id)
555                            .cell(NAME, Value::String(placed.id.clone()))
556                            .cell(KIND, match (is_section, is_detail) {
557                                (Some(cut), _) => serde_json::json!([{ "glyph": "\u{25E7}", "color": ACTIVE_COLOR, "tooltip": format!("section {}—{} of {}", cut.label, cut.label, cut.source) }]),
558                                (_, Some(circle)) => serde_json::json!([{ "glyph": "\u{25CE}", "color": ACTIVE_COLOR, "tooltip": format!("detail {} of {}", circle.label, circle.source) }]),
559                                _ => serde_json::json!([{ "glyph": "\u{1F5CE}", "color": ACTIVE_COLOR, "tooltip": "placed PMI view" }]),
560                            })
561                            .cell(
562                                VALUE,
563                                Value::String(match (is_section, is_detail) {
564                                    // A section's row says what it IS a section
565                                    // of; its saved view is its source's, and
566                                    // saying so twice tells a reader nothing.
567                                    (Some(cut), _) => format!(
568                                        "SECTION {}—{} of {} · {}:1 · ({:.0}, {:.0}) mm",
569                                        cut.label,
570                                        cut.label,
571                                        cut.source,
572                                        trim(placed.scale),
573                                        placed.position[0],
574                                        placed.position[1]
575                                    ),
576                                    // …and a detail's what it is a detail of,
577                                    // at the scale that is the whole point of it.
578                                    (_, Some(circle)) => format!(
579                                        "DETAIL {} of {} · {}:1 · ({:.0}, {:.0}) mm",
580                                        circle.label,
581                                        circle.source,
582                                        trim(placed.scale),
583                                        placed.position[0],
584                                        placed.position[1]
585                                    ),
586                                    _ => format!(
587                                        "{} · {}:1 · ({:.0}, {:.0}) mm · {}",
588                                        placed.view,
589                                        trim(placed.scale),
590                                        placed.position[0],
591                                        placed.position[1],
592                                        if placed.flatten_text { "flat" } else { "projected" }
593                                    ),
594                                }),
595                            )
596                            .cell(STATUS, serde_json::json!([{ "glyph": glyph, "color": color, "tooltip": tooltip }]))
597                            .cell(ON, Value::Null)
598                            .actions(placement_actions());
599                        child.editable = false;
600                        child.selected = selected == Some(placed.id.as_str());
601                        child
602                    })
603                    .collect();
604                // …then the sheet's OWN dimensions, under the placements they
605                // anchor to. A dimension belongs to the PAPER, so it is a
606                // child of the sheet and not of any one placement — including
607                // the unresolved ones, which is where their reason is read.
608                row.children.extend(sheet.dimensions.iter().map(|dimension| {
609                    let drawn = drawing.and_then(|d| d.dimensions.iter().find(|x| x.id == dimension.id));
610                    let (glyph, color, tooltip) = match drawn {
611                        Some(d) if d.error.is_empty() => (
612                            "\u{2713}",
613                            OK_COLOR,
614                            if d.tolerance_from.is_empty() {
615                                format!("{} \u{2014} {} mm off its anchors", d.text, trim(d.offset_mm))
616                            } else {
617                                format!(
618                                    "{} \u{2014} {} mm off its anchors; the tolerance is PMI dimension {}'s",
619                                    d.text,
620                                    trim(d.offset_mm),
621                                    d.tolerance_from
622                                )
623                            },
624                        ),
625                        Some(d) => ("\u{2715}", ERROR_COLOR, d.error.clone()),
626                        None => ("\u{2013}", MUTED_COLOR, "open the sheet to draw it".into()),
627                    };
628                    let measured = drawn
629                        .filter(|d| d.error.is_empty())
630                        .map(|d| match d.tolerance_from.as_str() {
631                            "" => d.text.clone(),
632                            from => format!("{} (from {from})", d.text),
633                        })
634                        .unwrap_or_else(|| "\u{2014}".into());
635                    let mut child = RowNode::new(&dimension.id)
636                        .cell(NAME, Value::String(dimension.id.clone()))
637                        .cell(KIND, serde_json::json!([{ "glyph": "\u{2194}", "color": ACTIVE_COLOR, "tooltip": "sheet dimension" }]))
638                        .cell(
639                            VALUE,
640                            Value::String(format!(
641                                "{} \u{00B7} {} \u{00B7} {measured}",
642                                if dimension.kind == "linear" {
643                                    dimension.alignment.clone()
644                                } else {
645                                    dimension.kind.clone()
646                                },
647                                match dimension.anchors.len() {
648                                    1 => "1 anchor".to_string(),
649                                    n => format!("{n} anchors"),
650                                }
651                            )),
652                        )
653                        .cell(STATUS, serde_json::json!([{ "glyph": glyph, "color": color, "tooltip": tooltip }]))
654                        .cell(ON, Value::Null)
655                        .actions(dimension_actions());
656                    child.editable = false;
657                    child.selected = selected == Some(dimension.id.as_str());
658                    child
659                }));
660                // …and one row per ORDINATE SET, beside the dimensions: a set
661                // belongs to the paper too, and its row reads how many members
662                // it draws and how many it has lost.
663                row.children.extend(sheet.ordinates.iter().map(|set| {
664                    let drawn = drawing.and_then(|d| d.ordinates.iter().find(|x| x.id == set.id));
665                    let (glyph, color, tooltip) = match drawn {
666                        Some(d) if !d.error.is_empty() => ("\u{2715}", ERROR_COLOR, d.error.clone()),
667                        Some(d) if d.lost() > 0 => (
668                            "\u{2715}",
669                            ERROR_COLOR,
670                            d.stations
671                                .iter()
672                                .filter(|station| !station.error.is_empty())
673                                .map(|station| station.error.clone())
674                                .collect::<Vec<_>>()
675                                .join("; "),
676                        ),
677                        Some(d) => (
678                            "\u{2713}",
679                            OK_COLOR,
680                            format!(
681                                "{} stations \u{2014} baseline {} mm from the datum",
682                                d.stations.len(),
683                                trim(d.offset_mm)
684                            ),
685                        ),
686                        None => ("\u{2013}", MUTED_COLOR, "open the sheet to draw it".into()),
687                    };
688                    let mut child = RowNode::new(&set.id)
689                        .cell(NAME, Value::String(set.id.clone()))
690                        .cell(KIND, serde_json::json!([{ "glyph": "\u{22EE}", "color": ACTIVE_COLOR, "tooltip": "ordinate set" }]))
691                        .cell(
692                            VALUE,
693                            Value::String(format!(
694                                "{} \u{00B7} {}",
695                                set.axis,
696                                match set.members.len() {
697                                    1 => "1 member".to_string(),
698                                    n => format!("{n} members"),
699                                }
700                            )),
701                        )
702                        .cell(STATUS, serde_json::json!([{ "glyph": glyph, "color": color, "tooltip": tooltip }]))
703                        .cell(ON, Value::Null)
704                        .actions(ordinate_actions());
705                    child.editable = false;
706                    child.selected = selected == Some(set.id.as_str());
707                    child
708                }));
709                row
710            })
711            .collect();
712        let spec = ColumnTreeSpec {
713            id: "sheets",
714            columns: &self.columns,
715            root_label: Some("Sheets"),
716            root_cells: None,
717            empty_hint: Some("(no sheets — Add sheet to start a drawing)"),
718            hits_prefix: "sheets:",
719            drag_rows: false,
720        };
721        let out = column_tree::column_tree(ui, &spec, &mut self.layout, &rows, Some(&mut self.hits));
722
723        // --- act on what the widget reported (at most one mutation) ------------------
724        if let Some(id) = &out.toggled {
725            if sheets.find_sheet(id).is_some() && !self.collapsed.remove(id) {
726                self.collapsed.insert(id.clone());
727            }
728        }
729        if let Some(click) = out.actions.first() {
730            *action = menu_action(click.row_id.clone(), &click.action);
731            return;
732        }
733        if let Some(edit) = out.edits.first() {
734            let id = edit.row_id.clone();
735            match edit.column.as_str() {
736                NAME if sheets.find_sheet(&id).is_some() => {
737                    *action = Some(Action::Rename(id, edit.value.as_str().unwrap_or("").to_string()));
738                }
739                ON if sheets.find_sheet(&id).is_some() => {
740                    *action = Some(Action::Open(edit.value.as_bool().unwrap_or(false).then_some(id)));
741                }
742                _ => {}
743            }
744            return;
745        }
746        let held_by_a_sheet = |id: &str| {
747            sheets.locate_view(id).is_some()
748                || sheets.locate_dimension(id).is_some()
749                || sheets.locate_ordinate(id).is_some()
750        };
751        // A DOUBLE click opens the row's dialog — a sheet's own included. Read
752        // first: egui reports the second press of a double click as a click too.
753        if let Some(id) = &out.double_clicked {
754            if held_by_a_sheet(id) || sheets.find_sheet(id).is_some() {
755                *action = Some(Action::Edit(Some(id.clone())));
756            }
757            return;
758        }
759        // A single click SELECTS: what a sheet holds becomes the selected
760        // object, and a sheet becomes the open one — which is what selecting a
761        // sheet means.
762        if let Some(id) = &out.clicked {
763            if held_by_a_sheet(id) {
764                *action = Some(Action::Select(id.clone()));
765            } else if sheets.find_sheet(id).is_some() && open_sheet != Some(id.as_str()) {
766                *action = Some(Action::Open(Some(id.clone())));
767            }
768        }
769    }
770
771    /// Draw ONE sheet object's dialog through the shared form view.
772    /// `catalogue_index` picks the schema: 0 = the sheet, 1 = a placement,
773    /// 2 = a dimension, 3 = an ordinate set, 4 = a section, 5 = a detail.
774    #[allow(clippy::too_many_arguments)]
775    fn show_form(
776        &mut self,
777        ui: &mut egui::Ui,
778        state: &EngineState,
779        id: &str,
780        label: &str,
781        seed: &Value,
782        catalogue_index: usize,
783        note: Option<&str>,
784        action: &mut Option<Action>,
785        close: &mut bool,
786    ) {
787        let catalogue = state.sheet_catalogue();
788        let Some(schema) = catalogue
789            .get("objects")
790            .and_then(Value::as_array)
791            .and_then(|objects| objects.get(catalogue_index))
792            .cloned()
793        else {
794            *close = true;
795            return;
796        };
797        let fields = form_fields_from_schema(&schema);
798        let mut params = seed.clone();
799        let title = format!("{label} {id}");
800        // The SHEET's revision rows: a list of records, drawn as the form's
801        // own section. Intent-out like the rest of the form — the drawer edits
802        // a copy and records what changed; the engine is driven below.
803        let is_sheet = catalogue_index == 0;
804        let revisions: Vec<brep_render::sheets::Revision> = if is_sheet {
805            serde_json::from_value(seed.get("revisions").cloned().unwrap_or(Value::Null)).unwrap_or_default()
806        } else {
807            Vec::new()
808        };
809        let revision_rows = std::cell::RefCell::new(revisions);
810        let revision_intents: std::cell::RefCell<Vec<RevisionIntent>> = std::cell::RefCell::new(Vec::new());
811        let revision_hits: std::cell::RefCell<Vec<(String, egui::Rect)>> = std::cell::RefCell::new(Vec::new());
812        let bom = std::cell::RefCell::new(serde_json::from_value::<Option<brep_render::sheets::BomTable>>(seed.get("bomTable").cloned().unwrap_or(Value::Null)).unwrap_or_default());
813        let bom_changed = std::cell::Cell::new(false);
814        let mut columns = if is_sheet { state.sheet_bom_columns() } else { Vec::new() };
815        let config = if state.settings.bom_columns.is_empty() { crate::panels::bom_columns::default_text() } else { state.settings.bom_columns.clone() };
816        for column in crate::panels::bom_columns::parse(&config).columns { let key = column.key(); if !columns.contains(&key) { columns.push(key); } }
817        if let Some(table) = bom.borrow().as_ref() { for column in &table.columns { if !columns.contains(column) { columns.push(column.clone()); } } }
818        columns.sort_by_key(|column| !bom.borrow().as_ref().is_some_and(|table| table.columns.contains(column)));
819        let draw_revisions = |ui: &mut egui::Ui| {
820            ui.label("BOM table");
821            let mut table = bom.borrow_mut();
822            let mut enabled = table.is_some();
823            let response = ui.checkbox(&mut enabled, "Show BOM table");
824            revision_hits.borrow_mut().push(("sheets:bom:enabled".into(), response.rect));
825            if response.changed() {
826                *table = enabled.then(brep_render::sheets::BomTable::default); bom_changed.set(true);
827            }
828            if let Some(table) = table.as_mut() {
829                ui.horizontal(|ui| {
830                    ui.label("Position (mm)");
831                    for coordinate in &mut table.position { if ui.add(egui::DragValue::new(coordinate).speed(1.)).changed() { bom_changed.set(true); } }
832                });
833                ui.horizontal(|ui| { ui.label("Column width (mm)"); if ui.add(egui::DragValue::new(&mut table.column_width_mm).range(8.0..=200.0)).changed() { bom_changed.set(true); } });
834                ui.label("Columns");
835                egui::ScrollArea::vertical().id_salt(("bom-columns", id)).max_height(180.0).show(ui, |ui| {
836                for column in &columns {
837                    let mut selected = table.columns.contains(column);
838                    let response = ui.checkbox(&mut selected, column.replace('_', " "));
839                    revision_hits.borrow_mut().push((format!("sheets:bom:column:{column}"), response.rect));
840                    if response.changed() {
841                        if selected { table.columns.push(column.clone()); } else { table.columns.retain(|c| c != column); }
842                        bom_changed.set(true);
843                    }
844                }
845                });
846            }
847            ui.separator();
848            ui.label("Revisions");
849            draw_revision_rows(ui, id, &revision_rows, &revision_intents, &revision_hits);
850        };
851        // The pick running in THIS object's form, if one is: that field draws
852        // focused and lists the anchors picked so far.
853        let picking = state
854            .ref_select_field()
855            .filter(|f| f.target == RefSelectTarget::Sheet && f.subject == id);
856        let spec = FormViewSpec {
857            title: &title,
858            subtitle: None,
859            fields: &fields,
860            banner: note.map(|note| FormBanner::note(note, egui::Color32::from_rgb(0x58, 0xa6, 0xff))),
861            trailing: None,
862            exit_label: "Return to tree",
863            extra: is_sheet.then_some(("Tables", &draw_revisions as &dyn Fn(&mut egui::Ui))),
864            rollback: false,
865            hidden: None,
866            hits_prefix: "sheets:",
867            read_only: state.history.locked(),
868            picking: picking.as_ref().map(|f| crate::form::ActivePick { path: &f.path, names: &f.names }),
869        };
870        let mut out = form_view(ui, &spec, &mut params, Some(&mut self.hits));
871        if is_sheet && bom_changed.get() { params["bomTable"] = serde_json::to_value(bom.into_inner()).unwrap_or(Value::Null); out.changed = true; }
872        for (key, rect) in revision_hits.into_inner() {
873            self.hits.insert(key, rect);
874        }
875        // ONE revision edit per frame, and only when the schema fields did not
876        // change on the same frame (each is a document write).
877        if !out.changed {
878            if let Some(intent) = revision_intents.into_inner().into_iter().next() {
879                *action = Some(match intent {
880                    RevisionIntent::Add => Action::AddRevision(id.to_string()),
881                    RevisionIntent::Update(index, field, value) => {
882                        Action::UpdateRevision(id.to_string(), index, serde_json::json!({ field: value }))
883                    }
884                    RevisionIntent::Remove(index) => Action::RemoveRevision(id.to_string(), index),
885                });
886            }
887        }
888        let anchor = self.hits.get("sheets:form:feature").map(|rect| rect.min).unwrap_or(egui::Pos2::ZERO);
889        self.hits.insert(format!("sheets:form:object:{id}"), egui::Rect::from_min_size(anchor, egui::Vec2::ZERO));
890        if out.changed {
891            *action = Some(match catalogue_index {
892                0 => Action::UpdateSheet(id.to_string(), params),
893                2 => Action::UpdateDimension(id.to_string(), params),
894                3 => Action::UpdateOrdinate(id.to_string(), params),
895                // A placement, a section or a detail: one update door.
896                _ => Action::UpdateView(id.to_string(), params),
897            });
898        }
899        // Every reference row's **Select** is the same gesture a feature's
900        // is: the reference picker. On a sheet the picks are anchors clicked
901        // on the paper, and Finish writes them into this object's field.
902        if let Some(activate) = out.ref_activate {
903            *action = Some(Action::BeginRefSelect {
904                id: id.to_string(),
905                path: activate.path,
906                label: activate.label,
907                filter: activate.filter,
908                multiple: activate.multiple,
909                seed: activate.seed,
910            });
911        }
912        // The focused reference field's own intents.
913        if let Some(index) = out.pick_remove {
914            *action = Some(Action::RefSelectRemove(index));
915        }
916        if out.pick_done {
917            *action = Some(Action::FinishRefSelect);
918        }
919        if out.exit_clicked {
920            *close = true;
921        }
922    }
923
924    /// The per-frame widget rects for the automation layer.
925    pub fn hits_json(&self) -> String {
926        crate::automation::hit_rects::hits_json(&self.hits)
927    }
928
929    /// Drop the published rects: the dock calls this for a pane it did not
930    /// draw this frame (`DockState::ui`), whose widgets are not on screen.
931    pub fn clear_hits(&mut self) {
932        self.hits.clear();
933    }
934}
935
936/// The sheet dialog's **Revisions** section: one row per revision — its
937/// letter, date and description as text edits and a ✕ that removes it — then
938/// **+ Add revision**. Every control publishes a keyed rect
939/// (`sheets:rev:<n>:rev|date|description|remove`, `sheets:rev:add`).
940fn draw_revision_rows(
941    ui: &mut egui::Ui,
942    sheet_id: &str,
943    rows: &std::cell::RefCell<Vec<brep_render::sheets::Revision>>,
944    intents: &std::cell::RefCell<Vec<RevisionIntent>>,
945    hits: &std::cell::RefCell<Vec<(String, egui::Rect)>>,
946) {
947    let mut rows = rows.borrow_mut();
948    if rows.is_empty() {
949        ui.label(egui::RichText::new("No revisions \u{2014} the sheet draws no revision table").weak().italics());
950    }
951    // Two lines per revision, because the pane is a narrow column: the letter,
952    // the date and the ✕ on the first, the description the whole width of the
953    // second.
954    for (index, row) in rows.iter_mut().enumerate() {
955        let mut edit = |ui: &mut egui::Ui, field: &'static str, value: &mut String, width: f32, hint: &str| {
956            let response = ui.add(
957                egui::TextEdit::singleline(value)
958                    .id(egui::Id::new(("sheet-revision", sheet_id, index, field)))
959                    .hint_text(hint)
960                    .desired_width(width),
961            );
962            hits.borrow_mut().push((format!("sheets:rev:{index}:{field}"), response.rect));
963            if response.changed() {
964                intents.borrow_mut().push(RevisionIntent::Update(index, field, value.clone()));
965            }
966        };
967        ui.horizontal(|ui| {
968            ui.label(egui::RichText::new("Rev").weak());
969            edit(ui, "rev", &mut row.rev, 36.0, "A");
970            ui.label(egui::RichText::new("Date").weak());
971            edit(ui, "date", &mut row.date, 88.0, "YYYY-MM-DD");
972            let remove = ui
973                .add(crate::icon_text::icon_button(ui, "\u{2715}"))
974                .on_hover_text("Remove this revision row");
975            hits.borrow_mut().push((format!("sheets:rev:{index}:remove"), remove.rect));
976            if remove.clicked() {
977                intents.borrow_mut().push(RevisionIntent::Remove(index));
978            }
979        });
980        // Capped, not the whole available width: in a scrolling pane that can
981        // run past the clip rect, and a rect egui will not click is a lie.
982        let width = ui.available_width().min(240.0);
983        edit(ui, "description", &mut row.description, width, "Description");
984        ui.add_space(4.0);
985    }
986    let add = ui
987        .add(crate::icon_text::icon_button(ui, "+ Add revision"))
988        .on_hover_text("Append a revision row: the next letter, today's date, no description");
989    hits.borrow_mut().push(("sheets:rev:add".into(), add.rect));
990    if add.clicked() {
991        intents.borrow_mut().push(RevisionIntent::Add);
992    }
993}
994
995/// `4` rather than `4.0`, `2.5` as `2.5` — a scale reads as a ratio.
996fn trim(value: f64) -> String {
997    let text = format!("{value:.3}");
998    let text = text.trim_end_matches('0').trim_end_matches('.');
999    if text.is_empty() { "0".into() } else { text.to_string() }
1000}
1001
1002/// The hit keys this panel publishes (see `automation::hit_keys`).
1003pub static HIT_KEYS: &[HitKeyDoc] = &[
1004    HitKeyDoc { panel: "sheets", prefix: "sheets:panel:clip", meaning: "the pane's clip rect \u{2014} a row scrolled outside it publishes a rect egui will not accept a click on", command: None },
1005    HitKeyDoc { panel: "sheets", prefix: "sheets:add", meaning: "add a sheet at the default paper size and open it", command: Some("sheet_add") },
1006    HitKeyDoc { panel: "sheets", prefix: "sheets:place", meaning: "open the place-view menu", command: None },
1007    HitKeyDoc { panel: "sheets", prefix: "sheets:place:", meaning: "place that saved PMI view on the open sheet (sheets:place:<view id>)", command: Some("sheet_place_view") },
1008    HitKeyDoc { panel: "sheets", prefix: "sheets:row:", meaning: "a sheet, placement, dimension or ordinate-set row (sheets:row:<id>) \u{2014} a click opens a sheet in the sheet viewport or SELECTS what it holds, a double click opens that row's dialog", command: None },
1009    HitKeyDoc { panel: "sheets", prefix: "sheets:cell:", meaning: "a row cell (sheets:cell:<id>:<column>) \u{2014} `sheets:cell:<sheet id>:on` is the toggle that OPENS a sheet in the sheet viewport", command: Some("sheet_open") },
1010    HitKeyDoc { panel: "sheets", prefix: "sheets:box:", meaning: "a row's collapse box (sheets:box:<row id>) \u{2014} a sheet's box folds its placed views away", command: None },
1011    HitKeyDoc { panel: "sheets", prefix: "sheets:col:", meaning: "a table column header (sheets:col:<field>) \u{2014} click to sort", command: None },
1012    HitKeyDoc { panel: "sheets", prefix: "sheets:grip:", meaning: "a column's resize grip (sheets:grip:<field>)", command: None },
1013    HitKeyDoc { panel: "sheets", prefix: "sheets:menu:", meaning: "a row's action-menu trigger cell (sheets:menu:<row id>); a right-click on the row opens the same menu", command: None },
1014    HitKeyDoc { panel: "sheets", prefix: "sheets:menuitem:", meaning: "one entry of the OPEN row action menu (sheets:menuitem:<row id>:<action>) \u{2014} published only while the menu is up", command: None },
1015    HitKeyDoc { panel: "sheets", prefix: "sheets:form:", meaning: "the open sheet / placement form (sheets:form:object:<id>, sheets:form:feature, sheets:form:return)", command: Some("sheet_update") },
1016    HitKeyDoc { panel: "sheets", prefix: "sheets:field:", meaning: "one schema field of the open form (sheets:field:<path>) \u{2014} the paper size, the placed view, its position and scale, `sheets:field:flattenText`, the checkbox that lays that view's annotation text flat on the sheet, and a sheet dimension's kind, alignment, offset and precision and its tolerance block — `sheets:field:tolMode` (none / symmetric / deviation / limits), `sheets:field:tolUpper`, `sheets:field:tolLower` and the `sheets:field:isReference` checkbox, which an ordinate set's form carries too. `sheets:field:anchors#activate` focuses the field and opens the anchor pick on the paper (anchors are clicked as `sheet/anchor:<ref>`; the same key, now Done, commits a list, and a single field commits on its pick), `sheets:field:anchors#line<n>` is one anchor row and `sheets:field:anchors#x<n>` drops it. An ORDINATE SET's form adds `sheets:field:axis` and its two reference fields `sheets:field:datum` and `sheets:field:members`; a SECTION's form its `sheets:field:cut` (the cutting line), `sheets:field:sectionLabel` and `sheets:field:sectionFlip`; a DETAIL's form its `sheets:field:centre`, `sheets:field:rim` and `sheets:field:detailLabel` \u{2014} each reference field with the same `#activate`, `#line<n>` and `#x<n>` keys", command: Some("sheet_update_dimension") },
1017    HitKeyDoc { panel: "sheets", prefix: "sheets:group:", meaning: "a collapsible group header of the open form (sheets:group:<name>)", command: None },
1018    HitKeyDoc { panel: "sheets", prefix: "sheets:bom:", meaning: "the sheet BOM table toggle and column choices: sheets:bom:enabled, sheets:bom:column:<attribute>", command: Some("sheet_update") },
1019    HitKeyDoc { panel: "sheets", prefix: "sheets:rev:", meaning: "the SHEET dialog's Revisions section: `sheets:rev:add` appends a row (the next letter, today's date), and per row `sheets:rev:<n>:rev`, `sheets:rev:<n>:date` and `sheets:rev:<n>:description` are its three text edits and `sheets:rev:<n>:remove` removes it. Rows keep their order; the sheet draws them as the revision table beside its title block", command: Some("sheet_add_revision") },
1020];
1021
1022
1023
1024