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