Skip to main content

brep_render/engine_state/
scene_query.rs

1use super::*;
2
3impl EngineState {
4    /// Ranked candidate list under CSS-pixel `(x, y)`, kernel names, priority
5    /// VERTEX > EDGE > FACE > … > SOLID. Feeds the host's multi-candidate popup.
6    pub fn pick_json(&self, x: f64, y: f64) -> String {
7        let candidates = pick::pick(&self.scene, &self.camera, x, y, &self.pick_options());
8        pick::candidates_to_json(&candidates)
9    }
10
11    /// The single best candidate under `(x, y)` (hover), or `null`.
12    pub fn hover_json(&self, x: f64, y: f64) -> String {
13        let candidates = pick::pick(&self.scene, &self.camera, x, y, &self.pick_options());
14        match candidates.first() {
15            Some(best) => pick::candidates_to_json(std::slice::from_ref(best))
16                .strip_prefix('[')
17                .and_then(|s| s.strip_suffix(']'))
18                .map(str::to_string)
19                .unwrap_or_else(|| "null".to_string()),
20            None => "null".to_string(),
21        }
22    }
23
24    // --- Settings / emphasis / visibility (R11/R14/R17) -------------------
25
26    pub fn apply_settings_json(&mut self, json: &str) -> Result<(), String> {
27        let prev_lod = self.settings.lod_factor;
28        self.settings.apply_json(json)?;
29        self.settings_generation = self.settings_generation.wrapping_add(1);
30        self.dirty = true;
31        // Projection rides in the settings JSON as `orthographic` (see `settings_json`)
32        // so the toolbar toggle AND a reload both go through this ONE apply path — the
33        // same way wireframe does. It is NOT a `RenderSettings` field: read it straight
34        // off the JSON and drive the camera. A PARTIAL apply (the wireframe toggle's
35        // `{"wireframe":true}`) omits the key and leaves the projection untouched, and
36        // the panel's full-buffer apply carries the live value (so it's a no-op).
37        if let Some(want_ortho) = serde_json::from_str::<serde_json::Value>(json)
38            .ok()
39            .and_then(|v| v.get("orthographic").and_then(|o| o.as_bool()))
40        {
41            let is_ortho = matches!(
42                self.camera.projection,
43                crate::view::Projection::Orthographic { .. }
44            );
45            if want_ortho != is_ortho {
46                self.set_projection(if want_ortho { "orthographic" } else { "perspective" });
47            }
48        }
49        // The LOD factor scales DISPLAY tessellation, so a change must re-run so the
50        // resident meshes re-tessellate at the new chord tolerance (the runner drops
51        // its reuse baseline when the lod differs). Every OTHER setting is pure
52        // render state and needs no re-run. Skip the re-run when there are no
53        // features (e.g. boot restores a saved `lodFactor` before any document is
54        // loaded): the run would be empty, and the real doc load re-runs with the
55        // lod already injected.
56        if self.settings.lod_factor != prev_lod && !self.history.is_empty() {
57            self.rerun_history();
58        }
59        // Sketch colors live in the settings too: when a sketch is being edited, push
60        // the (possibly) new palette into the live session and re-push the overlay so
61        // an edited color takes effect immediately (mirrors how a wireframe/lod change
62        // refreshes the view). Compute the palette first to avoid a split borrow.
63        if self.sketch_edit.is_some() {
64            let colors = self.settings.sketch_colors();
65            if let Some(edit) = self.sketch_edit.as_mut() {
66                edit.session.colors = colors;
67            }
68            self.refresh_sketch_overlay();
69        }
70        Ok(())
71    }
72
73    /// The FULL current settings as JSON (the round-trip counterpart of
74    /// [`apply_settings_json`]): the schema-driven form seeds its widgets from
75    /// this and the storage seam persists it.
76    pub fn settings_json(&self) -> String {
77        // Projection is live CAMERA state surfaced to the settings layer as a boolean
78        // (`orthographic`) so the toolbar toggle persists and the settings panel can
79        // round-trip it without clobbering. DERIVE it from the camera here — it is
80        // never a stored `RenderSettings` field — so it can NEVER drift from the
81        // actual projection no matter which code path last changed it.
82        let mut value: serde_json::Value =
83            serde_json::from_str(&self.settings.to_json()).unwrap_or(serde_json::Value::Null);
84        if let Some(obj) = value.as_object_mut() {
85            obj.insert(
86                "orthographic".into(),
87                serde_json::Value::Bool(matches!(
88                    self.camera.projection,
89                    crate::view::Projection::Orthographic { .. }
90                )),
91            );
92        }
93        value.to_string()
94    }
95
96    /// The current per-solid metadata color overrides as JSON —
97    /// `[{"name": "...", "override": "#rrggbb" | null}, …]`. Lets a UI list the
98    /// scene's solids with their current override so the picker reflects state.
99    pub fn solid_color_overrides_json(&self) -> String {
100        let solids: Vec<serde_json::Value> = self
101            .scene
102            .solids()
103            .iter()
104            .map(|solid| {
105                let over = solid.color_override.map(|rgb| {
106                    let q = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u32;
107                    format!("#{:02x}{:02x}{:02x}", q(rgb[0]), q(rgb[1]), q(rgb[2]))
108                });
109                serde_json::json!({ "name": solid.name, "override": over })
110            })
111            .collect();
112        serde_json::Value::Array(solids).to_string()
113    }
114
115    pub fn apply_emphasis_json(&mut self, json: &str) -> Result<(), String> {
116        self.emphasis.apply_json(json)?;
117        self.dirty = true;
118        Ok(())
119    }
120
121    pub fn set_visible(&mut self, name: &str, visible: bool) -> bool {
122        let ok = self.scene.set_visible(name, visible);
123        if ok {
124            self.dirty = true;
125        }
126        ok
127    }
128
129    /// Set (or clear) a solid's LIVE per-solid color override (R14) without a
130    /// history rerun — the app assigning a metadata color updates the view
131    /// immediately. `color_hex` is a CSS hex string (`#rrggbb`); `None` or an
132    /// empty/unparseable string clears back to the hashed/uniform base color.
133    /// Marks dirty; returns false if the solid name is unknown.
134    pub fn set_color_override(&mut self, name: &str, color_hex: Option<&str>) -> bool {
135        let color = match color_hex {
136            Some(hex) if !hex.trim().is_empty() => crate::style::parse_css_hex(hex),
137            _ => None,
138        };
139        let ok = self.scene.set_color_override(name, color);
140        if ok {
141            self.dirty = true;
142        }
143        ok
144    }
145
146    pub fn scene_listing_json(&self) -> String {
147        self.scene.listing_json()
148    }
149
150    // --- Overlay widgets --------------------------------------------------
151
152    /// The bbox the camera depth-range fit should use: the visible SOLIDS unioned
153    /// with the pushed OVERLAY groups (sketch curves/points, dimension leaders,
154    /// constraint glyphs). Folding in the overlay stops orbiting an editing sketch
155    /// from clipping it against the solids-only bounds (the reported clipping when
156    /// "Lock to sketch" is off). Callers must bind this to a local before
157    /// `camera.fit_depth_range` (which needs `&mut self.camera`).
158    pub fn depth_range_bbox(&self) -> crate::camera::Aabb {
159        let mut bbox = self.scene.bbox();
160        bbox.union(&self.widgets.overlay_groups_bbox());
161        bbox
162    }
163
164}
165
166// ============================================================================
167// Scene-tree accessors (appended — see the engine-native Scene panel slice).
168// Kept as a SEPARATE `impl` block so concurrent panel work does not conflict
169// with the primary surface above; purely additive over the existing
170// scene / emphasis API (`scene_listing_json`, `set_visible`, `selection_json`,
171// `clear_selection`, `apply_emphasis_json`).
172// ============================================================================
173impl EngineState {
174    /// A RICHER scene listing than [`scene_listing_json`](Self::scene_listing_json)
175    /// (which is counts only): per solid the individual face + edge kernel NAMES
176    /// and vertex refs (topo id + world position), plus visibility — the shape the
177    /// engine-native Scene tree lists entities from and the headed verifier asserts
178    /// against. Vertices carry no kernel name, so they are keyed by topo id + world
179    /// position (the same shape the emphasis vertex-ref selection uses).
180    pub fn scene_entities_json(&self) -> String {
181        let solids: Vec<serde_json::Value> = self
182            .scene
183            .solids()
184            .iter()
185            // Committed-sketch SHEETS are scene solids (pickable/measurable) but list
186            // under "Sketches" (`committed_sketches`), not among the real solids.
187            .filter(|solid| !solid.is_sketch)
188            .map(|solid| {
189                let faces: Vec<&str> = solid.faces.iter().map(|f| f.name.as_str()).collect();
190                let edges: Vec<&str> = solid.edges.iter().map(|e| e.name.as_str()).collect();
191                let vertices: Vec<serde_json::Value> = solid
192                    .vertices
193                    .iter()
194                    .map(|v| serde_json::json!({ "topoId": v.topo_id, "position": v.position }))
195                    .collect();
196                serde_json::json!({
197                    "name": solid.name,
198                    "visible": solid.visible,
199                    "faces": faces,
200                    "edges": edges,
201                    "vertices": vertices,
202                })
203            })
204            .collect();
205        serde_json::Value::Array(solids).to_string()
206    }
207
208    /// Drive the engine SELECTION by kernel NAME from a UI tree (the name-based
209    /// analogue of [`select_top_at`](Self::select_top_at), which picks under the
210    /// cursor). Replaces the current selection with the single named `solid` /
211    /// `face` / `edge` so clicking a Scene-tree row highlights that entity in the
212    /// viewport (the render pass reads `emphasis`). Vertices have no kernel name —
213    /// use [`select_vertex_by_position`](Self::select_vertex_by_position). Returns
214    /// false for an unknown `kind` or an empty `name`.
215    pub fn select_by_name(&mut self, kind: &str, name: &str) -> bool {
216        // A construction datum/plane routes to its own name-keyed selection.
217        if kind == "datum" {
218            return self.select_datum(name);
219        }
220        if name.is_empty() || !matches!(kind, "solid" | "face" | "edge") {
221            return false;
222        }
223        let had_datum = !self.emphasis.selected_datums.is_empty();
224        self.emphasis.selected_solids.clear();
225        self.emphasis.selected_faces.clear();
226        self.emphasis.selected_edges.clear();
227        self.emphasis.selected_vertices.clear();
228        self.emphasis.selected_datums.clear();
229        match kind {
230            "solid" => {
231                self.emphasis.selected_solids.insert(name.to_string());
232            }
233            "face" => {
234                self.emphasis.selected_faces.insert(name.to_string());
235            }
236            "edge" => {
237                self.emphasis.selected_edges.insert(name.to_string());
238            }
239            _ => unreachable!(),
240        }
241        self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
242        self.dirty = true;
243        if had_datum {
244            self.refresh_construction_datums();
245        }
246        true
247    }
248
249    /// Select a single vertex by its owning solid + world position — vertices have
250    /// no kernel name, so emphasis keys them by solid + position (matched with a
251    /// tolerance in the render pass). Replaces the current selection. Returns false
252    /// for an empty solid name.
253    pub fn select_vertex_by_position(&mut self, solid: &str, position: [f64; 3]) -> bool {
254        if solid.is_empty() {
255            return false;
256        }
257        let had_datum = !self.emphasis.selected_datums.is_empty();
258        self.emphasis.selected_solids.clear();
259        self.emphasis.selected_faces.clear();
260        self.emphasis.selected_edges.clear();
261        self.emphasis.selected_vertices.clear();
262        self.emphasis.selected_datums.clear();
263        self.emphasis.selected_vertices.push(crate::style::VertexRef {
264            solid: solid.to_string(),
265            position,
266        });
267        self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
268        self.dirty = true;
269        if had_datum {
270            self.refresh_construction_datums();
271        }
272        true
273    }
274}
275
276// Scene-tree accessor tests — kept in their OWN module (appended) so they do not
277// conflict with the primary `mod tests` above.
278#[cfg(test)]
279mod scene_tree_tests {
280    use super::*;
281
282    fn cube_history(name: &str) -> String {
283        serde_json::json!({
284            "expressions": "",
285            "configurator": {},
286            "features": [{
287                "type": "P.CU",
288                "inputParams": {
289                    "id": name,
290                    "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
291                    "transform": {
292                        "position": [0.0, 0.0, 0.0],
293                        "rotationEuler": [0.0, 0.0, 0.0],
294                        "scale": [1.0, 1.0, 1.0]
295                    },
296                    "boolean": { "targets": [], "operation": "NONE" }
297                },
298                "persistentData": {}
299            }]
300        })
301        .to_string()
302    }
303
304    #[test]
305    fn scene_entities_json_lists_solid_faces_edges_vertices() {
306        let mut engine = EngineState::new();
307        engine.set_history_json(&cube_history("Box")).unwrap();
308        let listing: serde_json::Value =
309            serde_json::from_str(&engine.scene_entities_json()).unwrap();
310        let arr = listing.as_array().unwrap();
311        assert_eq!(arr.len(), 1);
312        let solid = &arr[0];
313        assert_eq!(solid["name"], "Box");
314        assert_eq!(solid["visible"], true);
315        // A cube has 6 faces, 12 edges, 8 vertices.
316        assert_eq!(solid["faces"].as_array().unwrap().len(), 6);
317        assert_eq!(solid["edges"].as_array().unwrap().len(), 12);
318        assert_eq!(solid["vertices"].as_array().unwrap().len(), 8);
319        // Each vertex ref carries a position triple.
320        assert_eq!(solid["vertices"][0]["position"].as_array().unwrap().len(), 3);
321    }
322
323    #[test]
324    fn select_by_name_solid_sets_emphasis_and_replaces() {
325        let mut engine = EngineState::new();
326        engine.set_history_json(&cube_history("Box")).unwrap();
327        assert!(engine.select_by_name("solid", "Box"));
328        assert!(engine.emphasis.selected_solids.contains("Box"));
329        // A second solid selection REPLACES the first (single-select).
330        engine.emphasis.selected_faces.insert("stale".into());
331        assert!(engine.select_by_name("solid", "Box"));
332        assert!(engine.emphasis.selected_faces.is_empty());
333    }
334
335    #[test]
336    fn select_by_name_rejects_unknown_kind_or_empty_without_clearing() {
337        let mut engine = EngineState::new();
338        engine.set_history_json(&cube_history("Box")).unwrap();
339        engine.select_by_name("solid", "Box");
340        // An unknown kind / empty name is a no-op that keeps the current selection.
341        assert!(!engine.select_by_name("blob", "Box"));
342        assert!(!engine.select_by_name("solid", ""));
343        assert!(engine.emphasis.selected_solids.contains("Box"));
344    }
345
346    #[test]
347    fn select_vertex_by_position_records_vertex_ref() {
348        let mut engine = EngineState::new();
349        engine.set_history_json(&cube_history("Box")).unwrap();
350        assert!(engine.select_vertex_by_position("Box", [1.0, 2.0, 3.0]));
351        assert_eq!(engine.emphasis.selected_vertices.len(), 1);
352        let vr = &engine.emphasis.selected_vertices[0];
353        assert_eq!(vr.solid, "Box");
354        assert_eq!(vr.position, [1.0, 2.0, 3.0]);
355        // Empty solid name is rejected.
356        assert!(!engine.select_vertex_by_position("", [0.0, 0.0, 0.0]));
357    }
358
359    #[test]
360    fn set_visible_toggles_solid_and_shows_in_listing() {
361        let mut engine = EngineState::new();
362        engine.set_history_json(&cube_history("Box")).unwrap();
363        assert!(engine.set_visible("Box", false));
364        let listing: serde_json::Value =
365            serde_json::from_str(&engine.scene_entities_json()).unwrap();
366        assert_eq!(listing[0]["visible"], false);
367        assert!(!engine.set_visible("Nope", false));
368    }
369}
370
371// ============================================================================
372// Inspector mass properties (appended — see the Inspector-panel slice).
373// SEPARATE `impl` block so concurrent panel work appending to the primary block
374// does not conflict; purely additive over the existing history/scene API.
375// ============================================================================
376impl EngineState {
377    /// The resident kernel handle of every solid currently displayed, keyed by
378    /// name. Obtained by replaying the CURRENT rolled-to history prefix through
379    /// [`brep_kernel::execute_history`]: after a build the incremental cache
380    /// holds exactly this prefix, so the replay is a clean cache hit — it
381    /// re-tessellates nothing and hands back the SAME handles the scene was built
382    /// from (mirrors the pipeline's `fold_history`: removals then additions).
383    fn resident_solid_handles(&self) -> HashMap<String, u32> {
384        let request: HistoryRequest = match serde_json::from_value(self.history.prefix_request()) {
385            Ok(request) => request,
386            Err(_) => return HashMap::new(),
387        };
388        let result = brep_kernel::execute_history(&request);
389        let mut handles: HashMap<String, u32> = HashMap::new();
390        for feature in &result.results {
391            for removed in &feature.removed {
392                handles.remove(removed);
393            }
394            for added in &feature.added {
395                handles.insert(added.name.clone(), added.handle);
396            }
397        }
398        handles
399    }
400
401    /// Mass properties for the Inspector panel, from the kernel's exact
402    /// (divergence-theorem) integrator. `name = Some(solid)` reports that resident
403    /// solid; `None` reports the whole model. `density` (mass units per mm³; the
404    /// kernel length convention is millimetres) scales `mass` and the inertia
405    /// tensor — the centroid and principal axes are density-independent.
406    ///
407    /// Returns JSON:
408    /// ```json
409    /// { "ok": true, "target": "Box", "solidCount": 1, "density": 1.0,
410    ///   "volume": 5738.05, "surfaceArea": 2927.79, "mass": 5738.05,
411    ///   "centroid": [10.0, 10.0, 10.0],
412    ///   "inertia": [[..],[..],[..]] | null,
413    ///   "principalMoments": [a,b,c] | null,
414    ///   "principalAxes": [[..],[..],[..]] | null }
415    /// ```
416    /// A single resolved solid carries the full centroidal inertia tensor +
417    /// principal axes/moments; a multi-solid aggregate reports summed volume /
418    /// area / mass and the volume-weighted centroid, with the tensor fields
419    /// `null` (select one solid for its inertia). `ok:false` with a `message` on
420    /// no solids / an unknown name / an integrator failure.
421    pub fn mass_properties_json(&self, name: Option<&str>, density: f64) -> String {
422        let handles = self.resident_solid_handles();
423
424        // Resolve the target solids: a named solid (must be resident) or, for the
425        // whole model, every scene solid that has resident geometry (draw order).
426        let targets: Vec<String> = match name {
427            Some(name) if handles.contains_key(name) => vec![name.to_string()],
428            Some(name) => {
429                return serde_json::json!({
430                    "ok": false,
431                    "message": format!("solid '{name}' has no resident geometry"),
432                })
433                .to_string();
434            }
435            None => self
436                .scene
437                .solids()
438                .iter()
439                .map(|solid| solid.name.clone())
440                .filter(|name| handles.contains_key(name))
441                .collect(),
442        };
443        if targets.is_empty() {
444            return serde_json::json!({ "ok": false, "message": "no solids" }).to_string();
445        }
446
447        // Per-solid density mass properties straight from the kernel.
448        let mut props = Vec::with_capacity(targets.len());
449        for target in &targets {
450            match brep_kernel::mass_properties_handle_native(handles[target], density) {
451                Ok(properties) => props.push(properties),
452                Err(error) => {
453                    return serde_json::json!({
454                        "ok": false,
455                        "message": format!("{target}: {error}"),
456                    })
457                    .to_string();
458                }
459            }
460        }
461
462        let target_label = if targets.len() == 1 {
463            targets[0].clone()
464        } else {
465            "(whole model)".to_string()
466        };
467
468        if props.len() == 1 {
469            // Single solid: the full tensor + principal frame are meaningful.
470            let p = &props[0];
471            serde_json::json!({
472                "ok": true,
473                "target": target_label,
474                "solidCount": 1,
475                "density": p.density,
476                "volume": p.volume,
477                "surfaceArea": p.surface_area,
478                "mass": p.mass,
479                "centroid": [p.centroid.x, p.centroid.y, p.centroid.z],
480                "inertia": p.inertia,
481                "principalMoments": p.principal_moments,
482                "principalAxes": p.principal_axes,
483            })
484            .to_string()
485        } else {
486            // Aggregate: additive scalars + volume-weighted centroid. Combining
487            // the tensors needs a parallel-axis shift per solid; left to the
488            // single-solid view rather than approximated here.
489            let volume: f64 = props.iter().map(|p| p.volume).sum();
490            let surface_area: f64 = props.iter().map(|p| p.surface_area).sum();
491            let mass: f64 = props.iter().map(|p| p.mass).sum();
492            let centroid = if volume.abs() > f64::EPSILON {
493                let mut acc = [0.0f64; 3];
494                for p in &props {
495                    acc[0] += p.volume * p.centroid.x;
496                    acc[1] += p.volume * p.centroid.y;
497                    acc[2] += p.volume * p.centroid.z;
498                }
499                [acc[0] / volume, acc[1] / volume, acc[2] / volume]
500            } else {
501                [0.0, 0.0, 0.0]
502            };
503            serde_json::json!({
504                "ok": true,
505                "target": target_label,
506                "solidCount": props.len(),
507                "density": density,
508                "volume": volume,
509                "surfaceArea": surface_area,
510                "mass": mass,
511                "centroid": centroid,
512                "inertia": serde_json::Value::Null,
513                "principalMoments": serde_json::Value::Null,
514                "principalAxes": serde_json::Value::Null,
515            })
516            .to_string()
517        }
518    }
519}
520
521#[cfg(test)]
522mod inspector_tests {
523    use super::*;
524
525    /// The same 3-feature seed the app boots with: a 20 mm cube `Box`, a r=6
526    /// h=30 cylinder `Pin` through its centre, and `Cut` = SUBTRACT(Box, [Pin]).
527    /// The SUBTRACT result reuses the target's name, so the final solid is `Box`.
528    fn seed_history() -> String {
529        serde_json::json!({
530            "expressions": "",
531            "configurator": {},
532            "features": [
533                {
534                    "type": "P.CU",
535                    "inputParams": {
536                        "id": "Box",
537                        "sizeX": 20.0, "sizeY": 20.0, "sizeZ": 20.0,
538                        "transform": {
539                            "position": [0.0, 0.0, 0.0],
540                            "rotationEuler": [0.0, 0.0, 0.0],
541                            "scale": [1.0, 1.0, 1.0]
542                        },
543                        "boolean": { "targets": [], "operation": "NONE" }
544                    },
545                    "persistentData": {}
546                },
547                {
548                    "type": "P.CY",
549                    "inputParams": {
550                        "id": "Pin",
551                        "radius": 6.0, "height": 30.0,
552                        "transform": {
553                            "position": [10.0, -5.0, 10.0],
554                            "rotationEuler": [0.0, 0.0, 0.0],
555                            "scale": [1.0, 1.0, 1.0]
556                        },
557                        "boolean": { "targets": [], "operation": "NONE" }
558                    },
559                    "persistentData": {}
560                },
561                {
562                    "type": "B",
563                    "inputParams": {
564                        "id": "Cut",
565                        "targetSolid": "Box",
566                        "boolean": { "operation": "SUBTRACT", "targets": ["Pin"] }
567                    },
568                    "persistentData": {}
569                }
570            ]
571        })
572        .to_string()
573    }
574
575    #[test]
576    fn seed_box_mass_properties_match_analytic_cube() {
577        let mut engine = EngineState::new();
578        engine.set_history_json(&seed_history()).unwrap();
579        // Roll back to just the plain 20 mm cube (before the hole).
580        engine.roll_to(0);
581        assert_eq!(engine.scene.solids().len(), 1);
582
583        let value: serde_json::Value =
584            serde_json::from_str(&engine.mass_properties_json(Some("Box"), 1.0)).unwrap();
585        assert_eq!(value["ok"], true);
586        assert_eq!(value["solidCount"], 1);
587        // 20 mm cube: V = 8000, A = 6·400 = 2400, centroid at the centre (10,10,10).
588        assert!((value["volume"].as_f64().unwrap() - 8000.0).abs() < 1e-6);
589        assert!((value["surfaceArea"].as_f64().unwrap() - 2400.0).abs() < 1e-6);
590        for component in value["centroid"].as_array().unwrap() {
591            assert!((component.as_f64().unwrap() - 10.0).abs() < 1e-6);
592        }
593        // A single solid carries the inertia tensor + principal frame.
594        assert!(value["inertia"].is_array());
595        assert!(value["principalAxes"].is_array());
596
597        // Density scales mass linearly (mass = density · volume).
598        let scaled: serde_json::Value =
599            serde_json::from_str(&engine.mass_properties_json(Some("Box"), 2.5)).unwrap();
600        assert!((scaled["mass"].as_f64().unwrap() - 2.5 * 8000.0).abs() < 1e-6);
601    }
602
603    #[test]
604    fn seed_boolean_result_is_cube_with_through_hole() {
605        let mut engine = EngineState::new();
606        engine.set_history_json(&seed_history()).unwrap();
607        // The full seed leaves one solid: the cube minus the pin.
608        assert_eq!(engine.scene.solids().len(), 1);
609
610        // No selection → whole model; with one solid that resolves to the single
611        // solid's full properties.
612        let value: serde_json::Value =
613            serde_json::from_str(&engine.mass_properties_json(None, 1.0)).unwrap();
614        assert_eq!(value["ok"], true);
615        assert_eq!(value["solidCount"], 1);
616        // V = cube − cylinder-through-hole = 8000 − π·6²·20.
617        let expected = 8000.0 - std::f64::consts::PI * 36.0 * 20.0;
618        assert!(
619            (value["volume"].as_f64().unwrap() - expected).abs() < 1e-2,
620            "hole volume {} vs {expected}",
621            value["volume"]
622        );
623        // Symmetric about the cube centre in X and Z.
624        let centroid = value["centroid"].as_array().unwrap();
625        assert!((centroid[0].as_f64().unwrap() - 10.0).abs() < 1e-6);
626        assert!((centroid[2].as_f64().unwrap() - 10.0).abs() < 1e-6);
627    }
628
629    #[test]
630    fn unknown_solid_reports_not_ok() {
631        let mut engine = EngineState::new();
632        engine.set_history_json(&seed_history()).unwrap();
633        let value: serde_json::Value =
634            serde_json::from_str(&engine.mass_properties_json(Some("Nope"), 1.0)).unwrap();
635        assert_eq!(value["ok"], false);
636    }
637}
638
639// ============================================================================
640// Selection filter (which entity KINDS a plain viewport click may select) +
641// the quick selection actions (clear / hide). Appended as its OWN type + a
642// SEPARATE `impl` block so concurrent edits to the primary block don't conflict;
643// purely additive over the existing selection/pick API.
644//
645// Mirrors the earlier `SelectionFilter.allowedSelectionTypes`: the picker reports
646// EVERYTHING under the cursor (priority VERTEX > EDGE > FACE > SOLID), and the
647// filter narrows what a click actually grabs. `select_top_at` reuses the
648// existing `pick::pick_filtered` with the enabled kinds — the SAME type-
649// constrained pick the reference-selection widget uses — so a click resolves the
650// top-priority candidate whose kind is enabled and selects THAT kind (a FACE-only
651// filter selects a face, a SOLID-only filter the owning solid).
652// ============================================================================
653