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

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

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

/// The tabs of the Settings window — one per section. Each draws its own tree;
/// nothing is shared between them but the window.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
    /// The schema-driven render settings.
    Display,
    /// Assembly-wide configuration (today: the BOM's columns).
    Assemblies,
    /// The PLM server: its URL (editable wherever `plm.json` can be written),
    /// and for the PLM this session is on or configured for, sign-in, check,
    /// forget (plan S1 part 3). Present in every session (owner 2026-10-29).
    Plm,
}

/// The display-settings panel's own transient UI state. It holds NO model state:
/// the settings buffer is re-seeded from the live engine each frame (see
/// `SettingsPanel::settings_section`).
pub struct SettingsPanel {
    /// Whether the floating window is shown. Toggled by the toolbar gear button
    /// and by the window's own close (`×`) button; public so the toolbar can bind
    /// it.
    pub open: bool,
    /// Per-frame egui widget screen rects (keyed `tab:<name>` / `field:<key>` /
    /// `group:<name>` / …), published to JS for the headed verifier. Rebuilt every
    /// frame, so it holds only the ACTIVE tab's widgets.
    hits: HashMap<String, egui::Rect>,
    /// Which tab is shown. Defaults to `Display`, the tab the window has always
    /// opened on.
    tab: Tab,
    /// The `Display settings` root is collapsed (false = open — it defaults open).
    display_collapsed: bool,
    /// Setting GROUPS explicitly COLLAPSED, by group name (absent = open — groups
    /// default open, matching the retired per-group CollapsingHeaders).
    closed_groups: HashSet<String>,
    /// The `Assemblies` root is collapsed (false = open — see above).
    assemblies_collapsed: bool,
    /// The BOM-columns textarea's live edit buffer. Held here, not re-seeded
    /// per frame like the settings JSON, because a multi-line editor cannot be
    /// re-seeded mid-edit without fighting the caret. It tracks the engine
    /// while UNFOCUSED and commits on focus-loss (the expressions editor's
    /// rule); `None` = not yet seeded.
    bom_columns_buf: Option<String>,
    /// The PLM tab's state, built when the store first reports a PLM and
    /// rebuilt if its URL changes. `None` in a file-only session.
    plm: Option<crate::plm::connection::PlmConnection>,
    /// The Server URL field's edit buffer, and the store URL it was seeded
    /// from: re-seeded when the store's URL moves, never mid-edit. It commits
    /// on focus loss (Enter included), the BOM columns editor's rule.
    url_buf: Option<(String, Option<String>)>,
    /// What the last Server URL commit did: saved (the sentence says what
    /// happens next) or refused (the reason). Kept here, not on `plm`, which
    /// a new URL rebuilds.
    url_outcome: Option<crate::plm::connection::Outcome>,
    /// Set by the app every frame: why this session cannot switch to the PLM
    /// live (open named or dirty documents), or `None` when it can.
    pub reconnect_blocker: Option<String>,
    /// Connect now was pressed: the config root the app switches to.
    reconnect_request: Option<std::path::PathBuf>,
    /// The live session's sign-in was refused by the server (revoked,
    /// expired): the tab reads not connected, with this reason, until the
    /// person signs in again.
    signed_out: Option<String>,
}

impl SettingsPanel {
    /// Open the window on its PLM tab ("Open in CAD" before signing in): the
    /// tab exists while the store reports a PLM, signed in or not.
    pub fn show_plm_tab(&mut self) {
        self.open = true;
        self.tab = Tab::Plm;
    }

    /// The server ended this session's sign-in: open the PLM tab on its
    /// sign-in, saying `why`, instead of a raw error on every request.
    pub fn ask_plm_sign_in(&mut self, why: String) {
        self.signed_out = Some(why);
        self.show_plm_tab();
    }

    /// A native sign-in in the tab passed: `(username, token)`. The shell
    /// hands it to the live session when that session's token was refused;
    /// the tab reads connected again.
    pub fn take_fresh_plm_token(&mut self) -> Option<(String, String)> {
        let fresh = self.plm.as_mut()?.take_fresh_token()?;
        self.signed_out = None;
        Some(fresh)
    }

    /// Forget signed this machine out on the server since the last call: the
    /// tab reads not connected, and the shell expects the server's refusal.
    pub fn take_plm_signed_out_here(&mut self) -> bool {
        if !self.plm.as_mut().is_some_and(|plm| plm.take_signed_out_here()) {
            return false;
        }
        self.signed_out = Some("you signed out of the PLM on this machine: the next start is the file app, or sign in again".into());
        true
    }

    /// The live session's sign-in was refused (see [`Self::ask_plm_sign_in`]).
    pub fn plm_signed_out(&self) -> bool {
        self.signed_out.is_some()
    }

    /// A fresh panel. The settings buffer is re-seeded from the engine every frame
    /// (not stored), so construction needs no engine handle.
    pub fn new() -> Self {
        Self {
            open: false,
            hits: HashMap::new(),
            tab: Tab::Display,
            display_collapsed: false,
            closed_groups: HashSet::new(),
            assemblies_collapsed: false,
            bom_columns_buf: None,
            plm: None,
            url_buf: None,
            url_outcome: None,
            reconnect_blocker: None,
            reconnect_request: None,
            signed_out: 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([460.0, 480.0])
                // Rest on the right so it floats clear of the left panel; the user
                // can drag it anywhere.
                .default_pos(super::below_chrome(ctx, 720.0, 0.0))
                .show(ctx, |ui| self.body(ui, state, store));
            self.open = open;
        } else {
            // A CLOSED window publishes no rects. The publish used to sit inside
            // the branch above, so the last open frame's map stayed in the
            // registry for the rest of the session: `hit_rects settings/` kept
            // answering with fifty fields of a window that was not on screen, and
            // `click_widget` on one of them aimed a click at empty space. Same
            // rule as the workbench strip and the sketch overlay, which publish an
            // empty map while they are hidden.
            self.hits.clear();
        }

        // Publish this frame's widget rects for the headed verifier (parity
        // with the history + scene panels) — empty while the window is closed.
        if crate::automation::registry::enabled() {
            crate::automation::registry::publish("__brepSettingsHit", "settings window widget rects (field:*, group:*, panel:clip); empty while the window is closed", &self.hits_json());
            crate::automation::registry::publish("__brepPlm", "the Settings PLM tab (present in every session, so tab is always true): {tab, status, url, username, reason, busy, outcome, failed, linking, linkCode, linkUrl, linkToken, linkNote} for the session's PLM — status \"not-configured\" (and those null) when there is none — and the Server URL field {serverUrl, urlSource (\"plm.json\", \"BREP_PLM_URL\", \"--plm-url\" or null), urlEditable, urlField, urlOutcome, urlFailed}", &self.plm_json(store));
        }
    }

    /// 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 window's VISIBLE region, under the same key every scrolling pane
        // publishes. The Display tree is longer than the window, and egui hands
        // out a layout rect for a row that is scrolled past the bottom edge — a
        // click there lands outside the window's clip and does nothing at all,
        // which reads as "the switch is broken" rather than "the switch is off
        // screen". With the region published, `scroll_into_view` wheels the row
        // up first. Taken HERE rather than inside the scroll area so the tab
        // strip above it counts as visible too: a clip that excluded the strip
        // would send a click on `tab:assemblies` scrolling forever looking for
        // a widget that never moves.
        self.hits.insert("panel:clip".into(), ui.clip_rect());

        // 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").
        // The PLM tab is in every session: a file-only one shows its Server
        // URL field, which is how such a session connects.
        self.sync_plm(store.plm_session());
        let tab = self.tab;
        ui.horizontal(|ui| {
            let display = ui.selectable_value(&mut self.tab, Tab::Display, "Display");
            let assemblies = ui.selectable_value(&mut self.tab, Tab::Assemblies, "Assemblies");
            self.hits.insert("tab:display".into(), display.rect);
            self.hits.insert("tab:assemblies".into(), assemblies.rect);
            let plm = ui.selectable_value(&mut self.tab, Tab::Plm, "PLM");
            self.hits.insert("tab:plm".into(), plm.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::Plm => {
                        self.plm_url_section(ui, store);
                        self.plm_section(ui);
                    }
                }
            });
    }

    /// The `__brepPlm` state. Read from the store whether or not the window is
    /// open, so a script can see the session's PLM without opening Settings.
    fn plm_json(&mut self, store: &dyn ModelStore) -> String {
        use crate::plm::connection::{Outcome, Status};
        self.sync_plm(store.plm_session());
        let setting = store.plm_url_setting();
        let (url_outcome, url_failed) = match &self.url_outcome {
            Some(Outcome::Done(text)) => (Some(text.clone()), false),
            Some(Outcome::Failed(text)) => (Some(text.clone()), true),
            None => (None, false),
        };
        let field = serde_json::json!({
            "serverUrl": setting.as_ref().and_then(|s| s.url.clone()),
            "urlSource": setting.as_ref().and_then(|s| s.source).map(crate::plm::config::url_origin),
            "urlEditable": setting.is_some(),
            "urlField": self.url_buf.as_ref().map(|(buf, _)| buf.clone()),
            "urlOutcome": url_outcome, "urlFailed": url_failed,
        });
        let Some(plm) = self.plm.as_mut() else {
            let mut json = serde_json::json!({ "tab": true, "status": "not-configured", "url": null });
            json.as_object_mut().expect("an object").extend(field.as_object().expect("an object").clone());
            return json.to_string();
        };
        plm.poll();
        let (status, url, username, reason) = match &plm.status {
            Status::Connected { url, username } => ("connected", url.clone(), Some(username.clone()), None),
            Status::NotConnected { url, reason } => ("not-connected", url.clone(), None, reason.clone()),
        };
        let (outcome, failed) = match &plm.last {
            Some(Outcome::Done(text)) => (Some(text.clone()), false),
            Some(Outcome::Failed(text)) => (Some(text.clone()), true),
            None => (None, false),
        };
        let mut json = serde_json::json!({
            "tab": true, "status": status, "url": url, "username": username, "reason": reason,
            "busy": plm.busy(), "outcome": outcome, "failed": failed,
            "connectable": plm.connectable(), "reconnectBlocker": self.reconnect_blocker,
            "linking": plm.linking(),
            "linkCode": plm.link.as_ref().map(|l| l.code.clone()),
            "linkUrl": plm.link.as_ref().map(|l| l.url.clone()),
            // The token alone, for a script deciding the link as the browser would.
            "linkToken": plm.link.as_ref().and_then(|l| l.url.split_once("token=").map(|(_, t)| t.to_string())),
            "linkNote": plm.link.as_ref().and_then(|l| l.note.clone()),
        });
        json.as_object_mut().expect("an object").extend(field.as_object().expect("an object").clone());
        json.to_string()
    }

    /// Connect now's request, for the app to act on (once).
    pub fn take_reconnect_request(&mut self) -> Option<std::path::PathBuf> {
        self.reconnect_request.take()
    }

    /// What the app's switch did: shown in the tab as its outcome.
    pub fn reconnected(&mut self, outcome: Result<String, String>) {
        if let Some(plm) = self.plm.as_mut() {
            plm.reconnected(outcome);
        }
    }

    /// Keep the PLM tab's state in step with what the store reports.
    fn sync_plm(&mut self, session: Option<crate::plm::connection::Session>) {
        use crate::plm::connection::PlmConnection;
        let Some(session) = session else {
            self.plm = None;
            return;
        };
        let url = match &session.status {
            crate::plm::connection::Status::Connected { url, .. } | crate::plm::connection::Status::NotConnected { url, .. } => url.clone(),
        };
        let same = self.plm.as_ref().is_some_and(|c| c.url() == url && *c.keep() == session.keep);
        // Refused by the server: not connected, whatever the store's label says.
        let status = match (&self.signed_out, session.status) {
            (Some(why), crate::plm::connection::Status::Connected { url, .. }) => {
                crate::plm::connection::Status::NotConnected { url, reason: Some(why.clone()) }
            }
            (_, status) => status,
        };
        match self.plm.as_mut().filter(|_| same) {
            // Same server, same keep: a live switch moves only the status.
            Some(plm) => plm.set_status(status),
            None => self.plm = Some(PlmConnection::new(status, session.keep)),
        }
    }

    /// The PLM tab's Server URL (owner 2026-10-29): the URL this session uses
    /// or would use and where it came from, and — wherever a `plm.json` can be
    /// written — the field that writes it, committed on focus loss (Enter
    /// included) exactly as `--plm-configure` would write it. Hit keys
    /// `plm:url`, `plm:url-source`, `plm:url-outcome`.
    fn plm_url_section(&mut self, ui: &mut egui::Ui, store: &dyn ModelStore) {
        use crate::plm::config::{url_origin, Source};
        use crate::plm::connection::{Outcome, Status};
        let setting = store.plm_url_setting();
        let editable = setting.is_some();
        // No setting is the browser: its PLM is the server serving the page.
        let shown = match &setting {
            Some(setting) => setting.url.clone(),
            None => self.plm.as_ref().map(|plm| plm.url().to_string()).filter(|url| !url.is_empty()),
        };
        let source = setting.as_ref().and_then(|s| s.source);
        if self.url_buf.as_ref().is_none_or(|(_, from)| *from != shown) {
            self.url_buf = Some((shown.clone().unwrap_or_default(), shown.clone()));
        }

        tree::node(
            ui,
            TreeRow {
                guides: &[],
                is_last: true,
                expandable: false,
                expanded: true,
                root: true,
                glyph: None,
                label: "PLM server",
                selected: false,
                highlighted: false,
                draggable: false,
                tint: None,
            },
            |_| {},
        );
        let mut committed = false;
        let mut rect = egui::Rect::NOTHING;
        let (buf, _) = self.url_buf.as_mut().expect("seeded above");
        tree::node(ui, TreeRow::leaf(&[], true, "Server URL"), |ui| {
            ui.add_enabled_ui(editable, |ui| {
                let width = form::text_input_width(ui);
                let field = form::text_input(ui, buf, width);
                committed = field.lost_focus();
                rect = field.rect;
            });
        });
        self.hits.insert("plm:url".into(), rect);

        if committed && editable {
            let typed = buf.trim().to_string();
            if typed != shown.clone().unwrap_or_default() {
                self.url_outcome = Some(if typed.is_empty() {
                    Outcome::Failed("an empty URL is not saved: Forget this machine's sign-in removes the server from this machine".into())
                } else {
                    match store.save_plm_url(&typed) {
                        Err(why) => Outcome::Failed(format!("not saved: {why}")),
                        Ok(saved) => {
                            let mut said = format!("saved to plm.json: {}", saved.url);
                            if saved.token_dropped {
                                said += "; this machine's sign-in for the previous server is forgotten (a token is never sent to another server)";
                            }
                            match (source, self.plm.as_ref().map(|plm| &plm.status)) {
                                (Some(Source::Env | Source::Flag), _) => {
                                    said += &format!(
                                        "; this launch stays on {} ({} overrides plm.json), and a start without it uses {}",
                                        shown.clone().unwrap_or_default(),
                                        url_origin(source.expect("matched")),
                                        saved.url
                                    );
                                }
                                (_, Some(Status::Connected { url, .. })) if *url != saved.url => {
                                    said += &format!("; this session stays on {url} until the next start");
                                }
                                _ => said += "; sign in below, then Connect now (or the next start) puts this session on it",
                            }
                            // The store's URL moves only for a session on
                            // plm.json; keep the saved text either way.
                            let from = store.plm_url_setting().and_then(|s| s.url);
                            self.url_buf = Some((saved.url.clone(), from));
                            Outcome::Done(said)
                        }
                    }
                });
            }
        }

        let note = match (&setting, source) {
            (Some(_), Some(source @ (Source::Env | Source::Flag))) => format!(
                "From {}, which overrides plm.json for this launch. A URL saved here is what a launch without it uses.",
                url_origin(source)
            ),
            (Some(_), Some(Source::File)) => {
                "From plm.json. Changing the server forgets this machine's sign-in: a token is never sent to a different server.".into()
            }
            (Some(_), None) => "No PLM server is set: this session uses the files on this machine. Enter the server's URL (http:// or https://) and press Enter, then sign in.".into(),
            (None, _) if shown.is_some() => "The web app uses the PLM that serves this page.".into(),
            (None, _) => "This page is not served by a PLM, so there is no server to point it at: open the CAD app from your PLM's web page to use one.".into(),
        };
        let said = ui.add(egui::Label::new(note).wrap());
        self.hits.insert("plm:url-source".into(), said.rect);
        if let Some(outcome) = &self.url_outcome {
            let (text, color) = match outcome {
                Outcome::Done(text) => (text.clone(), ui.visuals().text_color()),
                Outcome::Failed(text) => (text.clone(), PROBLEM_AMBER),
            };
            let said = ui.add(egui::Label::new(egui::RichText::new(text).color(color)).wrap());
            self.hits.insert("plm:url-outcome".into(), said.rect);
        }
        ui.separator();
    }

    /// The `PLM` TAB (plan S1 part 3): where this session's PLM is and who it
    /// is signed in as, or why it is not; a token paste and a password
    /// sign-in; a check; and Forget. Hit keys `plm:*`.
    fn plm_section(&mut self, ui: &mut egui::Ui) {
        use crate::plm::connection::{Keep, Outcome, Status};
        let Some(plm) = self.plm.as_mut() else { return };
        if plm.poll() || plm.busy() {
            ui.ctx().request_repaint();
        }
        if plm.take_reload() {
            reload_page();
        }
        let hits = &mut self.hits;
        let status = match &plm.status {
            Status::Connected { url, username } => format!("Connected to {url} as {username}."),
            Status::NotConnected { url, reason: None } => format!("Not connected to {url}."),
            Status::NotConnected {
                url,
                reason: Some(why),
            } => format!("Not connected to {url}: {why}"),
        };
        let shown = ui.add(egui::Label::new(status).wrap());
        hits.insert("plm:status".into(), shown.rect);
        ui.separator();
        // A plm.json that names no usable server: the field above is the fix,
        // and nothing below has a server to ask.
        if plm.url().is_empty() {
            return;
        }

        let native = matches!(plm.keep(), Keep::Files(_));
        let idle = !plm.busy();
        if native {
            browser_sign_in(ui, plm, hits, idle);
            ui.separator();
            // The fallback, for a machine with no browser (or a person who
            // would rather type): a pasted token, or a password traded for one.
            let here = egui::CollapsingHeader::new("Sign in here instead")
                .id_salt("plm-sign-in-here")
                .default_open(false)
                .show(ui, |ui| {
                    ui.label("Paste an API token (from the PLM's web page):");
                    let token = ui.add(
                        egui::TextEdit::singleline(&mut plm.token_input)
                            .password(true)
                            .hint_text("plm_…"),
                    );
                    hits.insert("plm:token".into(), token.rect);
                    let use_token = ui.add_enabled(
                        idle && !plm.token_input.trim().is_empty(),
                        egui::Button::new("Use token"),
                    );
                    hits.insert("plm:use-token".into(), use_token.rect);
                    if use_token.clicked() {
                        plm.use_token();
                    }
                    ui.separator();
                    ui.label("Or sign in, and this machine keeps a token (not the password):");
                    let username =
                        ui.add(egui::TextEdit::singleline(&mut plm.username).hint_text("username"));
                    hits.insert("plm:username".into(), username.rect);
                    let password = ui.add(
                        egui::TextEdit::singleline(&mut plm.password)
                            .password(true)
                            .hint_text("password"),
                    );
                    hits.insert("plm:password".into(), password.rect);
                    let can_sign_in =
                        idle && !plm.username.trim().is_empty() && !plm.password.is_empty();
                    let sign_in = ui.add_enabled(can_sign_in, egui::Button::new("Sign in"));
                    hits.insert("plm:sign-in".into(), sign_in.rect);
                    if sign_in.clicked() {
                        plm.use_password();
                    }
                });
            hits.insert("plm:sign-in-here".into(), here.header_response.rect);
            ui.separator();
        } else {
            ui.label("This browser uses your PLM session. Manage your account and sign in on the PLM page.");
            let account = ui.hyperlink_to(
                "Open PLM account",
                format!("{}/#/account", plm.url().trim_end_matches('/')),
            );
            hits.insert("plm:account".into(), account.rect);
        }
        // Connect now (native): once the server has taken this machine's
        // sign-in, switch this session to it live instead of at next start
        // (only in the state a fresh start has: no named or dirty document).
        if native && matches!(plm.status, Status::NotConnected { .. }) && plm.connectable() {
            let blocked = self.reconnect_blocker.clone();
            let connect =
                ui.add_enabled(idle && blocked.is_none(), egui::Button::new("Connect now"));
            hits.insert("plm:connect".into(), connect.rect);
            if let Some(why) = &blocked {
                ui.label(egui::RichText::new(why).color(PROBLEM_AMBER));
            }
            if connect.clicked() {
                if let Keep::Files(root) = plm.keep() {
                    self.reconnect_request = Some(root.clone());
                }
            }
        }
        ui.horizontal(|ui| {
            let check = ui.add_enabled(idle, egui::Button::new("Check connection"));
            hits.insert("plm:check".into(), check.rect);
            if check.clicked() {
                plm.check();
            }
            if native {
                let forget =
                    ui.add_enabled(idle, egui::Button::new("Forget this machine's sign-in"));
                hits.insert("plm:forget".into(), forget.rect);
                if forget.clicked() {
                    plm.forget();
                }
            }
        });
        if !idle {
            ui.horizontal(|ui| {
                ui.spinner();
                ui.label("Asking the server…");
            });
        }
        if let Some(outcome) = &plm.last {
            let (text, color) = match outcome {
                Outcome::Done(text) => (text.clone(), ui.visuals().text_color()),
                Outcome::Failed(text) => (text.clone(), PROBLEM_AMBER),
            };
            let said = ui.add(egui::Label::new(egui::RichText::new(text).color(color)).wrap());
            hits.insert("plm:outcome".into(), said.rect);
        }
    }

    /// The `Assemblies` TAB: the BOM's COLUMN CONFIGURATION as one multiline
    /// textarea, one column per line, a leading `*` for shown.
    ///
    /// A hand-written root rather than a schema field: the schema's `FieldKind`s
    /// are all
    /// single-widget and render into a tree row's RIGHT-ALIGNED content slot,
    /// which is exactly the wrong place for a full-width multi-line editor.
    /// Adding a `TextArea` variant would also force both exhaustive
    /// `FieldKind` matches open for one consumer.
    ///
    /// Commits on focus-LOSS — which includes LEAVING THE TAB, since [`body`]
    /// draws the pre-click tab for one more frame so this editor is still on
    /// screen to blur ([`SettingsPanel::body`]) — not per keystroke: this text is
    /// persisted to the store on commit, and on native that is a rewrite of
    /// `~/.config/brep-app/settings.json` — per character is a file write per
    /// character. Parse problems are listed under the editor, naming the line,
    /// and the text is never rewritten by the panel: a typo costs one column,
    /// not the configuration.
    fn assemblies_section(
        &mut self,
        ui: &mut egui::Ui,
        state: &mut EngineState,
        store: &dyn ModelStore,
    ) {
        let open = !self.assemblies_collapsed;
        let root_resp = tree::node(
            ui,
            TreeRow {
                guides: &[],
                is_last: true,
                expandable: true,
                expanded: open,
                root: true,
                glyph: None,
                label: "Assemblies",
                selected: false,
                highlighted: false,
                draggable: false,
                tint: None,
            },
            |_| {},
        );
        self.hits.insert("box:__assemblies".into(), root_resp.box_rect);
        if root_resp.toggled || root_resp.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 before = state.settings_json();
        let mut settings_json: Value = serde_json::from_str(&before).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,
                highlighted: false,
                draggable: false,
                tint: None,
            },
            |_| {},
        );
        if root_resp.toggled || root_resp.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.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.
        //
        // ...and only when the settings actually MOVED. Some field reports a
        // change on every frame the tree is drawn, and the write it caused went
        // unnoticed on a file store (one settings.json rewrite per frame) until
        // the PLM made each one a request: with the window open the store was
        // never idle. Comparing through the engine's own serialisation makes a
        // no-op edit a no-op whichever widget claims otherwise.
        if changed {
            let json = settings_json.to_string();
            let _ = state.apply_settings_json(&json);
            if state.settings_json() != before {
                let _ = store.write(SETTINGS_KEY, &json);
            }
        }

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

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

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


/// The PLM tab's browser sign-in (native): one button, then "Continue in your
/// browser" with the code the page will show, the link to copy when no browser
/// opened, and Cancel. The link page opens through `open_url`: eframe hands it
/// to the default browser, and the headless automation host opens nothing. Hit keys `plm:browser-sign-in`, `plm:link-code`,
/// `plm:link-url`, `plm:copy-link`, `plm:cancel-link`.
fn browser_sign_in(
    ui: &mut egui::Ui,
    plm: &mut crate::plm::connection::PlmConnection,
    hits: &mut HashMap<String, egui::Rect>,
    idle: bool,
) {
    if let Some(url) = plm.take_open_request() {
        ui.ctx().open_url(egui::OpenUrl::new_tab(url));
    }
    if let Some(wait) = plm.link_wait() {
        ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(wait.max(0.05)));
    }
    let Some(link) = &plm.link else {
        if plm.linking() {
            ui.horizontal(|ui| {
                ui.spinner();
                ui.label("Starting the sign-in…");
            });
            return;
        }
        ui.label("Sign in with your PLM account in your web browser. This machine keeps a token, never your password.");
        let start = ui.add_enabled(idle, egui::Button::new("Sign in with your browser"));
        hits.insert("plm:browser-sign-in".into(), start.rect);
        if start.clicked() {
            plm.sign_in_with_browser();
        }
        return;
    };
    ui.strong("Continue in your browser");
    ui.label("Approve the sign-in on the PLM page that opened in your browser. It shows this code:");
    let code = ui.label(egui::RichText::new(&link.code).monospace().size(22.0).strong());
    hits.insert("plm:link-code".into(), code.rect);
    ui.label("No browser page? Copy this link into a browser:");
    let url = ui.add(egui::Label::new(egui::RichText::new(&link.url).small().weak()).wrap().selectable(true));
    hits.insert("plm:link-url".into(), url.rect);
    let (url_text, note) = (link.url.clone(), link.note.clone());
    ui.horizontal(|ui| {
        let copy = ui.button("Copy link");
        hits.insert("plm:copy-link".into(), copy.rect);
        if copy.clicked() {
            ui.ctx().copy_text(url_text);
        }
        let cancel = ui.button("Cancel");
        hits.insert("plm:cancel-link".into(), cancel.rect);
        if cancel.clicked() {
            plm.cancel_link();
        }
    });
    if let Some(note) = note {
        ui.label(egui::RichText::new(note).color(PROBLEM_AMBER));
    }
    if plm.link.is_some() {
        ui.horizontal(|ui| {
            ui.spinner();
            ui.label("Waiting for the approval…");
        });
    }
}

/// Reload the page onto the PLM session a web sign-in (or sign-out) made.
fn reload_page() {
    #[cfg(target_arch = "wasm32")]
    if let Some(window) = web_sys::window() {
        let _ = window.location().reload();
    }
}

/// The hit keys this panel publishes (see `automation::hit_keys`).
pub static HIT_KEYS: &[HitKeyDoc] = &[
    HitKeyDoc { panel: "settings", prefix: "field:", meaning: "a settings field by key", command: None },
    HitKeyDoc { panel: "settings", prefix: "group:", meaning: "a settings group header", command: None },
    HitKeyDoc { panel: "settings", prefix: "tab:", meaning: "a settings tab (tab:display, tab:assemblies, tab:plm — the PLM tab is in every session)", command: None },
    HitKeyDoc { panel: "settings", prefix: "plm:", meaning: "the PLM tab: url (the Server URL field, committed on Enter or focus loss into plm.json), url-source, url-outcome; status, check, outcome; hosted account; desktop token, use-token, username, password, sign-in, forget", command: None },
    HitKeyDoc { panel: "settings", prefix: "box:", meaning: "expand/collapse a section", command: None },
    HitKeyDoc { panel: "settings", prefix: "bom-columns:reset", meaning: "reset the BOM columns", command: None },
    HitKeyDoc { panel: "settings", prefix: "panel:clip", meaning: "the visible region of the window", command: None },
];