//! Physical properties of an assembly placement, with original body material assignments. use serde_json::{Value, json}; use solvecraft_doc::{Document, Mat}; use solvecraft_geom::Vec3; use crate::params::bad; use crate::{Result, Session}; fn descendants(p: &Value, cmd: &str) -> Result { match p.get("descendants") { None => Ok(true), Some(Value::Bool(v)) => Ok(*v), Some(_) => Err(bad(cmd, "`descendants` must be a boolean")), } } fn placed_doc(s: &Session) -> Document { let mut doc = (*s.doc).clone(); for occurrence in &mut doc.occurrences { if let Some(transform) = s.pending_moves.get(&occurrence.id) { occurrence.transform = *transform; } } doc } /// A property total must cover the entire assembly. The display traversal intentionally /// truncates hostile designs, so use an explicit error instead of accepting its prefix. fn placements(doc: &Document, cmd: &str) -> Result, Mat)>> { let mut out = vec![(0, Vec::new(), solvecraft_doc::IDENTITY)]; let mut stack = vec![(0, Vec::::new(), solvecraft_doc::IDENTITY)]; while let Some((component, path, transform)) = stack.pop() { for occurrence in doc.occurrences.iter().filter(|o| o.parent == component) { if path.len() >= 64 || out.len() >= 100_000 { return Err(bad(cmd, "assembly exceeds physical-properties traversal limits (64 levels or 100000 placements)")); } let mut child_path = path.clone(); child_path.push(occurrence.id); let world = solvecraft_doc::mat_mul(&transform, &occurrence.transform); out.push((occurrence.component, child_path.clone(), world)); stack.push((occurrence.component, child_path, world)); } } Ok(out) } pub(super) fn component(s: &mut Session, p: &Value) -> Result { let cmd = "component.properties"; let id = if p.get("component").is_some() { super::component(s, p, "component", cmd)? } else { s.active_component }; let doc = placed_doc(s); let path = doc.first_path(id); let name = if id == 0 { doc.name.clone() } else { doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).ok_or_else(|| bad(cmd, "no active component"))? }; let mut out = properties(s, &doc, &path, descendants(p, cmd)?, cmd)?; out["component"] = json!(id); out["name"] = json!(name); Ok(out) } pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result { let cmd = "occurrence.properties"; let id = super::occurrence(s, p, cmd)?; let doc = placed_doc(s); let paths: Vec> = placements(&doc, cmd)?.into_iter().filter(|(_, path, _)| path.last() == Some(&id)).map(|(_, path, _)| path).collect(); let path = if let Some(value) = p.get("path") { let path = value .as_array() .filter(|a| !a.is_empty() && a.len() <= 64) .and_then(|a| a.iter().map(Value::as_u64).collect::>>()) .ok_or_else(|| bad(cmd, "`path` must list 1…64 occurrence ids"))?; if !paths.contains(&path) { return Err(bad(cmd, "`path` does not identify a placement of this occurrence")); } path } else { match paths.as_slice() { [path] => path.clone(), [] => return Err(bad(cmd, "occurrence has no placement")), _ => return Err(bad(cmd, "occurrence has multiple placements; supply its `path`")), } }; let mut out = properties(s, &doc, &path, descendants(p, cmd)?, cmd)?; out["occurrence"] = json!(id); out["name"] = json!(doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.as_str())); Ok(out) } fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, cmd: &str) -> Result { let state = s.model.state(); let mut bodies = Vec::new(); let (mut volume, mut area, mut mass) = (0.0, 0.0, 0.0); let mut moment = Vec3::ZERO; let (mut min, mut max) = (Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY)); let (mut faces, mut edges, mut vertices) = (0, 0, 0); let mut placement_count = 0; for (component, path, transform) in placements(doc, cmd)?.into_iter().filter(|(_, path, _)| if descendants { path.starts_with(selected) } else { path == selected }) { placement_count += 1; for body in state.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == component) { let transform: Mat = doc.body_offsets.get(&body.name).map(|offset| solvecraft_doc::mat_mul(&transform, offset)).unwrap_or(transform); let placed = solvecraft_kernel::transform_matrix(&body.body, transform)?; let measured = solvecraft_kernel::measure(&placed)?; let density = doc.density(&body.name); let body_mass = measured.volume * density / 1000.0; volume += measured.volume; area += measured.area; mass += body_mass; moment += measured.centroid * body_mass; min = Vec3::new(min.x.min(measured.bbox.min.x), min.y.min(measured.bbox.min.y), min.z.min(measured.bbox.min.z)); max = Vec3::new(max.x.max(measured.bbox.max.x), max.y.max(measured.bbox.max.y), max.z.max(measured.bbox.max.z)); faces += measured.merged.faces; edges += measured.merged.edges; vertices += measured.merged.vertices; bodies.push(json!({"name": body.name, "component": component, "path": path, "material": doc.materials.get(&body.name).map(String::as_str).unwrap_or("Default"), "density_g_cm3": density, "mass_g": body_mass, "volume_mm3": measured.volume, "area_mm2": measured.area, "center_of_mass": measured.centroid, "bbox": {"min": measured.bbox.min, "max": measured.bbox.max}})); } } if !(volume.is_finite() && area.is_finite() && mass.is_finite() && moment.is_finite()) { return Err(bad(cmd, "physical properties exceed supported numeric range")); } let centroid = if mass > 0.0 { Some(moment / mass) } else { None }; if centroid.is_some_and(|point| !point.is_finite()) { return Err(bad(cmd, "center of mass exceeds supported numeric range")); } let bbox = if bodies.is_empty() { Value::Null } else { json!({"min": min, "max": max}) }; Ok(json!({"path": selected, "coordinate_frame": "world", "descendants": descendants, "placement_count": placement_count, "body_count": bodies.len(), "bodies": bodies, "total": {"volume_mm3": volume, "area_mm2": area, "mass_g": mass, "center_of_mass": centroid, "bbox": bbox, "faces": faces, "edges": edges, "vertices": vertices}})) } #[cfg(test)] #[path = "component_properties_tests.rs"] mod tests;