brep_app/panels/selection.rs
1//! Selection panel — the **selection filter** (which entity kinds a viewport
2//! click may pick), rendered as a single horizontal row 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 brep_render::engine_state::{EngineState, SelectionFilter};
32use crate::icon_text::IconTextUi as _;
33use eframe::egui;
34use std::collections::HashMap;
35
36/// The pickable kinds, in the order the filter row draws them: the geometry
37/// kinds coarsest-first, then the CONSTRUCTION kind last. SKETCH sits beside
38/// SOLID because a committed sketch is drawn as a sheet solid and picks as one;
39/// the two lanes split that single pick kind so a sketch can be made pickable
40/// (or not) independently of real bodies. It governs WHOLE sketches — a sketch's
41/// face and its drawn segments stay under Face and Edge.
42/// `(key, label)`: the `key` is the engine kind name + the `hits` map key
43/// suffix; the `label` is the checkbox caption. COMPONENT is the promotion
44/// kind: on, a pick landing on assembly-component geometry selects the WHOLE
45/// component; off, the click reaches the sub-entity kinds. PLANE is the
46/// construction kind: the drawn datum/plane cards, pickable like any face (they
47/// rank right after faces in the pick list, so a plane under geometry is
48/// reachable through the pick-list popup).
49const KINDS: [(&str, &str); 7] = [
50 ("COMPONENT", "Component"),
51 ("SOLID", "Solid"),
52 ("SKETCH", "Sketch"),
53 ("FACE", "Face"),
54 ("EDGE", "Edge"),
55 ("VERTEX", "Vertex"),
56 ("PLANE", "Plane"),
57];
58
59/// The selection panel's own state: the per-frame map of egui widget screen
60/// rects, published to JS for the headed verifier to drive real clicks. Rebuilt
61/// each frame (there is no DOM — egui is drawn on the canvas).
62#[derive(Default)]
63pub struct SelectionPanel {
64 hits: HashMap<String, egui::Rect>,
65}
66
67impl SelectionPanel {
68 pub fn new() -> Self {
69 Self::default()
70 }
71
72 /// Draw the selection filter as a single horizontal row into the shell's
73 /// bottom STATUS BAR: a leading "All" tristate checkbox + one CHECKBOX per
74 /// pickable kind (all reflecting the LIVE engine `selection_filter`). Rebuilds
75 /// `hits` as it draws. Called by the bottom bar in modeling mode.
76 pub fn show_status_bar(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
77 self.hits.clear();
78 ui.horizontal_wrapped(|ui| {
79 // While the reference-selection picker is active the engine
80 // temporarily CONSTRAINS the filter to the active field's allowed
81 // kinds (see `begin_ref_select`). Letting a bottom-bar click edit it
82 // then would clobber that constraint, so LOCK the row: it still shows
83 // the constrained kinds (greyed) but is non-interactive until the
84 // picker finishes/cancels and the filter is restored.
85 let locked = state.ref_select_active();
86 if locked {
87 ui.icon_label(egui::RichText::new("\u{1f512} Reference selection — filter locked").weak())
88 .on_hover_text(
89 "The pick filter is set by the field being referenced. \
90 Finish or cancel the reference selection to change it.",
91 );
92 } else {
93 ui.label("Pickable:");
94 }
95
96 // `add_enabled_ui(false, …)` greys the widgets AND makes them
97 // non-interactive, so no `.changed()`/`.clicked()` fires while locked
98 // — the constrained filter cannot be overwritten. Rects are still laid
99 // out and published so the verifier and layout stay consistent.
100 ui.add_enabled_ui(!locked, |ui| {
101 let mut filter = state.selection_filter();
102 let before = filter;
103
104 // Leading "All" TRISTATE CHECKBOX (mirrors the Scene tree's group
105 // checkbox idiom): checked when EVERY kind is on, the indeterminate
106 // dash when some-but-not-all are on, unchecked when none are.
107 // Toggling it applies the toggle-all semantic — all-on → clear all;
108 // partial or none → set all on — which is exactly the checkbox's
109 // post-click value. Published under `filter:ALL` for the verifier.
110 let all_on = KINDS.iter().all(|(k, _)| filter.get(k));
111 let any_on = KINDS.iter().any(|(k, _)| filter.get(k));
112 let mut all_checked = all_on;
113 let resp = ui.add(
114 egui::Checkbox::new(&mut all_checked, "All").indeterminate(any_on && !all_on),
115 );
116 self.hits.insert("filter:ALL".into(), resp.rect);
117 if resp.changed() {
118 let target = toggle_all_target(&filter);
119 for (kind, _) in KINDS {
120 filter.set(kind, target);
121 }
122 }
123
124 // One CHECKBOX per kind, reflecting the live filter. A `.changed()`
125 // checkbox updates the working copy; it is written back once below.
126 for (kind, label) in KINDS {
127 let mut on = filter.get(kind);
128 let resp = ui.checkbox(&mut on, label);
129 self.hits.insert(format!("filter:{kind}"), resp.rect);
130 if resp.changed() {
131 filter.set(kind, on);
132 }
133 }
134
135 if filter != before {
136 state.set_selection_filter(filter);
137 }
138 });
139 });
140 }
141
142 /// Drop the published widget rects. The bottom bar calls this in sketch mode
143 /// (when the filter row is NOT drawn) so the verifier never sees stale
144 /// last-modeling-frame rects for widgets that are no longer on screen.
145 pub fn clear_hits(&mut self) {
146 self.hits.clear();
147 }
148
149 /// The published widget hit-rects (egui points) for the headed verifier —
150 /// `filter:COMPONENT|SOLID|FACE|EDGE|VERTEX|PLANE` (the per-kind checkboxes)
151 /// + `filter:ALL` (the leading "All" tristate checkbox).
152 #[cfg(target_arch = "wasm32")]
153 pub fn hits_json(&self) -> String {
154 let map: serde_json::Map<String, serde_json::Value> = self
155 .hits
156 .iter()
157 .map(|(k, r)| {
158 (
159 k.clone(),
160 serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
161 )
162 })
163 .collect();
164 serde_json::Value::Object(map).to_string()
165 }
166}
167
168/// The "toggle all" target value: if EVERY pickable kind is currently on, the
169/// "All" checkbox turns them all OFF; otherwise (some-but-not-all on, or none on)
170/// it turns them all ON. Returns the value to write to every kind.
171fn toggle_all_target(filter: &SelectionFilter) -> bool {
172 !KINDS.iter().all(|(k, _)| filter.get(k))
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use brep_render::engine_state::EngineState;
179
180 /// Toggle-all semantics: all-on → all-off; any not-all state (partial OR
181 /// none) → all-on. These are the exact rules the bottom-bar button applies.
182 #[test]
183 fn toggle_all_target_semantics() {
184 // ALL on → target OFF.
185 let all = SelectionFilter::default();
186 assert!(
187 all.solid && all.face && all.edge && all.vertex && all.plane && all.component,
188 "default is all-on"
189 );
190 assert!(!toggle_all_target(&all), "all-on toggles to off");
191
192 // PARTIAL (only face) → target ON.
193 let partial = SelectionFilter {
194 solid: false,
195 sketch: false,
196 face: true,
197 edge: false,
198 vertex: false,
199 plane: false,
200 component: false,
201 };
202 assert!(toggle_all_target(&partial), "partial toggles to on");
203
204 // NONE on → target ON.
205 let none = SelectionFilter {
206 solid: false,
207 sketch: false,
208 face: false,
209 edge: false,
210 vertex: false,
211 plane: false,
212 component: false,
213 };
214 assert!(toggle_all_target(&none), "none toggles to on");
215 }
216
217 /// The status-bar row must publish a hit-rect for every checkbox
218 /// (`filter:SOLID|FACE|EDGE|VERTEX`) plus the leading "All" tristate checkbox
219 /// (`filter:ALL`) — the headed verifier drives them by these exact keys.
220 #[test]
221 fn status_bar_publishes_hit_rects() {
222 let mut panel = SelectionPanel::new();
223 let mut state = EngineState::new();
224 let ctx = egui::Context::default();
225 // One headless frame; layout records the per-frame hit-rects (no pointer
226 // input → nothing is clicked).
227 let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
228 panel.show_status_bar(ui, &mut state);
229 });
230 for key in [
231 "filter:COMPONENT",
232 "filter:SOLID",
233 "filter:FACE",
234 "filter:EDGE",
235 "filter:VERTEX",
236 "filter:PLANE",
237 "filter:ALL",
238 ] {
239 assert!(
240 panel.hits.contains_key(key),
241 "status bar must publish `{key}`, got {:?}",
242 panel.hits.keys().collect::<Vec<_>>()
243 );
244 }
245 }
246
247 /// A checkbox click drives the ENGINE filter through `set_selection_filter`:
248 /// unchecking Face disables face-picking, rechecking re-enables it. Driven
249 /// through the REAL egui widget via the accessibility tree (egui_kittest).
250 #[test]
251 fn checkbox_drives_engine_filter() {
252 use egui_kittest::kittest::Queryable;
253 let mut harness = egui_kittest::Harness::new_ui_state(
254 |ui, (panel, engine): &mut (SelectionPanel, EngineState)| {
255 panel.show_status_bar(ui, engine);
256 },
257 (SelectionPanel::new(), EngineState::new()),
258 );
259 harness.run();
260 assert!(harness.state().1.selection_filter().face, "default: face pickable");
261
262 harness.get_by_label("Face").click();
263 harness.run();
264 assert!(
265 !harness.state().1.selection_filter().face,
266 "unchecking Face must disable face-picking via set_selection_filter"
267 );
268
269 harness.get_by_label("Face").click();
270 harness.run();
271 assert!(
272 harness.state().1.selection_filter().face,
273 "rechecking Face must re-enable face-picking"
274 );
275 }
276
277 /// The leading "All" tristate checkbox drives the toggle-all semantic through
278 /// the ENGINE filter: from the all-on default a click clears every kind, and a
279 /// second click sets them all back on. Driven through the REAL egui widget via
280 /// the accessibility tree (egui_kittest).
281 #[test]
282 fn all_checkbox_toggles_every_kind() {
283 use egui_kittest::kittest::Queryable;
284 let mut harness = egui_kittest::Harness::new_ui_state(
285 |ui, (panel, engine): &mut (SelectionPanel, EngineState)| {
286 panel.show_status_bar(ui, engine);
287 },
288 (SelectionPanel::new(), EngineState::new()),
289 );
290 harness.run();
291 let f = harness.state().1.selection_filter();
292 assert!(
293 f.solid && f.face && f.edge && f.vertex && f.plane && f.component,
294 "default: all kinds on"
295 );
296
297 // All-on → clicking "All" clears every kind.
298 harness.get_by_label("All").click();
299 harness.run();
300 let f = harness.state().1.selection_filter();
301 assert!(
302 !f.solid && !f.face && !f.edge && !f.vertex && !f.plane && !f.component,
303 "clicking All while all-on must clear every kind: {f:?}"
304 );
305
306 // None-on → clicking "All" sets every kind.
307 harness.get_by_label("All").click();
308 harness.run();
309 let f = harness.state().1.selection_filter();
310 assert!(
311 f.solid && f.face && f.edge && f.vertex && f.plane && f.component,
312 "clicking All again must set every kind on: {f:?}"
313 );
314 }
315
316 /// The PLANE checkbox drives the ENGINE filter exactly like the Face one:
317 /// unchecking Plane disables construction-plane picking, rechecking re-enables
318 /// it. Driven through the REAL egui widget via the accessibility tree
319 /// (egui_kittest) — the same path a user's click takes.
320 #[test]
321 fn plane_checkbox_drives_engine_filter() {
322 use egui_kittest::kittest::Queryable;
323 let mut harness = egui_kittest::Harness::new_ui_state(
324 |ui, (panel, engine): &mut (SelectionPanel, EngineState)| {
325 panel.show_status_bar(ui, engine);
326 },
327 (SelectionPanel::new(), EngineState::new()),
328 );
329 harness.run();
330 assert!(harness.state().1.selection_filter().plane, "default: plane pickable");
331
332 harness.get_by_label("Plane").click();
333 harness.run();
334 let f = harness.state().1.selection_filter();
335 assert!(
336 !f.plane,
337 "unchecking Plane must disable plane-picking via set_selection_filter"
338 );
339 assert!(f.face, "…and must not disturb the other kinds: {f:?}");
340
341 harness.get_by_label("Plane").click();
342 harness.run();
343 assert!(
344 harness.state().1.selection_filter().plane,
345 "rechecking Plane must re-enable plane-picking"
346 );
347 }
348
349 /// Reference-selection LOCK: while the picker is active the engine constrains
350 /// the filter to the field's allowed kinds; the bottom-bar row must be locked
351 /// so a click cannot overwrite that constraint. Driven through the REAL egui
352 /// widget (egui_kittest): clicking a checkbox while locked is a no-op.
353 #[test]
354 fn ref_select_locks_the_filter_row() {
355 use egui_kittest::kittest::Queryable;
356 let mut state = EngineState::new();
357 // Activate ref-select for a FACE-only field. An absent feature id just
358 // resolves the "before" step to the current one — enough to flip
359 // `ref_select_active()` on and constrain the filter to face-only.
360 state.begin_ref_select(
361 "f",
362 vec!["p".into()],
363 "Ref".into(),
364 vec!["FACE".into()],
365 false,
366 vec![],
367 );
368 assert!(state.ref_select_active(), "ref-select is active");
369 let constrained = state.selection_filter();
370 assert!(
371 constrained.face && !constrained.solid,
372 "field filter constrained to face-only: {constrained:?}"
373 );
374
375 let mut harness = egui_kittest::Harness::new_ui_state(
376 |ui, (panel, engine): &mut (SelectionPanel, EngineState)| {
377 panel.show_status_bar(ui, engine);
378 },
379 (SelectionPanel::new(), state),
380 );
381 harness.run();
382 // Try to enable Solid: the row is locked (disabled), so the click is a
383 // no-op and the constrained filter is left exactly as the engine set it.
384 harness.get_by_label("Solid").click();
385 harness.run();
386 assert_eq!(
387 harness.state().1.selection_filter(),
388 constrained,
389 "a locked checkbox click must not change the ref-select-constrained filter"
390 );
391 }
392}