Skip to main content

brep_app/panels/
selection.rs

1//! Selection panel — the **selection filter** (which entity kinds a viewport
2//! click may pick), rendered as a row or compact popup in the shell's bottom
3//! STATUS BAR (not a side-panel section). Follows the panel pattern: a small
4//! state struct + a `show_status_bar(&mut self, ui, state)` the bottom bar calls
5//! once per frame in modeling mode; `EngineState` stays the single brain (it owns
6//! the filter + the selection), borrowed in.
7//!
8//! * **Filter** — a leading "All" tristate CHECKBOX followed by a real CHECKBOX
9//!   per kind (COMPONENT / SOLID / FACE / EDGE / VERTEX / PLANE; COMPONENT =
10//!   promote a pick on assembly-component geometry to the whole component;
11//!   PLANE = the construction datum/plane cards, pickable like any face). The
12//!   engine honors the filter in
13//!   `select_top_at` (via the planes-aware `pick_top_at` with the enabled
14//!   kinds), so a plain click grabs only an
15//!   allowed kind — a FACE-only filter selects a face, a SOLID-only filter the
16//!   owning solid, a PLANE-only filter the construction plane under the cursor. Defaults to ALL kinds enabled (everything under the cursor is
17//!   pickable, highest-priority kind wins). Reference-selection mode temporarily
18//!   constrains it to the active field's allowed kinds — while that picker is
19//!   active the row is LOCKED (greyed + non-interactive) so a click can't
20//!   overwrite the constraint. The model state is the engine's `selection_filter`;
21//!   this panel just reads/writes it.
22//!
23//! The quick actions on the current selection (Clear / Hide / Edit-owning-feature
24//! + the feature-from-selection actions) live in the dedicated
25//! [`crate::panels::context_bar`] (the engine-native successor to the old app's
26//! floating selection action bar).
27//!
28//! The panel owns only the per-frame `hits` map (widget screen rects) the headed
29//! verifier reads to drive real clicks, exactly like the toolbar/history panels.
30
31use crate::automation::hit_keys::HitKeyDoc;
32use brep_render::engine_state::{EngineState, SelectionFilter};
33use crate::icon_text::IconTextUi as _;
34use eframe::egui;
35use std::collections::HashMap;
36
37/// The pickable kinds, in the order the filter row draws them: the geometry
38/// kinds coarsest-first, then the CONSTRUCTION kind last. SKETCH sits beside
39/// SOLID because a committed sketch is drawn as a sheet solid and picks as one;
40/// the two lanes split that single pick kind so a sketch can be made pickable
41/// (or not) independently of real bodies. It governs WHOLE sketches — a sketch's
42/// face and its drawn segments stay under Face and Edge.
43/// `(key, label)`: the `key` is the engine kind name + the `hits` map key
44/// suffix; the `label` is the checkbox caption. COMPONENT is the promotion
45/// kind: on, a pick landing on assembly-component geometry selects the WHOLE
46/// component; off, the click reaches the sub-entity kinds. PLANE is the
47/// construction kind: the drawn datum/plane cards, pickable like any face (they
48/// rank right after faces in the pick list, so a plane under geometry is
49/// reachable through the pick-list popup).
50const KINDS: [(&str, &str); 7] = [
51    ("COMPONENT", "Component"),
52    ("SOLID", "Solid"),
53    ("SKETCH", "Sketch"),
54    ("FACE", "Face"),
55    ("EDGE", "Edge"),
56    ("VERTEX", "Vertex"),
57    ("PLANE", "Plane"),
58];
59
60/// The selection panel's own state: the per-frame map of egui widget screen
61/// rects, published to JS for the headed verifier to drive real clicks. Rebuilt
62/// each frame (there is no DOM — egui is drawn on the canvas).
63#[derive(Default)]
64pub struct SelectionPanel {
65    hits: HashMap<String, egui::Rect>,
66    inspection: super::selection_inspection::Cache,
67    /// The compact filter button's caption as last drawn, `None` while the
68    /// full row is drawn instead (published as `__brepFilterButton`).
69    button: Option<String>,
70}
71
72impl SelectionPanel {
73    pub fn new() -> Self {
74        Self::default()
75    }
76
77    /// Draw the selection filter as a row or compact popup in the shell's
78    /// bottom STATUS BAR: a leading "All" tristate checkbox + one CHECKBOX per
79    /// pickable kind (all reflecting the LIVE engine `selection_filter`). Rebuilds
80    /// `hits` as it draws. Called by the bottom bar in modeling mode.
81    pub fn show_status_bar(&mut self, ui: &mut egui::Ui, state: &mut EngineState, document: u64) {
82        let inspection = self.inspection.read(state, document);
83        // `Align::Min` for the same reason as the bar's own row: centring
84        // against the unbounded height below would double the bar.
85        ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
86            // Measurements hold the right end, leaving the filter button room
87            // on a narrow bar; a clipped reading shows in full on hover.
88            if let Some(text) = inspection["text"].as_str().filter(|s| !s.is_empty()) {
89                let width = (ui.available_width() - 100.0).max(0.0);
90                ui.scope(|ui| {
91                    ui.set_max_width(width);
92                    ui.add(egui::Label::new(text).truncate());
93                });
94            }
95            // The filter becomes a popup when the measurements leave too
96            // little room for all its checkboxes.
97            ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| {
98                ui.horizontal_wrapped(|ui| self.show_filter_bar(ui, state));
99            });
100        });
101    }
102
103    pub fn show_filter_bar(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
104        self.hits.clear();
105        self.button = None;
106        // A loading label can leave only a sliver of the status bar. Never
107        // wrap the filter into that sliver: a popup does not consume viewport
108        // height and remains usable with touch-sized controls. The whole
109        // filter is then ONE button whose caption says what is pickable.
110        if crate::viewport::touch::enabled(ui.ctx()) || ui.available_width() < crate::viewport::touch::NARROW_WIDTH {
111            let caption = button_caption(&state.selection_filter(), state.ref_select_active());
112            self.button = Some(caption.clone());
113            let menu = egui::containers::menu::MenuButton::new(caption)
114                .config(egui::containers::menu::MenuConfig::new()
115                    .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside))
116                .ui(ui, |ui| {
117                    ui.set_width(240.0_f32.min(ui.ctx().content_rect().width() - 24.0));
118                    egui::ScrollArea::vertical()
119                        .max_height((ui.ctx().content_rect().height() - 100.0).max(88.0))
120                        .show(ui, |ui| self.show_filters(ui, state));
121                });
122            self.hits.insert("filters:toggle".into(), menu.0.rect);
123        } else {
124            ui.horizontal_wrapped(|ui| self.show_filters(ui, state));
125        }
126    }
127
128    pub fn inspection_json(&mut self, state: &mut EngineState, document: u64) -> String {
129        self.inspection.read(state, document).to_string()
130    }
131
132    fn show_filters(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
133        // While the reference-selection picker is active the engine
134        // temporarily CONSTRAINS the filter to the active field's allowed
135        // kinds (see `begin_ref_select`). Letting a bottom-bar click edit it
136        // then would clobber that constraint, so LOCK the row: it still shows
137        // the constrained kinds (greyed) but is non-interactive until the
138        // picker finishes/cancels and the filter is restored.
139        let locked = state.ref_select_active();
140        if locked {
141            ui.icon_label(egui::RichText::new("\u{1f512} Reference selection — filter locked").weak())
142                .on_hover_text(
143                    "The pick filter is set by the field being referenced. \
144                     Finish the pick (Done on its field, or Escape) to change it.",
145                );
146        } else {
147            ui.label("Pickable:");
148        }
149
150        // `add_enabled_ui(false, …)` greys the widgets AND makes them
151        // non-interactive, so no `.changed()`/`.clicked()` fires while locked
152        // — the constrained filter cannot be overwritten. Rects are still laid
153        // out and published so the verifier and layout stay consistent.
154        ui.add_enabled_ui(!locked, |ui| {
155            let mut filter = state.selection_filter();
156            let before = filter;
157
158            // Leading "All" TRISTATE CHECKBOX (mirrors the Scene tree's group
159            // checkbox idiom): checked when EVERY kind is on, the indeterminate
160            // dash when some-but-not-all are on, unchecked when none are.
161            // Toggling it applies the toggle-all semantic — all-on → clear all;
162            // partial or none → set all on — which is exactly the checkbox's
163            // post-click value. Published under `filter:ALL` for the verifier.
164            let all_on = KINDS.iter().all(|(k, _)| filter.get(k));
165            let any_on = KINDS.iter().any(|(k, _)| filter.get(k));
166            let mut all_checked = all_on;
167            let resp = ui.add(
168                egui::Checkbox::new(&mut all_checked, "All").indeterminate(any_on && !all_on),
169            );
170            self.hits.insert("filter:ALL".into(), resp.rect);
171            if resp.changed() {
172                let target = toggle_all_target(&filter);
173                for (kind, _) in KINDS {
174                    filter.set(kind, target);
175                }
176            }
177
178            // One CHECKBOX per kind, reflecting the live filter. A `.changed()`
179            // checkbox updates the working copy; it is written back once below.
180            for (kind, label) in KINDS {
181                let mut on = filter.get(kind);
182                let resp = ui.checkbox(&mut on, label);
183                self.hits.insert(format!("filter:{kind}"), resp.rect);
184                if resp.changed() {
185                    filter.set(kind, on);
186                }
187            }
188
189            if filter != before {
190                state.set_selection_filter(filter);
191            }
192        });
193    }
194
195    /// Drop the published widget rects. The bottom bar calls this in sketch mode
196    /// (when the filter row is NOT drawn) so the verifier never sees stale
197    /// last-modeling-frame rects for widgets that are no longer on screen.
198    pub fn clear_hits(&mut self) {
199        self.hits.clear();
200        self.button = None;
201    }
202
203    /// The `__brepFilterButton` verifier global: `{shown, label}` — whether
204    /// the filter is collapsed to its one button, and the button's caption.
205    pub fn button_json(&self) -> String {
206        serde_json::json!({ "shown": self.button.is_some(), "label": self.button }).to_string()
207    }
208
209    /// The published widget hit-rects (egui points) for the headed verifier —
210    /// `filter:COMPONENT|SOLID|FACE|EDGE|VERTEX|PLANE` (the per-kind checkboxes)
211    /// + `filter:ALL` (the leading "All" tristate checkbox).
212    pub fn hits_json(&self) -> String {
213        crate::automation::hit_rects::hits_json(&self.hits)
214    }
215}
216
217/// The compact filter button's caption: what a click may pick, so the state
218/// shows without opening the popup. "All" and "None" for the two ends, the
219/// kind names for one or two, a count beyond that; a reference pick's
220/// constraint is marked locked (the popup cannot change it).
221fn button_caption(filter: &SelectionFilter, locked: bool) -> String {
222    let on: Vec<&str> = KINDS.iter().filter(|(k, _)| filter.get(k)).map(|(_, label)| *label).collect();
223    let what = match on.len() {
224        0 => "None".to_string(),
225        n if n == KINDS.len() => "All".to_string(),
226        1 | 2 => on.join(", "),
227        n => format!("{n} of {}", KINDS.len()),
228    };
229    if locked { format!("Filter (locked): {what}") } else { format!("Filter: {what}") }
230}
231
232/// The "toggle all" target value: if EVERY pickable kind is currently on, the
233/// "All" checkbox turns them all OFF; otherwise (some-but-not-all on, or none on)
234/// it turns them all ON. Returns the value to write to every kind.
235fn toggle_all_target(filter: &SelectionFilter) -> bool {
236    !KINDS.iter().all(|(k, _)| filter.get(k))
237}
238
239
240/// The hit keys this panel publishes (see `automation::hit_keys`).
241pub static HIT_KEYS: &[HitKeyDoc] = &[
242    HitKeyDoc { panel: "selection", prefix: "filters:toggle", meaning: "show or hide pickable filters when the status bar is compact", command: None },
243    HitKeyDoc { panel: "selection", prefix: "filter:", meaning: "toggle which entity kinds a click may pick (filter:ALL, filter:kind)", command: None },
244];