BREP_app 0.2.0

The BREP CAD application: an eframe (egui + wgpu) host that draws the brep-render 3D engine into an egui frame — native + wasm from one codebase.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
//! 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.
//!
//! # 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` / `Per-Solid Colors` — 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.
//!   * `Per-Solid Colors` → `[-] 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). Every ROOT defaults
//! OPEN too: the roots that used to default collapsed did so only because all
//! three 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::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 three 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 per-solid metadata color overrides.
    PerSolid,
}

/// 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>` /
    /// `solid:<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 `Per-solid colors` root is collapsed (false = open — it defaults open,
    /// like every other root: it has its own tab, so nothing else is pushed off).
    per_solid_collapsed: bool,
    /// 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>,
    /// 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(),
            tab: Tab::Display,
            display_collapsed: false,
            closed_groups: HashSet::new(),
            per_solid_collapsed: false,
            solid_override_edit: HashMap::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).
            #[cfg(target_arch = "wasm32")]
            publish("__brepSettingsHit", &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");
            let per_solid = ui.selectable_value(&mut self.tab, Tab::PerSolid, "Per-Solid Colors");
            self.hits.insert("tab:display".into(), display.rect);
            self.hits.insert("tab:assemblies".into(), assemblies.rect);
            self.hits.insert("tab:per-solid".into(), per_solid.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),
                    Tab::PerSolid => self.per_solid_color_section(ui, state),
                }
            });
    }

    /// 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, following the
    /// `Per-solid colors` precedent: 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,
            },
            |_| {},
        );
        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,
            },
            |_| {},
        );
        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 None);
            changed = ch;
            rect = r;
        });
        self.hits.insert(format!("field:{}", field.key()), rect);
        changed
    }

    /// Per-solid metadata color overrides — the "settings ↔ metadata" control, now
    /// the `Per-Solid Colors` TAB: 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 OPEN) ------------
        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 ModelStore for MemStore {
        fn read(&self, key: &str) -> Option<String> {
            self.map.borrow().get(key).cloned()
        }
        fn write(&self, key: &str, val: &str) -> Result<(), String> {
            self.map.borrow_mut().insert(key.to_string(), val.to_string());
            Ok(())
        }
    }

    /// 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 ModelStore,
        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 ModelStore,
        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.read(SETTINGS_KEY).expect("edit persisted through the store"),
        )
        .unwrap();
        assert_eq!(
            saved["wireframe"],
            Value::Bool(true),
            "the whole settings JSON is persisted with the edit"
        );
    }

    /// BEHAVIORAL, the model-overlay LABEL SCALE end to end through the panel:
    /// dragging the "Label scale" slider reaches the engine (so the labels resize
    /// LIVE — `viewport::labels` reads `settings.label_scale` every frame) AND the
    /// whole settings JSON is written to the store under `@settings`, which is what
    /// carries the setting across a document load and an app restart.
    ///
    /// This is the panel-side half of the coverage; `viewport::labels`' own tests
    /// pin that the font, the measured edit-box width and the chip padding all
    /// follow the value once it arrives.
    #[test]
    fn label_scale_slider_applies_to_the_engine_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_eq!(
            state.settings.label_scale, 1.0,
            "labels start at their native size"
        );
        let rect = *panel
            .hits
            .get("field:labelScale")
            .expect("the schema-driven panel renders a `Label scale` leaf");

        // The published rect spans the whole `egui::Slider`, whose RIGHT portion is
        // the value box — press on the rail in its left third and drag along it, so
        // the value lands somewhere other than the 1.0 default regardless of which
        // exact pixel the rail starts at.
        let y = rect.center().y;
        let from = egui::pos2(rect.left() + rect.width() * 0.15, y);
        let to = egui::pos2(rect.left() + rect.width() * 0.45, y);
        run_frame(
            &ctx,
            &mut panel,
            &mut state,
            &store,
            vec![
                egui::Event::PointerMoved(from),
                egui::Event::PointerButton {
                    pos: from,
                    button: egui::PointerButton::Primary,
                    pressed: true,
                    modifiers: egui::Modifiers::default(),
                },
            ],
        );
        run_frame(
            &ctx,
            &mut panel,
            &mut state,
            &store,
            vec![egui::Event::PointerMoved(to)],
        );
        run_frame(
            &ctx,
            &mut panel,
            &mut state,
            &store,
            vec![egui::Event::PointerButton {
                pos: to,
                button: egui::PointerButton::Primary,
                pressed: false,
                modifiers: egui::Modifiers::default(),
            }],
        );

        let applied = state.settings.label_scale;
        assert_ne!(
            applied, 1.0,
            "dragging the slider must reach the engine through apply_settings_json"
        );
        // The slider domain is the engine's clamp, so a dragged value is always usable.
        assert!(
            (0.25..=3.0).contains(&applied),
            "label scale stays inside the clamped domain, got {applied}"
        );

        // ...and the edit is persisted, so it survives a reload / restart.
        let saved: Value = serde_json::from_str(
            &store.read(SETTINGS_KEY).expect("the edit persisted through the store"),
        )
        .unwrap();
        let stored = saved["labelScale"].as_f64().expect("labelScale is persisted") as f32;
        assert_eq!(stored, applied, "the persisted value matches the live one");

        // A fresh engine restored from that JSON comes back with the same scale —
        // the actual restart path (`app.rs` reads `@settings` and applies it).
        let mut restored = EngineState::new();
        restored
            .apply_settings_json(&saved.to_string())
            .expect("the persisted settings re-apply");
        assert_eq!(
            restored.settings.label_scale, applied,
            "the label scale survives a reload of the persisted settings"
        );
    }

    /// 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)"
        );
    }

    /// The Assemblies TAB, reached by CLICKING the published tab-strip rect (so
    /// the strip itself is pinned, not just the field it sets): the window opens
    /// on `Display`, the tab switch swaps which section is drawn, and the
    /// BOM-columns textarea is seeded with the SHIPPED default (never a blank
    /// box). It commits on focus-loss — not per keystroke, since each commit is a
    /// settings file write — and persists the text VERBATIM so a malformed line
    /// survives.
    #[test]
    fn assemblies_tab_edits_bom_columns_and_persists_verbatim() {
        let ctx = egui::Context::default();
        let mut panel = SettingsPanel::new();
        let mut state = EngineState::new();
        let store = MemStore::default();

        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
        assert!(
            panel.hits.contains_key("field:wireframe"),
            "the window opens on the Display tab"
        );
        assert!(
            !panel.hits.contains_key("field:bomColumns"),
            "...which does not draw the BOM editor — it lives on its own tab now"
        );

        let tab = *panel.hits.get("tab:assemblies").expect("tab strip rect");
        click_at(&ctx, &mut panel, &mut state, &store, tab.center());
        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
        assert!(
            !panel.hits.contains_key("field:wireframe"),
            "one tab at a time — the display tree is gone"
        );
        assert!(
            panel.hits.contains_key("box:__assemblies"),
            "and the Assemblies root draws, OPEN (a tab holding one [+] row is \
             not worth the click)"
        );
        let editor = *panel
            .hits
            .get("field:bomColumns")
            .expect("the textarea draws");
        assert_eq!(
            panel.bom_columns_buf.as_deref(),
            Some(bom_columns::default_text().as_str()),
            "seeded with the shipped default, not a blank box"
        );

        // Type into it: focus, replace the text, and confirm NOTHING is
        // persisted until the focus leaves.
        click_at(&ctx, &mut panel, &mut state, &store, editor.center());
        panel.bom_columns_buf = Some("*part.Part_Number\nnonsense\n".into());
        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
        assert_eq!(
            state.settings.bom_columns, "",
            "typing alone writes neither the engine nor the store"
        );
        // The parse problem is surfaced while typing, though — it names the line.
        let parsed = bom_columns::parse(panel.bom_columns_buf.as_deref().unwrap());
        assert_eq!(parsed.problems.len(), 1);
        assert!(parsed.problems[0].starts_with("line 2:"));

        // Click away → focus lost → commit, VERBATIM (the bad line included).
        let root = *panel.hits.get("box:__assemblies").expect("root box rect");
        click_at(&ctx, &mut panel, &mut state, &store, root.center());
        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
        assert_eq!(
            state.settings.bom_columns, "*part.Part_Number\nnonsense\n",
            "committed exactly as typed"
        );
        let persisted: Value =
            serde_json::from_str(&store.read(SETTINGS_KEY).expect("settings persisted")).unwrap();
        assert_eq!(persisted["bomColumns"], "*part.Part_Number\nnonsense\n");
    }

    /// Reset puts the setting back to EMPTY — which MEANS "the shipped
    /// default" — rather than pasting today's default in as literal text, so a
    /// later change to that default still reaches the user.
    #[test]
    fn resetting_bom_columns_stores_empty_not_a_copy_of_the_default() {
        let ctx = egui::Context::default();
        let mut panel = SettingsPanel::new();
        let mut state = EngineState::new();
        let store = MemStore::default();
        state
            .apply_settings_json(r##"{"bomColumns": "*part.Mass\n"}"##)
            .unwrap();

        panel.tab = Tab::Assemblies;
        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
        assert_eq!(panel.bom_columns_buf.as_deref(), Some("*part.Mass\n"));
        let reset = *panel.hits.get("bom-columns:reset").expect("reset button");
        click_at(&ctx, &mut panel, &mut state, &store, reset.center());

        assert_eq!(state.settings.bom_columns, "", "back to the shipped default");
        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
        assert_eq!(
            panel.bom_columns_buf.as_deref(),
            Some(bom_columns::default_text().as_str()),
            "and the editor re-seeds from it"
        );
    }

    /// Switching tabs MID-EDIT still commits the BOM text. The editor commits on
    /// `lost_focus()`, which can only fire on a frame the editor is DRAWN — so
    /// `body` draws the tab that was active BEFORE the strip. Drop that one-frame
    /// defer and the section disappears on the very frame the click steals focus:
    /// egui drops the focus silently, no commit runs, and the next visit
    /// re-seeds the buffer from the engine — the user's typing is simply gone.
    #[test]
    fn switching_tabs_mid_edit_commits_the_bom_text() {
        let ctx = egui::Context::default();
        let mut panel = SettingsPanel::new();
        let mut state = EngineState::new();
        let store = MemStore::default();
        panel.tab = Tab::Assemblies;

        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
        let editor = *panel.hits.get("field:bomColumns").expect("the editor draws");
        click_at(&ctx, &mut panel, &mut state, &store, editor.center());
        panel.bom_columns_buf = Some("*part.Mass\n".into());
        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
        assert_eq!(
            state.settings.bom_columns, "",
            "typing alone commits nothing (a commit is a settings file write)"
        );

        let display_tab = *panel.hits.get("tab:display").expect("tab strip rect");
        click_at(&ctx, &mut panel, &mut state, &store, display_tab.center());
        assert_eq!(
            state.settings.bom_columns, "*part.Mass\n",
            "leaving the tab blurred the editor, which committed the edit"
        );
        assert_eq!(
            store.read(SETTINGS_KEY).map(|json| {
                serde_json::from_str::<Value>(&json).unwrap()["bomColumns"].clone()
            }),
            Some(Value::String("*part.Mass\n".into())),
            "...and persisted it"
        );

        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
        assert!(
            panel.hits.contains_key("field:wireframe"),
            "and the Display tab is what is drawn now"
        );
    }

    /// The third tab: `Per-Solid Colors` draws its root, OPEN, with nothing else
    /// beside it. (`EngineState::new()` has no solids, so the tree is its empty
    /// leaf — the root is what this pins.)
    #[test]
    fn per_solid_tab_draws_its_tree_alone_and_open() {
        let ctx = egui::Context::default();
        let mut panel = SettingsPanel::new();
        let mut state = EngineState::new();
        let store = MemStore::default();

        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
        let tab = *panel.hits.get("tab:per-solid").expect("tab strip rect");
        click_at(&ctx, &mut panel, &mut state, &store, tab.center());
        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);

        assert!(
            panel.hits.contains_key("box:__per_solid"),
            "the per-solid root draws, open; have {:?}",
            panel.hits.keys().collect::<Vec<_>>()
        );
        assert!(
            !panel.hits.contains_key("field:wireframe")
                && !panel.hits.contains_key("field:bomColumns"),
            "and neither other section is drawn beside it"
        );
    }
}