BREP_app 0.4.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
//! Display-settings panel — the schema-driven settings form. Drawn as a
//! FLOATING window (movable + resizable [`egui::Window`], toggled from the
//! toolbar gear ⚙ button), mirroring the Properties window: a `pub open` flag the
//! toolbar binds + a ctx-level `show(&mut self, ctx, state, store)` the shell
//! calls after the panels. The panel OWNS only its transient UI state (which
//! nodes are open); `EngineState` stays the single brain, borrowed in.
//!
//! # Model colours are NOT set here
//!
//! A body's or face's colour is a durable `color` METADATA attribute, set in the
//! Info window and saved with the document. This panel carries only the display
//! switch over it — `Faces ▸ Override model colors`, an ordinary schema `Bool`
//! that makes the viewport ignore those colours without touching them. The old
//! `Per-Solid Colors` tab wrote a transient override that no document ever
//! stored and any feature edit threw away; it is gone.
//!
//! # One TAB per section, each drawn as a tree
//!
//! The window opens on a tab strip ([`Tab`], the Info window's `selectable_value`
//! strip) — `Display` / `Assemblies` — and draws exactly ONE section below it. Each section is still the SAME connector-line `[+]/[-]` tree
//! the feature history and Scene panels use (the shared [`tree`] node helper), so
//! the whole app reads as one system:
//!   * `Display` → `[-] Display settings` (root) → one collapsible BRANCH per
//!     schema group (`Scene`, `Faces`, `Edges`, …) → one LEAF per field, whose
//!     node label is the field label and whose right-aligned content is the field
//!     input ([`form::field_input`], EXACTLY like the feature tree's
//!     `schema_field`).
//!   * `Assemblies` → `[-] Assemblies` (root) → the BOM column configuration.
//! Group open-state is tracked on the panel (default open). Every ROOT defaults
//! OPEN too: the roots that used to default collapsed did so only because they
//! shared one scroll — a tab whose entire content is one `[+]` row is not
//! worth the click.
//!
//! Only the ACTIVE tab's widget rects are published to `__brepSettingsHit`, since
//! `hits` is rebuilt each frame from what was actually drawn.

use crate::automation::hit_keys::HitKeyDoc;
use crate::form;
use crate::panels::bom_columns;
use crate::panels::tree::{self, TreeRow};
use crate::store::{ModelStore, SETTINGS_KEY};
use brep_render::engine_state::EngineState;
use brep_render::style::{settings_form_fields, FormField, RenderSettings};
use eframe::egui;
use serde_json::Value;
use std::collections::{HashMap, HashSet};

/// The amber a BOM-column parse problem is listed in — the status map's
/// warning amber, the same one the structure tree's outdated badge uses. A
/// problem is a note about ONE line, not a failure, so it is not error red.
const PROBLEM_AMBER: egui::Color32 = egui::Color32::from_rgb(0xff, 0x9f, 0x0a);

/// The tabs of the Settings window — one per section. Each draws its own tree;
/// nothing is shared between them but the window.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
    /// The schema-driven render settings.
    Display,
    /// Assembly-wide configuration (today: the BOM's columns).
    Assemblies,
}

/// The display-settings panel's own transient UI state. It holds NO model state:
/// the settings buffer is re-seeded from the live engine each frame (see
/// [`SettingsPanel::settings_section`]).
pub struct SettingsPanel {
    /// Whether the floating window is shown. Toggled by the toolbar gear button
    /// and by the window's own close (`×`) button; public so the toolbar can bind
    /// it.
    pub open: bool,
    /// Per-frame egui widget screen rects (keyed `tab:<name>` / `field:<key>` /
    /// `group:<name>` / …), published to JS for the headed verifier. Rebuilt every
    /// frame, so it holds only the ACTIVE tab's widgets.
    hits: HashMap<String, egui::Rect>,
    /// Which tab is shown. Defaults to `Display`, the tab the window has always
    /// opened on.
    tab: Tab,
    /// The `Display settings` root is collapsed (false = open — it defaults open).
    display_collapsed: bool,
    /// Setting GROUPS explicitly COLLAPSED, by group name (absent = open — groups
    /// default open, matching the retired per-group CollapsingHeaders).
    closed_groups: HashSet<String>,
    /// The `Assemblies` root is collapsed (false = open — see above).
    assemblies_collapsed: bool,
    /// The BOM-columns textarea's live edit buffer. Held here, not re-seeded
    /// per frame like the settings JSON, because a multi-line editor cannot be
    /// re-seeded mid-edit without fighting the caret. It tracks the engine
    /// while UNFOCUSED and commits on focus-loss (the expressions editor's
    /// rule); `None` = not yet seeded.
    bom_columns_buf: Option<String>,
}

impl SettingsPanel {
    /// A fresh panel. The settings buffer is re-seeded from the engine every frame
    /// (not stored), so construction needs no engine handle.
    pub fn new() -> Self {
        Self {
            open: false,
            hits: HashMap::new(),
            tab: Tab::Display,
            display_collapsed: false,
            closed_groups: HashSet::new(),
            assemblies_collapsed: false,
            bom_columns_buf: None,
        }
    }

    /// Draw the floating window (if open) at ctx level — after the panels, like
    /// the file dialog, so it floats over the shell. The `open` flag is shared with
    /// the toolbar gear button (which toggles it) and the window's own `×` (which
    /// closes it). `EngineState` is the single brain, borrowed in.
    pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
        if self.open {
            // `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.
            let mut open = true;
            egui::Window::new("Settings")
                .open(&mut open)
                .movable(true)
                .resizable(true)
                // A bounded default size + a fill ScrollArea (in `body`, under the
                // tab strip) makes the window FREELY resizable LARGER than its
                // content: without a filling child egui hugs the window to content
                // and won't grow.
                .default_size([320.0, 400.0])
                // Rest on the right so it floats clear of the left panel; the user
                // can drag it anywhere.
                .default_pos([720.0, 56.0])
                .show(ctx, |ui| self.body(ui, state, store));
            self.open = open;

            // Publish this frame's widget rects for the headed verifier (parity
            // with the history + scene panels).
            if crate::automation::registry::enabled() {
                crate::automation::registry::publish("__brepSettingsHit", "settings window widget rects (field:*, group:*)", &self.hits_json());
            }
        }
    }

    /// The window body: the tab strip, then the ONE section that tab selects —
    /// each still built on the shared [`tree`] node helper, so every tab reads as
    /// the same tree the history + scene panels draw.
    ///
    /// The strip sits ABOVE the ScrollArea (rather than inside the one `show` used
    /// to wrap the whole body in), so the tabs stay put while a long settings tree
    /// scrolls under them.
    fn body(&mut self, ui: &mut egui::Ui, state: &mut EngineState, store: &dyn ModelStore) {
        self.hits.clear();

        // The tab drawn this frame is the one that was active BEFORE the strip.
        // `selectable_value` switches `self.tab` mid-frame, and a section that
        // vanishes the same frame it loses the click never gets its focus-loss —
        // which is how the Assemblies editor COMMITS. Deferring by one frame lets
        // the editor blur normally (invisible at 60fps, and the difference between
        // "my BOM columns saved" and "my typing disappeared").
        let tab = self.tab;
        ui.horizontal(|ui| {
            let display = ui.selectable_value(&mut self.tab, Tab::Display, "Display");
            let assemblies = ui.selectable_value(&mut self.tab, Tab::Assemblies, "Assemblies");
            self.hits.insert("tab:display".into(), display.rect);
            self.hits.insert("tab:assemblies".into(), assemblies.rect);
        });
        // ...and because of that defer, the switch needs ONE more frame to show
        // its new section. The shell only requests a repaint while work is
        // pending (`app.rs`: a run / queries / mesh imports), so on native a
        // click with no further input would leave the strip highlighting a tab
        // whose content has not been drawn yet. Ask for that frame here.
        if self.tab != tab {
            ui.ctx().request_repaint();
        }
        ui.separator();

        egui::ScrollArea::vertical()
            .auto_shrink([false, false])
            .show(ui, |ui| {
                // Tight, tree-like row spacing so connector verticals read
                // continuously — the same the history + scene trees set (this panel
                // must match them). Set INSIDE the scroll so the tab strip above
                // keeps ordinary widget spacing.
                ui.spacing_mut().item_spacing.y = 2.0;
                match tab {
                    Tab::Display => self.settings_section(ui, state, store),
                    Tab::Assemblies => self.assemblies_section(ui, state, store),
                }
            });
    }

    /// The `Assemblies` TAB: the BOM's COLUMN CONFIGURATION as one multiline
    /// textarea, one column per line, a leading `*` for shown.
    ///
    /// A hand-written root rather than a schema field: the schema's `FieldKind`s
    /// are all
    /// single-widget and render into a tree row's RIGHT-ALIGNED content slot,
    /// which is exactly the wrong place for a full-width multi-line editor.
    /// Adding a `TextArea` variant would also force both exhaustive
    /// `FieldKind` matches open for one consumer.
    ///
    /// Commits on focus-LOSS — which includes LEAVING THE TAB, since [`body`]
    /// draws the pre-click tab for one more frame so this editor is still on
    /// screen to blur ([`SettingsPanel::body`]) — not per keystroke: this text is
    /// persisted to the store on commit, and on native that is a rewrite of
    /// `~/.config/brep-app/settings.json` — per character is a file write per
    /// character. Parse problems are listed under the editor, naming the line,
    /// and the text is never rewritten by the panel: a typo costs one column,
    /// not the configuration.
    fn assemblies_section(
        &mut self,
        ui: &mut egui::Ui,
        state: &mut EngineState,
        store: &dyn ModelStore,
    ) {
        let open = !self.assemblies_collapsed;
        let root_resp = tree::node(
            ui,
            TreeRow {
                guides: &[],
                is_last: true,
                expandable: true,
                expanded: open,
                root: true,
                glyph: None,
                label: "Assemblies",
                selected: false,
                draggable: false,
                tint: None,
            },
            |_| {},
        );
        self.hits.insert("box:__assemblies".into(), root_resp.box_rect);
        if root_resp.toggled || root_resp.label.clicked() {
            self.assemblies_collapsed = !self.assemblies_collapsed;
        }
        if !open {
            // Drop the buffer while closed so the next open re-seeds from the
            // engine (a BOM header drag rewrites this text behind the panel).
            self.bom_columns_buf = None;
            return;
        }

        // Empty stored text means "the shipped default", so the editor shows
        // the default rather than a blank box the user has to guess at.
        let stored = bom_columns::effective_text(&state.settings.bom_columns);
        let buffer = self.bom_columns_buf.get_or_insert_with(|| stored.clone());

        ui.label(egui::RichText::new("BOM columns").strong());
        ui.label(
            egui::RichText::new(
                "One per line, in order. A leading * shows it. \
                 part.<Field> is stored on the part, occurrence.<Field> on one placement. \
                 A line that is just - freezes the columns above it; the rest scroll.",
            )
            .weak(),
        );
        let editor = ui.add(
            egui::TextEdit::multiline(buffer)
                .id_salt("bom-columns-editor")
                .desired_rows(8)
                .desired_width(f32::INFINITY)
                .code_editor(),
        );
        self.hits.insert("field:bomColumns".into(), editor.rect);

        if editor.lost_focus() {
            // Commit: store the text VERBATIM (never the parse's idea of it).
            let mut settings_json: serde_json::Value =
                serde_json::from_str(&state.settings_json()).unwrap_or(serde_json::Value::Null);
            if let Some(object) = settings_json.as_object_mut() {
                object.insert(
                    "bomColumns".into(),
                    serde_json::Value::String(buffer.clone()),
                );
                let json = settings_json.to_string();
                let _ = state.apply_settings_json(&json);
                let _ = store.write(SETTINGS_KEY, &json);
            }
        } else if !editor.has_focus() && *buffer != stored {
            // Unfocused and out of step with the engine — the BOM's own header
            // drag rewrote the configuration. Track it rather than showing a
            // stale copy the next commit would write back.
            *buffer = stored;
        }

        // Parse problems, by line. Listed rather than thrown: the text stands
        // exactly as typed and every other line still works.
        let parsed = bom_columns::parse(buffer);
        if parsed.problems.is_empty() {
            ui.label(
                egui::RichText::new(format!(
                    "{} columns, {} shown",
                    parsed.columns.len(),
                    parsed.columns.iter().filter(|column| column.shown).count()
                ))
                .weak(),
            );
        } else {
            for problem in &parsed.problems {
                ui.label(egui::RichText::new(problem).color(PROBLEM_AMBER));
            }
        }
        let reset = ui.button("Reset BOM columns");
        self.hits.insert("bom-columns:reset".into(), reset.rect);
        if reset.clicked() {
            self.bom_columns_buf = None;
            let mut settings_json: serde_json::Value =
                serde_json::from_str(&state.settings_json()).unwrap_or(serde_json::Value::Null);
            if let Some(object) = settings_json.as_object_mut() {
                // Back to EMPTY, which means "the shipped default" — so a later
                // change to that default still reaches this user.
                object.insert("bomColumns".into(), serde_json::Value::String(String::new()));
                let json = settings_json.to_string();
                let _ = state.apply_settings_json(&json);
                let _ = store.write(SETTINGS_KEY, &json);
            }
        }
        ui.add_space(4.0);
    }

    /// The schema-driven display-settings TREE: a `Display settings` root, one
    /// collapsible branch per schema group, one leaf per field. Any edit applies to
    /// `EngineState` (bumps `settings_generation` + `dirty`, so the GPU refreshes)
    /// and persists through the storage seam.
    fn settings_section(
        &mut self,
        ui: &mut egui::Ui,
        state: &mut EngineState,
        store: &dyn ModelStore,
    ) {
        // Re-seed a per-frame LOCAL buffer from the LIVE engine settings BEFORE
        // rendering. The apply below writes the WHOLE buffer, so a buffer kept
        // across frames would clobber every setting changed elsewhere (the toolbar
        // wireframe / projection toggles) back to a stale snapshot — the "changing
        // Render Quality resets my wireframe" bug. A fresh local each frame makes
        // external changes authoritative and keeps untouched fields a no-op
        // round-trip (`apply_json`/`to_json` are a documented identity).
        let mut settings_json: Value =
            serde_json::from_str(&state.settings_json()).unwrap_or(Value::Null);
        let fields = settings_form_fields();

        // Group the schema's contiguous same-group runs, preserving order (the
        // schema lists each group's fields together).
        let mut groups: Vec<(String, Vec<&FormField>)> = Vec::new();
        for f in &fields {
            if let Some(g) = groups.iter_mut().find(|(n, _)| *n == f.group) {
                g.1.push(f);
            } else {
                groups.push((f.group.clone(), vec![f]));
            }
        }

        // --- ROOT: `[-] Display settings` (defaults open) ---------------------
        let root_open = !self.display_collapsed;
        let root_resp = tree::node(
            ui,
            TreeRow {
                guides: &[],
                is_last: true,
                expandable: true,
                expanded: root_open,
                root: true,
                glyph: None,
                label: "Display settings",
                selected: false,
                draggable: false,
                tint: None,
            },
            |_| {},
        );
        if root_resp.toggled || root_resp.label.clicked() {
            self.display_collapsed = !self.display_collapsed;
        }

        let mut changed = false;
        if root_open {
            let n = groups.len();
            for (gi, (gname, gfields)) in groups.iter().enumerate() {
                let is_last = gi + 1 == n;
                let open = !self.closed_groups.contains(gname);
                let resp = tree::node(ui, TreeRow::branch(&[], is_last, open, gname), |_| {});
                self.hits.insert(format!("group:{gname}"), resp.box_rect);
                if resp.toggled || resp.label.clicked() {
                    if open {
                        self.closed_groups.insert(gname.clone());
                    } else {
                        self.closed_groups.remove(gname);
                    }
                }
                if !open {
                    continue;
                }
                let base = tree::child_guides(&[], is_last);
                let m = gfields.len();
                for (fi, &f) in gfields.iter().enumerate() {
                    changed |= self.settings_leaf(ui, f, &mut settings_json, &base, fi + 1 == m);
                }
            }
        }

        // Commit the whole buffer ONCE on any edit (same apply + persist path as
        // before), so the engine re-runs / the GPU refreshes exactly as it did.
        if changed {
            let json = settings_json.to_string();
            let _ = state.apply_settings_json(&json);
            let _ = store.write(SETTINGS_KEY, &json);
        }

        // Reset to defaults — only while the display root is OPEN, matching the
        // retired CollapsingHeader that hid it when the section was collapsed. It
        // resets the DISPLAY settings only, which is why it belongs to this tab.
        if root_open {
            ui.add_space(2.0);
            if ui.button("Reset to defaults").clicked() {
                // Full reset: rebase to defaults, then apply the serialized defaults
                // (so every key returns, not just the overridden ones) + persist.
                state.settings = RenderSettings::default();
                let json = state.settings.to_json();
                let _ = state.apply_settings_json(&json);
                let _ = store.write(SETTINGS_KEY, &json);
            }
        }
    }

    /// Render one settings field as a tree LEAF: the field label is the node label;
    /// its input widget ([`form::field_input`]) fills the row's RIGHT-aligned
    /// content, exactly like the feature tree's `schema_field`. Settings keys are
    /// unique across the schema, so no id-stack scoping is needed. Returns whether
    /// the field changed (the caller commits the whole buffer once).
    fn settings_leaf(
        &mut self,
        ui: &mut egui::Ui,
        field: &FormField,
        current: &mut Value,
        guides: &[bool],
        is_last: bool,
    ) -> bool {
        let mut changed = false;
        let mut rect = egui::Rect::NOTHING;
        tree::node(ui, TreeRow::leaf(guides, is_last, &field.label), |ui| {
            // The tree row's content area is RIGHT-aligned (`right_to_left`), so the
            // input sits at the panel edge with the label on the left — the feature
            // tree's exact placement, and the layout `field_input` reads to keep its
            // inputs COMPACT here. Settings have no reference / button fields, so
            // `field_input`'s click sink is `None`.
            let (ch, r) = form::field_input(ui, field, current, None, &mut form::FieldActions::default());
            changed = ch;
            rect = r;
        });
        self.hits.insert(format!("field:{}", field.key()), rect);
        changed
    }

    /// The published widget hit-rects (egui points) for the headed verifier.
    pub fn hits_json(&self) -> String {
        crate::automation::hit_rects::hits_json(&self.hits)
    }
}

// BREP private tests: fbeefa5a776131c6

/// The hit keys this panel publishes (see `automation::hit_keys`).
pub static HIT_KEYS: &[HitKeyDoc] = &[
    HitKeyDoc { panel: "settings", prefix: "field:", meaning: "a settings field by key", command: None },
    HitKeyDoc { panel: "settings", prefix: "group:", meaning: "a settings group header", command: None },
    HitKeyDoc { panel: "settings", prefix: "tab:", meaning: "a settings tab (tab:display, tab:assemblies)", command: None },
    HitKeyDoc { panel: "settings", prefix: "box:", meaning: "expand/collapse a section", command: None },
    HitKeyDoc { panel: "settings", prefix: "bom-columns:reset", meaning: "reset the BOM columns", command: None },
];