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