Assemblies: mass, centre of mass and inertia per occurrence and rigid group (#9715) (#9717)

* claim: #9715

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Assemblies: mass, centre of mass and inertia per occurrence and rigid group (#9715)

assembly.mass_properties (and Session::mass_properties) gives, for every
occurrence and every rigidly-joined group, mass (kg), centre of mass (mm) and
the inertia tensor about it in world and frame axes (kg mm2), the input
the multibody dynamics work needs. Bodies are measured as Properties
measures them (kernel volume and centre, mesh gyration), once per body,
then moved by each placement and combined by the parallel-axis theorem.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
PaddleStroke
2026-10-31 04:35:29 +00:00
committed by GitHub
parent 0067dcab28
commit fd5d0e6738
8 changed files with 838 additions and 0 deletions
@@ -0,0 +1,32 @@
{
"en": { "cmd.assembly_mass_properties": "Mass Properties" },
"ar": { "cmd.assembly_mass_properties": "خصائص الكتلة" },
"bs": { "cmd.assembly_mass_properties": "Svojstva mase" },
"cs": { "cmd.assembly_mass_properties": "Hmotnostní vlastnosti" },
"da": { "cmd.assembly_mass_properties": "Masseegenskaber" },
"de": { "cmd.assembly_mass_properties": "Masseeigenschaften" },
"es": { "cmd.assembly_mass_properties": "Propiedades de masa" },
"fr": { "cmd.assembly_mass_properties": "Propriétés de masse" },
"gl": { "cmd.assembly_mass_properties": "Propiedades de masa" },
"he": { "cmd.assembly_mass_properties": "תכונות מסה" },
"hr": { "cmd.assembly_mass_properties": "Svojstva mase" },
"hu": { "cmd.assembly_mass_properties": "Tömegjellemzők" },
"id": { "cmd.assembly_mass_properties": "Properti massa" },
"it": { "cmd.assembly_mass_properties": "Proprietà di massa" },
"ja": { "cmd.assembly_mass_properties": "質量特性" },
"ko": { "cmd.assembly_mass_properties": "질량 특성" },
"ms": { "cmd.assembly_mass_properties": "Sifat jisim" },
"nb": { "cmd.assembly_mass_properties": "Masseegenskaper" },
"nl": { "cmd.assembly_mass_properties": "Massa-eigenschappen" },
"pl": { "cmd.assembly_mass_properties": "Właściwości masowe" },
"pt-BR": { "cmd.assembly_mass_properties": "Propriedades de massa" },
"ro": { "cmd.assembly_mass_properties": "Proprietăți de masă" },
"ru": { "cmd.assembly_mass_properties": "Массовые характеристики" },
"sr": { "cmd.assembly_mass_properties": "Својства масе" },
"sv": { "cmd.assembly_mass_properties": "Massegenskaper" },
"tr": { "cmd.assembly_mass_properties": "Kütle özellikleri" },
"uk": { "cmd.assembly_mass_properties": "Масові характеристики" },
"vi": { "cmd.assembly_mass_properties": "Thuộc tính khối lượng" },
"zh": { "cmd.assembly_mass_properties": "质量属性" },
"zh-TW": { "cmd.assembly_mass_properties": "質量屬性" }
}
+363
View File
@@ -0,0 +1,363 @@
//! Mass, centre of mass and inertia tensor per occurrence and per rigid group (#9715): what a
//! multibody dynamics solver needs of each moving part.
//!
//! Bodies are measured as `component.properties` measures them (docs/assembly-properties.md):
//! volume and centre from the kernel's measurement, the inertia from how the welded export mesh
//! spreads that volume (`solidcraft_geom::inertia`), at the body's material density (the default
//! material's when none is assigned). Each body is measured once however often it is placed;
//! each placement moves its centre and turns its tensor, and an occurrence's bodies combine by
//! the parallel-axis theorem. An open body adds no mass and is counted.
//!
//! Rigid groups are the occurrences that move as one body: those an unsuppressed rigid joint
//! ties together, and a nested occurrence that no joint places with the occurrence it sits in
//! (the joint solver leaves it where its parent puts it). A group holding the design itself or
//! a grounded occurrence is fixed. Occurrences are taken where the joint solver places them, so
//! an occurrence inside a subassembly placed more than once is listed at the subassembly's first
//! placement (a warning says so).
use std::collections::{BTreeMap, HashMap, HashSet};
use serde_json::Value;
use solidcraft_doc::joints::{self, JointKind, MAX_JOINTS};
use solidcraft_doc::{Document, IDENTITY, MAX_PLACEMENTS, Mat, ModelState, apply_point, mat_inverse, mat_mul};
use solidcraft_geom::Vec3;
use solidcraft_geom::inertia::{self, Tensor3};
use super::CommandSpec;
use crate::params::bad;
use crate::schema::mass_properties::{AssemblyMassParams, AssemblyMassResult, BodyMassEntry, MassFrame, OccurrenceMass, RigidGroupMass, RigidMass};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const CMD: &str = "assembly.mass_properties";
const CONTRACT: TypedContract<AssemblyMassParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Mass Properties", crate::checked!("assembly.mass_properties", run))
.icon("measure")
.noundo()
.contract(&CONTRACT)
.output(output_schema_for::<AssemblyMassResult>)
.params(
"no parameters → occurrences: [{occurrence (0: the design's own bodies), name, component, path, group, fixed, frame \
{origin, x, y, z}, bodies: [{name, material, density_g_cm3, closed, volume_mm3, mass_kg}], open_bodies_skipped, mass}], \
groups: [{group, occurrences, fixed, frame_occurrence, frame, mass}] (occurrences an unsuppressed rigid joint ties \
together, and a nested occurrence no joint places with its parent; fixed: holds the design or a grounded occurrence), \
warnings. mass: {mass_kg, center_of_mass_mm (world), center_of_mass_local_mm, inertia_world_kg_mm2, \
inertia_local_kg_mm2 (about the centre of mass, in world and frame axes)} or null when massless. Read-only; pending \
moves count; each body at its material's density (the default material's when none is assigned)",
)];
/// One rigid body's mass properties: mass (kg), centre of mass (mm, world) and the inertia
/// tensor about it (kg·mm², world axes; `None` when a closed body's mesh did not close).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct MassBody {
pub mass: f64,
pub center: Vec3,
pub inertia: Option<Tensor3>,
}
impl MassBody {
pub fn is_finite(&self) -> bool {
self.mass.is_finite() && self.center.is_finite() && self.inertia.is_none_or(|i| i.iter().flatten().all(|x| x.is_finite()))
}
/// Bodies taken as one: the masses add, the centre is mass-weighted, and each tensor moves to
/// the common centre by the parallel-axis theorem. The tensor is `None` when any part's is.
/// `None` when the total mass is not positive or a value is not finite.
pub fn combine(parts: &[MassBody]) -> Option<MassBody> {
let mass: f64 = parts.iter().map(|p| p.mass).sum();
if !(mass > 0.0 && mass.is_finite()) {
return None;
}
let center = parts.iter().fold(Vec3::ZERO, |acc, p| acc + p.center * p.mass) * (1.0 / mass);
let inertia = parts.iter().try_fold([[0.0f64; 3]; 3], |sum, p| {
let i = inertia::shifted(&p.inertia?, p.mass, p.center - center);
Some([0, 1, 2].map(|r| [0, 1, 2].map(|k| sum[r][k] + i[r][k])))
});
let out = MassBody { mass, center, inertia };
out.is_finite().then_some(out)
}
}
/// The 3 × 3 linear part of a placement, row by row (`Mat` is column-major).
fn linear(m: &Mat) -> Tensor3 {
[0, 1, 2].map(|i| [0, 1, 2].map(|j| m[j][i]))
}
fn transpose(a: &Tensor3) -> Tensor3 {
[0, 1, 2].map(|i| [0, 1, 2].map(|j| a[j][i]))
}
fn det3(a: &Tensor3) -> f64 {
a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
+ a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0])
}
/// The rotation (or reflection) a placement's axes make, when they are orthonormal.
fn rotation(m: &Mat) -> Option<Tensor3> {
let a = linear(m);
let ata = inertia::transformed(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], &transpose(&a));
let ok = (0..3).all(|i| (0..3).all(|j| (ata[i][j] - if i == j { 1.0 } else { 0.0 }).abs() <= 1e-9));
ok.then_some(a)
}
fn xyz(v: Vec3) -> [f64; 3] {
[v.x, v.y, v.z]
}
fn frame_of(m: &Mat) -> MassFrame {
let axis = |k: usize| {
let v = Vec3::new(m[k][0], m[k][1], m[k][2]);
let len = v.len();
xyz(if len > 0.0 && len.is_finite() { v * (1.0 / len) } else { v })
};
MassFrame { origin: [m[3][0], m[3][1], m[3][2]], x: axis(0), y: axis(1), z: axis(2) }
}
/// A world [`MassBody`] as the frame `m` sees it.
fn rigid_mass(b: &MassBody, m: &Mat) -> RigidMass {
let local = mat_inverse(m).map(|inv| apply_point(&inv, b.center)).unwrap_or(b.center);
RigidMass {
mass_kg: b.mass,
center_of_mass_mm: xyz(b.center),
center_of_mass_local_mm: xyz(local),
inertia_world_kg_mm2: b.inertia,
// Rᵀ I R: the world tensor in the frame's axes.
inertia_local_kg_mm2: b.inertia.zip(rotation(m)).map(|(i, r)| inertia::transformed(&i, &transpose(&r))),
}
}
/// Union-find over occurrence indices; the lower index roots a group, so the design (index 0)
/// roots its own.
struct Groups(Vec<usize>);
impl Groups {
fn root(&self, mut i: usize) -> usize {
// Bounded: each step goes to a lower index.
for _ in 0..self.0.len() {
match self.0.get(i) {
Some(&parent) if parent != i => i = parent,
_ => break,
}
}
i
}
fn join(&mut self, a: usize, b: usize) {
let (ra, rb) = (self.root(a), self.root(b));
if let Some(slot) = self.0.get_mut(ra.max(rb)) {
*slot = ra.min(rb);
}
}
}
/// Occurrence ids from the top of the assembly down, as `joints::occurrence_world` walks them.
fn canonical_path(doc: &Document, occ: u64) -> Vec<u64> {
let mut path = Vec::new();
let mut cur = doc.occurrences.iter().find(|o| o.id == occ);
for _ in 0..1000 {
let Some(o) = cur else { break };
path.push(o.id);
if o.parent == 0 {
break;
}
cur = doc.occurrence_of(o.parent);
}
path.reverse();
path
}
fn out_of_range() -> EngineError {
bad(CMD, "mass properties exceed supported numeric range")
}
impl Session {
/// Mass, centre of mass and inertia of every occurrence and rigid group of the design, as
/// `assembly.mass_properties` returns them (kg, mm, kg·mm²), pending moves included.
pub fn mass_properties(&self) -> Result<AssemblyMassResult> {
let mut doc = (*self.doc).clone();
for occurrence in &mut doc.occurrences {
if let Some(transform) = self.pending_moves.get(&occurrence.id) {
occurrence.transform = *transform;
}
}
let state = self.model.state();
assembly(&doc, &state)
}
}
/// A body measured once: closed, and if so its volume (mm³), centre (mm) and spread, all in its
/// component's frame before its offset.
struct Shape {
closed: bool,
volume: f64,
centroid: Vec3,
gyration: Option<Tensor3>,
}
/// [`Session::mass_properties`] of a document and its evaluated state.
pub fn assembly(doc: &Document, state: &ModelState) -> Result<AssemblyMassResult> {
if doc.occurrences.len() >= MAX_PLACEMENTS {
return Err(bad(CMD, format!("assembly exceeds {MAX_PLACEMENTS} occurrences")));
}
let mut warnings = Vec::new();
// Index 0 is the design itself; then the occurrences by id.
let mut ids: Vec<u64> = doc.occurrences.iter().map(|o| o.id).filter(|id| *id != 0).collect();
ids.sort_unstable();
ids.dedup();
ids.insert(0, 0);
let index: HashMap<u64, usize> = ids.iter().enumerate().map(|(i, id)| (*id, i)).collect();
let mut groups = Groups((0..ids.len()).collect());
let live: Vec<_> = doc.assembly.joints.iter().filter(|j| !j.suppressed).take(MAX_JOINTS).collect();
let jointed: HashSet<u64> = live.iter().flat_map(|j| [j.a.occurrence, j.b.occurrence]).collect();
for j in live.iter().filter(|j| j.kind == JointKind::Rigid) {
if let (Some(&a), Some(&b)) = (index.get(&j.a.occurrence), index.get(&j.b.occurrence)) {
groups.join(a, b);
}
}
for o in doc.occurrences.iter().filter(|o| o.parent != 0 && !jointed.contains(&o.id)) {
if let (Some(&child), Some(&parent)) = (index.get(&o.id), doc.occurrence_of(o.parent).and_then(|p| index.get(&p.id))) {
groups.join(child, parent);
}
}
for c in doc.components.iter().filter(|c| doc.occurrences.iter().any(|o| o.parent == c.id)) {
let copies = doc.occurrences.iter().filter(|o| o.component == c.id).count();
if copies > 1 {
warnings.push(format!(
"`{}` is placed {copies} times: the occurrences inside it are listed at its first placement only, where joints place them",
c.name
));
}
}
// Each placed body measured once, on worker threads.
let owners = doc.model_body_components(state);
let placed: HashSet<u64> =
ids.iter().map(|id| if *id == 0 { 0 } else { doc.occurrences.iter().find(|o| o.id == *id).map_or(u64::MAX, |o| o.component) }).collect();
let measured: Vec<usize> = (0..state.bodies.len()).filter(|i| owners.get(*i).is_some_and(|c| placed.contains(c))).collect();
let shapes = solidcraft_kernel::map_on_threads(&measured, 16, |&i| -> Result<Shape> {
let body = state.bodies.get(i).ok_or_else(|| EngineError::Other("body index out of range".into()))?;
let closed = crate::cmd::inspect::encloses_volume(&body.body);
if !closed {
return Ok(Shape { closed, volume: 0.0, centroid: Vec3::ZERO, gyration: None });
}
let m = solidcraft_kernel::measure_mass(&body.body)?;
let gyration = solidcraft_io::mass_properties(&body.body)?.and_then(|p| p.gyration());
Ok(Shape { closed, volume: m.volume, centroid: m.centroid, gyration })
});
let mut shape: BTreeMap<usize, Shape> = BTreeMap::new();
for (i, s) in measured.into_iter().zip(shapes) {
shape.insert(i, s.ok_or_else(|| bad(CMD, "measuring a body failed"))??);
}
let mut occurrences = Vec::with_capacity(ids.len());
let mut totals: Vec<Option<MassBody>> = Vec::with_capacity(ids.len());
for (i, &id) in ids.iter().enumerate() {
let (component, name, world, path, fixed) = if id == 0 {
(0, doc.name.clone(), IDENTITY, Vec::new(), true)
} else {
// Every id came from `doc.occurrences`, so the occurrence is there.
let o = doc.occurrences.iter().find(|o| o.id == id);
let (component, name, grounded) = o.map_or((u64::MAX, String::new(), false), |o| (o.component, o.name.clone(), o.grounded));
(component, name, joints::occurrence_world(doc, id), canonical_path(doc, id), grounded)
};
if world.iter().flatten().any(|x| !x.is_finite()) {
return Err(out_of_range());
}
let (mut bodies, mut parts, mut skipped) = (Vec::new(), Vec::new(), 0);
for (k, body) in state.bodies.iter().enumerate().filter(|(k, _)| owners.get(*k) == Some(&component)) {
let Some(s) = shape.get(&k) else { continue };
let density = doc.density(&body.name);
let mut entry = BodyMassEntry {
name: body.name.clone(),
material: doc.body_material(&body.name),
density_g_cm3: density,
closed: s.closed,
..BodyMassEntry::default()
};
if s.closed {
let at = doc.model_body_offset(body).map_or(world, |offset| mat_mul(&world, offset));
let l = linear(&at);
let volume = s.volume * det3(&l).abs();
// g/cm³ × mm³ is mg; kg is 1e-6 of that.
let mass = volume * density * 1e-6;
let inertia = s.gyration.map(|g| inertia::from_gyration(&inertia::transformed(&g, &l), mass));
let part = MassBody { mass, center: apply_point(&at, s.centroid), inertia };
if !(part.is_finite() && volume.is_finite()) {
return Err(out_of_range());
}
if inertia.is_none() {
warnings.push(format!("body `{}`: its mesh does not close, so its inertia is unknown", body.name));
}
parts.push(part);
entry.volume_mm3 = Some(volume);
entry.mass_kg = Some(mass);
} else {
skipped += 1;
warnings.push(format!("body `{}` is not a closed solid: it adds no mass", body.name));
}
bodies.push(entry);
}
let total = MassBody::combine(&parts);
if total.is_none() && parts.iter().map(|p| p.mass).sum::<f64>() > 0.0 {
return Err(out_of_range());
}
if total.is_some() && rotation(&world).is_none() {
warnings.push(format!("`{name}` is scaled: its inertia in its own axes is left out"));
}
totals.push(total);
occurrences.push(OccurrenceMass {
occurrence: id,
name,
component,
path,
group: i,
fixed,
frame: frame_of(&world),
bodies,
open_bodies_skipped: skipped,
mass: total.map(|t| rigid_mass(&t, &world)),
});
}
// Groups in order of their lowest occurrence id (their union-find root).
let mut by_root: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for i in 0..occurrences.len() {
by_root.entry(groups.root(i)).or_default().push(i);
}
let mut out_groups = Vec::with_capacity(by_root.len());
for (g, members) in by_root.values().enumerate() {
let fixed = members.iter().any(|&m| occurrences.get(m).is_some_and(|o| o.fixed));
let frame_occurrence = members.first().and_then(|&m| ids.get(m)).copied().unwrap_or(0);
let world = if frame_occurrence == 0 { IDENTITY } else { joints::occurrence_world(doc, frame_occurrence) };
let parts: Vec<MassBody> = members.iter().filter_map(|&m| totals.get(m).copied().flatten()).collect();
let total = MassBody::combine(&parts);
if total.is_none() && !parts.is_empty() {
return Err(out_of_range());
}
for &m in members {
if let Some(o) = occurrences.get_mut(m) {
o.group = g;
}
}
out_groups.push(RigidGroupMass {
group: g,
occurrences: members.iter().filter_map(|&m| ids.get(m).copied()).collect(),
fixed,
frame_occurrence,
frame: frame_of(&world),
mass: total.map(|t| rigid_mass(&t, &world)),
});
}
Ok(AssemblyMassResult { occurrences, groups: out_groups, warnings, param_warnings: Vec::new() })
}
fn run(s: &mut Session, _p: &Value) -> Result<Value> {
let r = s.mass_properties()?;
serde_json::to_value(r).map_err(|e| EngineError::Other(e.to_string()))
}
#[cfg(test)]
#[path = "mass_properties_tests.rs"]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests;
@@ -0,0 +1,269 @@
//! `assembly.mass_properties` against closed-form values: a box, a cylinder, two boxes apart
//! (parallel axis), a turned occurrence, a rigid group, and inputs that must not panic.
use std::f64::consts::PI;
use serde_json::{Value, json};
use super::MassBody;
use crate::Session;
use solidcraft_geom::Vec3;
use solidcraft_geom::inertia::Tensor3 as Tensor;
fn run(s: &mut Session, cmd: &str, params: Value) -> Value {
s.execute(cmd, &params).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn num(v: &Value) -> f64 {
v.as_f64().unwrap_or_else(|| panic!("not a number: {v}"))
}
fn near(actual: f64, expected: f64, rel: f64) {
assert!((actual - expected).abs() <= rel * expected.abs().max(1e-9), "{actual} != {expected} (rel {rel})");
}
fn tensor(v: &Value) -> Tensor {
let mut t = [[0.0; 3]; 3];
for (i, row) in t.iter_mut().enumerate() {
for (j, cell) in row.iter_mut().enumerate() {
*cell = num(&v[i][j]);
}
}
t
}
fn diag(t: &Tensor, expected: [f64; 3], rel: f64) {
let scale = expected.iter().fold(0.0f64, |a, b| a.max(b.abs()));
for i in 0..3 {
near(t[i][i], expected[i], rel);
for j in 0..3 {
if i != j {
assert!(t[i][j].abs() <= rel * scale, "off-diagonal [{i}][{j}] = {} in {t:?}", t[i][j]);
}
}
}
}
/// A new component (not grounded) holding a box; its occurrence id and the body's name.
fn part(s: &mut Session, name: &str, size: [f64; 3], material: &str) -> (u64, u64, String) {
run(s, "component.activate", json!({"component": "root"}));
let c = run(s, "component.create", json!({"name": name, "grounded": false}));
run(s, "solid.box", json!({"length": size[0], "width": size[1], "height": size[2]}));
let body = s.model.state().bodies.last().map(|b| b.name.clone()).unwrap_or_default();
run(s, "material.assign", json!({"bodies": [body], "material": material}));
(c["component"].as_u64().unwrap_or(0), c["occurrence"].as_u64().unwrap_or(0), body)
}
fn occurrence(r: &Value, id: u64) -> &Value {
r["occurrences"].as_array().and_then(|a| a.iter().find(|o| o["occurrence"] == id)).unwrap_or_else(|| panic!("no occurrence {id} in {r}"))
}
/// Box `a × b × c` of mass `m` about its centre: `m/12 (b² + c²)` and so on.
fn box_inertia(m: f64, [a, b, c]: [f64; 3]) -> [f64; 3] {
[m / 12.0 * (b * b + c * c), m / 12.0 * (a * a + c * c), m / 12.0 * (a * a + b * b)]
}
#[test]
fn a_box_has_its_closed_form_mass_centre_and_inertia() {
let mut s = Session::default();
let (_, occ, body) = part(&mut s, "Block", [10.0, 20.0, 30.0], "Steel");
let rho = s.doc.density(&body);
let m = 6000.0 * rho * 1e-6;
let r = run(&mut s, "assembly.mass_properties", json!({}));
let o = occurrence(&r, occ);
assert_eq!(o["bodies"][0]["material"], "Steel");
assert_eq!(o["bodies"][0]["closed"], true);
near(num(&o["bodies"][0]["volume_mm3"]), 6000.0, 1e-9);
let mass = &o["mass"];
near(num(&mass["mass_kg"]), m, 1e-9);
for (k, c) in [5.0, 10.0, 15.0].iter().enumerate() {
near(num(&mass["center_of_mass_mm"][k]), *c, 1e-9);
near(num(&mass["center_of_mass_local_mm"][k]), *c, 1e-9);
}
diag(&tensor(&mass["inertia_world_kg_mm2"]), box_inertia(m, [10.0, 20.0, 30.0]), 1e-9);
diag(&tensor(&mass["inertia_local_kg_mm2"]), box_inertia(m, [10.0, 20.0, 30.0]), 1e-9);
// The design itself is a fixed group of its own, without mass.
assert_eq!(r["groups"][0]["occurrences"], json!([0]));
assert_eq!(r["groups"][0]["fixed"], true);
assert!(r["groups"][0]["mass"].is_null());
let g = &r["groups"][o["group"].as_u64().unwrap_or(0) as usize];
assert_eq!(g["occurrences"], json!([occ]));
assert_eq!(g["fixed"], false);
near(num(&g["mass"]["mass_kg"]), m, 1e-9);
}
#[test]
fn a_cylinder_matches_its_closed_form_within_the_tessellation() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Shaft", "grounded": false}));
run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 40}));
let body = s.model.state().bodies.last().map(|b| b.name.clone()).unwrap_or_default();
let rho = s.doc.density(&body);
let (r_, h) = (10.0f64, 40.0f64);
let m = PI * r_ * r_ * h * rho * 1e-6;
let r = run(&mut s, "assembly.mass_properties", json!({}));
let o = &r["occurrences"][1];
assert_eq!(o["bodies"][0]["material"], s.doc.body_material(&body));
let mass = &o["mass"];
near(num(&mass["mass_kg"]), m, 1e-3);
near(num(&mass["center_of_mass_mm"][2]), 20.0, 1e-3);
assert!(num(&mass["center_of_mass_mm"][0]).abs() < 1e-3);
let side = m * (3.0 * r_ * r_ + h * h) / 12.0;
diag(&tensor(&mass["inertia_world_kg_mm2"]), [side, side, m * r_ * r_ / 2.0], 2e-3);
}
#[test]
fn two_boxes_apart_combine_by_the_parallel_axis_theorem() {
let mut s = Session::default();
let (_, occ, body) = part(&mut s, "Pair", [10.0, 10.0, 10.0], "Aluminum");
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [30, 0, 0]}));
let second = s.model.state().bodies.last().map(|b| b.name.clone()).unwrap_or_default();
assert_ne!(second, body);
let (r1, r2) = (s.doc.density(&body), s.doc.density(&second));
let (m1, m2) = (1000.0 * r1 * 1e-6, 1000.0 * r2 * 1e-6);
let m = m1 + m2;
// Centres at x = 5 and x = 35.
let cx = (5.0 * m1 + 35.0 * m2) / m;
let r = run(&mut s, "assembly.mass_properties", json!({}));
let mass = &occurrence(&r, occ)["mass"];
near(num(&mass["mass_kg"]), m, 1e-9);
near(num(&mass["center_of_mass_mm"][0]), cx, 1e-9);
let own = |mi: f64| mi / 12.0 * 200.0;
let shift = m1 * (cx - 5.0).powi(2) + m2 * (35.0 - cx).powi(2);
diag(&tensor(&mass["inertia_world_kg_mm2"]), [own(m1) + own(m2), own(m1) + own(m2) + shift, own(m1) + own(m2) + shift], 1e-9);
}
#[test]
fn a_turned_occurrence_turns_its_world_tensor_but_not_its_own() {
let mut s = Session::default();
let (_, occ, body) = part(&mut s, "Block", [10.0, 20.0, 30.0], "Steel");
let m = 6000.0 * s.doc.density(&body) * 1e-6;
// A quarter turn about z, kept pending (pending moves count, as Properties shows them).
run(&mut s, "occurrence.move", json!({"occurrence": occ, "axis": [0, 0, 1], "angle": "90 deg", "translate": [100, 0, 0], "capture": false}));
let r = run(&mut s, "assembly.mass_properties", json!({}));
let o = occurrence(&r, occ);
let frame = &o["frame"];
near(num(&frame["x"][1]), 1.0, 1e-9);
near(num(&frame["y"][0]), -1.0, 1e-9);
let mass = &o["mass"];
let local = box_inertia(m, [10.0, 20.0, 30.0]);
diag(&tensor(&mass["inertia_world_kg_mm2"]), [local[1], local[0], local[2]], 1e-9);
diag(&tensor(&mass["inertia_local_kg_mm2"]), local, 1e-9);
for (k, c) in [5.0, 10.0, 15.0].iter().enumerate() {
near(num(&mass["center_of_mass_local_mm"][k]), *c, 1e-9);
}
// World centre: (5, 10, 15) turned about z then moved.
let world: Vec<f64> = (0..3).map(|k| num(&mass["center_of_mass_mm"][k])).collect();
let expected = [num(&frame["origin"][0]) - 10.0, num(&frame["origin"][1]) + 5.0, num(&frame["origin"][2]) + 15.0];
for (a, e) in world.iter().zip(expected) {
near(*a, e, 1e-9);
}
}
#[test]
fn a_rigid_group_is_one_body_and_other_joints_keep_bodies_apart() {
let mut s = Session::default();
let (_, a, body_a) = part(&mut s, "Base", [10.0, 10.0, 10.0], "Steel");
let (_, b, body_b) = part(&mut s, "Cap", [10.0, 10.0, 10.0], "Steel");
run(&mut s, "occurrence.move", json!({"occurrence": b, "translate": [0, 0, 40]}));
let (_, c, _) = part(&mut s, "Loose", [10.0, 10.0, 10.0], "Steel");
run(&mut s, "occurrence.move", json!({"occurrence": c, "translate": [100, 0, 0]}));
run(&mut s, "joint.rigid_group", json!({"occurrences": [a, b]}));
let r = run(&mut s, "assembly.mass_properties", json!({}));
let (ga, gb, gc) = (occurrence(&r, a)["group"].clone(), occurrence(&r, b)["group"].clone(), occurrence(&r, c)["group"].clone());
assert_eq!(ga, gb);
assert_ne!(ga, gc);
let g = &r["groups"][ga.as_u64().unwrap_or(0) as usize];
let mut held: Vec<u64> = vec![a, b];
held.sort_unstable();
assert_eq!(g["occurrences"], json!(held));
assert_eq!(g["frame_occurrence"], json!(held[0]));
let m1 = 1000.0 * s.doc.density(&body_a) * 1e-6;
let m2 = 1000.0 * s.doc.density(&body_b) * 1e-6;
let m = m1 + m2;
let mass = &g["mass"];
near(num(&mass["mass_kg"]), m, 1e-9);
// Centres at z = 5 and z = 45: the common one halfway, each 20 mm off it.
near(num(&mass["center_of_mass_mm"][2]), 25.0, 1e-9);
let own = m1 / 12.0 * 200.0 + m2 / 12.0 * 200.0;
let shift = m1 * 400.0 + m2 * 400.0;
diag(&tensor(&mass["inertia_world_kg_mm2"]), [own + shift, own + shift, own], 1e-9);
}
#[test]
fn a_nested_occurrence_no_joint_places_moves_with_its_parent() {
let mut s = Session::default();
let outer = run(&mut s, "component.create", json!({"name": "Assembly", "grounded": false}));
let inner = run(&mut s, "component.create", json!({"name": "Inner", "grounded": false}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
let r = run(&mut s, "assembly.mass_properties", json!({}));
let (o, i) = (outer["occurrence"].as_u64().unwrap_or(0), inner["occurrence"].as_u64().unwrap_or(0));
assert_eq!(occurrence(&r, o)["group"], occurrence(&r, i)["group"]);
assert!(occurrence(&r, o)["mass"].is_null(), "the outer component has no bodies of its own");
assert_eq!(occurrence(&r, i)["path"], json!([o, i]));
}
#[test]
fn hostile_and_degenerate_inputs_never_panic() {
// An empty design: the design itself, without mass.
let mut s = Session::default();
let r = run(&mut s, "assembly.mass_properties", json!({}));
assert_eq!(r["occurrences"].as_array().map(Vec::len), Some(1));
assert!(r["occurrences"][0]["mass"].is_null());
// A pending placement that is not finite is refused, not propagated.
let (_, occ, _) = part(&mut s, "Block", [10.0, 10.0, 10.0], "Steel");
let mut bad = solidcraft_doc::IDENTITY;
bad[3][0] = f64::NAN;
s.pending_moves.insert(occ, bad);
assert!(s.execute("assembly.mass_properties", &json!({})).is_err());
let mut huge = solidcraft_doc::IDENTITY;
huge[0][0] = 1e200;
huge[1][1] = 1e200;
huge[2][2] = 1e200;
s.pending_moves.insert(occ, huge);
assert!(s.execute("assembly.mass_properties", &json!({})).is_err());
// A flattening placement: no volume, so no mass, and no panic.
let mut flat = solidcraft_doc::IDENTITY;
flat[2][2] = 0.0;
s.pending_moves.insert(occ, flat);
let r = run(&mut s, "assembly.mass_properties", json!({}));
assert!(occurrence(&r, occ)["mass"].is_null(), "{r}");
// A rigid joint to an occurrence that is gone is ignored.
s.pending_moves.clear();
let mut doc = (*s.doc).clone();
if let Some(j) = doc.assembly.joints.first_mut() {
j.b.occurrence = 999_999;
}
s.doc = std::sync::Arc::new(doc);
run(&mut s, "assembly.mass_properties", json!({}));
// The combination itself.
assert_eq!(MassBody::combine(&[]), None);
let neg = MassBody { mass: -1.0, ..MassBody::default() };
assert_eq!(MassBody::combine(&[neg]), None);
let nan = MassBody { mass: 1.0, center: Vec3::new(f64::NAN, 0.0, 0.0), ..MassBody::default() };
assert_eq!(MassBody::combine(&[nan]), None);
let inf = MassBody { mass: f64::INFINITY, ..MassBody::default() };
assert_eq!(MassBody::combine(&[inf]), None);
// A part whose tensor is unknown leaves the total's unknown, not wrong.
let known = MassBody { mass: 1.0, center: Vec3::ZERO, inertia: Some([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]) };
let unknown = MassBody { mass: 1.0, center: Vec3::new(2.0, 0.0, 0.0), inertia: None };
let both = MassBody::combine(&[known, unknown]).unwrap_or_else(|| panic!("positive mass"));
assert_eq!(both.inertia, None);
near(both.center.x, 1.0, 1e-12);
}
#[test]
fn combining_two_point_like_bodies_adds_the_parallel_axis_terms() {
let unit: Tensor = [[1.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 3.0]];
let a = MassBody { mass: 1.0, center: Vec3::new(0.0, 0.0, 0.0), inertia: Some(unit) };
let b = MassBody { mass: 3.0, center: Vec3::new(4.0, 4.0, 0.0), inertia: Some(unit) };
let t = MassBody::combine(&[a, b]).and_then(|t| t.inertia).unwrap_or_else(|| panic!("combines"));
// Centre (3, 3, 0); a is (−3, −3) from it, b (1, 1): Σ m d² terms and −Σ m dx dy.
let (sxx, sxy) = (1.0 * 9.0 + 3.0 * 1.0, 1.0 * 9.0 + 3.0 * 1.0);
near(t[0][0], 2.0 + sxx, 1e-12);
near(t[1][1], 4.0 + sxx, 1e-12);
near(t[2][2], 6.0 + 2.0 * sxx, 1e-12);
near(t[0][1], -sxy, 1e-12);
near(t[1][0], -sxy, 1e-12);
}
+2
View File
@@ -60,6 +60,7 @@ pub(crate) mod inspect;
mod interference;
pub(crate) mod joints;
mod libraries;
pub mod mass_properties;
mod material_properties;
mod materials;
mod measure_sel;
@@ -326,6 +327,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(config::COMMANDS.iter());
v.extend(parts::COMMANDS.iter());
v.extend(bom::COMMANDS.iter());
v.extend(mass_properties::COMMANDS.iter());
v.extend(browser::COMMANDS.iter());
v.extend(delete::COMMANDS.iter());
v.extend(edit::COMMANDS.iter());
+114
View File
@@ -0,0 +1,114 @@
//! Contracts and result types for the commands in `cmd/mass_properties.rs`.
//!
//! The reply types are also the Rust API a dynamics solver reads (`Session::mass_properties`):
//! units are kg, mm and kg·mm² throughout.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Mass, centre of mass and inertia tensor of every occurrence of the design and of every rigid
/// group (occurrences joined by rigid joints, which move as one body), from each body's material
/// density. Read-only.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AssemblyMassParams {}
/// A placed coordinate frame in world coordinates (mm): its origin and its unit axes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct MassFrame {
pub origin: [f64; 3],
pub x: [f64; 3],
pub y: [f64; 3],
pub z: [f64; 3],
}
/// Mass, centre of mass and inertia tensor of one rigid body (or of several taken as one).
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct RigidMass {
/// Mass in kg.
pub mass_kg: f64,
/// Centre of mass in world coordinates, mm.
pub center_of_mass_mm: [f64; 3],
/// Centre of mass in the frame's coordinates, mm.
pub center_of_mass_local_mm: [f64; 3],
/// Inertia tensor about the centre of mass, world axes, kg·mm², row by row: the moments on
/// the diagonal (Ixx = ∫ (y² + z²) dm …), the negated products of inertia off it
/// (−∫ x y dm …), as URDF and most dynamics libraries take it. `null` when a closed body's
/// mesh does not close (a warning names it).
pub inertia_world_kg_mm2: Option<[[f64; 3]; 3]>,
/// The same tensor in the frame's axes, kg·mm². `null` as above, or when the frame is
/// scaled (its axes are not a rotation).
pub inertia_local_kg_mm2: Option<[[f64; 3]; 3]>,
}
/// One body of an occurrence.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct BodyMassEntry {
pub name: String,
/// Its physical material (the default material when none is assigned).
pub material: String,
pub density_g_cm3: f64,
/// A closed solid: an open body (a surface) has no volume and adds no mass.
pub closed: bool,
/// Volume in mm³ and mass in kg, for a closed body only.
pub volume_mm3: Option<f64>,
pub mass_kg: Option<f64>,
}
/// One occurrence (0: the design's own bodies, which never move).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct OccurrenceMass {
pub occurrence: u64,
pub name: String,
/// The component it places.
pub component: u64,
/// Occurrence ids from the top of the assembly down (empty for 0).
pub path: Vec<u64>,
/// Index of its rigid group in `groups`.
pub group: usize,
/// Grounded, or the design itself.
pub fixed: bool,
/// The occurrence's frame (its component's origin and axes as placed).
pub frame: MassFrame,
pub bodies: Vec<BodyMassEntry>,
/// Bodies left out because they are not closed solids.
pub open_bodies_skipped: usize,
/// Its own bodies' totals in its frame; `null` when it holds no closed body.
pub mass: Option<RigidMass>,
}
/// Occurrences that move as one body.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct RigidGroupMass {
/// Its index in `groups`.
pub group: usize,
/// The occurrences it holds, ascending (0: the design's own bodies).
pub occurrences: Vec<u64>,
/// Holds the design itself or a grounded occurrence: it does not move.
pub fixed: bool,
/// The occurrence whose frame `frame` is: 0 (world) for a fixed group holding the design,
/// else the group's lowest occurrence id.
pub frame_occurrence: u64,
pub frame: MassFrame,
/// Totals of all its occurrences in `frame` (parallel-axis theorem); `null` when massless.
pub mass: Option<RigidMass>,
}
/// What `assembly.mass_properties` returns.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct AssemblyMassResult {
/// Every occurrence, the design's own bodies (0) first, then by occurrence id.
pub occurrences: Vec<OccurrenceMass>,
/// The rigid groups, in order of their lowest occurrence id.
pub groups: Vec<RigidGroupMass>,
/// Approximations made (open bodies, repeated subassemblies, scaled frames).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "mass_properties_tests.rs"]
mod tests;
@@ -0,0 +1,28 @@
use serde_json::json;
use super::{AssemblyMassParams, AssemblyMassResult};
use crate::Session;
use crate::schema::tests::{assert_keys, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<AssemblyMassParams>(), &[]);
}
#[test]
fn replies_match_the_output_schema() {
let schema = output_schema_for::<AssemblyMassResult>();
let schema = schema.as_value();
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let empty = s.execute("assembly.mass_properties", &json!({})).unwrap_or_else(|e| panic!("{e}"));
assert!(conforms(schema, schema, &empty), "{empty} doesn't match {schema}");
assert!(empty.get("param_warnings").is_none(), "{empty}");
s.execute("component.create", &json!({"name": "Block"})).unwrap_or_else(|e| panic!("{e}"));
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap_or_else(|e| panic!("{e}"));
let r = s.execute("assembly.mass_properties", &json!({"unknown_extra": 1})).unwrap_or_else(|e| panic!("{e}"));
assert!(r.get("param_warnings").is_some(), "{r}");
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
s.param_check = ParamCheck::Strict;
assert!(s.execute("assembly.mass_properties", &json!({"unknown_extra": 1})).is_err());
}
+1
View File
@@ -60,6 +60,7 @@ pub mod edit;
pub mod features;
pub mod inspect;
pub mod joints;
pub mod mass_properties;
pub mod print_face;
pub mod render;
pub mod render_lights;
+29
View File
@@ -69,3 +69,32 @@ quality. Each body is tessellated once, however many times the assembly places i
turns its tensor. A closed body whose mesh does not close has `inertia_g_mm2: null`, and so does
the total. Open bodies have no volume, so they have no tensor either, and the total leaves them
out.
## Per occurrence and per rigid group
`assembly.mass_properties` (no parameters) gives what a dynamics solver needs of each moving
part, measured as above, in kg, mm and kg·mm²:
```json
{"command":"assembly.mass_properties","params":{}}
```
- `occurrences`: the design's own bodies (`occurrence: 0`) and then every occurrence, each with
its `frame` (origin and unit axes in world coordinates), its own bodies (material, density,
volume and mass), and `mass`: its bodies' total `mass_kg`, `center_of_mass_mm` (world) and
`center_of_mass_local_mm` (in its frame), and the tensor about that centre in world axes
(`inertia_world_kg_mm2`) and in its frame's axes (`inertia_local_kg_mm2`). Only the bodies the
occurrence's component owns count, not those of occurrences nested in it.
- `groups`: occurrences that move as one body, each with the occurrences it holds, the same
`mass` totals (combined with the parallel-axis theorem) in the frame of its lowest
occurrence (`frame_occurrence`; 0 is the world), and `fixed` when it holds the design itself
or a grounded occurrence. An unsuppressed rigid joint (Rigid Group makes them) puts its two
occurrences in one group, and a nested occurrence that no joint places goes with the
occurrence it sits in, since the joint solver leaves it where its parent puts it.
- `warnings`: open bodies (no mass), closed bodies whose mesh does not close (`inertia_*: null`),
scaled placements (no tensor in their own axes) and subassemblies placed more than once (the
occurrences inside them are listed at the first placement, where joints place them).
A body without a material assignment has the default material's density, as Properties shows.
Pending occurrence moves count. Rust callers get the same numbers, typed, from
`Session::mass_properties()`.