Skip to main content

brep_app/panels/
scene.rs

1//! Scene panel — the engine-native **Scene tree** ("Scene Manager"), the second
2//! sidebar tree in the design reference. Built on the SAME reusable [`tree`] node
3//! helper (connector lines + `[+]`/`[-]` collapse boxes) the history panel uses,
4//! so the two trees read as one system.
5//!
6//! # What it draws
7//!
8//! Above the tree sits a **type-visibility button row** (`Faces` / `Edges` /
9//! `Vertices`, plus `Sketches` / `Planes` when the scene has them): each button
10//! hides/shows EVERY object of that type SCENE-WIDE in one click — the missing
11//! bulk complement to the per-solid group tristate below. The toggle follows the
12//! same rule as the group checkbox + selection-filter toggle-all: if EVERY object
13//! of that type is currently visible → hide them all; otherwise (some or all
14//! hidden) → show them all. There is deliberately no `Solids` button — the Scene
15//! ROOT checkbox already toggles every solid.
16//!
17//! A file-tree of the engine's display scene:
18//!   * `[-] Scene <☑>` — the root; its checkbox toggles ALL solids' visibility.
19//!   * per solid `[-] <Name> <☑>` — the checkbox toggles that solid's visibility
20//!     through the engine ([`EngineState::set_visible`]); expands to
21//!   * `[+] Faces <☑>`, `[+] Edges <☑>`, `[+] Vertices <☑>` — each expands to the
22//!     individual entities BY KERNEL NAME (vertices by index/position).
23//!
24//! # Selection sync (both ways)
25//!
26//! Clicking an entity row drives the engine's name-based SELECTION
27//! ([`EngineState::select_by_name`] / [`select_vertex_by_position`]) so it
28//! highlights (emphasis) in the viewport; and the engine's CURRENT selection
29//! (`state.emphasis`) bolds the matching tree row — the same emphasis the
30//! viewport reads, so a viewport pick lights up the tree and vice-versa.
31//!
32//! # This panel OWNS NO model state
33//!
34//! The scene + selection live in the engine ([`EngineState`], the single source
35//! of truth). The panel holds only transient UI state: which nodes are expanded
36//! and the per-frame `hits` map (widget screen rects) the headed verifier reads.
37//! Each frame it snapshots the scene into owned rows FIRST, draws from that, and
38//! applies at most one deferred engine mutation after the draw loop (so no borrow
39//! of `state` is held across a `&mut` call — the history panel's pattern).
40//!
41//! # Two deliberate reshapes (functional-over-1:1, per the design doc)
42//!
43//! * The visibility checkbox is drawn in the row's RIGHT slot: the shared tree
44//!   widget reserves the left columns for the collapse box + connector + glyph,
45//!   and (per the constraints) it is reused verbatim, not modified. Functionally
46//!   identical to the reference's left checkbox.
47//! * **Per-entity visibility (live):** the engine now hides individual faces /
48//!   edges / vertices and whole groups ([`EngineState::set_entity_visible`] /
49//!   [`EngineState::set_group_visible`]). So each Faces/Edges/Vertices group
50//!   checkbox is a live TRISTATE (all / some / none of that kind shown) and each
51//!   entity leaf carries its own live checkbox — the render pass skips a hidden
52//!   entity's triangles / segments / point. The whole-solid + whole-scene
53//!   checkboxes still compose: a hidden solid draws nothing; re-showing it keeps
54//!   any per-entity hides intact.
55
56use crate::panels::toolbar_button;
57use crate::panels::tree::{self, TreeRow};
58use brep_render::engine_state::EngineState;
59use brep_render::visibility::{EntityKind as VisKind, GroupState};
60use eframe::egui;
61use std::collections::{HashMap, HashSet};
62
63/// One entity leaf's identity — how a click maps to an engine selection call.
64#[derive(Clone)]
65enum EntityKind {
66    /// Face, selected by kernel name (empty = unnamed → not selectable).
67    Face(String),
68    /// Edge, selected by kernel name (empty = unnamed → not selectable).
69    Edge(String),
70    /// Vertex, selected by owning-solid + world position (no kernel name).
71    Vertex([f64; 3]),
72}
73
74/// One row under a Faces/Edges/Vertices group — a display label, its selection
75/// identity, whether it is currently in the engine selection, and whether it is
76/// currently VISIBLE in the engine (its live per-entity checkbox state).
77#[derive(Clone)]
78struct Entity {
79    label: String,
80    kind: EntityKind,
81    selected: bool,
82    visible: bool,
83}
84
85/// One solid's owned snapshot for the frame (decoupled from `state.scene` so the
86/// draw loop can issue deferred `&mut state` mutations afterwards).
87struct SolidRow {
88    name: String,
89    visible: bool,
90    selected: bool,
91    faces: Vec<Entity>,
92    edges: Vec<Entity>,
93    vertices: Vec<Entity>,
94}
95
96/// A deferred engine mutation, collected during the draw and applied once after
97/// the loop (one per frame — the history panel's pattern).
98enum Action {
99    SetVisible(String, bool),
100    SetAllVisible(bool),
101    /// Hide/show one entity: `(solid, kind, index-in-kind-list, visible)`.
102    SetEntityVisible(String, VisKind, usize, bool),
103    /// Hide/show a whole group: `(solid, kind, visible)`.
104    SetGroupVisible(String, VisKind, bool),
105    /// Hide/show one group KIND across EVERY solid in the scene: `(kind, visible)`.
106    SetAllGroupVisible(VisKind, bool),
107    /// Hide/show EVERY committed sketch's overlay: `(visible)`.
108    SetAllSketchVisible(bool),
109    /// Hide/show EVERY construction datum/plane: `(visible)`.
110    SetAllDatumVisible(bool),
111    Select(&'static str, String),
112    SelectVertex(String, [f64; 3]),
113    /// Show/hide a committed sketch's persistent overlay: `(feature-id, visible)`.
114    SetSketchVisible(String, bool),
115    /// Show/hide a construction datum/plane's plane: `(frame-name, visible)`.
116    SetDatumVisible(String, bool),
117    /// Select a construction datum/plane by frame NAME (a row click).
118    SelectDatum(String),
119}
120
121/// The Scene tree panel's transient UI state (the scene + selection live in the
122/// engine).
123#[derive(Default)]
124pub struct ScenePanel {
125    /// Per-frame egui widget screen rects, published to JS for the headed
126    /// verifier. Rebuilt every frame.
127    hits: HashMap<String, egui::Rect>,
128    /// The Scene ROOT is collapsed (absent/false = open — it defaults open).
129    root_collapsed: bool,
130    /// Solids explicitly COLLAPSED, by name (absent = open — solids default open,
131    /// matching the reference showing a solid's Faces/Edges/Vertices).
132    collapsed_solids: HashSet<String>,
133    /// Faces/Edges/Vertices group nodes explicitly EXPANDED, keyed
134    /// `"<solid>/<group>"` (absent = collapsed — groups default collapsed `[+]`).
135    expanded_groups: HashSet<String>,
136    /// The `Planes & Datums` group node is collapsed (absent/false = open — it
137    /// defaults open so construction datums are visible in the tree).
138    datums_collapsed: bool,
139}
140
141impl ScenePanel {
142    pub fn new() -> Self {
143        Self::default()
144    }
145
146    /// Draw the Scene tree. Snapshots the scene + current selection into owned
147    /// rows, draws them via the shared [`tree`] node helper, then applies at most
148    /// one deferred engine mutation.
149    pub fn show(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
150        self.hits.clear();
151
152        // --- snapshot scene + selection (owned) so we can mutate after drawing --
153        let sel_solids = state.emphasis.selected_solids.clone();
154        let sel_faces = state.emphasis.selected_faces.clone();
155        let sel_edges = state.emphasis.selected_edges.clone();
156        let sel_vertices = state.emphasis.selected_vertices.clone();
157        let solids = snapshot(state, &sel_solids, &sel_faces, &sel_edges, &sel_vertices);
158        // Committed sketches (id, visible) — listed under the solids in the tree.
159        let sketches = state.committed_sketches();
160        // Construction datums/planes (name, visible) + which are selected — listed as
161        // the tree's LAST group.
162        let sel_datums = state.emphasis.selected_datums.clone();
163        let datums = state.construction_datums();
164
165        // Tight, tree-like row spacing so connector verticals read continuously.
166        ui.spacing_mut().item_spacing.y = 2.0;
167
168        let mut action: Option<Action> = None;
169
170        // --- TYPE-VISIBILITY button row (scene-wide, ABOVE the tree) -----------
171        // One button per display TYPE; each hides/shows EVERY object of that type
172        // across the whole scene. Same toggle rule as the per-solid group tristate
173        // + the selection-filter toggle-all: all-visible → hide all; otherwise
174        // (some or all hidden) → show all. Buttons show a "pressed" (selected) look
175        // when all of that type are currently visible. There is no `Solids` button
176        // — the Scene root checkbox already toggles every solid.
177        ui.horizontal(|ui| {
178            let has_solids = !solids.is_empty();
179
180            // Faces / Edges / Vertices — scene-wide across ALL solids. All-visible
181            // means every solid reports GroupState::All for that kind.
182            let groups: [(VisKind, &str, bool); 3] = [
183                (
184                    VisKind::Face,
185                    "Faces",
186                    has_solids
187                        && solids
188                            .iter()
189                            .all(|s| matches!(group_state(&s.faces), GroupState::All)),
190                ),
191                (
192                    VisKind::Edge,
193                    "Edges",
194                    has_solids
195                        && solids
196                            .iter()
197                            .all(|s| matches!(group_state(&s.edges), GroupState::All)),
198                ),
199                (
200                    VisKind::Vertex,
201                    "Vertices",
202                    has_solids
203                        && solids
204                            .iter()
205                            .all(|s| matches!(group_state(&s.vertices), GroupState::All)),
206                ),
207            ];
208            for (kind, label, all_visible) in groups {
209                let tip = if all_visible {
210                    format!("Hide all {}", label.to_lowercase())
211                } else {
212                    format!("Show all {}", label.to_lowercase())
213                };
214                // Disabled (greyed + non-interactive) with no solids, matching the
215                // Scene root checkbox's `add_enabled(!solids.is_empty(), …)` spirit.
216                let resp = ui
217                    .add_enabled_ui(has_solids, |ui| {
218                        toolbar_button::toggle(ui, all_visible, label, &tip)
219                    })
220                    .inner;
221                self.hits.insert(format!("typevis:{label}"), resp.rect);
222                if resp.clicked() {
223                    action = Some(Action::SetAllGroupVisible(kind, !all_visible));
224                }
225            }
226
227            // Sketches — shown only when the scene has committed sketches (mirrors
228            // `render_sketches` being conditional). All-visible = every sketch shown.
229            if !sketches.is_empty() {
230                let all_visible = sketches.iter().all(|(_, v)| *v);
231                let tip = if all_visible {
232                    "Hide all sketches"
233                } else {
234                    "Show all sketches"
235                };
236                let resp = toolbar_button::toggle(ui, all_visible, "Sketches", tip);
237                self.hits.insert("typevis:Sketches".into(), resp.rect);
238                if resp.clicked() {
239                    action = Some(Action::SetAllSketchVisible(!all_visible));
240                }
241            }
242
243            // Planes & Datums — shown only when the scene has construction datums
244            // (mirrors `render_datums` being conditional). Hit key is the literal
245            // `typevis:Datums` the verifier drives; the button LABEL reads "Planes".
246            if !datums.is_empty() {
247                let all_visible = datums.iter().all(|(_, v)| *v);
248                let tip = if all_visible {
249                    "Hide all planes & datums"
250                } else {
251                    "Show all planes & datums"
252                };
253                let resp = toolbar_button::toggle(ui, all_visible, "Planes", tip);
254                self.hits.insert("typevis:Datums".into(), resp.rect);
255                if resp.clicked() {
256                    action = Some(Action::SetAllDatumVisible(!all_visible));
257                }
258            }
259        });
260
261        // --- ROOT: `[-] Scene  ☑` (visibility toggles every solid) ------------
262        let root_open = !self.root_collapsed;
263        let all_visible = !solids.is_empty() && solids.iter().all(|s| s.visible);
264        let mut root_vis = all_visible;
265        let mut root_vis_rect = egui::Rect::NOTHING;
266        let mut root_vis_clicked = false;
267        let root_resp = tree::node(
268            ui,
269            TreeRow {
270                guides: &[],
271                is_last: true,
272                expandable: true,
273                expanded: root_open,
274                root: true,
275                glyph: None,
276                label: "Scene",
277                selected: false,
278                draggable: false,
279            },
280            |ui| {
281                let cb = ui.add_enabled(!solids.is_empty(), egui::Checkbox::new(&mut root_vis, ""));
282                root_vis_rect = cb.rect;
283                root_vis_clicked = cb.clicked();
284            },
285        );
286        self.hits.insert("box:__scene".into(), root_resp.box_rect);
287        self.hits.insert("vis:__scene".into(), root_vis_rect);
288        if root_vis_clicked {
289            action = Some(Action::SetAllVisible(root_vis));
290        }
291        if root_resp.toggled || root_resp.label.clicked() {
292            self.root_collapsed = !self.root_collapsed;
293        }
294
295        if solids.is_empty() && sketches.is_empty() && datums.is_empty() {
296            let g = tree::child_guides(&[], true);
297            tree::node(ui, TreeRow::leaf(&g, true, "(scene is empty)"), |_| {});
298        }
299
300        if root_open {
301            // Top-level children under the root, in order: solids, then each committed
302            // sketch as its OWN top-level row (no group wrapper — a committed sketch is
303            // a scene solid, listed like the solids), then the `Planes & Datums` group
304            // (the last child), so nothing "below" is marked last while a later child
305            // still follows.
306            let has_sketches = !sketches.is_empty();
307            let has_datums = !datums.is_empty();
308            let n = solids.len();
309            for (si, solid) in solids.iter().enumerate() {
310                let is_last = !has_sketches && !has_datums && si + 1 == n;
311                self.render_solid(ui, solid, is_last, &mut action);
312            }
313            let m = sketches.len();
314            for (i, (id, visible)) in sketches.iter().enumerate() {
315                let is_last = !has_datums && i + 1 == m;
316                let selected = sel_solids.contains(id);
317                self.render_sketch_row(ui, id, *visible, selected, is_last, &mut action);
318            }
319            if has_datums {
320                self.render_datums(ui, &datums, &sel_datums, &mut action);
321            }
322        }
323
324        // --- apply the one deferred engine mutation ---------------------------
325        match action {
326            Some(Action::SetVisible(name, v)) => {
327                state.set_visible(&name, v);
328            }
329            Some(Action::SetAllVisible(v)) => {
330                for s in &solids {
331                    state.set_visible(&s.name, v);
332                }
333            }
334            Some(Action::SetEntityVisible(name, kind, index, v)) => {
335                state.set_entity_visible(&name, kind, index, v);
336            }
337            Some(Action::SetGroupVisible(name, kind, v)) => {
338                state.set_group_visible(&name, kind, v);
339            }
340            Some(Action::SetAllGroupVisible(kind, v)) => {
341                for s in &solids {
342                    state.set_group_visible(&s.name, kind, v);
343                }
344            }
345            Some(Action::SetAllSketchVisible(v)) => {
346                for (id, _) in &sketches {
347                    state.set_sketch_visible(id, v);
348                }
349            }
350            Some(Action::SetAllDatumVisible(v)) => {
351                for (name, _) in &datums {
352                    state.set_datum_visible(name, v);
353                }
354            }
355            Some(Action::Select(kind, name)) => {
356                state.select_by_name(kind, &name);
357            }
358            Some(Action::SelectVertex(solid, pos)) => {
359                state.select_vertex_by_position(&solid, pos);
360            }
361            Some(Action::SetSketchVisible(id, v)) => {
362                state.set_sketch_visible(&id, v);
363            }
364            Some(Action::SetDatumVisible(name, v)) => {
365                state.set_datum_visible(&name, v);
366            }
367            Some(Action::SelectDatum(name)) => {
368                state.select_datum(&name);
369            }
370            None => {}
371        }
372
373        // --- verifier hooks (wasm only): scene listing + widget hit-rects ------
374        // Published from the panel (not the shared shell) so the headed verifier
375        // can assert the tree contents / visibility and drive real clicks, without
376        // touching `app.rs`'s shared publish block.
377        #[cfg(target_arch = "wasm32")]
378        {
379            publish("__brepScene", &state.scene_entities_json());
380            publish("__brepSketches", &state.sketch_entities_json());
381            publish("__brepDatums", &state.datum_entities_json());
382            publish("__brepSceneVis", &state.scene_visibility_json());
383            publish("__brepSceneHit", &self.hits_json());
384        }
385    }
386
387    /// One committed sketch as a TOP-LEVEL leaf row (no group wrapper): a
388    /// visibility checkbox wired to
389    /// [`EngineState::set_sketch_visible`](brep_render::engine_state::EngineState::set_sketch_visible)
390    /// and a label that SELECTS the sketch's sheet solid on click (a committed
391    /// sketch is a scene solid, dim-cyan / `is_sketch`-styled in the viewport, kept
392    /// OUT of the plain-solid rows by the [`snapshot`] filter so it lists exactly
393    /// once). Rendered under the solids, before the `Planes & Datums` group;
394    /// `is_last` is set only when it is the final top-level child.
395    fn render_sketch_row(
396        &mut self,
397        ui: &mut egui::Ui,
398        id: &str,
399        visible: bool,
400        selected: bool,
401        is_last: bool,
402        action: &mut Option<Action>,
403    ) {
404        let mut vis = visible;
405        let mut vis_rect = egui::Rect::NOTHING;
406        let mut vis_clicked = false;
407        let resp = tree::node(
408            ui,
409            TreeRow::leaf(&[], is_last, id).selected(selected),
410            |ui| {
411                let cb = ui.add(egui::Checkbox::new(&mut vis, ""));
412                vis_rect = cb.rect;
413                vis_clicked = cb.clicked();
414            },
415        );
416        self.hits.insert(format!("vis:sketch/{id}"), vis_rect);
417        self.hits.insert(format!("sel:sketch/{id}"), resp.label.rect);
418        if vis_clicked {
419            *action = Some(Action::SetSketchVisible(id.to_string(), vis));
420        } else if resp.label.clicked() {
421            // A committed sketch is a scene solid — select it like any solid.
422            *action = Some(Action::Select("solid", id.to_string()));
423        }
424    }
425
426    /// The `Planes & Datums` group node + (when open) one row per construction
427    /// datum/plane frame, each with a visibility checkbox wired to
428    /// [`EngineState::set_datum_visible`](brep_render::engine_state::EngineState::set_datum_visible)
429    /// and a label that selects the datum
430    /// ([`EngineState::select_datum`](brep_render::engine_state::EngineState::select_datum))
431    /// on click. Rendered as the Scene root's LAST child (only when there is at
432    /// least one construction datum).
433    fn render_datums(
434        &mut self,
435        ui: &mut egui::Ui,
436        datums: &[(String, bool)],
437        sel_datums: &HashSet<String>,
438        action: &mut Option<Action>,
439    ) {
440        let open = !self.datums_collapsed;
441        let resp = tree::node(
442            ui,
443            TreeRow::branch(&[], true, open, "Planes & Datums"),
444            |ui| {
445                ui.add_space(6.0);
446                ui.label(egui::RichText::new(format!("{}", datums.len())).weak());
447            },
448        );
449        self.hits.insert("box:__datums".into(), resp.box_rect);
450        if resp.toggled || resp.label.clicked() {
451            self.datums_collapsed = !self.datums_collapsed;
452        }
453        if !open {
454            return;
455        }
456
457        let base = tree::child_guides(&[], true);
458        let m = datums.len();
459        for (i, (name, visible)) in datums.iter().enumerate() {
460            let last = i + 1 == m;
461            let selected = sel_datums.contains(name);
462            let mut vis = *visible;
463            let mut vis_rect = egui::Rect::NOTHING;
464            let mut vis_clicked = false;
465            let resp = tree::node(
466                ui,
467                TreeRow::leaf(&base, last, name).selected(selected),
468                |ui| {
469                    let cb = ui.add(egui::Checkbox::new(&mut vis, ""));
470                    vis_rect = cb.rect;
471                    vis_clicked = cb.clicked();
472                },
473            );
474            self.hits.insert(format!("vis:datum/{name}"), vis_rect);
475            self.hits.insert(format!("sel:datum/{name}"), resp.label.rect);
476            if vis_clicked {
477                *action = Some(Action::SetDatumVisible(name.clone(), vis));
478            } else if resp.label.clicked() {
479                *action = Some(Action::SelectDatum(name.clone()));
480            }
481        }
482    }
483
484    /// One solid node + (when open) its Faces / Edges / Vertices groups.
485    fn render_solid(
486        &mut self,
487        ui: &mut egui::Ui,
488        solid: &SolidRow,
489        is_last: bool,
490        action: &mut Option<Action>,
491    ) {
492        let name = &solid.name;
493        let open = !self.collapsed_solids.contains(name);
494
495        let mut vis = solid.visible;
496        let mut vis_rect = egui::Rect::NOTHING;
497        let mut vis_clicked = false;
498        let resp = tree::node(
499            ui,
500            TreeRow::branch(&[], is_last, open, name).selected(solid.selected),
501            |ui| {
502                let cb = ui.add(egui::Checkbox::new(&mut vis, ""));
503                vis_rect = cb.rect;
504                vis_clicked = cb.clicked();
505            },
506        );
507        self.hits.insert(format!("box:{name}"), resp.box_rect);
508        self.hits.insert(format!("vis:{name}"), vis_rect);
509        self.hits.insert(format!("sel:{name}"), resp.label.rect);
510
511        if vis_clicked {
512            *action = Some(Action::SetVisible(name.clone(), vis));
513        }
514        if resp.toggled {
515            if open {
516                self.collapsed_solids.insert(name.clone());
517            } else {
518                self.collapsed_solids.remove(name);
519            }
520        }
521        if resp.label.clicked() {
522            *action = Some(Action::Select("solid", name.clone()));
523        }
524
525        if open {
526            let base = tree::child_guides(&[], is_last);
527            self.render_group(ui, name, &base, false, "Faces", VisKind::Face, &solid.faces, action);
528            self.render_group(ui, name, &base, false, "Edges", VisKind::Edge, &solid.edges, action);
529            self.render_group(ui, name, &base, true, "Vertices", VisKind::Vertex, &solid.vertices, action);
530        }
531    }
532
533    /// One Faces/Edges/Vertices group node + (when open) its entity leaves. The
534    /// group checkbox is a live TRISTATE that hides/shows every entity of `kind`;
535    /// each leaf carries its own live checkbox that hides just that entity.
536    #[allow(clippy::too_many_arguments)]
537    fn render_group(
538        &mut self,
539        ui: &mut egui::Ui,
540        solid_name: &str,
541        base: &[bool],
542        is_last: bool,
543        group: &str,
544        kind: VisKind,
545        entities: &[Entity],
546        action: &mut Option<Action>,
547    ) {
548        let key = format!("{solid_name}/{group}");
549        let open = self.expanded_groups.contains(&key);
550
551        // Tristate over the group's entities (empty group reads All → checked).
552        let state = group_state(entities);
553        let mut checked = matches!(state, GroupState::All);
554        let indeterminate = matches!(state, GroupState::Partial);
555        let mut vis_rect = egui::Rect::NOTHING;
556        let mut vis_clicked = false;
557        let resp = tree::node(
558            ui,
559            TreeRow::branch(base, is_last, open, group),
560            |ui| {
561                // right-to-left: the tristate group checkbox (rightmost), then count.
562                let cb = ui.add(
563                    egui::Checkbox::new(&mut checked, "").indeterminate(indeterminate),
564                );
565                vis_rect = cb.rect;
566                vis_clicked = cb.clicked();
567                ui.add_space(6.0);
568                ui.label(egui::RichText::new(format!("{}", entities.len())).weak());
569            },
570        );
571        self.hits.insert(format!("box:{key}"), resp.box_rect);
572        self.hits.insert(format!("vis:{key}"), vis_rect);
573        if vis_clicked {
574            // Standard tristate: All → hide all; None/Partial → show all.
575            let want_visible = !matches!(state, GroupState::All);
576            *action = Some(Action::SetGroupVisible(solid_name.to_string(), kind, want_visible));
577        }
578        if resp.toggled || resp.label.clicked() {
579            if open {
580                self.expanded_groups.remove(&key);
581            } else {
582                self.expanded_groups.insert(key.clone());
583            }
584        }
585
586        if !open {
587            return;
588        }
589        let gg = tree::child_guides(base, is_last);
590        if entities.is_empty() {
591            tree::node(ui, TreeRow::leaf(&gg, true, "(none)"), |_| {});
592            return;
593        }
594        let m = entities.len();
595        for (ei, e) in entities.iter().enumerate() {
596            let last = ei + 1 == m;
597            let mut vis = e.visible;
598            let mut ev_rect = egui::Rect::NOTHING;
599            let mut ev_clicked = false;
600            let resp = tree::node(
601                ui,
602                TreeRow::leaf(&gg, last, &e.label).selected(e.selected),
603                |ui| {
604                    let cb = ui.add(egui::Checkbox::new(&mut vis, ""));
605                    ev_rect = cb.rect;
606                    ev_clicked = cb.clicked();
607                },
608            );
609            self.hits.insert(format!("sel:{key}/{ei}"), resp.label.rect);
610            self.hits.insert(format!("vis:{key}/{ei}"), ev_rect);
611            if ev_clicked {
612                *action = Some(Action::SetEntityVisible(solid_name.to_string(), kind, ei, vis));
613            } else if resp.label.clicked() {
614                *action = Some(match &e.kind {
615                    EntityKind::Face(n) => Action::Select("face", n.clone()),
616                    EntityKind::Edge(n) => Action::Select("edge", n.clone()),
617                    EntityKind::Vertex(p) => Action::SelectVertex(solid_name.to_string(), *p),
618                });
619            }
620        }
621    }
622
623    /// The published widget hit-rects (egui points) for the headed verifier.
624    #[cfg(target_arch = "wasm32")]
625    pub fn hits_json(&self) -> String {
626        let map: serde_json::Map<String, serde_json::Value> = self
627            .hits
628            .iter()
629            .map(|(k, r)| {
630                (
631                    k.clone(),
632                    serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
633                )
634            })
635            .collect();
636        serde_json::Value::Object(map).to_string()
637    }
638}
639
640/// The tristate for a group of entity rows, computed from their live per-entity
641/// `visible` flags (the same the engine would report). An empty group reads
642/// [`GroupState::All`] — nothing to hide, so the checkbox shows checked.
643fn group_state(entities: &[Entity]) -> GroupState {
644    if entities.is_empty() {
645        return GroupState::All;
646    }
647    let visible = entities.iter().filter(|e| e.visible).count();
648    if visible == entities.len() {
649        GroupState::All
650    } else if visible == 0 {
651        GroupState::None
652    } else {
653        GroupState::Partial
654    }
655}
656
657/// Snapshot `state.scene` into owned rows, precomputing each entity's `selected`
658/// flag against the current engine selection (cloned in by the caller) and its
659/// live per-entity `visible` flag. Face / edge labels fall back to `Face i` /
660/// `Edge i` when the kernel left them unnamed; unnamed entities are then not
661/// name-selectable (the click is a no-op) but still hideable (by index).
662fn snapshot(
663    state: &EngineState,
664    sel_solids: &HashSet<String>,
665    sel_faces: &HashSet<String>,
666    sel_edges: &HashSet<String>,
667    sel_vertices: &[brep_render::style::VertexRef],
668) -> Vec<SolidRow> {
669    // Vertex positions are set exactly from the same source; a tiny tolerance
670    // guards float round-trips.
671    const TOL: f64 = 1e-6;
672    state
673        .scene
674        .solids()
675        .iter()
676        // Committed-sketch SHEETS are scene solids too, but they list as their OWN
677        // top-level sketch rows (`render_sketch_row`, checkbox wired to
678        // `set_sketch_visible`) — never as plain solid rows, so they are dropped here
679        // and never counted by the root / type-visibility toggles.
680        .filter(|s| !s.is_sketch)
681        .map(|s| {
682            let faces = s
683                .faces
684                .iter()
685                .enumerate()
686                .map(|(i, f)| Entity {
687                    label: if f.name.is_empty() {
688                        format!("Face {i}")
689                    } else {
690                        f.name.clone()
691                    },
692                    selected: !f.name.is_empty() && sel_faces.contains(&f.name),
693                    visible: s.visibility.is_face_visible(i),
694                    kind: EntityKind::Face(f.name.clone()),
695                })
696                .collect();
697            let edges = s
698                .edges
699                .iter()
700                .enumerate()
701                .map(|(i, e)| Entity {
702                    label: if e.name.is_empty() {
703                        format!("Edge {i}")
704                    } else {
705                        e.name.clone()
706                    },
707                    selected: !e.name.is_empty() && sel_edges.contains(&e.name),
708                    visible: s.visibility.is_edge_visible(i),
709                    kind: EntityKind::Edge(e.name.clone()),
710                })
711                .collect();
712            let vertices = s
713                .vertices
714                .iter()
715                .enumerate()
716                .map(|(i, v)| Entity {
717                    label: format!("Vertex {i}"),
718                    selected: sel_vertices.iter().any(|r| {
719                        r.solid == s.name
720                            && (r.position[0] - v.position[0]).abs() <= TOL
721                            && (r.position[1] - v.position[1]).abs() <= TOL
722                            && (r.position[2] - v.position[2]).abs() <= TOL
723                    }),
724                    visible: s.visibility.is_vertex_visible(i),
725                    kind: EntityKind::Vertex(v.position),
726                })
727                .collect();
728            SolidRow {
729                name: s.name.clone(),
730                visible: s.visible,
731                selected: sel_solids.contains(&s.name),
732                faces,
733                edges,
734                vertices,
735            }
736        })
737        .collect()
738}
739
740/// Mirror an engine JSON string to `window.<name>` (wasm/verification only).
741#[cfg(target_arch = "wasm32")]
742fn publish(name: &str, json: &str) {
743    if let Some(win) = web_sys::window() {
744        let _ = js_sys::Reflect::set(
745            &win,
746            &wasm_bindgen::JsValue::from_str(name),
747            &wasm_bindgen::JsValue::from_str(json),
748        );
749    }
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755
756    /// A one-primitive cube history, name-parameterized — mirrors the fixture the
757    /// engine-layer visibility tests use so the solid lands as `name` with 6 faces
758    /// / 12 edges / 8 vertices.
759    fn cube_history(name: &str) -> String {
760        serde_json::json!({
761            "expressions": "",
762            "configurator": {},
763            "features": [{
764                "type": "P.CU",
765                "inputParams": {
766                    "id": name,
767                    "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
768                    "transform": {
769                        "position": [0.0, 0.0, 0.0],
770                        "rotationEuler": [0.0, 0.0, 0.0],
771                        "scale": [1.0, 1.0, 1.0]
772                    },
773                    "boolean": { "targets": [], "operation": "NONE" }
774                },
775                "persistentData": {}
776            }]
777        })
778        .to_string()
779    }
780
781    /// Run ONE egui frame of the Scene panel headlessly (no window / GPU), feeding
782    /// `events` as this frame's input. Layout is deterministic, so widget rects are
783    /// stable frame-to-frame and the `hits` map can be read back to drive a real
784    /// pointer click at a checkbox's screen rect.
785    fn run_frame(
786        ctx: &egui::Context,
787        panel: &mut ScenePanel,
788        state: &mut EngineState,
789        events: Vec<egui::Event>,
790        scroll: bool,
791    ) {
792        let raw = egui::RawInput {
793            screen_rect: Some(egui::Rect::from_min_size(
794                egui::pos2(0.0, 0.0),
795                egui::vec2(800.0, 600.0),
796            )),
797            events,
798            ..Default::default()
799        };
800        let _ = ctx.run_ui(raw, |ui| {
801            // `scroll` wraps the tree in the app's `ScrollArea::vertical()` so the
802            // click-routing is exercised under the real sidebar geometry (tree.rs
803            // warns right-slot content underlapping the scrollbar can be eaten as a
804            // scroll drag), not just on a bare root Ui.
805            if scroll {
806                egui::ScrollArea::vertical().show(ui, |ui| panel.show(ui, state));
807            } else {
808                panel.show(ui, state);
809            }
810        });
811    }
812
813    /// Left-click at `pos` split across a press frame and a release frame (egui
814    /// fires `clicked()` on release), re-running the panel each frame so the
815    /// deferred visibility mutation is applied.
816    fn click_at(
817        ctx: &egui::Context,
818        panel: &mut ScenePanel,
819        state: &mut EngineState,
820        pos: egui::Pos2,
821        scroll: bool,
822    ) {
823        run_frame(
824            ctx,
825            panel,
826            state,
827            vec![
828                egui::Event::PointerMoved(pos),
829                egui::Event::PointerButton {
830                    pos,
831                    button: egui::PointerButton::Primary,
832                    pressed: true,
833                    modifiers: egui::Modifiers::default(),
834                },
835            ],
836            scroll,
837        );
838        run_frame(
839            ctx,
840            panel,
841            state,
842            vec![egui::Event::PointerButton {
843                pos,
844                button: egui::PointerButton::Primary,
845                pressed: false,
846                modifiers: egui::Modifiers::default(),
847            }],
848            scroll,
849        );
850    }
851
852    /// End-to-end through the real egui widget dispatch: clicking a group's
853    /// tristate checkbox in the Scene tree must hide EVERY entity of that kind on
854    /// the solid (the deferred `SetGroupVisible` action reaching
855    /// `EngineState::set_group_visible`). Faces is the reported-broken case; Edges
856    /// is the working control — both are driven so a face-specific regression shows
857    /// up as an asymmetry, not a harness artifact.
858    fn drive_group_checkbox(kind: VisKind, hit_key: &str, scroll: bool) {
859        let ctx = egui::Context::default();
860        let mut state = EngineState::new();
861        state.set_history_json(&cube_history("Box")).unwrap();
862        let mut panel = ScenePanel::new();
863
864        // Frame 1: lay the tree out (no input) so `hits` holds the checkbox rects.
865        run_frame(&ctx, &mut panel, &mut state, vec![], scroll);
866        assert_eq!(
867            state.group_visibility("Box", kind),
868            Some(GroupState::All),
869            "precondition: every {kind:?} starts visible"
870        );
871        let rect = *panel.hits.get(hit_key).unwrap_or_else(|| {
872            panic!(
873                "missing hit rect {hit_key}; have {:?}",
874                panel.hits.keys().collect::<Vec<_>>()
875            )
876        });
877
878        // Click the group checkbox → all of that kind hidden.
879        click_at(&ctx, &mut panel, &mut state, rect.center(), scroll);
880        assert_eq!(
881            state.group_visibility("Box", kind),
882            Some(GroupState::None),
883            "clicking the {kind:?} group checkbox must hide the whole group"
884        );
885
886        // Click again → all shown (the tristate re-shows a fully-hidden group).
887        run_frame(&ctx, &mut panel, &mut state, vec![], scroll);
888        let rect = *panel.hits.get(hit_key).expect("checkbox rect after hide");
889        click_at(&ctx, &mut panel, &mut state, rect.center(), scroll);
890        assert_eq!(
891            state.group_visibility("Box", kind),
892            Some(GroupState::All),
893            "re-clicking the {kind:?} group checkbox must re-show the whole group"
894        );
895    }
896
897    #[test]
898    fn scene_tree_faces_group_checkbox_hides_and_shows_through_egui() {
899        drive_group_checkbox(VisKind::Face, "vis:Box/Faces", false);
900    }
901
902    #[test]
903    fn scene_tree_edges_group_checkbox_hides_and_shows_through_egui() {
904        drive_group_checkbox(VisKind::Edge, "vis:Box/Edges", false);
905    }
906
907    /// Same as the faces group test, but with the tree wrapped in the app's
908    /// `ScrollArea::vertical()` — proves the checkbox click routes correctly under
909    /// the real sidebar geometry (not eaten as a scroll drag).
910    #[test]
911    fn scene_tree_faces_group_checkbox_works_inside_scroll_area() {
912        drive_group_checkbox(VisKind::Face, "vis:Box/Faces", true);
913    }
914
915    /// An INDIVIDUAL face leaf checkbox (group expanded first) hides just that one
916    /// face — the deferred `SetEntityVisible` action reaching
917    /// `EngineState::set_entity_visible`. The other reported-broken case alongside
918    /// the group toggle.
919    #[test]
920    fn scene_tree_individual_face_checkbox_hides_one_face_through_egui() {
921        let ctx = egui::Context::default();
922        let mut state = EngineState::new();
923        state.set_history_json(&cube_history("Box")).unwrap();
924        let mut panel = ScenePanel::new();
925
926        // Frame 1: layout → the Faces group's collapse box rect is published.
927        run_frame(&ctx, &mut panel, &mut state, vec![], false);
928        let box_rect = *panel.hits.get("box:Box/Faces").expect("faces group collapse box");
929
930        // Expand the Faces group (a panel-local toggle) so its leaves render.
931        click_at(&ctx, &mut panel, &mut state, box_rect.center(), false);
932        run_frame(&ctx, &mut panel, &mut state, vec![], false);
933        assert_eq!(
934            state.entity_visible("Box", VisKind::Face, 0),
935            Some(true),
936            "face 0 starts visible"
937        );
938        let leaf = *panel.hits.get("vis:Box/Faces/0").unwrap_or_else(|| {
939            panic!(
940                "missing face-0 leaf checkbox; have {:?}",
941                panel.hits.keys().collect::<Vec<_>>()
942            )
943        });
944
945        // Click face 0's checkbox → only face 0 hidden.
946        click_at(&ctx, &mut panel, &mut state, leaf.center(), false);
947        assert_eq!(
948            state.entity_visible("Box", VisKind::Face, 0),
949            Some(false),
950            "clicking face 0's checkbox must hide exactly face 0"
951        );
952        assert_eq!(
953            state.entity_visible("Box", VisKind::Face, 1),
954            Some(true),
955            "sibling face 1 stays visible"
956        );
957        assert_eq!(
958            state.group_visibility("Box", VisKind::Face),
959            Some(GroupState::Partial),
960            "one hidden face → group reads Partial"
961        );
962    }
963
964    /// A TWO-solid history (two independent cubes) so a scene-wide type button can
965    /// be proven to touch EVERY solid, not just one. The cubes are offset in X so
966    /// they stay separate solids named `Box1` / `Box2` (each 6 faces / 12 edges /
967    /// 8 vertices).
968    fn two_cube_history() -> String {
969        let cube = |name: &str, x: f64| {
970            serde_json::json!({
971                "type": "P.CU",
972                "inputParams": {
973                    "id": name,
974                    "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
975                    "transform": {
976                        "position": [x, 0.0, 0.0],
977                        "rotationEuler": [0.0, 0.0, 0.0],
978                        "scale": [1.0, 1.0, 1.0]
979                    },
980                    "boolean": { "targets": [], "operation": "NONE" }
981                },
982                "persistentData": {}
983            })
984        };
985        serde_json::json!({
986            "expressions": "",
987            "configurator": {},
988            "features": [cube("Box1", 0.0), cube("Box2", 20.0)]
989        })
990        .to_string()
991    }
992
993    /// End-to-end through real egui widget dispatch: clicking a scene-wide
994    /// type-visibility button (above the tree) must hide EVERY solid's group of
995    /// that kind (the deferred `SetAllGroupVisible` action looping the scene), then
996    /// re-clicking must re-show them all — the same all→hide / otherwise→show rule
997    /// the per-solid group tristate uses. Driven across TWO solids so a per-solid
998    /// (not scene-wide) regression would surface as `Box2` staying visible.
999    fn drive_type_button(kind: VisKind, hit_key: &str) {
1000        let ctx = egui::Context::default();
1001        let mut state = EngineState::new();
1002        state.set_history_json(&two_cube_history()).unwrap();
1003        let mut panel = ScenePanel::new();
1004
1005        // Frame 1: lay the row out so `hits` holds the button rect.
1006        run_frame(&ctx, &mut panel, &mut state, vec![], false);
1007        for name in ["Box1", "Box2"] {
1008            assert_eq!(
1009                state.group_visibility(name, kind),
1010                Some(GroupState::All),
1011                "precondition: every {kind:?} on {name} starts visible"
1012            );
1013        }
1014        let rect = *panel.hits.get(hit_key).unwrap_or_else(|| {
1015            panic!(
1016                "missing hit rect {hit_key}; have {:?}",
1017                panel.hits.keys().collect::<Vec<_>>()
1018            )
1019        });
1020
1021        // Click the type button → that kind hidden on EVERY solid.
1022        click_at(&ctx, &mut panel, &mut state, rect.center(), false);
1023        for name in ["Box1", "Box2"] {
1024            assert_eq!(
1025                state.group_visibility(name, kind),
1026                Some(GroupState::None),
1027                "the scene-wide {kind:?} button must hide {name}'s whole group"
1028            );
1029        }
1030
1031        // Click again → shown everywhere (the button re-shows a fully-hidden type).
1032        run_frame(&ctx, &mut panel, &mut state, vec![], false);
1033        let rect = *panel.hits.get(hit_key).expect("button rect after hide");
1034        click_at(&ctx, &mut panel, &mut state, rect.center(), false);
1035        for name in ["Box1", "Box2"] {
1036            assert_eq!(
1037                state.group_visibility(name, kind),
1038                Some(GroupState::All),
1039                "re-clicking the scene-wide {kind:?} button must re-show {name}'s group"
1040            );
1041        }
1042    }
1043
1044    #[test]
1045    fn scene_type_faces_button_hides_and_shows_all_solids_through_egui() {
1046        drive_type_button(VisKind::Face, "typevis:Faces");
1047    }
1048
1049    #[test]
1050    fn scene_type_edges_button_hides_and_shows_all_solids_through_egui() {
1051        drive_type_button(VisKind::Edge, "typevis:Edges");
1052    }
1053
1054    /// The Vertices type button, exercised the same way — the third scene-wide
1055    /// group kind, to guard against a kind-specific wiring slip.
1056    #[test]
1057    fn scene_type_vertices_button_hides_and_shows_all_solids_through_egui() {
1058        drive_type_button(VisKind::Vertex, "typevis:Vertices");
1059    }
1060
1061    /// A cube (real solid) followed by a committed closed-rectangle sketch on the XY
1062    /// plane. The sketch has no consumer, so it survives as a top-level
1063    /// committed-sketch row alongside the cube.
1064    fn cube_and_sketch_history() -> String {
1065        serde_json::json!({
1066            "expressions": "",
1067            "configurator": {},
1068            "features": [
1069                {
1070                    "type": "P.CU",
1071                    "inputParams": {
1072                        "id": "Box",
1073                        "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
1074                        "transform": {
1075                            "position": [0.0, 0.0, 0.0],
1076                            "rotationEuler": [0.0, 0.0, 0.0],
1077                            "scale": [1.0, 1.0, 1.0]
1078                        },
1079                        "boolean": { "targets": [], "operation": "NONE" }
1080                    },
1081                    "persistentData": {}
1082                },
1083                {
1084                    "type": "S",
1085                    "inputParams": { "id": "Sk" },
1086                    "persistentData": {
1087                        "basis": { "origin": [0, 0, 0], "x": [1, 0, 0], "y": [0, 1, 0], "z": [0, 0, 1] },
1088                        "sketch": {
1089                            "points": [
1090                                { "id": 0, "x": 0.0,  "y": 0.0 },
1091                                { "id": 1, "x": 10.0, "y": 0.0 },
1092                                { "id": 2, "x": 10.0, "y": 6.0 },
1093                                { "id": 3, "x": 0.0,  "y": 6.0 }
1094                            ],
1095                            "geometries": [
1096                                { "id": 10, "type": "line", "points": [0, 1] },
1097                                { "id": 11, "type": "line", "points": [1, 2] },
1098                                { "id": 12, "type": "line", "points": [2, 3] },
1099                                { "id": 13, "type": "line", "points": [3, 0] }
1100                            ],
1101                            "constraints": []
1102                        }
1103                    }
1104                }
1105            ]
1106        })
1107        .to_string()
1108    }
1109
1110    /// A committed sketch lists as a TOP-LEVEL row with its OWN sketch-specific
1111    /// widgets (`sel:sketch/Sk` + `vis:sketch/Sk`, the latter wired to
1112    /// `set_sketch_visible`), NOT wrapped in a `Sketches` group node
1113    /// (`box:__sketches` is gone) and NOT double-listed as a plain solid branch
1114    /// (`box:Sk` / `vis:Sk` absent — the `snapshot` `is_sketch` filter). The real
1115    /// cube still lists as its own solid branch (`box:Box`).
1116    #[test]
1117    fn committed_sketch_is_a_top_level_row_not_a_group() {
1118        let ctx = egui::Context::default();
1119        let mut state = EngineState::new();
1120        state.set_history_json(&cube_and_sketch_history()).unwrap();
1121        let mut panel = ScenePanel::new();
1122
1123        // One layout frame publishes the tree's widget hit-rects.
1124        run_frame(&ctx, &mut panel, &mut state, vec![], false);
1125        let keys = || panel.hits.keys().cloned().collect::<Vec<_>>();
1126
1127        // The sketch is a top-level committed-sketch row with sketch-specific hits.
1128        assert!(
1129            panel.hits.contains_key("sel:sketch/Sk"),
1130            "sketch select row present: {:?}",
1131            keys()
1132        );
1133        assert!(
1134            panel.hits.contains_key("vis:sketch/Sk"),
1135            "sketch visibility checkbox present: {:?}",
1136            keys()
1137        );
1138        // No `Sketches` group wrapper node.
1139        assert!(
1140            !panel.hits.contains_key("box:__sketches"),
1141            "no Sketches group node: {:?}",
1142            keys()
1143        );
1144        // Not double-listed as a plain solid row (the snapshot filters is_sketch).
1145        assert!(
1146            !panel.hits.contains_key("box:Sk"),
1147            "sketch is not a plain solid branch: {:?}",
1148            keys()
1149        );
1150        assert!(
1151            !panel.hits.contains_key("vis:Sk"),
1152            "sketch is not a plain solid checkbox: {:?}",
1153            keys()
1154        );
1155        // The real solid still lists as its own solid branch.
1156        assert!(
1157            panel.hits.contains_key("box:Box"),
1158            "cube solid branch present: {:?}",
1159            keys()
1160        );
1161    }
1162}