Skip to main content

brep_app/panels/
part_properties.rs

1//! The PART PROPERTIES dialog — the open document's own BOM attributes, on the
2//! toolbar.
3//!
4//! # What it edits
5//!
6//! `partAttributes`, the top-level document key described in
7//! `brep_render::engine_state`'s BOM-attributes block. A document IS a part: a
8//! part file's Part Number, Material and Mass live on it and travel with it,
9//! which is exactly why an assembly's BOM can read them off a parts-library
10//! entry's EMBEDDED document. This dialog is the other door onto the same
11//! record — the one for the part you have OPEN, which until now had no editor
12//! at all. (An assembly document has the record too, and for the same reason: a
13//! rigidly nested assembly is ONE part, one BOM row, in its parent.)
14//!
15//! OCCURRENCE attributes are deliberately absent. They describe one PLACEMENT,
16//! so they belong to the assembly that places the part, not to the part — the
17//! BOM panel's occurrence columns are their editor.
18//!
19//! # Every field, not the starred ones
20//!
21//! The BOM column configuration decides what the parts LIST is wide enough to
22//! show. It says nothing about what a part HAS. So the dialog draws every part
23//! field — the configured ones in the user's own order, then the built-ins the
24//! configuration leaves out, then anything already stored that neither names
25//! (see [`bom_columns::part_fields`]). A field a user hid from the table is
26//! still a field of the part, and a dialog that hid it too would leave a stored
27//! value with no way to see or clear it.
28//!
29//! # The editors are the BOM's own
30//!
31//! Each row draws [`column_tree::value_editor`] for the field's `CellKind` —
32//! the very function the BOM table's cells draw. One attribute cannot have two
33//! editing behaviours when it has one store: text commits on focus-loss (an
34//! attribute write re-runs the history), a choice always offers the blank
35//! entry so it can be cleared, and clearing writes `""`, which the engine reads
36//! as REMOVE.
37
38use crate::automation::hit_keys::HitKeyDoc;
39use crate::column_tree;
40use crate::panels::bom_columns;
41use brep_render::engine_state::EngineState;
42use eframe::egui;
43use serde_json::Value;
44use std::collections::HashMap;
45
46/// The dialog's state: whether it is open, and this frame's widget rects.
47#[derive(Default)]
48pub struct PartPropertiesPanel {
49    /// Whether the floating window is shown. Toggled by the toolbar's
50    /// Properties button and by the window's own `×`; public so the toolbar can
51    /// bind it, exactly as the Settings window's flag is.
52    pub open: bool,
53    /// Per-frame egui widget screen rects (keyed `field:<Field>`), published for
54    /// the automation layer. Rebuilt every frame.
55    hits: HashMap<String, egui::Rect>,
56}
57
58impl PartPropertiesPanel {
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    /// Draw the floating window (if open) at ctx level, after the panels, so it
64    /// floats over the shell. `title` is the active document's tab title — the
65    /// part whose properties these are, named in the window so a user with
66    /// several tabs open can never edit the wrong one — and `document` its
67    /// process-unique id, which salts the per-field widget ids so an
68    /// in-progress edit belongs to the document it was typed into and cannot
69    /// reappear over another tab's value.
70    pub fn show(
71        &mut self,
72        ctx: &egui::Context,
73        state: &mut EngineState,
74        title: &str,
75        document: u64,
76    ) {
77        if !self.open {
78            return;
79        }
80        // `egui::Window::open` needs its own `&mut bool`; borrow a copy so the
81        // draw closure can still take `&mut self`, then fold the close back in.
82        let mut open = true;
83        egui::Window::new("Part Properties")
84            .open(&mut open)
85            .movable(true)
86            .resizable(true)
87            // A bounded default size + the filling ScrollArea in `body`: without
88            // a filling child egui hugs the window to its content and the user
89            // cannot drag it larger (the Settings window's rule).
90            .default_size([340.0, 420.0])
91            // Offset from the Settings window's own default rest position, so
92            // opening both does not stack them pixel-for-pixel.
93            .default_pos([660.0, 96.0])
94            .show(ctx, |ui| self.body(ui, state, title, document));
95        self.open = open;
96
97        if crate::automation::registry::enabled() {
98            crate::automation::registry::publish(
99                "__brepPartPropertiesHit",
100                "part properties window widget rects (field:*)",
101                &self.hits_json(),
102            );
103        }
104    }
105
106    /// The window body: the part's name, then one labelled row per field.
107    fn body(&mut self, ui: &mut egui::Ui, state: &mut EngineState, title: &str, document: u64) {
108        self.hits.clear();
109        ui.label(egui::RichText::new(title).strong());
110        ui.label(
111            egui::RichText::new(
112                "Stored on this document and carried into any assembly's BOM.",
113            )
114            .weak()
115            .small(),
116        );
117        ui.separator();
118
119        // Read the record LIVE each frame rather than caching it: an undo, the
120        // automation layer, or a switch to another document tab all change it
121        // under the dialog, and a cached copy would show — and then write back —
122        // the previous document's values.
123        let stored = state.document_part_attributes();
124        // Through `effective_text`, exactly as the BOM table reads its columns:
125        // the stored setting is EMPTY until the user configures it, and the
126        // shipped default is what the table is ordered by. Reading the raw
127        // string here would order a never-configured document's fields by the
128        // catalogue instead, so the dialog and the table would disagree for
129        // every user who never opened the settings.
130        let fields = bom_columns::part_fields(
131            &bom_columns::effective_text(&state.settings.bom_columns),
132            &stored,
133        );
134
135        // Commit AFTER the loop: `set_document_part_attribute` re-runs the
136        // history, and re-entering the engine mid-draw while `stored` is
137        // borrowed from it is exactly the kind of half-applied frame that makes
138        // a text buffer fight its own value. A LIST, not one edit: a frame that
139        // blurs a text field while a drag value moves carries two, and dropping
140        // either is a lost keystroke.
141        let mut edits: Vec<(String, Value)> = Vec::new();
142        egui::ScrollArea::vertical()
143            .auto_shrink([false, false])
144            .show(ui, |ui| {
145                egui::Grid::new("part-properties-grid")
146                    .num_columns(2)
147                    .spacing([8.0, 4.0])
148                    .striped(true)
149                    .show(ui, |ui| {
150                        for field in &fields {
151                            ui.label(field.label());
152                            let size = egui::vec2(
153                                ui.available_width().max(80.0),
154                                ui.spacing().interact_size.y,
155                            );
156                            let (committed, rect) = column_tree::value_editor(
157                                ui,
158                                egui::Id::new(("part-properties", document, &field.field)),
159                                &field.kind(),
160                                stored.get(&field.field),
161                                size,
162                            );
163                            self.hits.insert(format!("field:{}", field.field), rect);
164                            if let Some(value) = committed {
165                                edits.push((field.field.clone(), value));
166                            }
167                            ui.end_row();
168                        }
169                    });
170            });
171
172        for (key, value) in edits {
173            // A refusal here is a programming error (an empty key), not
174            // something the user can produce from a drawn row, so it has no
175            // banner: the field list never yields one.
176            let _ = state.set_document_part_attribute(&key, value);
177        }
178    }
179
180    /// The published widget hit-rects (egui points) for the automation layer.
181    pub fn hits_json(&self) -> String {
182        crate::automation::hit_rects::hits_json(&self.hits)
183    }
184}
185
186/// The hit keys this panel publishes (see `automation::hit_keys`).
187pub static HIT_KEYS: &[HitKeyDoc] = &[HitKeyDoc {
188    panel: "partproperties",
189    prefix: "field:",
190    meaning: "a part-attribute editor in the Part Properties window (field:<Field>)",
191    command: Some("part_attribute_set"),
192}];
193
194// BREP private tests: adca130b32a1325e