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