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