BREP_app 0.2.0

The BREP CAD application: an eframe (egui + wgpu) host that draws the brep-render 3D engine into an egui frame — native + wasm from one codebase.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
//! Info windows — MULTIPLE **pinned per-entity** inspector windows (the engine-
//! native successor to the single Properties window). Each is a floating (movable
//! + resizable [`egui::Window`], drawn at ctx level like the file dialog) window
//! that inspects ONE fixed object — a solid / face / edge name — split across the
//! same two tabs the old Properties window used:
//!
//! * **Tab 1 — Metadata (editable):** the name-keyed attribute editor. Each row
//!   is `key + editable value + ×`, plus an "add attribute" row. Edits go straight
//!   to the engine store via [`EngineState::set_metadata_attribute`] /
//!   [`EngineState::remove_metadata_attribute`]; they persist WITH the model. The
//!   well-known `density` attribute drives a solid's weight.
//! * **Tab 2 — Info (READ-ONLY):** the resolved measurements + provenance from
//!   [`EngineState::object_info_json`] — never editable.
//!
//! **Pinning.** The KEY difference from the old Properties window: a window's
//! `target` name is FIXED when it is opened (from the [`crate::panels::context_bar`]
//! Info action) and is NEVER reassigned from the live selection. All engine data is
//! fetched BY THAT NAME (`object_info_json(target)` / `object_metadata_json(target)`),
//! so a window keeps showing its entity's data forever, regardless of what the user
//! selects afterwards. Selecting another object opens a NEW window (or focuses an
//! existing one) — it never re-targets an open one.
//!
//! [`InfoWindows`] is the manager the shell owns: a `Vec` of pinned windows, drawn
//! every frame and pruned when the user closes one (its `×`). `open_for` opens one
//! window per selected name with a DEDUP rule (already-open name → keep it, don't
//! duplicate). Each window owns its OWN edit buffers so multiple windows never
//! share state. `EngineState` (brep-render) stays the single brain, borrowed in.
//! All lengths are millimetres (the kernel convention).

use brep_render::engine_state::EngineState;
use eframe::egui;
use serde_json::Value;
use std::collections::{BTreeMap, HashMap};

/// The two tabs of an Info window.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Tab {
    /// The editable name-keyed attribute editor.
    Metadata,
    /// The read-only measurements + provenance.
    Info,
}

/// ONE pinned per-entity inspector window. Its `target` is fixed at open time and
/// drives every engine query — later selection changes CANNOT retarget it (that is
/// the whole point). Owns its own tab + edit buffers so windows never share state.
struct PinnedInfoWindow {
    /// The object name this window is PINNED to (a solid / face / edge). Fixed at
    /// construction; never reassigned. Also the window title.
    target: String,
    /// Whether the window is still shown. Its own `×` clears this; the manager then
    /// prunes it. Never re-opened — a closed window is dropped, a re-request makes a
    /// fresh one.
    open: bool,
    /// The active tab.
    tab: Tab,
    /// Whether the metadata edit buffers have been seeded from the store yet. Seeded
    /// LAZILY on the first `show` (not at construction) so `open_for` stays engine-
    /// free and the manager is unit-testable without an `EngineState`.
    seeded: bool,
    /// Live edit buffers for the target's existing attributes (`key → value`),
    /// seeded from the engine store once, then kept in lock-step as the user types.
    values: BTreeMap<String, String>,
    /// The "add attribute" row buffers (attribute name + value).
    new_key: String,
    new_value: String,
    /// The window's default open position (cascaded per open-order so a multi-select
    /// open doesn't stack every window on the exact same spot).
    default_pos: [f32; 2],
    /// Per-frame interactive-widget screen rects (egui points), keyed with this
    /// window's target so the manager can publish them un-ambiguously for the headed
    /// verifier.
    hits: HashMap<String, egui::Rect>,
}

impl PinnedInfoWindow {
    fn new(target: impl Into<String>, default_pos: [f32; 2]) -> Self {
        Self {
            target: target.into(),
            open: true,
            tab: Tab::Info,
            seeded: false,
            values: BTreeMap::new(),
            new_key: String::new(),
            new_value: String::new(),
            default_pos,
            hits: HashMap::new(),
        }
    }

    /// Seed the metadata edit buffers from the store the first time we draw. Keeping
    /// this out of the constructor is what lets `open_for` run without an engine.
    fn ensure_seeded(&mut self, state: &EngineState) {
        if self.seeded {
            return;
        }
        self.seeded = true;
        if let Some(record) = parse(&state.object_metadata_json(&self.target)).as_object() {
            for (key, value) in record {
                self.values
                    .insert(key.clone(), value.as_str().unwrap_or("").to_string());
            }
        }
    }

    /// Draw the window (if open) at ctx level. Folds the window's own close (`×`)
    /// back into `self.open` so the manager can prune it.
    fn show(&mut self, ctx: &egui::Context, state: &mut EngineState) {
        self.hits.clear();
        if !self.open {
            return;
        }
        self.ensure_seeded(state);
        // `egui::Window::open` needs its own `&mut bool`; borrow a copy so the draw
        // closure can still take `&mut self`, then fold the close back in. The title
        // is the entity name; the manager's dedup guarantees it is unique, so the
        // derived egui window id never collides.
        let mut open = true;
        egui::Window::new(&self.target)
            .id(egui::Id::new(("brep-info-window", self.target.as_str())))
            .open(&mut open)
            .movable(true)
            .resizable(true)
            // Bounded default size + a fill ScrollArea (below) so the window is
            // FREELY resizable LARGER than its content (egui otherwise hugs the
            // window to content and refuses to grow).
            .default_size([300.0, 360.0])
            .default_pos(self.default_pos)
            .show(ctx, |ui| {
                egui::ScrollArea::vertical()
                    .auto_shrink([false, false])
                    .show(ui, |ui| self.body(ui, state));
            });
        self.open = open;
    }

    /// The window body: the tab strip, then the active tab. There is always a
    /// target (the window is opened FOR one), so no "select an object" hint.
    fn body(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
        ui.horizontal(|ui| {
            let meta = ui.selectable_value(&mut self.tab, Tab::Metadata, "Metadata");
            let info = ui.selectable_value(&mut self.tab, Tab::Info, "Info");
            self.hits
                .insert(format!("{}:tab:metadata", self.target), meta.rect);
            self.hits.insert(format!("{}:tab:info", self.target), info.rect);
        });
        ui.separator();

        let name = self.target.clone();
        match self.tab {
            Tab::Metadata => self.metadata_tab(ui, state, &name),
            Tab::Info => info_tab(ui, state, &name),
        }
    }

    /// Tab 1 — the editable, name-keyed attribute editor. Each existing attribute is
    /// `key + editable value + ×`; the add row appends a new attribute. Every
    /// mutation writes straight through to the engine store (which persists with the
    /// model), keeping the local buffers in lock-step.
    fn metadata_tab(&mut self, ui: &mut egui::Ui, state: &mut EngineState, name: &str) {
        ui.label("Attributes (name-keyed; survive edits, rollback and re-tessellation):");
        ui.add_space(2.0);

        let keys: Vec<String> = self.values.keys().cloned().collect();
        let mut remove: Option<String> = None;
        egui::Grid::new(("info-metadata-grid", name))
            .num_columns(3)
            .striped(true)
            .show(ui, |ui| {
                for key in &keys {
                    // A `#RRGGBB` value gets a swatch beside its name — the
                    // form an imported STEP colour arrives in (kernel
                    // `io/appearance.rs`), and the form a user can type back.
                    // Keyed on the VALUE, not the attribute name, so any
                    // colour-valued attribute reads the same way.
                    let swatch = self.values.get(key).and_then(|value| hex_color(value));
                    ui.horizontal(|ui| {
                        if let Some(color) = swatch {
                            let (rect, _) = ui
                                .allocate_exact_size(egui::vec2(11.0, 11.0), egui::Sense::hover());
                            ui.painter().rect_filled(rect, 2.0, color);
                        }
                        ui.label(key);
                    });
                    if let Some(value) = self.values.get_mut(key) {
                        let edit =
                            ui.add(egui::TextEdit::singleline(value).desired_width(140.0));
                        if edit.changed() {
                            state.set_metadata_attribute(name, key, value);
                        }
                        self.hits.insert(format!("{name}:value:{key}"), edit.rect);
                    }
                    let del = ui.button("\u{00d7}").on_hover_text("Remove attribute");
                    self.hits.insert(format!("{name}:remove:{key}"), del.rect);
                    if del.clicked() {
                        remove = Some(key.clone());
                    }
                    ui.end_row();
                }
            });
        if let Some(key) = remove {
            state.remove_metadata_attribute(name, &key);
            self.values.remove(&key);
        }

        if keys.is_empty() {
            ui.weak("(no attributes yet)");
        }

        ui.add_space(6.0);
        ui.separator();
        ui.label("Add attribute:");
        ui.horizontal(|ui| {
            let key = ui.add(
                egui::TextEdit::singleline(&mut self.new_key)
                    .hint_text("name")
                    .desired_width(110.0),
            );
            let val = ui.add(
                egui::TextEdit::singleline(&mut self.new_value)
                    .hint_text("value")
                    .desired_width(140.0),
            );
            let add = ui.button("Add");
            self.hits.insert(format!("{name}:new:key"), key.rect);
            self.hits.insert(format!("{name}:new:value"), val.rect);
            self.hits.insert(format!("{name}:new:add"), add.rect);
            let commit = add.clicked()
                || (val.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)));
            let trimmed = self.new_key.trim().to_string();
            if commit && !trimmed.is_empty() {
                state.set_metadata_attribute(name, &trimmed, &self.new_value);
                self.values.insert(trimmed, self.new_value.clone());
                self.new_key.clear();
                self.new_value.clear();
            }
        });

        ui.add_space(6.0);
        ui.weak("Hint: the well-known `density` attribute (mass per mm\u{00b3}) drives a solid's weight.");
    }
}

/// A metadata value that IS a colour: `#RRGGBB` (case-insensitive, `#` required),
/// the one form the kernel stamps and the one the swatch renders. Anything else
/// is `None` and shows as plain text.
fn hex_color(value: &str) -> Option<egui::Color32> {
    let digits = value.trim().strip_prefix('#')?;
    if digits.len() != 6 || !digits.bytes().all(|byte| byte.is_ascii_hexdigit()) {
        return None;
    }
    let channel = |start: usize| u8::from_str_radix(&digits[start..start + 2], 16).ok();
    Some(egui::Color32::from_rgb(
        channel(0)?,
        channel(2)?,
        channel(4)?,
    ))
}

/// The shell-owned manager of the pinned Info windows: opens them (from the context
/// bar's Info action), draws them each frame, and prunes the ones the user closes.
#[derive(Default)]
pub struct InfoWindows {
    /// The open windows, in open order. Independent state per window.
    windows: Vec<PinnedInfoWindow>,
    /// Monotonic count of windows ever opened this session — used only to cascade
    /// each new window's default position so multi-select opens don't stack exactly.
    opened_count: usize,
}

impl InfoWindows {
    pub fn new() -> Self {
        Self::default()
    }

    /// Open one pinned window per name (a viewport multi-select yields N names → N
    /// windows). DEDUP: if a window is already open for a name, keep it — don't
    /// duplicate. Empty names are skipped. Engine-free (buffers seed lazily on the
    /// first draw) so it needs no `EngineState`.
    pub fn open_for(&mut self, names: &[String]) {
        for name in names {
            if name.is_empty() {
                continue;
            }
            // Already showing this exact entity → keep the existing window.
            if self.windows.iter().any(|w| w.open && w.target == *name) {
                continue;
            }
            // Cascade the default position off the open-order so a multi-select open
            // fans the windows out instead of stacking them on one spot.
            let k = (self.opened_count % 8) as f32;
            self.opened_count += 1;
            let pos = [1040.0 - 24.0 * k, 56.0 + 24.0 * k];
            self.windows.push(PinnedInfoWindow::new(name.clone(), pos));
        }
    }

    /// Draw every open window at ctx level (like the file dialog / settings window),
    /// then drop the ones the user closed. Independent windows → independent state.
    pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState) {
        for w in &mut self.windows {
            w.show(ctx, state);
        }
        self.prune();
    }

    /// Drop windows the user closed via their `×`. Called by `show` after drawing;
    /// exposed for the manager unit tests.
    fn prune(&mut self) {
        self.windows.retain(|w| w.open);
    }

    /// The FIXED target names of the currently-open windows, in open order — the
    /// unit-test seam proving pin-independence, dedup and prune with NO engine.
    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
    pub fn targets(&self) -> Vec<String> {
        self.windows
            .iter()
            .filter(|w| w.open)
            .map(|w| w.target.clone())
            .collect()
    }

    /// Composite state of every open window for the headed verifier — each record is
    /// `{ target, tab, metadata{…}, info{…} }`, all keyed by the window's FIXED
    /// target so the verifier can assert a window keeps its entity across selection
    /// changes. (wasm only; present-but-dead on native so `tab`/`target` count as
    /// read there too.)
    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
    pub fn published_json(&self, state: &mut EngineState) -> String {
        let windows: Vec<Value> = self
            .windows
            .iter()
            .filter(|w| w.open)
            .map(|w| {
                serde_json::json!({
                    "target": w.target,
                    "tab": match w.tab { Tab::Metadata => "metadata", Tab::Info => "info" },
                    "metadata": parse(&state.object_metadata_json(&w.target)),
                    "info": parse(&state.object_info_json(&w.target)),
                })
            })
            .collect();
        serde_json::json!({ "count": windows.len(), "windows": windows }).to_string()
    }

    /// The union of every open window's per-frame interactive-widget screen rects
    /// (egui points) as `{ key: [x, y, w, h] }`, each key prefixed with the window's
    /// target (`<name>:tab:info`, `<name>:value:<k>`, `<name>:new:add`, …).
    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
    pub fn hits_json(&self) -> String {
        let mut map = serde_json::Map::new();
        for w in &self.windows {
            for (key, rect) in &w.hits {
                map.insert(
                    key.clone(),
                    serde_json::json!([rect.min.x, rect.min.y, rect.width(), rect.height()]),
                );
            }
        }
        Value::Object(map).to_string()
    }
}

/// Tab 2 — the READ-ONLY info: name / creating feature, then the kind-specific
/// measurements. NOTHING here is editable.
fn info_tab(ui: &mut egui::Ui, state: &mut EngineState, name: &str) {
    let info = parse(&state.object_info_json(name));
    if info.get("ok").and_then(Value::as_bool) != Some(true) {
        ui.label(
            info.get("message")
                .and_then(Value::as_str)
                .unwrap_or("no info for this object"),
        );
        return;
    }
    let kind = info.get("kind").and_then(Value::as_str).unwrap_or("");

    egui::Grid::new(("info-info-grid", name))
        .num_columns(2)
        .striped(true)
        .show(ui, |ui| {
            grid_row(ui, "Name / ID", name.to_string());
            grid_row(ui, "Kind", kind.to_string());
            grid_row(ui, "Creating feature", creating_feature_str(&info));
            match kind {
                "solid" => {
                    grid_row(ui, "Volume (mm\u{00b3})", num(getf(&info, "volume")));
                    grid_row(ui, "Surface area (mm\u{00b2})", num(getf(&info, "surfaceArea")));
                    grid_row(ui, "Edge length total (mm)", num(getf(&info, "edgeLengthTotal")));
                    grid_row(ui, "Density (mass/mm\u{00b3})", num(getf(&info, "density")));
                    grid_row(ui, "Weight (mass)", num(getf(&info, "weight")));
                }
                "face" => {
                    grid_row(ui, "Solid", getstr(&info, "solid"));
                    grid_row(ui, "Surface type", getstr(&info, "surfaceType"));
                    grid_row(ui, "Area (mm\u{00b2})", num(getf(&info, "area")));
                    grid_row(ui, "Edge length total (mm)", num(getf(&info, "edgeLengthTotal")));
                }
                "edge" => {
                    grid_row(ui, "Solid", getstr(&info, "solid"));
                    grid_row(ui, "Length (mm)", num(getf(&info, "length")));
                }
                _ => {}
            }
        });

    ui.add_space(6.0);
    ui.weak("Read-only. Set `density` on the Metadata tab to drive a solid's weight.");
}

/// The `creatingFeature` provenance as `id (type)`, or `—` when the object has no
/// known producer (a `null` provenance).
fn creating_feature_str(info: &Value) -> String {
    match info.get("creatingFeature") {
        Some(Value::Object(feature)) => {
            let id = feature.get("id").and_then(Value::as_str).unwrap_or("");
            let kind = feature.get("type").and_then(Value::as_str).unwrap_or("");
            if kind.is_empty() {
                id.to_string()
            } else {
                format!("{id} ({kind})")
            }
        }
        _ => "\u{2014}".to_string(),
    }
}

/// Parse an engine JSON string, defaulting to `null` on any error.
fn parse(json: &str) -> Value {
    serde_json::from_str(json).unwrap_or(Value::Null)
}

/// `label: value` row inside an `egui::Grid`.
fn grid_row(ui: &mut egui::Ui, label: &str, value: String) {
    ui.label(label);
    ui.label(value);
    ui.end_row();
}

/// A JSON number field (`0` for a missing / null field).
fn getf(value: &Value, key: &str) -> f64 {
    value.get(key).and_then(Value::as_f64).unwrap_or(0.0)
}

/// A JSON string field (empty for a missing / null field).
fn getstr(value: &Value, key: &str) -> String {
    value.get(key).and_then(Value::as_str).unwrap_or("").to_string()
}

/// Format a number readably (fixed 3 decimals, trailing-zero trimmed); `0` stays
/// `0`. `pub(crate)`: the interference window's volume labels reuse THIS
/// formatter (UI-consistency directive — one measurement format).
pub(crate) fn num(x: f64) -> String {
    if x == 0.0 {
        return "0".to_string();
    }
    let mut s = format!("{x:.3}");
    if s.contains('.') {
        while s.ends_with('0') {
            s.pop();
        }
        if s.ends_with('.') {
            s.pop();
        }
    }
    s
}

#[cfg(test)]
mod tests {
    use super::*;

    fn names(list: &[&str]) -> Vec<String> {
        list.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn open_for_creates_one_window_per_name() {
        // A multi-select viewport pick (3 names) → 3 independent windows.
        let mut m = InfoWindows::new();
        m.open_for(&names(&["A", "B", "C"]));
        assert_eq!(m.targets(), names(&["A", "B", "C"]));
    }

    #[test]
    fn dedup_keeps_existing_window_for_same_name() {
        // Clicking Info again while the SAME entity is selected must not duplicate.
        let mut m = InfoWindows::new();
        m.open_for(&names(&["A"]));
        m.open_for(&names(&["A"]));
        assert_eq!(m.targets(), names(&["A"]));
    }

    #[test]
    fn later_open_does_not_retarget_earlier_window() {
        // PIN-INDEPENDENCE: opening a window for B (i.e. the user selected B and hit
        // Info) leaves A's window untouched — a window is pinned to its open-time
        // name and no later action retargets it.
        let mut m = InfoWindows::new();
        m.open_for(&names(&["A"]));
        m.open_for(&names(&["B"]));
        assert_eq!(m.targets(), names(&["A", "B"]));
        assert_eq!(m.windows[0].target, "A");
        assert_eq!(m.windows[1].target, "B");
    }

    #[test]
    fn empty_names_are_ignored() {
        let mut m = InfoWindows::new();
        m.open_for(&names(&["", "A", ""]));
        assert_eq!(m.targets(), names(&["A"]));
    }

    #[test]
    fn prune_drops_closed_windows() {
        // The window's `×` clears its `open`; the manager then drops it.
        let mut m = InfoWindows::new();
        m.open_for(&names(&["A", "B"]));
        m.windows[0].open = false;
        m.prune();
        assert_eq!(m.targets(), names(&["B"]));
        assert_eq!(m.windows.len(), 1);
    }

    #[test]
    fn reopen_after_close_creates_a_fresh_window() {
        // Once closed + pruned, re-requesting the same name makes a NEW window.
        let mut m = InfoWindows::new();
        m.open_for(&names(&["A"]));
        m.windows[0].open = false;
        m.prune();
        m.open_for(&names(&["A"]));
        assert_eq!(m.targets(), names(&["A"]));
    }
}