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