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