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//! A file-tree of the engine's display scene:
9//!   * `[-] Scene <☑>` — the root; its checkbox toggles ALL solids' visibility.
10//!   * per solid `[-] <Name> <☑>` — the checkbox toggles that solid's visibility
11//!     through the engine ([`EngineState::set_visible`]); expands to
12//!   * `[+] Faces <☑>`, `[+] Edges <☑>`, `[+] Vertices <☑>` — each expands to the
13//!     individual entities BY KERNEL NAME (vertices by index/position).
14//!
15//! # Selection sync (both ways)
16//!
17//! Clicking an entity row drives the engine's name-based SELECTION
18//! ([`EngineState::select_by_name`] / [`select_vertex_by_position`]) so it
19//! highlights (emphasis) in the viewport; and the engine's CURRENT selection
20//! (`state.emphasis`) bolds the matching tree row — the same emphasis the
21//! viewport reads, so a viewport pick lights up the tree and vice-versa.
22//!
23//! # This panel OWNS NO model state
24//!
25//! The scene + selection live in the engine ([`EngineState`], the single source
26//! of truth). The panel holds only transient UI state: which nodes are expanded
27//! and the per-frame `hits` map (widget screen rects) the headed verifier reads.
28//! Each frame it snapshots the scene into owned rows FIRST, draws from that, and
29//! applies at most one deferred engine mutation after the draw loop (so no borrow
30//! of `state` is held across a `&mut` call — the history panel's pattern).
31//!
32//! # Two deliberate reshapes (functional-over-1:1, per the design doc)
33//!
34//! * The visibility checkbox is drawn in the row's RIGHT slot: the shared tree
35//!   widget reserves the left columns for the collapse box + connector + glyph,
36//!   and (per the constraints) it is reused verbatim, not modified. Functionally
37//!   identical to the reference's left checkbox.
38//! * **Per-entity visibility (live):** the engine now hides individual faces /
39//!   edges / vertices and whole groups ([`EngineState::set_entity_visible`] /
40//!   [`EngineState::set_group_visible`]). So each Faces/Edges/Vertices group
41//!   checkbox is a live TRISTATE (all / some / none of that kind shown) and each
42//!   entity leaf carries its own live checkbox — the render pass skips a hidden
43//!   entity's triangles / segments / point. The whole-solid + whole-scene
44//!   checkboxes still compose: a hidden solid draws nothing; re-showing it keeps
45//!   any per-entity hides intact.
46
47use crate::panels::tree::{self, TreeRow};
48use brep_render::engine_state::EngineState;
49use brep_render::visibility::{EntityKind as VisKind, GroupState};
50use eframe::egui;
51use std::collections::{HashMap, HashSet};
52
53/// One entity leaf's identity — how a click maps to an engine selection call.
54#[derive(Clone)]
55enum EntityKind {
56    /// Face, selected by kernel name (empty = unnamed → not selectable).
57    Face(String),
58    /// Edge, selected by kernel name (empty = unnamed → not selectable).
59    Edge(String),
60    /// Vertex, selected by owning-solid + world position (no kernel name).
61    Vertex([f64; 3]),
62}
63
64/// One row under a Faces/Edges/Vertices group — a display label, its selection
65/// identity, whether it is currently in the engine selection, and whether it is
66/// currently VISIBLE in the engine (its live per-entity checkbox state).
67#[derive(Clone)]
68struct Entity {
69    label: String,
70    kind: EntityKind,
71    selected: bool,
72    visible: bool,
73}
74
75/// One solid's owned snapshot for the frame (decoupled from `state.scene` so the
76/// draw loop can issue deferred `&mut state` mutations afterwards).
77struct SolidRow {
78    name: String,
79    visible: bool,
80    selected: bool,
81    faces: Vec<Entity>,
82    edges: Vec<Entity>,
83    vertices: Vec<Entity>,
84}
85
86/// A deferred engine mutation, collected during the draw and applied once after
87/// the loop (one per frame — the history panel's pattern).
88enum Action {
89    SetVisible(String, bool),
90    SetAllVisible(bool),
91    /// Hide/show one entity: `(solid, kind, index-in-kind-list, visible)`.
92    SetEntityVisible(String, VisKind, usize, bool),
93    /// Hide/show a whole group: `(solid, kind, visible)`.
94    SetGroupVisible(String, VisKind, bool),
95    Select(&'static str, String),
96    SelectVertex(String, [f64; 3]),
97    /// Show/hide a committed sketch's persistent overlay: `(feature-id, visible)`.
98    SetSketchVisible(String, bool),
99    /// Show/hide a construction datum/plane's plane: `(frame-name, visible)`.
100    SetDatumVisible(String, bool),
101    /// Select a construction datum/plane by frame NAME (a row click).
102    SelectDatum(String),
103}
104
105/// The Scene tree panel's transient UI state (the scene + selection live in the
106/// engine).
107#[derive(Default)]
108pub struct ScenePanel {
109    /// Per-frame egui widget screen rects, published to JS for the headed
110    /// verifier. Rebuilt every frame.
111    hits: HashMap<String, egui::Rect>,
112    /// The Scene ROOT is collapsed (absent/false = open — it defaults open).
113    root_collapsed: bool,
114    /// Solids explicitly COLLAPSED, by name (absent = open — solids default open,
115    /// matching the reference showing a solid's Faces/Edges/Vertices).
116    collapsed_solids: HashSet<String>,
117    /// Faces/Edges/Vertices group nodes explicitly EXPANDED, keyed
118    /// `"<solid>/<group>"` (absent = collapsed — groups default collapsed `[+]`).
119    expanded_groups: HashSet<String>,
120    /// The `Sketches` group node is collapsed (absent/false = open — it defaults
121    /// open so committed sketches are visible in the tree).
122    sketches_collapsed: bool,
123    /// The `Planes & Datums` group node is collapsed (absent/false = open — it
124    /// defaults open so construction datums are visible in the tree).
125    datums_collapsed: bool,
126}
127
128impl ScenePanel {
129    pub fn new() -> Self {
130        Self::default()
131    }
132
133    /// Draw the Scene tree. Snapshots the scene + current selection into owned
134    /// rows, draws them via the shared [`tree`] node helper, then applies at most
135    /// one deferred engine mutation.
136    pub fn show(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
137        self.hits.clear();
138
139        // --- snapshot scene + selection (owned) so we can mutate after drawing --
140        let sel_solids = state.emphasis.selected_solids.clone();
141        let sel_faces = state.emphasis.selected_faces.clone();
142        let sel_edges = state.emphasis.selected_edges.clone();
143        let sel_vertices = state.emphasis.selected_vertices.clone();
144        let solids = snapshot(state, &sel_solids, &sel_faces, &sel_edges, &sel_vertices);
145        // Committed sketches (id, visible) — listed under the solids in the tree.
146        let sketches = state.committed_sketches();
147        // Construction datums/planes (name, visible) + which are selected — listed as
148        // the tree's LAST group.
149        let sel_datums = state.emphasis.selected_datums.clone();
150        let datums = state.construction_datums();
151
152        // Tight, tree-like row spacing so connector verticals read continuously.
153        ui.spacing_mut().item_spacing.y = 2.0;
154
155        let mut action: Option<Action> = None;
156
157        // --- ROOT: `[-] Scene  ☑` (visibility toggles every solid) ------------
158        let root_open = !self.root_collapsed;
159        let all_visible = !solids.is_empty() && solids.iter().all(|s| s.visible);
160        let mut root_vis = all_visible;
161        let mut root_vis_rect = egui::Rect::NOTHING;
162        let mut root_vis_clicked = false;
163        let root_resp = tree::node(
164            ui,
165            TreeRow {
166                guides: &[],
167                is_last: true,
168                expandable: true,
169                expanded: root_open,
170                root: true,
171                glyph: None,
172                label: "Scene",
173                selected: false,
174                draggable: false,
175            },
176            |ui| {
177                let cb = ui.add_enabled(!solids.is_empty(), egui::Checkbox::new(&mut root_vis, ""));
178                root_vis_rect = cb.rect;
179                root_vis_clicked = cb.clicked();
180            },
181        );
182        self.hits.insert("box:__scene".into(), root_resp.box_rect);
183        self.hits.insert("vis:__scene".into(), root_vis_rect);
184        if root_vis_clicked {
185            action = Some(Action::SetAllVisible(root_vis));
186        }
187        if root_resp.toggled || root_resp.label.clicked() {
188            self.root_collapsed = !self.root_collapsed;
189        }
190
191        if solids.is_empty() && sketches.is_empty() && datums.is_empty() {
192            let g = tree::child_guides(&[], true);
193            tree::node(ui, TreeRow::leaf(&g, true, "(scene is empty)"), |_| {});
194        }
195
196        if root_open {
197            // Group order under the root: solids, then `Sketches`, then
198            // `Planes & Datums` (the last child), so nothing "below" is last while a
199            // later group follows.
200            let has_sketches = !sketches.is_empty();
201            let has_datums = !datums.is_empty();
202            let n = solids.len();
203            for (si, solid) in solids.iter().enumerate() {
204                let is_last = !has_sketches && !has_datums && si + 1 == n;
205                self.render_solid(ui, solid, is_last, &mut action);
206            }
207            if has_sketches {
208                self.render_sketches(ui, &sketches, &sel_solids, !has_datums, &mut action);
209            }
210            if has_datums {
211                self.render_datums(ui, &datums, &sel_datums, &mut action);
212            }
213        }
214
215        // --- apply the one deferred engine mutation ---------------------------
216        match action {
217            Some(Action::SetVisible(name, v)) => {
218                state.set_visible(&name, v);
219            }
220            Some(Action::SetAllVisible(v)) => {
221                for s in &solids {
222                    state.set_visible(&s.name, v);
223                }
224            }
225            Some(Action::SetEntityVisible(name, kind, index, v)) => {
226                state.set_entity_visible(&name, kind, index, v);
227            }
228            Some(Action::SetGroupVisible(name, kind, v)) => {
229                state.set_group_visible(&name, kind, v);
230            }
231            Some(Action::Select(kind, name)) => {
232                state.select_by_name(kind, &name);
233            }
234            Some(Action::SelectVertex(solid, pos)) => {
235                state.select_vertex_by_position(&solid, pos);
236            }
237            Some(Action::SetSketchVisible(id, v)) => {
238                state.set_sketch_visible(&id, v);
239            }
240            Some(Action::SetDatumVisible(name, v)) => {
241                state.set_datum_visible(&name, v);
242            }
243            Some(Action::SelectDatum(name)) => {
244                state.select_datum(&name);
245            }
246            None => {}
247        }
248
249        // --- verifier hooks (wasm only): scene listing + widget hit-rects ------
250        // Published from the panel (not the shared shell) so the headed verifier
251        // can assert the tree contents / visibility and drive real clicks, without
252        // touching `app.rs`'s shared publish block.
253        #[cfg(target_arch = "wasm32")]
254        {
255            publish("__brepScene", &state.scene_entities_json());
256            publish("__brepSketches", &state.sketch_entities_json());
257            publish("__brepDatums", &state.datum_entities_json());
258            publish("__brepSceneVis", &state.scene_visibility_json());
259            publish("__brepSceneHit", &self.hits_json());
260        }
261    }
262
263    /// The `Sketches` group node + (when open) one row per committed sketch, each
264    /// with a visibility checkbox wired to
265    /// [`EngineState::set_sketch_visible`](brep_render::engine_state::EngineState::set_sketch_visible)
266    /// and a label that SELECTS the sketch's sheet solid on click (a committed
267    /// sketch is a scene solid). Rendered under the solids (before the
268    /// `Planes & Datums` group); `is_last` is set only when no datum group follows.
269    fn render_sketches(
270        &mut self,
271        ui: &mut egui::Ui,
272        sketches: &[(String, bool)],
273        sel_solids: &HashSet<String>,
274        is_last: bool,
275        action: &mut Option<Action>,
276    ) {
277        let open = !self.sketches_collapsed;
278        let resp = tree::node(
279            ui,
280            TreeRow::branch(&[], is_last, open, "Sketches"),
281            |ui| {
282                ui.add_space(6.0);
283                ui.label(egui::RichText::new(format!("{}", sketches.len())).weak());
284            },
285        );
286        self.hits.insert("box:__sketches".into(), resp.box_rect);
287        if resp.toggled || resp.label.clicked() {
288            self.sketches_collapsed = !self.sketches_collapsed;
289        }
290        if !open {
291            return;
292        }
293
294        let base = tree::child_guides(&[], is_last);
295        let m = sketches.len();
296        for (i, (id, visible)) in sketches.iter().enumerate() {
297            let last = i + 1 == m;
298            let selected = sel_solids.contains(id);
299            let mut vis = *visible;
300            let mut vis_rect = egui::Rect::NOTHING;
301            let mut vis_clicked = false;
302            let resp = tree::node(
303                ui,
304                TreeRow::leaf(&base, last, id).selected(selected),
305                |ui| {
306                    let cb = ui.add(egui::Checkbox::new(&mut vis, ""));
307                    vis_rect = cb.rect;
308                    vis_clicked = cb.clicked();
309                },
310            );
311            self.hits.insert(format!("vis:sketch/{id}"), vis_rect);
312            self.hits.insert(format!("sel:sketch/{id}"), resp.label.rect);
313            if vis_clicked {
314                *action = Some(Action::SetSketchVisible(id.clone(), vis));
315            } else if resp.label.clicked() {
316                // A committed sketch is a scene solid — select it like any solid.
317                *action = Some(Action::Select("solid", id.clone()));
318            }
319        }
320    }
321
322    /// The `Planes & Datums` group node + (when open) one row per construction
323    /// datum/plane frame, each with a visibility checkbox wired to
324    /// [`EngineState::set_datum_visible`](brep_render::engine_state::EngineState::set_datum_visible)
325    /// and a label that selects the datum
326    /// ([`EngineState::select_datum`](brep_render::engine_state::EngineState::select_datum))
327    /// on click. Rendered as the Scene root's LAST child (only when there is at
328    /// least one construction datum).
329    fn render_datums(
330        &mut self,
331        ui: &mut egui::Ui,
332        datums: &[(String, bool)],
333        sel_datums: &HashSet<String>,
334        action: &mut Option<Action>,
335    ) {
336        let open = !self.datums_collapsed;
337        let resp = tree::node(
338            ui,
339            TreeRow::branch(&[], true, open, "Planes & Datums"),
340            |ui| {
341                ui.add_space(6.0);
342                ui.label(egui::RichText::new(format!("{}", datums.len())).weak());
343            },
344        );
345        self.hits.insert("box:__datums".into(), resp.box_rect);
346        if resp.toggled || resp.label.clicked() {
347            self.datums_collapsed = !self.datums_collapsed;
348        }
349        if !open {
350            return;
351        }
352
353        let base = tree::child_guides(&[], true);
354        let m = datums.len();
355        for (i, (name, visible)) in datums.iter().enumerate() {
356            let last = i + 1 == m;
357            let selected = sel_datums.contains(name);
358            let mut vis = *visible;
359            let mut vis_rect = egui::Rect::NOTHING;
360            let mut vis_clicked = false;
361            let resp = tree::node(
362                ui,
363                TreeRow::leaf(&base, last, name).selected(selected),
364                |ui| {
365                    let cb = ui.add(egui::Checkbox::new(&mut vis, ""));
366                    vis_rect = cb.rect;
367                    vis_clicked = cb.clicked();
368                },
369            );
370            self.hits.insert(format!("vis:datum/{name}"), vis_rect);
371            self.hits.insert(format!("sel:datum/{name}"), resp.label.rect);
372            if vis_clicked {
373                *action = Some(Action::SetDatumVisible(name.clone(), vis));
374            } else if resp.label.clicked() {
375                *action = Some(Action::SelectDatum(name.clone()));
376            }
377        }
378    }
379
380    /// One solid node + (when open) its Faces / Edges / Vertices groups.
381    fn render_solid(
382        &mut self,
383        ui: &mut egui::Ui,
384        solid: &SolidRow,
385        is_last: bool,
386        action: &mut Option<Action>,
387    ) {
388        let name = &solid.name;
389        let open = !self.collapsed_solids.contains(name);
390
391        let mut vis = solid.visible;
392        let mut vis_rect = egui::Rect::NOTHING;
393        let mut vis_clicked = false;
394        let resp = tree::node(
395            ui,
396            TreeRow::branch(&[], is_last, open, name).selected(solid.selected),
397            |ui| {
398                let cb = ui.add(egui::Checkbox::new(&mut vis, ""));
399                vis_rect = cb.rect;
400                vis_clicked = cb.clicked();
401            },
402        );
403        self.hits.insert(format!("box:{name}"), resp.box_rect);
404        self.hits.insert(format!("vis:{name}"), vis_rect);
405        self.hits.insert(format!("sel:{name}"), resp.label.rect);
406
407        if vis_clicked {
408            *action = Some(Action::SetVisible(name.clone(), vis));
409        }
410        if resp.toggled {
411            if open {
412                self.collapsed_solids.insert(name.clone());
413            } else {
414                self.collapsed_solids.remove(name);
415            }
416        }
417        if resp.label.clicked() {
418            *action = Some(Action::Select("solid", name.clone()));
419        }
420
421        if open {
422            let base = tree::child_guides(&[], is_last);
423            self.render_group(ui, name, &base, false, "Faces", VisKind::Face, &solid.faces, action);
424            self.render_group(ui, name, &base, false, "Edges", VisKind::Edge, &solid.edges, action);
425            self.render_group(ui, name, &base, true, "Vertices", VisKind::Vertex, &solid.vertices, action);
426        }
427    }
428
429    /// One Faces/Edges/Vertices group node + (when open) its entity leaves. The
430    /// group checkbox is a live TRISTATE that hides/shows every entity of `kind`;
431    /// each leaf carries its own live checkbox that hides just that entity.
432    #[allow(clippy::too_many_arguments)]
433    fn render_group(
434        &mut self,
435        ui: &mut egui::Ui,
436        solid_name: &str,
437        base: &[bool],
438        is_last: bool,
439        group: &str,
440        kind: VisKind,
441        entities: &[Entity],
442        action: &mut Option<Action>,
443    ) {
444        let key = format!("{solid_name}/{group}");
445        let open = self.expanded_groups.contains(&key);
446
447        // Tristate over the group's entities (empty group reads All → checked).
448        let state = group_state(entities);
449        let mut checked = matches!(state, GroupState::All);
450        let indeterminate = matches!(state, GroupState::Partial);
451        let mut vis_rect = egui::Rect::NOTHING;
452        let mut vis_clicked = false;
453        let resp = tree::node(
454            ui,
455            TreeRow::branch(base, is_last, open, group),
456            |ui| {
457                // right-to-left: the tristate group checkbox (rightmost), then count.
458                let cb = ui.add(
459                    egui::Checkbox::new(&mut checked, "").indeterminate(indeterminate),
460                );
461                vis_rect = cb.rect;
462                vis_clicked = cb.clicked();
463                ui.add_space(6.0);
464                ui.label(egui::RichText::new(format!("{}", entities.len())).weak());
465            },
466        );
467        self.hits.insert(format!("box:{key}"), resp.box_rect);
468        self.hits.insert(format!("vis:{key}"), vis_rect);
469        if vis_clicked {
470            // Standard tristate: All → hide all; None/Partial → show all.
471            let want_visible = !matches!(state, GroupState::All);
472            *action = Some(Action::SetGroupVisible(solid_name.to_string(), kind, want_visible));
473        }
474        if resp.toggled || resp.label.clicked() {
475            if open {
476                self.expanded_groups.remove(&key);
477            } else {
478                self.expanded_groups.insert(key.clone());
479            }
480        }
481
482        if !open {
483            return;
484        }
485        let gg = tree::child_guides(base, is_last);
486        if entities.is_empty() {
487            tree::node(ui, TreeRow::leaf(&gg, true, "(none)"), |_| {});
488            return;
489        }
490        let m = entities.len();
491        for (ei, e) in entities.iter().enumerate() {
492            let last = ei + 1 == m;
493            let mut vis = e.visible;
494            let mut ev_rect = egui::Rect::NOTHING;
495            let mut ev_clicked = false;
496            let resp = tree::node(
497                ui,
498                TreeRow::leaf(&gg, last, &e.label).selected(e.selected),
499                |ui| {
500                    let cb = ui.add(egui::Checkbox::new(&mut vis, ""));
501                    ev_rect = cb.rect;
502                    ev_clicked = cb.clicked();
503                },
504            );
505            self.hits.insert(format!("sel:{key}/{ei}"), resp.label.rect);
506            self.hits.insert(format!("vis:{key}/{ei}"), ev_rect);
507            if ev_clicked {
508                *action = Some(Action::SetEntityVisible(solid_name.to_string(), kind, ei, vis));
509            } else if resp.label.clicked() {
510                *action = Some(match &e.kind {
511                    EntityKind::Face(n) => Action::Select("face", n.clone()),
512                    EntityKind::Edge(n) => Action::Select("edge", n.clone()),
513                    EntityKind::Vertex(p) => Action::SelectVertex(solid_name.to_string(), *p),
514                });
515            }
516        }
517    }
518
519    /// The published widget hit-rects (egui points) for the headed verifier.
520    #[cfg(target_arch = "wasm32")]
521    pub fn hits_json(&self) -> String {
522        let map: serde_json::Map<String, serde_json::Value> = self
523            .hits
524            .iter()
525            .map(|(k, r)| {
526                (
527                    k.clone(),
528                    serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
529                )
530            })
531            .collect();
532        serde_json::Value::Object(map).to_string()
533    }
534}
535
536/// The tristate for a group of entity rows, computed from their live per-entity
537/// `visible` flags (the same the engine would report). An empty group reads
538/// [`GroupState::All`] — nothing to hide, so the checkbox shows checked.
539fn group_state(entities: &[Entity]) -> GroupState {
540    if entities.is_empty() {
541        return GroupState::All;
542    }
543    let visible = entities.iter().filter(|e| e.visible).count();
544    if visible == entities.len() {
545        GroupState::All
546    } else if visible == 0 {
547        GroupState::None
548    } else {
549        GroupState::Partial
550    }
551}
552
553/// Snapshot `state.scene` into owned rows, precomputing each entity's `selected`
554/// flag against the current engine selection (cloned in by the caller) and its
555/// live per-entity `visible` flag. Face / edge labels fall back to `Face i` /
556/// `Edge i` when the kernel left them unnamed; unnamed entities are then not
557/// name-selectable (the click is a no-op) but still hideable (by index).
558fn snapshot(
559    state: &EngineState,
560    sel_solids: &HashSet<String>,
561    sel_faces: &HashSet<String>,
562    sel_edges: &HashSet<String>,
563    sel_vertices: &[brep_render::style::VertexRef],
564) -> Vec<SolidRow> {
565    // Vertex positions are set exactly from the same source; a tiny tolerance
566    // guards float round-trips.
567    const TOL: f64 = 1e-6;
568    state
569        .scene
570        .solids()
571        .iter()
572        .map(|s| {
573            let faces = s
574                .faces
575                .iter()
576                .enumerate()
577                .map(|(i, f)| Entity {
578                    label: if f.name.is_empty() {
579                        format!("Face {i}")
580                    } else {
581                        f.name.clone()
582                    },
583                    selected: !f.name.is_empty() && sel_faces.contains(&f.name),
584                    visible: s.visibility.is_face_visible(i),
585                    kind: EntityKind::Face(f.name.clone()),
586                })
587                .collect();
588            let edges = s
589                .edges
590                .iter()
591                .enumerate()
592                .map(|(i, e)| Entity {
593                    label: if e.name.is_empty() {
594                        format!("Edge {i}")
595                    } else {
596                        e.name.clone()
597                    },
598                    selected: !e.name.is_empty() && sel_edges.contains(&e.name),
599                    visible: s.visibility.is_edge_visible(i),
600                    kind: EntityKind::Edge(e.name.clone()),
601                })
602                .collect();
603            let vertices = s
604                .vertices
605                .iter()
606                .enumerate()
607                .map(|(i, v)| Entity {
608                    label: format!("Vertex {i}"),
609                    selected: sel_vertices.iter().any(|r| {
610                        r.solid == s.name
611                            && (r.position[0] - v.position[0]).abs() <= TOL
612                            && (r.position[1] - v.position[1]).abs() <= TOL
613                            && (r.position[2] - v.position[2]).abs() <= TOL
614                    }),
615                    visible: s.visibility.is_vertex_visible(i),
616                    kind: EntityKind::Vertex(v.position),
617                })
618                .collect();
619            SolidRow {
620                name: s.name.clone(),
621                visible: s.visible,
622                selected: sel_solids.contains(&s.name),
623                faces,
624                edges,
625                vertices,
626            }
627        })
628        .collect()
629}
630
631/// Mirror an engine JSON string to `window.<name>` (wasm/verification only).
632#[cfg(target_arch = "wasm32")]
633fn publish(name: &str, json: &str) {
634    if let Some(win) = web_sys::window() {
635        let _ = js_sys::Reflect::set(
636            &win,
637            &wasm_bindgen::JsValue::from_str(name),
638            &wasm_bindgen::JsValue::from_str(json),
639        );
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646
647    /// A one-primitive cube history, name-parameterized — mirrors the fixture the
648    /// engine-layer visibility tests use so the solid lands as `name` with 6 faces
649    /// / 12 edges / 8 vertices.
650    fn cube_history(name: &str) -> String {
651        serde_json::json!({
652            "expressions": "",
653            "configurator": {},
654            "features": [{
655                "type": "P.CU",
656                "inputParams": {
657                    "id": name,
658                    "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
659                    "transform": {
660                        "position": [0.0, 0.0, 0.0],
661                        "rotationEuler": [0.0, 0.0, 0.0],
662                        "scale": [1.0, 1.0, 1.0]
663                    },
664                    "boolean": { "targets": [], "operation": "NONE" }
665                },
666                "persistentData": {}
667            }]
668        })
669        .to_string()
670    }
671
672    /// Run ONE egui frame of the Scene panel headlessly (no window / GPU), feeding
673    /// `events` as this frame's input. Layout is deterministic, so widget rects are
674    /// stable frame-to-frame and the `hits` map can be read back to drive a real
675    /// pointer click at a checkbox's screen rect.
676    fn run_frame(
677        ctx: &egui::Context,
678        panel: &mut ScenePanel,
679        state: &mut EngineState,
680        events: Vec<egui::Event>,
681        scroll: bool,
682    ) {
683        let raw = egui::RawInput {
684            screen_rect: Some(egui::Rect::from_min_size(
685                egui::pos2(0.0, 0.0),
686                egui::vec2(800.0, 600.0),
687            )),
688            events,
689            ..Default::default()
690        };
691        let _ = ctx.run_ui(raw, |ui| {
692            // `scroll` wraps the tree in the app's `ScrollArea::vertical()` so the
693            // click-routing is exercised under the real sidebar geometry (tree.rs
694            // warns right-slot content underlapping the scrollbar can be eaten as a
695            // scroll drag), not just on a bare root Ui.
696            if scroll {
697                egui::ScrollArea::vertical().show(ui, |ui| panel.show(ui, state));
698            } else {
699                panel.show(ui, state);
700            }
701        });
702    }
703
704    /// Left-click at `pos` split across a press frame and a release frame (egui
705    /// fires `clicked()` on release), re-running the panel each frame so the
706    /// deferred visibility mutation is applied.
707    fn click_at(
708        ctx: &egui::Context,
709        panel: &mut ScenePanel,
710        state: &mut EngineState,
711        pos: egui::Pos2,
712        scroll: bool,
713    ) {
714        run_frame(
715            ctx,
716            panel,
717            state,
718            vec![
719                egui::Event::PointerMoved(pos),
720                egui::Event::PointerButton {
721                    pos,
722                    button: egui::PointerButton::Primary,
723                    pressed: true,
724                    modifiers: egui::Modifiers::default(),
725                },
726            ],
727            scroll,
728        );
729        run_frame(
730            ctx,
731            panel,
732            state,
733            vec![egui::Event::PointerButton {
734                pos,
735                button: egui::PointerButton::Primary,
736                pressed: false,
737                modifiers: egui::Modifiers::default(),
738            }],
739            scroll,
740        );
741    }
742
743    /// End-to-end through the real egui widget dispatch: clicking a group's
744    /// tristate checkbox in the Scene tree must hide EVERY entity of that kind on
745    /// the solid (the deferred `SetGroupVisible` action reaching
746    /// `EngineState::set_group_visible`). Faces is the reported-broken case; Edges
747    /// is the working control — both are driven so a face-specific regression shows
748    /// up as an asymmetry, not a harness artifact.
749    fn drive_group_checkbox(kind: VisKind, hit_key: &str, scroll: bool) {
750        let ctx = egui::Context::default();
751        let mut state = EngineState::new();
752        state.set_history_json(&cube_history("Box")).unwrap();
753        let mut panel = ScenePanel::new();
754
755        // Frame 1: lay the tree out (no input) so `hits` holds the checkbox rects.
756        run_frame(&ctx, &mut panel, &mut state, vec![], scroll);
757        assert_eq!(
758            state.group_visibility("Box", kind),
759            Some(GroupState::All),
760            "precondition: every {kind:?} starts visible"
761        );
762        let rect = *panel.hits.get(hit_key).unwrap_or_else(|| {
763            panic!(
764                "missing hit rect {hit_key}; have {:?}",
765                panel.hits.keys().collect::<Vec<_>>()
766            )
767        });
768
769        // Click the group checkbox → all of that kind hidden.
770        click_at(&ctx, &mut panel, &mut state, rect.center(), scroll);
771        assert_eq!(
772            state.group_visibility("Box", kind),
773            Some(GroupState::None),
774            "clicking the {kind:?} group checkbox must hide the whole group"
775        );
776
777        // Click again → all shown (the tristate re-shows a fully-hidden group).
778        run_frame(&ctx, &mut panel, &mut state, vec![], scroll);
779        let rect = *panel.hits.get(hit_key).expect("checkbox rect after hide");
780        click_at(&ctx, &mut panel, &mut state, rect.center(), scroll);
781        assert_eq!(
782            state.group_visibility("Box", kind),
783            Some(GroupState::All),
784            "re-clicking the {kind:?} group checkbox must re-show the whole group"
785        );
786    }
787
788    #[test]
789    fn scene_tree_faces_group_checkbox_hides_and_shows_through_egui() {
790        drive_group_checkbox(VisKind::Face, "vis:Box/Faces", false);
791    }
792
793    #[test]
794    fn scene_tree_edges_group_checkbox_hides_and_shows_through_egui() {
795        drive_group_checkbox(VisKind::Edge, "vis:Box/Edges", false);
796    }
797
798    /// Same as the faces group test, but with the tree wrapped in the app's
799    /// `ScrollArea::vertical()` — proves the checkbox click routes correctly under
800    /// the real sidebar geometry (not eaten as a scroll drag).
801    #[test]
802    fn scene_tree_faces_group_checkbox_works_inside_scroll_area() {
803        drive_group_checkbox(VisKind::Face, "vis:Box/Faces", true);
804    }
805
806    /// An INDIVIDUAL face leaf checkbox (group expanded first) hides just that one
807    /// face — the deferred `SetEntityVisible` action reaching
808    /// `EngineState::set_entity_visible`. The other reported-broken case alongside
809    /// the group toggle.
810    #[test]
811    fn scene_tree_individual_face_checkbox_hides_one_face_through_egui() {
812        let ctx = egui::Context::default();
813        let mut state = EngineState::new();
814        state.set_history_json(&cube_history("Box")).unwrap();
815        let mut panel = ScenePanel::new();
816
817        // Frame 1: layout → the Faces group's collapse box rect is published.
818        run_frame(&ctx, &mut panel, &mut state, vec![], false);
819        let box_rect = *panel.hits.get("box:Box/Faces").expect("faces group collapse box");
820
821        // Expand the Faces group (a panel-local toggle) so its leaves render.
822        click_at(&ctx, &mut panel, &mut state, box_rect.center(), false);
823        run_frame(&ctx, &mut panel, &mut state, vec![], false);
824        assert_eq!(
825            state.entity_visible("Box", VisKind::Face, 0),
826            Some(true),
827            "face 0 starts visible"
828        );
829        let leaf = *panel.hits.get("vis:Box/Faces/0").unwrap_or_else(|| {
830            panic!(
831                "missing face-0 leaf checkbox; have {:?}",
832                panel.hits.keys().collect::<Vec<_>>()
833            )
834        });
835
836        // Click face 0's checkbox → only face 0 hidden.
837        click_at(&ctx, &mut panel, &mut state, leaf.center(), false);
838        assert_eq!(
839            state.entity_visible("Box", VisKind::Face, 0),
840            Some(false),
841            "clicking face 0's checkbox must hide exactly face 0"
842        );
843        assert_eq!(
844            state.entity_visible("Box", VisKind::Face, 1),
845            Some(true),
846            "sibling face 1 stays visible"
847        );
848        assert_eq!(
849            state.group_visibility("Box", VisKind::Face),
850            Some(GroupState::Partial),
851            "one hidden face → group reads Partial"
852        );
853    }
854}