brep_app/panels/info_windows.rs
1//! Pinned per-entity inspectors with editable metadata and read-only measurements.
2//!
3//! Each window keeps its target name and edit buffers independently of selection.
4//! Opening an existing target keeps its window; closing it discards its buffers.
5//! Metadata edits persist through [`EngineState`]. Lengths are millimetres.
6
7use crate::color::rgb_to_hex as hex_string;
8use crate::automation::hit_keys::HitKeyDoc;
9use brep_render::engine_state::EngineState;
10use eframe::egui;
11use serde_json::Value;
12use std::collections::{BTreeMap, HashMap};
13
14/// Width cap for the attribute-NAME column of the metadata grid, in points.
15/// Wide enough for the names the importer writes (`step_name`, `step_colour`)
16/// and for a hand-typed one, narrow enough that the value editor and the remove
17/// × always fit beside it in the window's default 300 pt width.
18const KEY_COL_WIDTH: f32 = 120.0;
19
20/// The two tabs of an Info window.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22enum Tab {
23 /// The editable name-keyed attribute editor.
24 Metadata,
25 /// The read-only measurements + provenance.
26 Info,
27}
28
29/// ONE pinned per-entity inspector window. Its `target` is fixed at open time and
30/// drives every engine query — later selection changes CANNOT retarget it (that is
31/// the whole point). Owns its own tab + edit buffers so windows never share state.
32struct PinnedInfoWindow {
33 /// The object name this window is PINNED to (a solid / face / edge). Fixed at
34 /// construction; never reassigned. Also the window title.
35 target: String,
36 /// Whether the window is still shown. Its own `×` clears this; the manager then
37 /// prunes it. Never re-opened — a closed window is dropped, a re-request makes a
38 /// fresh one.
39 open: bool,
40 /// The active tab.
41 tab: Tab,
42 /// Whether the metadata edit buffers have been seeded from the store yet. Seeded
43 /// LAZILY on the first `show` (not at construction) so `open_for` stays engine-
44 /// free and the manager is unit-testable without an `EngineState`.
45 seeded: bool,
46 /// Live edit buffers for the target's existing attributes (`key → value`),
47 /// seeded from the engine store once, then kept in lock-step as the user types.
48 values: BTreeMap<String, String>,
49 /// The "add attribute" row buffers (attribute name + value).
50 new_key: String,
51 new_value: String,
52 /// Working colour for the `Set color` row — the one-click way to give an
53 /// object that has no `color` attribute yet. A visible orange so the first
54 /// click produces an obviously-applied colour rather than something that
55 /// might be the default shade.
56 new_color: [u8; 3],
57 /// The window's default open position (cascaded per open-order so a multi-select
58 /// open doesn't stack every window on the exact same spot).
59 default_pos: [f32; 2],
60 /// Per-frame interactive-widget screen rects (egui points), keyed with this
61 /// window's target so the manager can publish them un-ambiguously for the headed
62 /// verifier.
63 hits: HashMap<String, egui::Rect>,
64}
65
66impl PinnedInfoWindow {
67 fn new(target: impl Into<String>, default_pos: [f32; 2]) -> Self {
68 Self {
69 target: target.into(),
70 open: true,
71 tab: Tab::Info,
72 seeded: false,
73 values: BTreeMap::new(),
74 new_key: String::new(),
75 new_value: String::new(),
76 new_color: [0xff, 0x88, 0x00],
77 default_pos,
78 hits: HashMap::new(),
79 }
80 }
81
82 /// Seed the metadata edit buffers from the store the first time we draw. Keeping
83 /// this out of the constructor is what lets `open_for` run without an engine.
84 fn ensure_seeded(&mut self, state: &EngineState) {
85 if self.seeded {
86 return;
87 }
88 self.seeded = true;
89 if let Some(record) = parse(&state.object_metadata_json(&self.target)).as_object() {
90 for (key, value) in record {
91 self.values
92 .insert(key.clone(), value.as_str().unwrap_or("").to_string());
93 }
94 }
95 }
96
97 /// Draw the window (if open) at ctx level. Folds the window's own close (`×`)
98 /// back into `self.open` so the manager can prune it.
99 fn show(&mut self, ctx: &egui::Context, state: &mut EngineState) {
100 self.hits.clear();
101 if !self.open {
102 return;
103 }
104 self.ensure_seeded(state);
105 // `egui::Window::open` needs its own `&mut bool`; borrow a copy so the draw
106 // closure can still take `&mut self`, then fold the close back in. The title
107 // is the entity name; the manager's dedup guarantees it is unique, so the
108 // derived egui window id never collides.
109 let mut open = true;
110 egui::Window::new(&self.target)
111 .id(egui::Id::new(("brep-info-window", self.target.as_str())))
112 .open(&mut open)
113 .movable(true)
114 .resizable(true)
115 // Bounded default size + a fill ScrollArea (below) so the window is
116 // FREELY resizable LARGER than its content (egui otherwise hugs the
117 // window to content and refuses to grow).
118 .default_size([300.0, 360.0])
119 .default_pos(self.default_pos)
120 .show(ctx, |ui| {
121 egui::ScrollArea::vertical()
122 .auto_shrink([false, false])
123 .show(ui, |ui| self.body(ui, state));
124 });
125 self.open = open;
126 }
127
128 /// The window body: the tab strip, then the active tab. There is always a
129 /// target (the window is opened FOR one), so no "select an object" hint.
130 fn body(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
131 ui.horizontal(|ui| {
132 let meta = ui.selectable_value(&mut self.tab, Tab::Metadata, "Metadata");
133 let info = ui.selectable_value(&mut self.tab, Tab::Info, "Info");
134 self.hits
135 .insert(format!("{}:tab:metadata", self.target), meta.rect);
136 self.hits.insert(format!("{}:tab:info", self.target), info.rect);
137 });
138 ui.separator();
139
140 let name = self.target.clone();
141 match self.tab {
142 Tab::Metadata => self.metadata_tab(ui, state, &name),
143 Tab::Info => info_tab(ui, state, &name),
144 }
145 }
146
147 /// Tab 1 — the editable, name-keyed attribute editor. Each existing attribute is
148 /// `key + editable value + ×`; the add row appends a new attribute. Every
149 /// mutation writes straight through to the engine store (which persists with the
150 /// model), keeping the local buffers in lock-step.
151 fn metadata_tab(&mut self, ui: &mut egui::Ui, state: &mut EngineState, name: &str) {
152 ui.label("Attributes (name-keyed; survive edits, rollback and re-tessellation):");
153 ui.add_space(2.0);
154
155 let keys: Vec<String> = self.values.keys().cloned().collect();
156 let mut remove: Option<String> = None;
157 egui::Grid::new(("info-metadata-grid", name))
158 .num_columns(3)
159 .striped(true)
160 .show(ui, |ui| {
161 for key in &keys {
162 // A `#RRGGBB` value gets a live colour PICKER beside its
163 // name — the form an imported STEP colour arrives in (kernel
164 // `io/appearance.rs`), and now the form a user can edit by
165 // eye instead of typing hex. Keyed on the VALUE, not the
166 // attribute name, so any colour-valued attribute is
167 // editable the same way; `color` is simply the one the
168 // renderer reads.
169 let swatch = self.values.get(key).and_then(|value| hex_color(value));
170 let mut picked: Option<String> = None;
171 let hits = &mut self.hits;
172 ui.horizontal(|ui| {
173 if let Some(color) = swatch {
174 let mut rgb = [color.r(), color.g(), color.b()];
175 let resp = ui.color_edit_button_srgb(&mut rgb);
176 if resp.changed() {
177 picked = Some(hex_string(rgb));
178 }
179 hits.insert(format!("{name}:swatch:{key}"), resp.rect);
180 }
181 // The key is CAPPED and truncates inside its cap. A
182 // grid column sizes to its widest cell, so an attribute
183 // name — user-typed, or carried in from a STEP import —
184 // otherwise widens this column and pushes the value
185 // editor and the remove × past the window's right edge,
186 // where the body's vertical-only ScrollArea clips them
187 // out of reach rather than scrolling to them. An elided
188 // Label tooltips its own full text, so the whole key is
189 // still one hover away.
190 ui.scope(|ui| {
191 ui.set_max_width(KEY_COL_WIDTH);
192 ui.add(egui::Label::new(key).truncate());
193 });
194 });
195 // Commit a picked colour through the SAME engine seam a typed
196 // value uses, so the viewport updates on the drag.
197 if let Some(hex) = picked {
198 state.set_metadata_attribute(name, key, &hex);
199 if let Some(slot) = self.values.get_mut(key) {
200 *slot = hex;
201 }
202 }
203 if let Some(value) = self.values.get_mut(key) {
204 let edit =
205 ui.add(egui::TextEdit::singleline(value).desired_width(140.0));
206 if edit.changed() {
207 state.set_metadata_attribute(name, key, value);
208 }
209 self.hits.insert(format!("{name}:value:{key}"), edit.rect);
210 }
211 let del = ui.button("\u{00d7}").on_hover_text("Remove attribute");
212 self.hits.insert(format!("{name}:remove:{key}"), del.rect);
213 if del.clicked() {
214 remove = Some(key.clone());
215 }
216 ui.end_row();
217 }
218 });
219 if let Some(key) = remove {
220 state.remove_metadata_attribute(name, &key);
221 self.values.remove(&key);
222 }
223
224 if keys.is_empty() {
225 ui.weak("(no attributes yet)");
226 }
227
228 // An object with no colour yet gets a one-click way to have one —
229 // picking a model colour should not require knowing the `color` key or
230 // hex syntax. Once set, the picker in the grid above edits it.
231 if !self.values.contains_key(COLOR_KEY) {
232 ui.add_space(6.0);
233 ui.horizontal(|ui| {
234 let picker = ui.color_edit_button_srgb(&mut self.new_color);
235 let set = ui
236 .button("Set color")
237 .on_hover_text("Give this object a `color` attribute (saved with the model)");
238 self.hits.insert(format!("{name}:new-color:pick"), picker.rect);
239 self.hits.insert(format!("{name}:new-color:set"), set.rect);
240 if set.clicked() {
241 let hex = hex_string(self.new_color);
242 state.set_metadata_attribute(name, COLOR_KEY, &hex);
243 self.values.insert(COLOR_KEY.to_string(), hex);
244 }
245 });
246 }
247
248 ui.add_space(6.0);
249 ui.separator();
250 ui.label("Add attribute:");
251 ui.horizontal(|ui| {
252 let key = ui.add(
253 egui::TextEdit::singleline(&mut self.new_key)
254 .hint_text("name")
255 .desired_width(110.0),
256 );
257 let val = ui.add(
258 egui::TextEdit::singleline(&mut self.new_value)
259 .hint_text("value")
260 .desired_width(140.0),
261 );
262 let add = ui.button("Add");
263 self.hits.insert(format!("{name}:new:key"), key.rect);
264 self.hits.insert(format!("{name}:new:value"), val.rect);
265 self.hits.insert(format!("{name}:new:add"), add.rect);
266 let commit = add.clicked()
267 || (val.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)));
268 let trimmed = self.new_key.trim().to_string();
269 if commit && !trimmed.is_empty() {
270 state.set_metadata_attribute(name, &trimmed, &self.new_value);
271 self.values.insert(trimmed, self.new_value.clone());
272 self.new_key.clear();
273 self.new_value.clear();
274 }
275 });
276
277 ui.add_space(6.0);
278 ui.weak(
279 "Hint: `density` (mass per mm\u{00b3}) drives a solid's weight; `color` \
280 (#RRGGBB) drives how it is shaded.",
281 );
282 }
283}
284
285/// The metadata attribute the renderer shades from — the kernel's own key, so
286/// the Info window and a STEP import can never disagree about its spelling.
287const COLOR_KEY: &str = brep_render::brep_kernel::COLOR_METADATA_KEY;
288
289fn hex_color(value: &str) -> Option<egui::Color32> {
290 crate::color::parse_hex_color(value.trim())
291}
292
293/// The shell-owned manager of the pinned Info windows: opens them (from the context
294/// bar's Info action), draws them each frame, and prunes the ones the user closes.
295#[derive(Default)]
296pub struct InfoWindows {
297 /// The open windows, in open order. Independent state per window.
298 windows: Vec<PinnedInfoWindow>,
299 /// Monotonic count of windows ever opened this session — used only to cascade
300 /// each new window's default position so multi-select opens don't stack exactly.
301 opened_count: usize,
302}
303
304impl InfoWindows {
305 pub fn new() -> Self {
306 Self::default()
307 }
308
309 /// Open one pinned window per name (a viewport multi-select yields N names → N
310 /// windows). DEDUP: if a window is already open for a name, keep it — don't
311 /// duplicate. Empty names are skipped. Engine-free (buffers seed lazily on the
312 /// first draw) so it needs no `EngineState`.
313 pub fn open_for(&mut self, names: &[String]) {
314 for name in names {
315 if name.is_empty() {
316 continue;
317 }
318 // Already showing this exact entity → keep the existing window.
319 if self.windows.iter().any(|w| w.open && w.target == *name) {
320 continue;
321 }
322 // Cascade the default position off the open-order so a multi-select open
323 // fans the windows out instead of stacking them on one spot.
324 let k = (self.opened_count % 8) as f32;
325 self.opened_count += 1;
326 let pos = [1040.0 - 24.0 * k, 56.0 + 24.0 * k];
327 self.windows.push(PinnedInfoWindow::new(name.clone(), pos));
328 }
329 }
330
331 /// Draw every open window at ctx level (like the file dialog / settings window),
332 /// then drop the ones the user closed. Independent windows → independent state.
333 pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState) {
334 for w in &mut self.windows {
335 w.show(ctx, state);
336 }
337 self.prune();
338 }
339
340 /// Drop windows the user closed via their `×`. Called by `show` after drawing;
341 /// exposed for the manager unit tests.
342 fn prune(&mut self) {
343 self.windows.retain(|w| w.open);
344 }
345
346 /// The FIXED target names of the currently-open windows, in open order — the
347 /// unit-test seam proving pin-independence, dedup and prune with NO engine.
348 #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
349 pub fn targets(&self) -> Vec<String> {
350 self.windows
351 .iter()
352 .filter(|w| w.open)
353 .map(|w| w.target.clone())
354 .collect()
355 }
356
357 /// Composite state of every open window for the headed verifier — each record is
358 /// `{ target, tab, metadata{…}, info{…} }`, all keyed by the window's FIXED
359 /// target so the verifier can assert a window keeps its entity across selection
360 /// changes. (wasm only; present-but-dead on native so `tab`/`target` count as
361 /// read there too.)
362 #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
363 pub fn published_json(&self, state: &mut EngineState) -> String {
364 let windows: Vec<Value> = self
365 .windows
366 .iter()
367 .filter(|w| w.open)
368 .map(|w| {
369 serde_json::json!({
370 "target": w.target,
371 "tab": match w.tab { Tab::Metadata => "metadata", Tab::Info => "info" },
372 "metadata": parse(&state.object_metadata_json(&w.target)),
373 "info": parse(&state.object_info_json(&w.target)),
374 })
375 })
376 .collect();
377 serde_json::json!({ "count": windows.len(), "windows": windows }).to_string()
378 }
379
380 /// The union of every open window's per-frame interactive-widget screen rects
381 /// (egui points) as `{ key: [x, y, w, h] }`, each key prefixed with the window's
382 /// target (`<name>:tab:info`, `<name>:value:<k>`, `<name>:new:add`, …).
383 #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
384 pub fn hits_json(&self) -> String {
385 crate::automation::hit_rects::hits_json(self.windows.iter().flat_map(|window| &window.hits))
386 }
387}
388
389/// Tab 2 — the READ-ONLY info: name / creating feature, then the kind-specific
390/// measurements. NOTHING here is editable.
391fn info_tab(ui: &mut egui::Ui, state: &mut EngineState, name: &str) {
392 let info = parse(&state.object_info_json(name));
393 if info.get("ok").and_then(Value::as_bool) != Some(true) {
394 ui.label(
395 info.get("message")
396 .and_then(Value::as_str)
397 .unwrap_or("no info for this object"),
398 );
399 return;
400 }
401 let kind = info.get("kind").and_then(Value::as_str).unwrap_or("");
402
403 egui::Grid::new(("info-info-grid", name))
404 .num_columns(2)
405 .striped(true)
406 .show(ui, |ui| {
407 grid_row(ui, "Name / ID", name.to_string());
408 grid_row(ui, "Kind", kind.to_string());
409 grid_row(ui, "Creating feature", creating_feature_str(&info));
410 match kind {
411 "solid" => {
412 grid_row(ui, "Volume (mm\u{00b3})", num(getf(&info, "volume")));
413 grid_row(ui, "Surface area (mm\u{00b2})", num(getf(&info, "surfaceArea")));
414 grid_row(ui, "Edge length total (mm)", num(getf(&info, "edgeLengthTotal")));
415 grid_row(ui, "Density (mass/mm\u{00b3})", num(getf(&info, "density")));
416 grid_row(ui, "Weight (mass)", num(getf(&info, "weight")));
417 }
418 "face" => {
419 grid_row(ui, "Solid", getstr(&info, "solid"));
420 grid_row(ui, "Surface type", getstr(&info, "surfaceType"));
421 grid_row(ui, "Area (mm\u{00b2})", num(getf(&info, "area")));
422 grid_row(ui, "Edge length total (mm)", num(getf(&info, "edgeLengthTotal")));
423 }
424 "edge" => {
425 grid_row(ui, "Solid", getstr(&info, "solid"));
426 grid_row(ui, "Length (mm)", num(getf(&info, "length")));
427 }
428 _ => {}
429 }
430 });
431
432 ui.add_space(6.0);
433 ui.weak("Read-only. Set `density` on the Metadata tab to drive a solid's weight.");
434}
435
436/// The `creatingFeature` provenance as `id (type)`, or `—` when the object has no
437/// known producer (a `null` provenance).
438fn creating_feature_str(info: &Value) -> String {
439 match info.get("creatingFeature") {
440 Some(Value::Object(feature)) => {
441 let id = feature.get("id").and_then(Value::as_str).unwrap_or("");
442 let kind = feature.get("type").and_then(Value::as_str).unwrap_or("");
443 if kind.is_empty() {
444 id.to_string()
445 } else {
446 format!("{id} ({kind})")
447 }
448 }
449 _ => "\u{2014}".to_string(),
450 }
451}
452
453/// Parse an engine JSON string, defaulting to `null` on any error.
454fn parse(json: &str) -> Value {
455 serde_json::from_str(json).unwrap_or(Value::Null)
456}
457
458/// `label: value` row inside an `egui::Grid`.
459fn grid_row(ui: &mut egui::Ui, label: &str, value: String) {
460 ui.label(label);
461 ui.label(value);
462 ui.end_row();
463}
464
465/// A JSON number field (`0` for a missing / null field).
466fn getf(value: &Value, key: &str) -> f64 {
467 value.get(key).and_then(Value::as_f64).unwrap_or(0.0)
468}
469
470/// A JSON string field (empty for a missing / null field).
471fn getstr(value: &Value, key: &str) -> String {
472 value.get(key).and_then(Value::as_str).unwrap_or("").to_string()
473}
474
475/// Format a number readably (fixed 3 decimals, trailing-zero trimmed); `0` stays
476/// `0`. `pub(crate)`: the interference window's volume labels reuse THIS
477/// formatter (UI-consistency directive — one measurement format).
478pub(crate) fn num(x: f64) -> String {
479 if x == 0.0 {
480 return "0".to_string();
481 }
482 let mut s = format!("{x:.3}");
483 if s.contains('.') {
484 while s.ends_with('0') {
485 s.pop();
486 }
487 if s.ends_with('.') {
488 s.pop();
489 }
490 }
491 s
492}
493
494// BREP private tests: 77ce84201de6e18b
495
496/// The hit keys this panel publishes (see `automation::hit_keys`).
497pub static HIT_KEYS: &[HitKeyDoc] = &[
498 HitKeyDoc { panel: "infowindows", prefix: ":tab:info", meaning: "a window's info tab ({name}:tab:info)", command: None },
499 HitKeyDoc { panel: "infowindows", prefix: ":new:", meaning: "add a metadata key/value ({name}:new:key, :new:value, :new:add)", command: Some("metadata_set") },
500 HitKeyDoc { panel: "infowindows", prefix: ":new-color:", meaning: "pick or set a colour ({name}:new-color:pick, :set)", command: Some("metadata_set") },
501 HitKeyDoc { panel: "infowindows", prefix: ":value:", meaning: "edit a metadata value ({name}:value:key)", command: Some("metadata_set") },
502 HitKeyDoc { panel: "infowindows", prefix: ":remove:", meaning: "remove a metadata key ({name}:remove:key)", command: Some("metadata_remove") },
503 HitKeyDoc { panel: "infowindows", prefix: ":swatch:", meaning: "a colour swatch ({name}:swatch:key)", command: None },
504];