BREP_app 0.1.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
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
//! Display-settings panel — the schema-driven settings form + the per-solid
//! metadata color overrides (Phase 1). 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, the per-solid
//! color-picker working values); `EngineState` stays the single brain, borrowed
//! in.
//!
//! # Rendered as a tree — the same tree as the history + scene panels
//!
//! The settings live in the SAME connector-line `[+]/[-]` tree the feature
//! history and Scene panels use (the shared [`tree`] node helper), so the whole
//! sidebar reads as one system:
//!   * `[-] 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`).
//!   * `[-] Per-solid colors` (root) → one LEAF per scene solid (enable checkbox
//!     + color picker in the right slot).
//! Group open-state is tracked on the panel (default open); the per-solid root
//! defaults collapsed (its retired CollapsingHeader was `default_open(false)`).

use crate::form;
use crate::panels::tree::{self, TreeRow};
use crate::store::Store;
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 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 `field:<key>` / `solid:<name>` /
    /// …), published to JS for the headed verifier. Rebuilt every frame.
    hits: HashMap<String, egui::Rect>,
    /// 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 `Per-solid colors` root is collapsed (defaults COLLAPSED — its retired
    /// CollapsingHeader was `default_open(false)`).
    per_solid_collapsed: bool,
    /// Per-solid color-picker working state (so a live drag keeps its value even
    /// before it is committed to the scene override).
    solid_override_edit: HashMap<String, [u8; 3]>,
}

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(),
            display_collapsed: false,
            closed_groups: HashSet::new(),
            per_solid_collapsed: true,
            solid_override_edit: HashMap::new(),
        }
    }

    /// 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 Store) {
        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 (below) 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| {
                    egui::ScrollArea::vertical()
                        .auto_shrink([false, false])
                        .show(ui, |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).
            #[cfg(target_arch = "wasm32")]
            publish("__brepSettingsHit", &self.hits_json());
        }
    }

    /// The window body: the display-settings tree + the per-solid colors tree, both
    /// built on the shared [`tree`] node helper so the dialog reads as one tree.
    fn body(&mut self, ui: &mut egui::Ui, state: &mut EngineState, store: &dyn Store) {
        self.hits.clear();
        // Tight, tree-like row spacing so connector verticals read continuously —
        // the same the history + scene trees set (this panel must match them).
        ui.spacing_mut().item_spacing.y = 2.0;

        self.settings_section(ui, state, store);
        self.per_solid_color_section(ui, state);
    }

    /// 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 Store) {
        // 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,
            },
            |_| {},
        );
        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);
            store.save("settings", &json);
        }

        // Reset to defaults — only while the display root is OPEN, matching the
        // retired CollapsingHeader that hid it when the section was collapsed.
        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);
                store.save("settings", &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. Settings have no reference / button fields, so
            // `field_input`'s optional renderer + click sink are `None`.
            let (ch, r) = form::field_input(ui, field, current, None, None, &mut None);
            changed = ch;
            rect = r;
        });
        self.hits.insert(format!("field:{}", field.key()), rect);
        changed
    }

    /// Per-solid metadata color overrides — the "settings ↔ metadata" control, now
    /// a tree: a `Per-solid colors` root + one LEAF per scene solid (the enable
    /// checkbox + color picker in the row's right slot).
    ///
    /// COLOR PRECEDENCE (final pixel color of a face), highest wins:
    ///   1. selection / hover emphasis  (Emphasis::face_state → selected/hover)
    ///   2. per-solid metadata override (this control → SolidDisplay.color_override)
    ///   3. faceColorMode global        (Uniform faceColor | HashedBySolid)
    /// (1) is applied in the draw pass; (2)/(3) are resolved in
    /// `RenderCore::face_base_color`. So a solid recolored here overrides the
    /// global face color, but a selection still highlights it.
    fn per_solid_color_section(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
        let names: Vec<String> = state
            .scene
            .solids()
            .iter()
            .map(|solid| solid.name.clone())
            .collect();

        // --- ROOT: `[-] Per-solid colors  <count>` (defaults COLLAPSED) -------
        let open = !self.per_solid_collapsed;
        let root_resp = tree::node(
            ui,
            TreeRow {
                guides: &[],
                is_last: true,
                expandable: true,
                expanded: open,
                root: true,
                glyph: None,
                label: "Per-solid colors",
                selected: false,
                draggable: false,
            },
            |ui| {
                ui.add_space(6.0);
                ui.label(egui::RichText::new(format!("{}", names.len())).weak());
            },
        );
        self.hits.insert("box:__per_solid".into(), root_resp.box_rect);
        if root_resp.toggled || root_resp.label.clicked() {
            self.per_solid_collapsed = !self.per_solid_collapsed;
        }
        if !open {
            return;
        }

        let base = tree::child_guides(&[], true);
        if names.is_empty() {
            tree::node(ui, TreeRow::leaf(&base, true, "(no solids)"), |_| {});
            return;
        }

        // One deferred mutation per frame — the scene panel's pattern — so no
        // `&mut state` is held across the draw.
        let mut color_action: Option<(String, Option<String>)> = None;
        let m = names.len();
        for (i, name) in names.iter().enumerate() {
            let last = i + 1 == m;
            let current_override = state.scene.solid(name).and_then(|s| s.color_override);
            let cached = self.solid_override_edit.get(name).copied();
            let mut enabled = current_override.is_some();
            let mut rgb = current_override
                .map(|c| {
                    [
                        (c[0] * 255.0).round() as u8,
                        (c[1] * 255.0).round() as u8,
                        (c[2] * 255.0).round() as u8,
                    ]
                })
                .or(cached)
                // A clear, obvious demo red so enabling an override is visible at a
                // glance overriding the global face color.
                .unwrap_or([255, 51, 51]);
            let mut enable_rect = egui::Rect::NOTHING;
            let mut toggled = false;
            let mut picker_changed = false;
            let resp = tree::node(ui, TreeRow::leaf(&base, last, name), |ui| {
                // right-to-left: the enable checkbox (rightmost, the scene tree's
                // right-slot convention), then the color picker to its left when
                // enabled.
                let cb = ui.add(egui::Checkbox::new(&mut enabled, ""));
                enable_rect = cb.rect;
                toggled = cb.changed();
                if enabled {
                    picker_changed = ui.color_edit_button_srgb(&mut rgb).changed();
                }
            });
            self.hits.insert(format!("solid:{name}"), resp.label.rect);
            self.hits.insert(format!("solid-enable:{name}"), enable_rect);
            if enabled {
                self.solid_override_edit.insert(name.clone(), rgb);
                if toggled || picker_changed {
                    let hex = format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]);
                    color_action = Some((name.clone(), Some(hex)));
                }
            } else if toggled {
                color_action = Some((name.clone(), None));
            }
        }

        if let Some((name, hex)) = color_action {
            state.set_color_override(&name, hex.as_deref());
        }
    }

    /// The published widget hit-rects (egui points) for the headed verifier.
    #[cfg(target_arch = "wasm32")]
    pub fn hits_json(&self) -> String {
        let map: serde_json::Map<String, Value> = self
            .hits
            .iter()
            .map(|(k, r)| {
                (
                    k.clone(),
                    serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
                )
            })
            .collect();
        Value::Object(map).to_string()
    }
}

/// Mirror a JSON string to `window.<name>` (wasm/verification only).
#[cfg(target_arch = "wasm32")]
fn publish(name: &str, json: &str) {
    if let Some(win) = web_sys::window() {
        let _ = js_sys::Reflect::set(
            &win,
            &wasm_bindgen::JsValue::from_str(name),
            &wasm_bindgen::JsValue::from_str(json),
        );
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;
    use std::collections::HashMap as Map;

    /// An in-memory settings store so a round-trip test never touches the config
    /// dir. `save` is `&self` (the trait's contract), so `RefCell` suffices.
    #[derive(Default)]
    struct MemStore {
        map: RefCell<Map<String, String>>,
    }
    impl Store for MemStore {
        fn load(&self, key: &str) -> Option<String> {
            self.map.borrow().get(key).cloned()
        }
        fn save(&self, key: &str, val: &str) {
            self.map.borrow_mut().insert(key.to_string(), val.to_string());
        }
    }

    /// Run ONE headless frame of the settings BODY on a plain Ui (skipping the
    /// floating Window geometry, which would clip on a small test screen), feeding
    /// `events` as this frame's input. Layout is deterministic, so the `hits` rects
    /// are stable frame-to-frame and can be read back to drive a real click.
    fn run_frame(
        ctx: &egui::Context,
        panel: &mut SettingsPanel,
        state: &mut EngineState,
        store: &dyn Store,
        events: Vec<egui::Event>,
    ) {
        let raw = egui::RawInput {
            screen_rect: Some(egui::Rect::from_min_size(
                egui::pos2(0.0, 0.0),
                egui::vec2(400.0, 800.0),
            )),
            events,
            ..Default::default()
        };
        let _ = ctx.run_ui(raw, |ui| panel.body(ui, state, store));
    }

    /// Left-click at `pos` split across a press frame and a release frame (egui
    /// fires `clicked()` on release), re-running the body each frame so the deferred
    /// apply/persist happens.
    fn click_at(
        ctx: &egui::Context,
        panel: &mut SettingsPanel,
        state: &mut EngineState,
        store: &dyn Store,
        pos: egui::Pos2,
    ) {
        run_frame(
            ctx,
            panel,
            state,
            store,
            vec![
                egui::Event::PointerMoved(pos),
                egui::Event::PointerButton {
                    pos,
                    button: egui::PointerButton::Primary,
                    pressed: true,
                    modifiers: egui::Modifiers::default(),
                },
            ],
        );
        run_frame(
            ctx,
            panel,
            state,
            store,
            vec![egui::Event::PointerButton {
                pos,
                button: egui::PointerButton::Primary,
                pressed: false,
                modifiers: egui::Modifiers::default(),
            }],
        );
    }

    /// STRUCTURAL: every schema field renders as a tree LEAF (a `field:<key>` hit
    /// rect). Because a collapsed group would omit its leaves, this also proves
    /// every GROUP defaults OPEN — i.e. the whole settings form is drawn as tree
    /// nodes (the gate's headless "groups render as tree nodes" assertion).
    #[test]
    fn settings_tree_renders_every_field_as_a_leaf() {
        let ctx = egui::Context::default();
        let mut state = EngineState::new();
        let mut panel = SettingsPanel::new();
        let store = MemStore::default();

        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);

        for field in settings_form_fields() {
            let key = format!("field:{}", field.key());
            assert!(
                panel.hits.contains_key(&key),
                "missing tree leaf for settings field {key}; have {:?}",
                panel.hits.keys().collect::<Vec<_>>()
            );
        }
    }

    /// BEHAVIORAL: clicking a Bool field's checkbox in the tree reaches the SAME
    /// apply path as before — the engine setting flips AND the whole settings JSON
    /// is persisted through the store.
    #[test]
    fn settings_tree_field_edit_applies_and_persists() {
        let ctx = egui::Context::default();
        let mut state = EngineState::new();
        let mut panel = SettingsPanel::new();
        let store = MemStore::default();

        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
        assert!(!state.settings.wireframe, "wireframe starts off");
        let rect = *panel
            .hits
            .get("field:wireframe")
            .expect("wireframe leaf checkbox rect");

        click_at(&ctx, &mut panel, &mut state, &store, rect.center());

        assert!(
            state.settings.wireframe,
            "clicking the checkbox flips the engine setting through apply_settings_json"
        );
        let saved: Value = serde_json::from_str(
            &store.load("settings").expect("edit persisted through the store"),
        )
        .unwrap();
        assert_eq!(
            saved["wireframe"],
            Value::Bool(true),
            "the whole settings JSON is persisted with the edit"
        );
    }

    /// RE-SEED GUARD: the per-frame re-seed keeps EXTERNAL setting changes
    /// authoritative — a value changed outside the panel (a toolbar toggle) must
    /// survive an UNRELATED edit made through the tree. This pins the "changing
    /// Render Quality resets my wireframe" fix.
    #[test]
    fn settings_tree_edit_preserves_externally_changed_setting() {
        let ctx = egui::Context::default();
        let mut state = EngineState::new();
        let mut panel = SettingsPanel::new();
        let store = MemStore::default();

        // Lay out once, then externally flip wireframe on (as the toolbar would —
        // straight onto the engine, bypassing this panel's buffer).
        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
        state.settings.wireframe = true;
        assert!(state.settings.wireframe);

        // Edit a DIFFERENT field through the tree (toggle flatShading).
        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
        let before = state.settings.flat_shading;
        let rect = *panel
            .hits
            .get("field:flatShading")
            .expect("flatShading leaf checkbox rect");

        click_at(&ctx, &mut panel, &mut state, &store, rect.center());

        assert_ne!(
            state.settings.flat_shading, before,
            "the tree edit took effect"
        );
        assert!(
            state.settings.wireframe,
            "the externally-set wireframe survived the unrelated tree edit (re-seed intact)"
        );
    }
}