Skip to main content

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];