Skip to main content

brep_app/panels/
settings.rs

1//! Display-settings panel — the schema-driven settings form + the per-solid
2//! metadata color overrides (Phase 1). Drawn as a FLOATING window (movable +
3//! resizable [`egui::Window`], toggled from the toolbar gear ⚙ button), mirroring
4//! the Properties window: a `pub open` flag the toolbar binds + a ctx-level
5//! `show(&mut self, ctx, state, store)` the shell calls after the panels. The
6//! panel OWNS only its transient UI state (which nodes are open, the per-solid
7//! color-picker working values); `EngineState` stays the single brain, borrowed
8//! in.
9//!
10//! # Rendered as a tree — the same tree as the history + scene panels
11//!
12//! The settings live in the SAME connector-line `[+]/[-]` tree the feature
13//! history and Scene panels use (the shared [`tree`] node helper), so the whole
14//! sidebar reads as one system:
15//!   * `[-] Display settings` (root) → one collapsible BRANCH per schema group
16//!     (`Scene`, `Faces`, `Edges`, …) → one LEAF per field, whose node label is
17//!     the field label and whose right-aligned content is the field input
18//!     ([`form::field_input`], EXACTLY like the feature tree's `schema_field`).
19//!   * `[-] Per-solid colors` (root) → one LEAF per scene solid (enable checkbox
20//!     + color picker in the right slot).
21//! Group open-state is tracked on the panel (default open); the per-solid root
22//! defaults collapsed (its retired CollapsingHeader was `default_open(false)`).
23
24use crate::form;
25use crate::panels::tree::{self, TreeRow};
26use crate::store::Store;
27use brep_render::engine_state::EngineState;
28use brep_render::style::{settings_form_fields, FormField, RenderSettings};
29use eframe::egui;
30use serde_json::Value;
31use std::collections::{HashMap, HashSet};
32
33/// The display-settings panel's own transient UI state. It holds NO model state:
34/// the settings buffer is re-seeded from the live engine each frame (see
35/// [`SettingsPanel::settings_section`]).
36pub struct SettingsPanel {
37    /// Whether the floating window is shown. Toggled by the toolbar gear button
38    /// and by the window's own close (`×`) button; public so the toolbar can bind
39    /// it.
40    pub open: bool,
41    /// Per-frame egui widget screen rects (keyed `field:<key>` / `solid:<name>` /
42    /// …), published to JS for the headed verifier. Rebuilt every frame.
43    hits: HashMap<String, egui::Rect>,
44    /// The `Display settings` root is collapsed (false = open — it defaults open).
45    display_collapsed: bool,
46    /// Setting GROUPS explicitly COLLAPSED, by group name (absent = open — groups
47    /// default open, matching the retired per-group CollapsingHeaders).
48    closed_groups: HashSet<String>,
49    /// The `Per-solid colors` root is collapsed (defaults COLLAPSED — its retired
50    /// CollapsingHeader was `default_open(false)`).
51    per_solid_collapsed: bool,
52    /// Per-solid color-picker working state (so a live drag keeps its value even
53    /// before it is committed to the scene override).
54    solid_override_edit: HashMap<String, [u8; 3]>,
55}
56
57impl SettingsPanel {
58    /// A fresh panel. The settings buffer is re-seeded from the engine every frame
59    /// (not stored), so construction needs no engine handle.
60    pub fn new() -> Self {
61        Self {
62            open: false,
63            hits: HashMap::new(),
64            display_collapsed: false,
65            closed_groups: HashSet::new(),
66            per_solid_collapsed: true,
67            solid_override_edit: HashMap::new(),
68        }
69    }
70
71    /// Draw the floating window (if open) at ctx level — after the panels, like
72    /// the file dialog, so it floats over the shell. The `open` flag is shared with
73    /// the toolbar gear button (which toggles it) and the window's own `×` (which
74    /// closes it). `EngineState` is the single brain, borrowed in.
75    pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn Store) {
76        if self.open {
77            // `egui::Window::open` needs its own `&mut bool`; borrow a copy so the
78            // draw closure can still take `&mut self`, then fold the close back in.
79            let mut open = true;
80            egui::Window::new("Settings")
81                .open(&mut open)
82                .movable(true)
83                .resizable(true)
84                // A bounded default size + a fill ScrollArea (below) makes the
85                // window FREELY resizable LARGER than its content: without a
86                // filling child egui hugs the window to content and won't grow.
87                .default_size([320.0, 400.0])
88                // Rest on the right so it floats clear of the left panel; the user
89                // can drag it anywhere.
90                .default_pos([720.0, 56.0])
91                .show(ctx, |ui| {
92                    egui::ScrollArea::vertical()
93                        .auto_shrink([false, false])
94                        .show(ui, |ui| self.body(ui, state, store));
95                });
96            self.open = open;
97
98            // Publish this frame's widget rects for the headed verifier (parity
99            // with the history + scene panels).
100            #[cfg(target_arch = "wasm32")]
101            publish("__brepSettingsHit", &self.hits_json());
102        }
103    }
104
105    /// The window body: the display-settings tree + the per-solid colors tree, both
106    /// built on the shared [`tree`] node helper so the dialog reads as one tree.
107    fn body(&mut self, ui: &mut egui::Ui, state: &mut EngineState, store: &dyn Store) {
108        self.hits.clear();
109        // Tight, tree-like row spacing so connector verticals read continuously —
110        // the same the history + scene trees set (this panel must match them).
111        ui.spacing_mut().item_spacing.y = 2.0;
112
113        self.settings_section(ui, state, store);
114        self.per_solid_color_section(ui, state);
115    }
116
117    /// The schema-driven display-settings TREE: a `Display settings` root, one
118    /// collapsible branch per schema group, one leaf per field. Any edit applies to
119    /// `EngineState` (bumps `settings_generation` + `dirty`, so the GPU refreshes)
120    /// and persists through the storage seam.
121    fn settings_section(&mut self, ui: &mut egui::Ui, state: &mut EngineState, store: &dyn Store) {
122        // Re-seed a per-frame LOCAL buffer from the LIVE engine settings BEFORE
123        // rendering. The apply below writes the WHOLE buffer, so a buffer kept
124        // across frames would clobber every setting changed elsewhere (the toolbar
125        // wireframe / projection toggles) back to a stale snapshot — the "changing
126        // Render Quality resets my wireframe" bug. A fresh local each frame makes
127        // external changes authoritative and keeps untouched fields a no-op
128        // round-trip (`apply_json`/`to_json` are a documented identity).
129        let mut settings_json: Value =
130            serde_json::from_str(&state.settings_json()).unwrap_or(Value::Null);
131        let fields = settings_form_fields();
132
133        // Group the schema's contiguous same-group runs, preserving order (the
134        // schema lists each group's fields together).
135        let mut groups: Vec<(String, Vec<&FormField>)> = Vec::new();
136        for f in &fields {
137            if let Some(g) = groups.iter_mut().find(|(n, _)| *n == f.group) {
138                g.1.push(f);
139            } else {
140                groups.push((f.group.clone(), vec![f]));
141            }
142        }
143
144        // --- ROOT: `[-] Display settings` (defaults open) ---------------------
145        let root_open = !self.display_collapsed;
146        let root_resp = tree::node(
147            ui,
148            TreeRow {
149                guides: &[],
150                is_last: true,
151                expandable: true,
152                expanded: root_open,
153                root: true,
154                glyph: None,
155                label: "Display settings",
156                selected: false,
157                draggable: false,
158            },
159            |_| {},
160        );
161        if root_resp.toggled || root_resp.label.clicked() {
162            self.display_collapsed = !self.display_collapsed;
163        }
164
165        let mut changed = false;
166        if root_open {
167            let n = groups.len();
168            for (gi, (gname, gfields)) in groups.iter().enumerate() {
169                let is_last = gi + 1 == n;
170                let open = !self.closed_groups.contains(gname);
171                let resp = tree::node(ui, TreeRow::branch(&[], is_last, open, gname), |_| {});
172                self.hits.insert(format!("group:{gname}"), resp.box_rect);
173                if resp.toggled || resp.label.clicked() {
174                    if open {
175                        self.closed_groups.insert(gname.clone());
176                    } else {
177                        self.closed_groups.remove(gname);
178                    }
179                }
180                if !open {
181                    continue;
182                }
183                let base = tree::child_guides(&[], is_last);
184                let m = gfields.len();
185                for (fi, &f) in gfields.iter().enumerate() {
186                    changed |= self.settings_leaf(ui, f, &mut settings_json, &base, fi + 1 == m);
187                }
188            }
189        }
190
191        // Commit the whole buffer ONCE on any edit (same apply + persist path as
192        // before), so the engine re-runs / the GPU refreshes exactly as it did.
193        if changed {
194            let json = settings_json.to_string();
195            let _ = state.apply_settings_json(&json);
196            store.save("settings", &json);
197        }
198
199        // Reset to defaults — only while the display root is OPEN, matching the
200        // retired CollapsingHeader that hid it when the section was collapsed.
201        if root_open {
202            ui.add_space(2.0);
203            if ui.button("Reset to defaults").clicked() {
204                // Full reset: rebase to defaults, then apply the serialized defaults
205                // (so every key returns, not just the overridden ones) + persist.
206                state.settings = RenderSettings::default();
207                let json = state.settings.to_json();
208                let _ = state.apply_settings_json(&json);
209                store.save("settings", &json);
210            }
211        }
212    }
213
214    /// Render one settings field as a tree LEAF: the field label is the node label;
215    /// its input widget ([`form::field_input`]) fills the row's RIGHT-aligned
216    /// content, exactly like the feature tree's `schema_field`. Settings keys are
217    /// unique across the schema, so no id-stack scoping is needed. Returns whether
218    /// the field changed (the caller commits the whole buffer once).
219    fn settings_leaf(
220        &mut self,
221        ui: &mut egui::Ui,
222        field: &FormField,
223        current: &mut Value,
224        guides: &[bool],
225        is_last: bool,
226    ) -> bool {
227        let mut changed = false;
228        let mut rect = egui::Rect::NOTHING;
229        tree::node(ui, TreeRow::leaf(guides, is_last, &field.label), |ui| {
230            // The tree row's content area is RIGHT-aligned (`right_to_left`), so the
231            // input sits at the panel edge with the label on the left — the feature
232            // tree's exact placement. Settings have no reference / button fields, so
233            // `field_input`'s optional renderer + click sink are `None`.
234            let (ch, r) = form::field_input(ui, field, current, None, None, &mut None);
235            changed = ch;
236            rect = r;
237        });
238        self.hits.insert(format!("field:{}", field.key()), rect);
239        changed
240    }
241
242    /// Per-solid metadata color overrides — the "settings ↔ metadata" control, now
243    /// a tree: a `Per-solid colors` root + one LEAF per scene solid (the enable
244    /// checkbox + color picker in the row's right slot).
245    ///
246    /// COLOR PRECEDENCE (final pixel color of a face), highest wins:
247    ///   1. selection / hover emphasis  (Emphasis::face_state → selected/hover)
248    ///   2. per-solid metadata override (this control → SolidDisplay.color_override)
249    ///   3. faceColorMode global        (Uniform faceColor | HashedBySolid)
250    /// (1) is applied in the draw pass; (2)/(3) are resolved in
251    /// `RenderCore::face_base_color`. So a solid recolored here overrides the
252    /// global face color, but a selection still highlights it.
253    fn per_solid_color_section(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
254        let names: Vec<String> = state
255            .scene
256            .solids()
257            .iter()
258            .map(|solid| solid.name.clone())
259            .collect();
260
261        // --- ROOT: `[-] Per-solid colors  <count>` (defaults COLLAPSED) -------
262        let open = !self.per_solid_collapsed;
263        let root_resp = tree::node(
264            ui,
265            TreeRow {
266                guides: &[],
267                is_last: true,
268                expandable: true,
269                expanded: open,
270                root: true,
271                glyph: None,
272                label: "Per-solid colors",
273                selected: false,
274                draggable: false,
275            },
276            |ui| {
277                ui.add_space(6.0);
278                ui.label(egui::RichText::new(format!("{}", names.len())).weak());
279            },
280        );
281        self.hits.insert("box:__per_solid".into(), root_resp.box_rect);
282        if root_resp.toggled || root_resp.label.clicked() {
283            self.per_solid_collapsed = !self.per_solid_collapsed;
284        }
285        if !open {
286            return;
287        }
288
289        let base = tree::child_guides(&[], true);
290        if names.is_empty() {
291            tree::node(ui, TreeRow::leaf(&base, true, "(no solids)"), |_| {});
292            return;
293        }
294
295        // One deferred mutation per frame — the scene panel's pattern — so no
296        // `&mut state` is held across the draw.
297        let mut color_action: Option<(String, Option<String>)> = None;
298        let m = names.len();
299        for (i, name) in names.iter().enumerate() {
300            let last = i + 1 == m;
301            let current_override = state.scene.solid(name).and_then(|s| s.color_override);
302            let cached = self.solid_override_edit.get(name).copied();
303            let mut enabled = current_override.is_some();
304            let mut rgb = current_override
305                .map(|c| {
306                    [
307                        (c[0] * 255.0).round() as u8,
308                        (c[1] * 255.0).round() as u8,
309                        (c[2] * 255.0).round() as u8,
310                    ]
311                })
312                .or(cached)
313                // A clear, obvious demo red so enabling an override is visible at a
314                // glance overriding the global face color.
315                .unwrap_or([255, 51, 51]);
316            let mut enable_rect = egui::Rect::NOTHING;
317            let mut toggled = false;
318            let mut picker_changed = false;
319            let resp = tree::node(ui, TreeRow::leaf(&base, last, name), |ui| {
320                // right-to-left: the enable checkbox (rightmost, the scene tree's
321                // right-slot convention), then the color picker to its left when
322                // enabled.
323                let cb = ui.add(egui::Checkbox::new(&mut enabled, ""));
324                enable_rect = cb.rect;
325                toggled = cb.changed();
326                if enabled {
327                    picker_changed = ui.color_edit_button_srgb(&mut rgb).changed();
328                }
329            });
330            self.hits.insert(format!("solid:{name}"), resp.label.rect);
331            self.hits.insert(format!("solid-enable:{name}"), enable_rect);
332            if enabled {
333                self.solid_override_edit.insert(name.clone(), rgb);
334                if toggled || picker_changed {
335                    let hex = format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]);
336                    color_action = Some((name.clone(), Some(hex)));
337                }
338            } else if toggled {
339                color_action = Some((name.clone(), None));
340            }
341        }
342
343        if let Some((name, hex)) = color_action {
344            state.set_color_override(&name, hex.as_deref());
345        }
346    }
347
348    /// The published widget hit-rects (egui points) for the headed verifier.
349    #[cfg(target_arch = "wasm32")]
350    pub fn hits_json(&self) -> String {
351        let map: serde_json::Map<String, Value> = self
352            .hits
353            .iter()
354            .map(|(k, r)| {
355                (
356                    k.clone(),
357                    serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
358                )
359            })
360            .collect();
361        Value::Object(map).to_string()
362    }
363}
364
365/// Mirror a JSON string to `window.<name>` (wasm/verification only).
366#[cfg(target_arch = "wasm32")]
367fn publish(name: &str, json: &str) {
368    if let Some(win) = web_sys::window() {
369        let _ = js_sys::Reflect::set(
370            &win,
371            &wasm_bindgen::JsValue::from_str(name),
372            &wasm_bindgen::JsValue::from_str(json),
373        );
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use std::cell::RefCell;
381    use std::collections::HashMap as Map;
382
383    /// An in-memory settings store so a round-trip test never touches the config
384    /// dir. `save` is `&self` (the trait's contract), so `RefCell` suffices.
385    #[derive(Default)]
386    struct MemStore {
387        map: RefCell<Map<String, String>>,
388    }
389    impl Store for MemStore {
390        fn load(&self, key: &str) -> Option<String> {
391            self.map.borrow().get(key).cloned()
392        }
393        fn save(&self, key: &str, val: &str) {
394            self.map.borrow_mut().insert(key.to_string(), val.to_string());
395        }
396    }
397
398    /// Run ONE headless frame of the settings BODY on a plain Ui (skipping the
399    /// floating Window geometry, which would clip on a small test screen), feeding
400    /// `events` as this frame's input. Layout is deterministic, so the `hits` rects
401    /// are stable frame-to-frame and can be read back to drive a real click.
402    fn run_frame(
403        ctx: &egui::Context,
404        panel: &mut SettingsPanel,
405        state: &mut EngineState,
406        store: &dyn Store,
407        events: Vec<egui::Event>,
408    ) {
409        let raw = egui::RawInput {
410            screen_rect: Some(egui::Rect::from_min_size(
411                egui::pos2(0.0, 0.0),
412                egui::vec2(400.0, 800.0),
413            )),
414            events,
415            ..Default::default()
416        };
417        let _ = ctx.run_ui(raw, |ui| panel.body(ui, state, store));
418    }
419
420    /// Left-click at `pos` split across a press frame and a release frame (egui
421    /// fires `clicked()` on release), re-running the body each frame so the deferred
422    /// apply/persist happens.
423    fn click_at(
424        ctx: &egui::Context,
425        panel: &mut SettingsPanel,
426        state: &mut EngineState,
427        store: &dyn Store,
428        pos: egui::Pos2,
429    ) {
430        run_frame(
431            ctx,
432            panel,
433            state,
434            store,
435            vec![
436                egui::Event::PointerMoved(pos),
437                egui::Event::PointerButton {
438                    pos,
439                    button: egui::PointerButton::Primary,
440                    pressed: true,
441                    modifiers: egui::Modifiers::default(),
442                },
443            ],
444        );
445        run_frame(
446            ctx,
447            panel,
448            state,
449            store,
450            vec![egui::Event::PointerButton {
451                pos,
452                button: egui::PointerButton::Primary,
453                pressed: false,
454                modifiers: egui::Modifiers::default(),
455            }],
456        );
457    }
458
459    /// STRUCTURAL: every schema field renders as a tree LEAF (a `field:<key>` hit
460    /// rect). Because a collapsed group would omit its leaves, this also proves
461    /// every GROUP defaults OPEN — i.e. the whole settings form is drawn as tree
462    /// nodes (the gate's headless "groups render as tree nodes" assertion).
463    #[test]
464    fn settings_tree_renders_every_field_as_a_leaf() {
465        let ctx = egui::Context::default();
466        let mut state = EngineState::new();
467        let mut panel = SettingsPanel::new();
468        let store = MemStore::default();
469
470        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
471
472        for field in settings_form_fields() {
473            let key = format!("field:{}", field.key());
474            assert!(
475                panel.hits.contains_key(&key),
476                "missing tree leaf for settings field {key}; have {:?}",
477                panel.hits.keys().collect::<Vec<_>>()
478            );
479        }
480    }
481
482    /// BEHAVIORAL: clicking a Bool field's checkbox in the tree reaches the SAME
483    /// apply path as before — the engine setting flips AND the whole settings JSON
484    /// is persisted through the store.
485    #[test]
486    fn settings_tree_field_edit_applies_and_persists() {
487        let ctx = egui::Context::default();
488        let mut state = EngineState::new();
489        let mut panel = SettingsPanel::new();
490        let store = MemStore::default();
491
492        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
493        assert!(!state.settings.wireframe, "wireframe starts off");
494        let rect = *panel
495            .hits
496            .get("field:wireframe")
497            .expect("wireframe leaf checkbox rect");
498
499        click_at(&ctx, &mut panel, &mut state, &store, rect.center());
500
501        assert!(
502            state.settings.wireframe,
503            "clicking the checkbox flips the engine setting through apply_settings_json"
504        );
505        let saved: Value = serde_json::from_str(
506            &store.load("settings").expect("edit persisted through the store"),
507        )
508        .unwrap();
509        assert_eq!(
510            saved["wireframe"],
511            Value::Bool(true),
512            "the whole settings JSON is persisted with the edit"
513        );
514    }
515
516    /// RE-SEED GUARD: the per-frame re-seed keeps EXTERNAL setting changes
517    /// authoritative — a value changed outside the panel (a toolbar toggle) must
518    /// survive an UNRELATED edit made through the tree. This pins the "changing
519    /// Render Quality resets my wireframe" fix.
520    #[test]
521    fn settings_tree_edit_preserves_externally_changed_setting() {
522        let ctx = egui::Context::default();
523        let mut state = EngineState::new();
524        let mut panel = SettingsPanel::new();
525        let store = MemStore::default();
526
527        // Lay out once, then externally flip wireframe on (as the toolbar would —
528        // straight onto the engine, bypassing this panel's buffer).
529        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
530        state.settings.wireframe = true;
531        assert!(state.settings.wireframe);
532
533        // Edit a DIFFERENT field through the tree (toggle flatShading).
534        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
535        let before = state.settings.flat_shading;
536        let rect = *panel
537            .hits
538            .get("field:flatShading")
539            .expect("flatShading leaf checkbox rect");
540
541        click_at(&ctx, &mut panel, &mut state, &store, rect.center());
542
543        assert_ne!(
544            state.settings.flat_shading, before,
545            "the tree edit took effect"
546        );
547        assert!(
548            state.settings.wireframe,
549            "the externally-set wireframe survived the unrelated tree edit (re-seed intact)"
550        );
551    }
552}