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}