mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
Properties: moment of inertia, principal axes and first moments of a part or assembly (#8095) (#8103)
* claim: #8095 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Properties: moment of inertia, principal axes and first moments of a part or assembly (#8095) component.properties and occurrence.properties report each closed body's inertia tensor about its centre of mass, and in total the tensor about the total centre of mass (world axes, g·mm²), its principal moments and axes, the first moments, and with `about: [x, y, z]` the tensor about any point. The tensor comes from each body's welded export mesh, tessellated once per body, scaled to the measured mass and turned by each placement; bodies add by parallel axes. The maths is a new crates/geom inertia module. The Properties window shows the first moments, the tensor, the principal moments and axes, and the view marks the centre of mass with the principal axes while it is open. Inspect → Measure → Properties now opens that window (it ran the command and showed nothing). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Properties: Copy matrix, and the whole design's card titled just Properties (#8095) Copy matrix, under the tensor, puts it on the clipboard as three lines of tab-separated values at full precision, in the units shown. The card for the whole design is titled Properties; one opened on a component, placement or bodies keeps its name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Properties: measure beside the window, so a large assembly doesn't freeze it (#8095) Properties ran on the window thread: 18 s on a 144-body design, with the window answering nothing meanwhile (#5203). In the desktop app the card now measures on a copy of the session on a worker, the way #4614 made agents' measures work, says "Measuring…" until the reply is in, and Close drops it. A quick measure still shows at once; headless, tests and the web measure in place. 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:
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"en": {
|
||||
"ui.properties.copy_matrix": "Copy matrix",
|
||||
"ui.properties.first_moments": "First moments",
|
||||
"ui.properties.inertia": "Moment of inertia",
|
||||
"ui.properties.inertia_note": "About the center of mass, in world axes (g·{unit}²)",
|
||||
"ui.properties.inertia_unavailable": "Not available: a body's mesh does not close.",
|
||||
"ui.properties.inertia_x": "Ixx, Ixy, Ixz",
|
||||
"ui.properties.inertia_y": "Iyx, Iyy, Iyz",
|
||||
"ui.properties.inertia_z": "Izx, Izy, Izz",
|
||||
"ui.properties.measuring": "Measuring…",
|
||||
"ui.properties.measuring_failed": "Measuring stopped before it finished.",
|
||||
"ui.properties.principal_axis": "Principal axis {number}",
|
||||
"ui.properties.principal_moments": "Principal moments"
|
||||
},
|
||||
"en-GB": {
|
||||
"ui.properties.inertia_note": "About the centre of mass, in world axes (g·{unit}²)"
|
||||
},
|
||||
"ar": {
|
||||
"ui.properties.copy_matrix": "نسخ المصفوفة",
|
||||
"ui.properties.first_moments": "العزوم الأولى",
|
||||
"ui.properties.inertia": "عزم القصور الذاتي",
|
||||
"ui.properties.inertia_note": "حول مركز الكتلة، بمحاور العالم (g·{unit}²)",
|
||||
"ui.properties.inertia_unavailable": "غير متاح: شبكة أحد الأجسام غير مغلقة.",
|
||||
"ui.properties.inertia_x": "Ixx, Ixy, Ixz",
|
||||
"ui.properties.inertia_y": "Iyx, Iyy, Iyz",
|
||||
"ui.properties.inertia_z": "Izx, Izy, Izz",
|
||||
"ui.properties.measuring": "جارٍ القياس…",
|
||||
"ui.properties.measuring_failed": "توقف القياس قبل أن يكتمل.",
|
||||
"ui.properties.principal_axis": "المحور الرئيسي {number}",
|
||||
"ui.properties.principal_moments": "العزوم الرئيسية"
|
||||
},
|
||||
"de": {
|
||||
"ui.properties.copy_matrix": "Matrix kopieren",
|
||||
"ui.properties.first_moments": "Statische Momente",
|
||||
"ui.properties.inertia": "Trägheitsmoment",
|
||||
"ui.properties.inertia_note": "Um den Massenschwerpunkt, in Weltachsen (g·{unit}²)",
|
||||
"ui.properties.inertia_unavailable": "Nicht verfügbar: Das Netz eines Körpers ist nicht geschlossen.",
|
||||
"ui.properties.inertia_x": "Ixx, Ixy, Ixz",
|
||||
"ui.properties.inertia_y": "Iyx, Iyy, Iyz",
|
||||
"ui.properties.inertia_z": "Izx, Izy, Izz",
|
||||
"ui.properties.measuring": "Wird gemessen…",
|
||||
"ui.properties.measuring_failed": "Die Messung wurde vor dem Ende abgebrochen.",
|
||||
"ui.properties.principal_axis": "Hauptachse {number}",
|
||||
"ui.properties.principal_moments": "Hauptträgheitsmomente"
|
||||
},
|
||||
"he": {
|
||||
"ui.properties.copy_matrix": "העתקת המטריצה",
|
||||
"ui.properties.first_moments": "מומנטים ראשונים",
|
||||
"ui.properties.inertia": "מומנט התמד",
|
||||
"ui.properties.inertia_note": "סביב מרכז מסה, בצירי העולם (g·{unit}²)",
|
||||
"ui.properties.inertia_unavailable": "לא זמין: הרשת של אחד הגופים אינה סגורה.",
|
||||
"ui.properties.inertia_x": "Ixx, Ixy, Ixz",
|
||||
"ui.properties.inertia_y": "Iyx, Iyy, Iyz",
|
||||
"ui.properties.inertia_z": "Izx, Izy, Izz",
|
||||
"ui.properties.measuring": "מודד…",
|
||||
"ui.properties.measuring_failed": "המדידה נעצרה לפני שהסתיימה.",
|
||||
"ui.properties.principal_axis": "ציר ראשי {number}",
|
||||
"ui.properties.principal_moments": "מומנטים ראשיים"
|
||||
},
|
||||
"ja": {
|
||||
"ui.properties.copy_matrix": "行列をコピー",
|
||||
"ui.properties.first_moments": "一次モーメント",
|
||||
"ui.properties.inertia": "慣性モーメント",
|
||||
"ui.properties.inertia_note": "重心まわり、ワールド軸 (g·{unit}²)",
|
||||
"ui.properties.inertia_unavailable": "利用できません: ボディのメッシュが閉じていません。",
|
||||
"ui.properties.inertia_x": "Ixx, Ixy, Ixz",
|
||||
"ui.properties.inertia_y": "Iyx, Iyy, Iyz",
|
||||
"ui.properties.inertia_z": "Izx, Izy, Izz",
|
||||
"ui.properties.measuring": "測定中…",
|
||||
"ui.properties.measuring_failed": "測定が完了する前に停止しました。",
|
||||
"ui.properties.principal_axis": "主軸 {number}",
|
||||
"ui.properties.principal_moments": "主慣性モーメント"
|
||||
},
|
||||
"ko": {
|
||||
"ui.properties.copy_matrix": "행렬 복사",
|
||||
"ui.properties.first_moments": "1차 모멘트",
|
||||
"ui.properties.inertia": "관성 모멘트",
|
||||
"ui.properties.inertia_note": "질량 중심 기준, 월드 축 (g·{unit}²)",
|
||||
"ui.properties.inertia_unavailable": "사용할 수 없음: 바디의 메시가 닫혀 있지 않습니다.",
|
||||
"ui.properties.inertia_x": "Ixx, Ixy, Ixz",
|
||||
"ui.properties.inertia_y": "Iyx, Iyy, Iyz",
|
||||
"ui.properties.inertia_z": "Izx, Izy, Izz",
|
||||
"ui.properties.measuring": "측정 중…",
|
||||
"ui.properties.measuring_failed": "측정이 끝나기 전에 중단되었습니다.",
|
||||
"ui.properties.principal_axis": "주축 {number}",
|
||||
"ui.properties.principal_moments": "주관성 모멘트"
|
||||
},
|
||||
"vi": {
|
||||
"ui.properties.copy_matrix": "Sao chép ma trận",
|
||||
"ui.properties.first_moments": "Mômen bậc nhất",
|
||||
"ui.properties.inertia": "Mômen quán tính",
|
||||
"ui.properties.inertia_note": "Quanh trọng tâm, theo các trục thế giới (g·{unit}²)",
|
||||
"ui.properties.inertia_unavailable": "Không khả dụng: lưới của một thân khối không kín.",
|
||||
"ui.properties.inertia_x": "Ixx, Ixy, Ixz",
|
||||
"ui.properties.inertia_y": "Iyx, Iyy, Iyz",
|
||||
"ui.properties.inertia_z": "Izx, Izy, Izz",
|
||||
"ui.properties.measuring": "Đang đo…",
|
||||
"ui.properties.measuring_failed": "Phép đo đã dừng trước khi hoàn tất.",
|
||||
"ui.properties.principal_axis": "Trục chính {number}",
|
||||
"ui.properties.principal_moments": "Mômen quán tính chính"
|
||||
},
|
||||
"zh": {
|
||||
"ui.properties.copy_matrix": "复制矩阵",
|
||||
"ui.properties.first_moments": "一阶矩",
|
||||
"ui.properties.inertia": "转动惯量",
|
||||
"ui.properties.inertia_note": "相对质心,世界坐标轴 (g·{unit}²)",
|
||||
"ui.properties.inertia_unavailable": "不可用:某个实体的网格未闭合。",
|
||||
"ui.properties.inertia_x": "Ixx, Ixy, Ixz",
|
||||
"ui.properties.inertia_y": "Iyx, Iyy, Iyz",
|
||||
"ui.properties.inertia_z": "Izx, Izy, Izz",
|
||||
"ui.properties.measuring": "正在测量…",
|
||||
"ui.properties.measuring_failed": "测量在完成前已停止。",
|
||||
"ui.properties.principal_axis": "主轴 {number}",
|
||||
"ui.properties.principal_moments": "主惯性矩"
|
||||
}
|
||||
}
|
||||
@@ -78,10 +78,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
),
|
||||
CommandSpec::new("component.properties", "Component Physical Properties", properties::component)
|
||||
.icon("measure").noundo()
|
||||
.params("component?: id or name (default active); descendants?: bool (default true). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates; volume, density, mass and center of mass only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals"),
|
||||
.params("component?: id or name (default active); descendants?: bool (default true); about?: [x, y, z] (mm). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates; volume, density, mass, center of mass and inertia_g_mm2 (the inertia tensor about the body's center of mass, world axes, from its tessellation) only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total.first_moment_g_mm (mass × center of mass), inertia_g_mm2 about the total center of mass, principal_moments_g_mm2 (smallest first) and principal_axes (unit, right-handed); with about, total.inertia_about {point, inertia_g_mm2}"),
|
||||
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence)
|
||||
.icon("measure").noundo()
|
||||
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true). Physical properties of this placement in world coordinates, including pending moves; volume, density, mass and center of mass only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals"),
|
||||
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true); about?: [x, y, z] (mm). Physical properties of this placement in world coordinates, including pending moves; volume, density, mass, center of mass and inertia_g_mm2 only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total inertia as in component.properties"),
|
||||
CommandSpec::new("component.rename", "Rename Component", rename).icon("rename").params("component, name"),
|
||||
CommandSpec::new("component.delete", "Delete Component", delete).icon("delete").params("component: deletes it, its subcomponents, occurrences and features"),
|
||||
CommandSpec::new("component.unlock", "Unlock Contents", unlock).icon("rigid_group").params(
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! Physical properties of an assembly placement, with original body material assignments.
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{Document, Mat};
|
||||
use solidcraft_geom::inertia::{self, Tensor3};
|
||||
use solidcraft_geom::{Aabb3, Vec3};
|
||||
|
||||
use crate::params::bad;
|
||||
@@ -14,6 +17,23 @@ fn descendants(p: &Value, cmd: &str) -> Result<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Principal moments this close (a share of the largest) count as equal: a round body's
|
||||
/// tessellation tells its two equal moments apart by about 1e-4, which turns their axes at random.
|
||||
const EQUAL_MOMENTS: f64 = 1e-3;
|
||||
|
||||
/// The point `about` asks for the inertia tensor about, if any.
|
||||
fn about(p: &Value, cmd: &str) -> Result<Option<Vec3>> {
|
||||
match p.get("about") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => crate::params::vec3(v).map(Some).ok_or_else(|| bad(cmd, "`about` must be a point [x, y, z] in mm")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The 3 × 3 linear part of a placement, row by row: its turn, and any scale or mirror.
|
||||
fn linear(m: &Mat) -> Tensor3 {
|
||||
[0, 1, 2].map(|i| [0, 1, 2].map(|j| m[j][i]))
|
||||
}
|
||||
|
||||
fn placed_doc(s: &Session) -> Document {
|
||||
let mut doc = (*s.doc).clone();
|
||||
for occurrence in &mut doc.occurrences {
|
||||
@@ -58,7 +78,7 @@ pub(super) fn component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} 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)?;
|
||||
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
|
||||
out["component"] = json!(id);
|
||||
out["name"] = json!(name);
|
||||
Ok(out)
|
||||
@@ -79,13 +99,13 @@ pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(cmd, "occurrence has multiple placements; supply its `path`")),
|
||||
}
|
||||
};
|
||||
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, cmd)?;
|
||||
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, about(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<Value> {
|
||||
fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, about: Option<Vec3>, cmd: &str) -> Result<Value> {
|
||||
let state = s.model.state();
|
||||
let mut bodies = Vec::new();
|
||||
let (mut volume, mut area, mut mass) = (0.0, 0.0, 0.0);
|
||||
@@ -114,19 +134,40 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
|
||||
}
|
||||
out
|
||||
};
|
||||
// How each closed body's volume spreads about its centre of mass (#8095): one tessellation
|
||||
// however often it is placed, which each placement then turns.
|
||||
let components: BTreeSet<u64> = selected_placements.iter().map(|(component, _, _)| *component).collect();
|
||||
let spread_bodies: Vec<usize> = (0..state.bodies.len())
|
||||
.filter(|&i| {
|
||||
state.bodies.get(i).is_some_and(|b| components.contains(&doc.model_body_component(b)) && crate::cmd::inspect::encloses_volume(&b.body))
|
||||
})
|
||||
.collect();
|
||||
let spreads = solidcraft_kernel::map_on_threads(&spread_bodies, 16, |&i| {
|
||||
state.bodies.get(i).and_then(|b| solidcraft_io::mass_properties(&b.body).ok().flatten()).and_then(|m| m.gyration())
|
||||
});
|
||||
let spread: BTreeMap<usize, Tensor3> = spread_bodies.into_iter().zip(spreads).filter_map(|(i, g)| Some((i, g.flatten()?))).collect();
|
||||
// Closed bodies' (mass, centre of mass, inertia about it), all in world axes.
|
||||
let mut parts: Vec<(f64, Vec3, Option<Tensor3>)> = Vec::new();
|
||||
for (component, path, transform) in selected_placements {
|
||||
placement_count += 1;
|
||||
for body in state.bodies.iter().filter(|b| doc.model_body_component(b) == component) {
|
||||
for (index, body) in state.bodies.iter().enumerate().filter(|(_, b)| doc.model_body_component(b) == component) {
|
||||
let transform: Mat = doc.model_body_offset(body).map(|offset| solidcraft_doc::mat_mul(&transform, offset)).unwrap_or(transform);
|
||||
let placed = solidcraft_kernel::transform_matrix(&body.body, transform)?;
|
||||
let measured = solidcraft_kernel::measure(&placed)?;
|
||||
let density = doc.density(&body.name);
|
||||
let body_mass = measured.volume * density / 1000.0;
|
||||
let closed = crate::cmd::inspect::encloses_volume(&placed);
|
||||
// The mesh's spread with the exact mass, so the tensor agrees with the mass shown.
|
||||
let body_inertia = spread
|
||||
.get(&index)
|
||||
.filter(|_| closed)
|
||||
.map(|g| inertia::from_gyration(&inertia::transformed(g, &linear(&transform)), body_mass))
|
||||
.filter(|i| i.iter().flatten().all(|x| x.is_finite()));
|
||||
if closed {
|
||||
volume += measured.volume;
|
||||
mass += body_mass;
|
||||
moment += measured.centroid * body_mass;
|
||||
parts.push((body_mass, measured.centroid, body_inertia));
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
@@ -140,6 +181,7 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
|
||||
"closed": closed, "density_g_cm3": closed.then_some(density),
|
||||
"mass_g": closed.then_some(body_mass), "volume_mm3": closed.then_some(measured.volume),
|
||||
"area_mm2": measured.area, "center_of_mass": closed.then_some(measured.centroid),
|
||||
"inertia_g_mm2": body_inertia,
|
||||
"bbox": {"min": measured.bbox.min, "max": measured.bbox.max}}));
|
||||
}
|
||||
}
|
||||
@@ -150,11 +192,29 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
|
||||
if centroid.is_some_and(|point| !point.is_finite()) {
|
||||
return Err(bad(cmd, "center of mass exceeds supported numeric range"));
|
||||
}
|
||||
// About the centre of mass: each body's own tensor, plus its mass at its distance from that
|
||||
// centre (parallel axes). Unknown when any closed body's mesh did not close.
|
||||
let at_centre = centroid.and_then(|c| {
|
||||
parts.iter().try_fold([[0.0f64; 3]; 3], |sum, (m, at, i)| {
|
||||
let i = inertia::shifted(&(*i)?, *m, *at - c);
|
||||
Some([0, 1, 2].map(|r| [0, 1, 2].map(|k| sum[r][k] + i[r][k])))
|
||||
})
|
||||
});
|
||||
let at_centre = at_centre.filter(|i| i.iter().flatten().all(|x| x.is_finite()));
|
||||
let principal = at_centre.and_then(|i| inertia::principal(&i, EQUAL_MOMENTS));
|
||||
let bbox = if bodies.is_empty() { Value::Null } else { json!({"min": bounds.min, "max": bounds.max}) };
|
||||
Ok(json!({"path": selected, "coordinate_frame": "world", "descendants": descendants,
|
||||
let mut out = json!({"path": selected, "coordinate_frame": "world", "descendants": descendants,
|
||||
"placement_count": placement_count, "body_count": bodies.len(), "bodies": bodies,
|
||||
"total": {"open_bodies_skipped": skipped, "volume_mm3": volume, "area_mm2": area, "mass_g": mass,
|
||||
"center_of_mass": centroid, "bbox": bbox, "faces": faces, "edges": edges, "vertices": vertices}}))
|
||||
"center_of_mass": centroid, "first_moment_g_mm": moment, "inertia_g_mm2": at_centre,
|
||||
"principal_moments_g_mm2": principal.map(|(moments, _)| moments), "principal_axes": principal.map(|(_, axes)| axes),
|
||||
"bbox": bbox, "faces": faces, "edges": edges, "vertices": vertices}});
|
||||
if let Some(point) = about {
|
||||
let shifted =
|
||||
centroid.zip(at_centre).map(|(c, i)| inertia::shifted(&i, mass, point - c)).filter(|i| i.iter().flatten().all(|x| x.is_finite()));
|
||||
out["total"]["inertia_about"] = json!({"point": point, "inertia_g_mm2": shifted});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -98,6 +98,114 @@ fn properties_reject_hostile_identifiers_and_options() {
|
||||
for path in [json!([]), json!(["bad"]), json!([0]), json!(vec![occurrence; 65])] {
|
||||
assert!(s.execute("occurrence.properties", &json!({"occurrence": occurrence, "path": path})).is_err());
|
||||
}
|
||||
for about in [json!("origin"), json!([1, 2]), json!([1, 2, "z"]), json!([f64::MAX, 0, 0]), json!({"x": 1})] {
|
||||
assert!(s.execute("component.properties", &json!({"about": about})).is_err(), "{about}");
|
||||
}
|
||||
}
|
||||
|
||||
fn tensor(v: &Value) -> [[f64; 3]; 3] {
|
||||
[0, 1, 2].map(|r| [0, 1, 2].map(|c| v[r][c].as_f64().unwrap_or_else(|| panic!("no [{r}][{c}] in {v}"))))
|
||||
}
|
||||
|
||||
fn tensor_close(actual: &Value, want: [[f64; 3]; 3], rel: f64) {
|
||||
let got = tensor(actual);
|
||||
let scale = want.iter().flatten().fold(0.0f64, |m, x| m.max(x.abs()));
|
||||
for (g, w) in got.iter().flatten().zip(want.iter().flatten()) {
|
||||
assert!((g - w).abs() <= rel * scale, "{got:?} != {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #8095: a 10 × 20 × 30 mm steel box's inertia tensor about its centre of mass is
|
||||
/// m (b² + c²) / 12 … on the diagonal, its principal axes are its edges, and turning its
|
||||
/// placement turns the tensor with it.
|
||||
#[test]
|
||||
fn box_inertia_matches_the_closed_form_and_turns_with_its_placement() {
|
||||
let mut s = Session::default();
|
||||
let c = run(&mut s, "component.create", json!({"name": "Block", "grounded": false}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "Block"}));
|
||||
run(&mut s, "material.assign", json!({"bodies": ["Block"], "material": "Steel"}));
|
||||
let r = run(&mut s, "component.properties", json!({"component": c["component"]}));
|
||||
let total = &r["total"];
|
||||
close(&total["bbox"]["max"][1], 20.0);
|
||||
let m = total["mass_g"].as_f64().unwrap();
|
||||
close(&total["mass_g"], 6000.0 * s.doc.density("Block") / 1000.0);
|
||||
let (a, b, h) = (10.0, 20.0, 30.0);
|
||||
let (ixx, iyy, izz) = (m * (b * b + h * h) / 12.0, m * (a * a + h * h) / 12.0, m * (a * a + b * b) / 12.0);
|
||||
tensor_close(&total["inertia_g_mm2"], [[ixx, 0.0, 0.0], [0.0, iyy, 0.0], [0.0, 0.0, izz]], 1e-9);
|
||||
tensor_close(&r["bodies"][0]["inertia_g_mm2"], [[ixx, 0.0, 0.0], [0.0, iyy, 0.0], [0.0, 0.0, izz]], 1e-9);
|
||||
// Smallest first: about the longest edge (z), largest about the shortest (x).
|
||||
for (k, want) in [izz, iyy, ixx].into_iter().enumerate() {
|
||||
assert!((total["principal_moments_g_mm2"][k].as_f64().unwrap() - want).abs() < 1e-9 * ixx, "{total}");
|
||||
}
|
||||
// Right-handed: z × y is −x.
|
||||
assert_eq!(total["principal_axes"], json!([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]]));
|
||||
for (k, want) in [5.0, 10.0, 15.0].iter().enumerate() {
|
||||
close(&total["first_moment_g_mm"][k], m * want);
|
||||
}
|
||||
// A quarter turn about z swaps the x and y moments; the principal moments stay.
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": c["occurrence"], "axis": [0, 0, 1], "angle": "90 deg", "capture": false}));
|
||||
let turned = run(&mut s, "component.properties", json!({"component": c["component"]}));
|
||||
tensor_close(&turned["total"]["inertia_g_mm2"], [[iyy, 0.0, 0.0], [0.0, ixx, 0.0], [0.0, 0.0, izz]], 1e-9);
|
||||
assert_eq!(turned["total"]["principal_moments_g_mm2"], total["principal_moments_g_mm2"]);
|
||||
let x_axis = &turned["total"]["principal_axes"][2];
|
||||
assert!(x_axis[0].as_f64().unwrap().abs() < 1e-9 && (x_axis[1].as_f64().unwrap().abs() - 1.0).abs() < 1e-9, "{turned}");
|
||||
}
|
||||
|
||||
/// #8095: an assembly's tensor adds each placement's own about the shared centre of mass by
|
||||
/// parallel axes, and `about` takes it about any point; a cylinder's comes from its
|
||||
/// tessellation, close to m r² / 2 about its axis.
|
||||
#[test]
|
||||
fn assembly_inertia_adds_placements_by_parallel_axes_and_about_any_point() {
|
||||
let mut s = Session::default();
|
||||
let (component, _, body) = box_in(&mut s, "Block");
|
||||
run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20, 0, 0]}));
|
||||
let root = run(&mut s, "component.properties", json!({"component": "root", "about": [0, 0, 0]}));
|
||||
let total = &root["total"];
|
||||
let m = s.doc.density(&body);
|
||||
close(&total["mass_g"], 2.0 * m);
|
||||
// Each 10 mm cube: m (10² + 10²) / 12 on its diagonal; each sits 10 mm along x from (15, 5, 5).
|
||||
let own = m * 200.0 / 12.0;
|
||||
let at_centre = [[2.0 * own, 0.0, 0.0], [0.0, 2.0 * own + 2.0 * m * 100.0, 0.0], [0.0, 0.0, 2.0 * own + 2.0 * m * 100.0]];
|
||||
tensor_close(&total["inertia_g_mm2"], at_centre, 1e-9);
|
||||
// About the origin: the parallel-axis term of the whole mass at (15, 5, 5).
|
||||
let (mm, cv) = (2.0 * m, [15.0, 5.0, 5.0]);
|
||||
let c2: f64 = cv.iter().map(|x| x * x).sum();
|
||||
let at_origin = [0, 1, 2].map(|r| [0, 1, 2].map(|k| at_centre[r][k] + mm * (if r == k { c2 } else { 0.0 } - cv[r] * cv[k])));
|
||||
assert_eq!(total["inertia_about"]["point"], json!([0.0, 0.0, 0.0]));
|
||||
tensor_close(&total["inertia_about"]["inertia_g_mm2"], at_origin, 1e-9);
|
||||
assert!(total.get("inertia_about").is_some() && run(&mut s, "component.properties", json!({}))["total"].get("inertia_about").is_none());
|
||||
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 8, "height": 10, "body_name": "Pin"}));
|
||||
let r = run(&mut s, "component.properties", json!({}));
|
||||
let (m, r2) = (r["total"]["mass_g"].as_f64().unwrap(), 64.0);
|
||||
let i = tensor(&r["total"]["inertia_g_mm2"]);
|
||||
assert!((i[2][2] - m * r2 / 2.0).abs() < 1e-3 * m * r2 / 2.0, "{i:?}");
|
||||
assert!((i[0][0] - m * (3.0 * r2 + 100.0) / 12.0).abs() < 1e-3 * i[0][0], "{i:?}");
|
||||
}
|
||||
|
||||
/// #8095: an open surface has no volume, so no tensor; the closed bodies beside it keep theirs.
|
||||
#[test]
|
||||
fn open_bodies_have_no_inertia_and_leave_the_closed_ones_alone() {
|
||||
let mut s = Session::default();
|
||||
for (command, params) in [
|
||||
("sketch.create", json!({"plane": "XY", "name": "Profile"})),
|
||||
("sketch.line", json!({"points": [[30, 0], [30, 40]], "ids": ["side"]})),
|
||||
("sketch.finish", json!({})),
|
||||
("surface.revolve", json!({"sketch": "Profile", "curves": ["side"], "axis": "y", "angle": "270 deg", "body_name": "Tube"})),
|
||||
] {
|
||||
run(&mut s, command, params);
|
||||
}
|
||||
let open = run(&mut s, "component.properties", json!({"about": [1, 2, 3]}));
|
||||
assert_eq!(open["bodies"][0]["inertia_g_mm2"], Value::Null);
|
||||
for key in ["inertia_g_mm2", "principal_moments_g_mm2", "principal_axes"] {
|
||||
assert_eq!(open["total"][key], Value::Null, "{key}");
|
||||
}
|
||||
assert_eq!(open["total"]["inertia_about"]["inertia_g_mm2"], Value::Null);
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [100, 0, 0], "body_name": "Box"}));
|
||||
let both = run(&mut s, "component.properties", json!({}));
|
||||
let m = both["total"]["mass_g"].as_f64().unwrap();
|
||||
tensor_close(&both["total"]["inertia_g_mm2"], [[m * 200.0 / 12.0, 0.0, 0.0], [0.0, m * 200.0 / 12.0, 0.0], [0.0, 0.0, m * 200.0 / 12.0]], 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Inertia tensors (#8095): built from how a body's volume spreads about its centre of mass,
|
||||
//! taken about another point (parallel axes), and their principal moments and axes.
|
||||
|
||||
use crate::{MassProperties, Vec3};
|
||||
|
||||
/// A symmetric 3 × 3 tensor, row by row.
|
||||
pub type Tensor3 = [[f64; 3]; 3];
|
||||
|
||||
/// Jacobi sweeps before [`principal`] gives up; a 3 × 3 tensor settles in well under ten.
|
||||
const MAX_SWEEPS: usize = 50;
|
||||
|
||||
const IDENTITY3: Tensor3 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||||
|
||||
impl MassProperties {
|
||||
/// Second moments about the centroid per unit volume, ∫ (x − c)(x − c)ᵀ dV / V. Unlike the
|
||||
/// inertia it follows any linear map L of the body as L G Lᵀ (a scale or a mirror too), and
|
||||
/// [`from_gyration`] turns it into the inertia of a mass spread that way.
|
||||
pub fn gyration(&self) -> Option<Tensor3> {
|
||||
// I = tr(Σ) E − Σ, so tr(I) = 2 tr(Σ) and Σ = tr(I)/2 E − I.
|
||||
let i = &self.inertia;
|
||||
let half = (i[0][0] + i[1][1] + i[2][2]) / 2.0;
|
||||
let mut g = [[0.0f64; 3]; 3];
|
||||
for (row_out, (a, row)) in g.iter_mut().zip(i.iter().enumerate()) {
|
||||
for (cell, (b, x)) in row_out.iter_mut().zip(row.iter().enumerate()) {
|
||||
*cell = (if a == b { half } else { 0.0 } - x) / self.volume;
|
||||
}
|
||||
}
|
||||
g.iter().flatten().all(|x| x.is_finite()).then_some(g)
|
||||
}
|
||||
}
|
||||
|
||||
/// The inertia tensor of `mass` spread as `gyration` about its centre of mass: m (tr(G) E − G).
|
||||
pub fn from_gyration(gyration: &Tensor3, mass: f64) -> Tensor3 {
|
||||
let trace = gyration[0][0] + gyration[1][1] + gyration[2][2];
|
||||
let mut out = [[0.0f64; 3]; 3];
|
||||
for (i, (row_out, row)) in out.iter_mut().zip(gyration).enumerate() {
|
||||
for (j, (cell, x)) in row_out.iter_mut().zip(row).enumerate() {
|
||||
*cell = mass * (if i == j { trace } else { 0.0 } - x);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `g` of a body moved by the linear map `l` (row by row): L G Lᵀ. A gyration follows any
|
||||
/// linear map; an inertia tensor follows a rotation this way.
|
||||
pub fn transformed(g: &Tensor3, l: &Tensor3) -> Tensor3 {
|
||||
mul(&mul(l, g), &transpose(l))
|
||||
}
|
||||
|
||||
/// `inertia` of `mass` about its centre of mass, taken about a point `d` from that centre
|
||||
/// instead (parallel-axis theorem): I + m (|d|² E − d dᵀ).
|
||||
pub fn shifted(inertia: &Tensor3, mass: f64, d: Vec3) -> Tensor3 {
|
||||
let dv = [d.x, d.y, d.z];
|
||||
let d2 = d.dot(d);
|
||||
let mut out = *inertia;
|
||||
for (i, (row, di)) in out.iter_mut().zip(dv).enumerate() {
|
||||
for (j, (cell, dj)) in row.iter_mut().zip(dv).enumerate() {
|
||||
*cell += mass * (if i == j { d2 } else { 0.0 } - di * dj);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Principal moments of `inertia`, smallest first, and the unit axis each is about: the axes
|
||||
/// with no products of inertia between them. The first two point along the world axis each is
|
||||
/// nearest to, and the third is the first × the second (right-handed). Moments within `equal`
|
||||
/// (a share of the largest) of each other count as equal, as a round body's do though its
|
||||
/// tessellation tells them apart: their axes are free in the plane (or space) they share, and
|
||||
/// the world axes nearest it are given. `None` for a tensor with non-finite entries.
|
||||
pub fn principal(inertia: &Tensor3, equal: f64) -> Option<([f64; 3], [Vec3; 3])> {
|
||||
if !inertia.iter().flatten().all(|x| x.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
// The symmetric part: products of inertia summed with round-off can differ in the last bit.
|
||||
let mut a = *inertia;
|
||||
for (i, row) in a.iter_mut().enumerate() {
|
||||
for (j, cell) in row.iter_mut().enumerate() {
|
||||
*cell = 0.5 * (inertia[i][j] + inertia[j][i]);
|
||||
}
|
||||
}
|
||||
let scale = a.iter().flatten().fold(0.0f64, |m, x| m.max(x.abs()));
|
||||
let mut v = IDENTITY3;
|
||||
// Cyclic Jacobi: each rotation zeroes one product of inertia; the sweep repeats until all are
|
||||
// round-off small against the largest entry.
|
||||
for _ in 0..MAX_SWEEPS {
|
||||
if a[0][1].abs() + a[0][2].abs() + a[1][2].abs() <= scale * 1e-15 {
|
||||
break;
|
||||
}
|
||||
for (p, q) in [(0, 1), (0, 2), (1, 2)] {
|
||||
let apq = a[p][q];
|
||||
if apq == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let theta = (a[q][q] - a[p][p]) / (2.0 * apq);
|
||||
let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
|
||||
let c = 1.0 / (t * t + 1.0).sqrt();
|
||||
let s = t * c;
|
||||
let mut j = IDENTITY3;
|
||||
j[p][p] = c;
|
||||
j[q][q] = c;
|
||||
j[p][q] = s;
|
||||
j[q][p] = -s;
|
||||
a = mul(&mul(&transpose(&j), &a), &j);
|
||||
v = mul(&v, &j);
|
||||
}
|
||||
}
|
||||
let mut order = [0usize, 1, 2];
|
||||
order.sort_by(|&x, &y| a[x][x].total_cmp(&a[y][y]));
|
||||
let moments = order.map(|k| a[k][k]);
|
||||
let [e0, e1, _] = order.map(|k| Vec3::new(v[0][k], v[1][k], v[2][k]));
|
||||
let largest = moments.iter().fold(0.0f64, |m, x| m.max(x.abs()));
|
||||
let same = |x: f64, y: f64| (y - x).abs() <= equal * largest;
|
||||
let (low, high) = (same(moments[0], moments[1]), same(moments[1], moments[2]));
|
||||
let (first, second) = if low && high {
|
||||
(Vec3::X, Vec3::Y)
|
||||
} else if low {
|
||||
// The first two share a plane: the world axis nearest it, then its perpendicular there.
|
||||
let normal = e0.cross(e1).normalized()?;
|
||||
let first = nearest_in_plane(normal)?;
|
||||
(first, normal.cross(first).normalized()?)
|
||||
} else if high {
|
||||
(e0, nearest_in_plane(e0)?)
|
||||
} else {
|
||||
(e0, e1)
|
||||
};
|
||||
let (first, second) = (forward(first), forward(second));
|
||||
let axes = [first, second, first.cross(second)];
|
||||
(moments.iter().all(|x| x.is_finite()) && axes.iter().all(|e| e.is_finite())).then_some((moments, axes))
|
||||
}
|
||||
|
||||
/// `e` turned to point along the world axis it is nearest to, so the same body gives the same
|
||||
/// axes every time.
|
||||
fn forward(e: Vec3) -> Vec3 {
|
||||
let lead = [e.x, e.y, e.z].into_iter().fold(0.0f64, |m, x| if x.abs() > m.abs() { x } else { m });
|
||||
if lead < 0.0 { e * -1.0 } else { e }
|
||||
}
|
||||
|
||||
/// The unit direction in the plane through the origin with unit `normal` nearest a world axis
|
||||
/// (X first when two are as near).
|
||||
fn nearest_in_plane(normal: Vec3) -> Option<Vec3> {
|
||||
let along = |w: Vec3| w - normal * normal.dot(w);
|
||||
let best = [Vec3::X, Vec3::Y, Vec3::Z].into_iter().map(along).fold(Vec3::ZERO, |b, p| if p.len2() > b.len2() * (1.0 + 1e-9) { p } else { b });
|
||||
best.normalized()
|
||||
}
|
||||
|
||||
fn mul(a: &Tensor3, b: &Tensor3) -> Tensor3 {
|
||||
let mut out = [[0.0f64; 3]; 3];
|
||||
for (row_out, row) in out.iter_mut().zip(a) {
|
||||
for (j, cell) in row_out.iter_mut().enumerate() {
|
||||
*cell = row.iter().zip(b).map(|(x, b_row)| x * b_row[j]).sum();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn transpose(a: &Tensor3) -> Tensor3 {
|
||||
[[a[0][0], a[1][0], a[2][0]], [a[0][1], a[1][1], a[2][1]], [a[0][2], a[1][2], a[2][2]]]
|
||||
}
|
||||
@@ -25,6 +25,7 @@ pub mod fill;
|
||||
pub mod fill_general;
|
||||
mod grid;
|
||||
pub mod hlr;
|
||||
pub mod inertia;
|
||||
mod mesh;
|
||||
pub mod mesh_distance;
|
||||
pub mod network;
|
||||
|
||||
@@ -530,6 +530,99 @@ fn mass_properties_need_consistently_oriented_faces() {
|
||||
assert!(m.mass_properties().is_none());
|
||||
}
|
||||
|
||||
/// `m` with every vertex moved by the linear map `l` (row by row).
|
||||
fn mapped(m: &Mesh, l: &inertia::Tensor3) -> Mesh {
|
||||
let apply = |p: Vec3| {
|
||||
let [x, y, z] = l.map(|r| r[0] * p.x + r[1] * p.y + r[2] * p.z);
|
||||
Vec3::new(x, y, z)
|
||||
};
|
||||
Mesh { positions: m.positions.iter().map(|p| apply(*p)).collect(), triangles: m.triangles.clone(), ..Default::default() }
|
||||
}
|
||||
|
||||
fn tensors_close(a: &inertia::Tensor3, b: &inertia::Tensor3, tol: f64) -> bool {
|
||||
a.iter().flatten().zip(b.iter().flatten()).all(|(x, y)| (x - y).abs() <= tol)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gyration_follows_a_rotation_a_scale_and_a_mirror_of_the_body() {
|
||||
let base = offset_box(Vec3::new(5.0, -3.0, 2.0), 20.0, 30.0, 40.0);
|
||||
let props = base.mass_properties().unwrap();
|
||||
let g = props.gyration().unwrap();
|
||||
assert!(tensors_close(&inertia::from_gyration(&g, props.volume), &props.inertia, 1e-6 * props.inertia[0][0]));
|
||||
let (c, s) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
|
||||
let (cx, sx) = (20f64.to_radians().cos(), 20f64.to_radians().sin());
|
||||
let turn_z = [[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]];
|
||||
let turn_x = [[1.0, 0.0, 0.0], [0.0, cx, -sx], [0.0, sx, cx]];
|
||||
let turn = [0, 1, 2].map(|i| [0, 1, 2].map(|j| (0..3).map(|k| turn_z[i][k] * turn_x[k][j]).sum::<f64>()));
|
||||
for l in [turn, [[2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.5]], [[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]] {
|
||||
let moved = mapped(&base, &l).mass_properties().expect("a mapped box is still closed");
|
||||
let want = moved.inertia;
|
||||
let got = inertia::from_gyration(&inertia::transformed(&g, &l), moved.volume);
|
||||
assert!(tensors_close(&got, &want, 1e-6 * want[0][0].abs().max(want[2][2].abs())), "{l:?}\n{got:?}\n{want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn principal_axes_of_a_turned_box_are_its_edges() {
|
||||
let (a, b, c) = (20.0, 30.0, 40.0);
|
||||
let v = a * b * c;
|
||||
let (cs, sn) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
|
||||
let turn = [[cs, -sn, 0.0], [sn, cs, 0.0], [0.0, 0.0, 1.0]];
|
||||
let m = mapped(&offset_box(Vec3::ZERO, a, b, c), &turn).mass_properties().unwrap();
|
||||
assert!(m.inertia[0][1].abs() > 1.0, "turned about z, the box has a product of inertia: {:?}", m.inertia);
|
||||
let (moments, axes) = inertia::principal(&m.inertia, 0.0).unwrap();
|
||||
// Smallest about the longest edge (z), largest about the shortest (the turned x).
|
||||
let want = [v * (a * a + b * b) / 12.0, v * (a * a + c * c) / 12.0, v * (b * b + c * c) / 12.0];
|
||||
for (got, want) in moments.iter().zip(want) {
|
||||
assert!(close(*got, want, 1e-9), "{moments:?} {want}");
|
||||
}
|
||||
let edges = [Vec3::Z, Vec3::new(-sn, cs, 0.0), Vec3::new(cs, sn, 0.0)];
|
||||
for (axis, edge) in axes.iter().zip(edges) {
|
||||
assert!((axis.dot(edge).abs() - 1.0).abs() < 1e-9, "{axes:?}");
|
||||
}
|
||||
assert!((axes[0].cross(axes[1]) - axes[2]).len() < 1e-12, "right-handed: {axes:?}");
|
||||
// The first two point along the world axis nearest them: +z, then +y.
|
||||
assert!(axes[0].z > 0.0 && axes[1].y > 0.0, "{axes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shifted_inertia_about_a_box_corner_matches_the_closed_form() {
|
||||
let (a, b, c) = (20.0, 30.0, 40.0);
|
||||
let m = offset_box(Vec3::ZERO, a, b, c).mass_properties().unwrap();
|
||||
let mass = 3.0 * m.volume;
|
||||
let at_centre = m.inertia.map(|r| r.map(|x| 3.0 * x));
|
||||
let corner = inertia::shifted(&at_centre, mass, Vec3::ZERO - m.centroid);
|
||||
let want = [
|
||||
[mass * (b * b + c * c) / 3.0, -mass * a * b / 4.0, -mass * a * c / 4.0],
|
||||
[-mass * a * b / 4.0, mass * (a * a + c * c) / 3.0, -mass * b * c / 4.0],
|
||||
[-mass * a * c / 4.0, -mass * b * c / 4.0, mass * (a * a + b * b) / 3.0],
|
||||
];
|
||||
assert!(tensors_close(&corner, &want, 1e-9 * want[0][0]), "{corner:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn principal_axes_of_equal_zero_or_broken_tensors() {
|
||||
let sphere = [[5.0, 0.0, 0.0], [0.0, 5.0, 0.0], [0.0, 0.0, 5.0]];
|
||||
assert_eq!(inertia::principal(&sphere, 0.0), Some(([5.0; 3], [Vec3::X, Vec3::Y, Vec3::Z])));
|
||||
assert_eq!(inertia::principal(&[[0.0; 3]; 3], 0.0), Some(([0.0; 3], [Vec3::X, Vec3::Y, Vec3::Z])));
|
||||
assert!(inertia::principal(&[[f64::NAN, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], 0.0).is_none());
|
||||
assert!(inertia::principal(&[[f64::INFINITY, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], 0.0).is_none());
|
||||
// Products of inertia far larger than the moments still settle.
|
||||
let (moments, _) = inertia::principal(&[[1.0, 1e12, 0.0], [1e12, 1.0, 0.0], [0.0, 0.0, 1.0]], 0.0).unwrap();
|
||||
assert!(close(moments[0], -1e12, 1e-9) && close(moments[2], 1e12, 1e-9), "{moments:?}");
|
||||
// A tessellated pin along z: x and y differ in the fifth digit, with a stray product between
|
||||
// them. Exactly, their axes are turned at random; counted as equal, they are X and Y.
|
||||
let pin = [[7.99497, -0.000535, 0.0], [-0.000535, 7.99536, 0.0], [0.0, 0.0, 5.18447]];
|
||||
let (_, exact) = inertia::principal(&pin, 0.0).unwrap();
|
||||
assert!(exact[1].x.abs() < 0.99, "{exact:?}");
|
||||
assert_eq!(inertia::principal(&pin, 1e-3).map(|(_, axes)| axes), Some([Vec3::Z, Vec3::X, Vec3::Y]));
|
||||
// The same with the round pair smallest (a disc), and all three alike (a cube's tessellation).
|
||||
let disc = [[3.00001, 0.0002, 0.0], [0.0002, 3.0, 0.0], [0.0, 0.0, 6.0]];
|
||||
assert_eq!(inertia::principal(&disc, 1e-3).map(|(_, axes)| axes), Some([Vec3::X, Vec3::Y, Vec3::Z]));
|
||||
let cube = [[2.0, 1e-5, 0.0], [1e-5, 2.0001, 0.0], [0.0, 0.0, 1.9999]];
|
||||
assert_eq!(inertia::principal(&cube, 1e-3).map(|(_, axes)| axes), Some([Vec3::X, Vec3::Y, Vec3::Z]));
|
||||
}
|
||||
|
||||
mod close_pairs {
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ pub use threemf::{
|
||||
pub use urdf::{UrdfOptions, UrdfPackage, urdf};
|
||||
|
||||
use solidcraft_doc::{Document, Metadata, ModelState};
|
||||
use solidcraft_geom::{Mesh, Vec3};
|
||||
use solidcraft_geom::{MassProperties, Mesh, Vec3};
|
||||
|
||||
/// Design file extension: new designs, autosaves and recovery files are written as `.sc`.
|
||||
pub const DESIGN_EXT: &str = "sc";
|
||||
@@ -425,6 +425,14 @@ fn weld_eps(b: &solidcraft_kernel::Body) -> f64 {
|
||||
(b.size() * 1e-6).max(1e-7)
|
||||
}
|
||||
|
||||
/// Unit-density [`MassProperties`] of a body from its welded [`export_mesh`], the mesh URDF links
|
||||
/// take their inertia from; `None` when that mesh does not close (an open surface).
|
||||
pub fn mass_properties(b: &solidcraft_kernel::Body) -> Result<Option<MassProperties>> {
|
||||
let m = export_mesh(b)?;
|
||||
let (positions, triangles) = weld(&m.positions, &m.triangles, weld_eps(b));
|
||||
Ok(Mesh { positions, triangles, ..Mesh::default() }.mass_properties())
|
||||
}
|
||||
|
||||
/// Binary STL of several meshes (one solid).
|
||||
pub fn stl_binary(meshes: &[Mesh], name: &str) -> Vec<u8> {
|
||||
let n: usize = meshes.iter().map(|m| m.triangles.len()).sum();
|
||||
|
||||
@@ -231,6 +231,8 @@ pub struct MenuState {
|
||||
pub link_copies: Option<crate::linked_source::Copies>,
|
||||
/// The Properties window: title and measurements.
|
||||
pub props: Option<(String, Value)>,
|
||||
/// Its measurement while it runs beside the window (#5203).
|
||||
pub props_measuring: Option<crate::design_properties::Measuring>,
|
||||
/// The viewport menu opened over something: it offers Select Other.
|
||||
pub over_geometry: bool,
|
||||
/// The plane under the cursor when the viewport menu opened: it offers a sketch on it.
|
||||
@@ -1381,16 +1383,8 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
"inspect.measure"
|
||||
};
|
||||
let params = if command == "inspect.measure" { json!({ "bodies": bodies }) } else { p.clone() };
|
||||
match app.session.execute(command, ¶ms) {
|
||||
Ok(v) => {
|
||||
let title = v["name"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| if bodies.len() == 1 { bodies.join("") } else { format!("{} bodies", bodies.len()) });
|
||||
app.menu.props = Some((title, v));
|
||||
}
|
||||
Err(e) => app.set_status(e.to_string(), true),
|
||||
}
|
||||
let fallback = if bodies.len() == 1 { bodies.join("") } else { format!("{} bodies", bodies.len()) };
|
||||
crate::design_properties::open_object(app, command, ¶ms, fallback);
|
||||
}
|
||||
"ui.designProperties" => app.start("document.metadata"),
|
||||
"ui.export" => {
|
||||
|
||||
@@ -4,14 +4,19 @@
|
||||
//! Cancel and the close button change nothing. File → Design Properties…, the Browser's
|
||||
//! Document Settings, the root component's menu and `ui.start` of `document.metadata` or
|
||||
//! `document.metadata.set` open it. [`show_object`] draws the Properties card (mass, volume, area,
|
||||
//! centre of mass, bounding box, counts) of the body or component a menu asked about; both are
|
||||
//! `ui_kit::Window` cards.
|
||||
//! centre of mass, bounding box, counts, and for a component its first moments and moment of
|
||||
//! inertia: #8095) of the body or component a menu or Inspect → Measure → Properties asked
|
||||
//! about; both are `ui_kit::Window` cards. While the card is open, [`overlay`] marks the centre
|
||||
//! of mass in the view with the principal axes standing out of it.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
use egui::{Align2, FontId, Stroke};
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcraft_engine::doc::metadata::{DESCRIPTION, Owner, keys};
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::ui_kit;
|
||||
use crate::viewport::Proj;
|
||||
|
||||
/// Longest preview of another tool's value in the list.
|
||||
const PREVIEW_CHARS: usize = 80;
|
||||
@@ -115,9 +120,207 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
app.design_properties = Some(d);
|
||||
}
|
||||
|
||||
/// Open the Properties window on `command`'s reply (`component.properties`,
|
||||
/// `occurrence.properties` or `inspect.measure`), titled with the reply's name, else `fallback`.
|
||||
/// A refusal goes to the status line.
|
||||
pub fn open_object(app: &mut SolveApp, command: &str, params: &Value, fallback: String) {
|
||||
close_object(app);
|
||||
// A large assembly takes seconds to measure, which froze the window (#5203): measured on a
|
||||
// copy beside it, the card shows when done. Headless and in tests (no wait set), here.
|
||||
if let Some(wait) = app.apply_off_thread
|
||||
&& let Some(rx) = measure_beside(app.session.scratch(), command, params)
|
||||
{
|
||||
match rx.recv_timeout(wait) {
|
||||
Ok(reply) => opened(app, reply, fallback),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => app.menu.props_measuring = Some(Measuring { fallback, rx }),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => app.set_status(app.tr("ui.properties.measuring_failed").to_string(), true),
|
||||
}
|
||||
return;
|
||||
}
|
||||
let reply = app.session.execute(command, params).map_err(|e| e.to_string());
|
||||
opened(app, reply, fallback);
|
||||
}
|
||||
|
||||
/// Close the Properties card, and drop a measurement still running for it.
|
||||
pub fn close_object(app: &mut SolveApp) {
|
||||
app.menu.props = None;
|
||||
app.menu.props_measuring = None;
|
||||
}
|
||||
|
||||
/// A Properties measurement running beside the window (#5203): the card's title when the reply
|
||||
/// has no name, and the reply to come.
|
||||
pub struct Measuring {
|
||||
fallback: String,
|
||||
rx: std::sync::mpsc::Receiver<Result<Value, String>>,
|
||||
}
|
||||
|
||||
/// Show `reply` in the card, titled with its name (else `fallback`); a refusal goes to the
|
||||
/// status line.
|
||||
fn opened(app: &mut SolveApp, reply: Result<Value, String>, fallback: String) {
|
||||
match reply {
|
||||
Ok(v) => {
|
||||
let title = v["name"].as_str().map(str::to_string).unwrap_or(fallback);
|
||||
app.menu.props = Some((title, v));
|
||||
}
|
||||
Err(e) => app.set_status(e, true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `command` on `copy` on a worker; the reply comes on the returned channel.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn measure_beside(mut copy: solidcraft_engine::Session, command: &str, params: &Value) -> Option<std::sync::mpsc::Receiver<Result<Value, String>>> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let (command, params) = (command.to_string(), params.clone());
|
||||
std::thread::Builder::new()
|
||||
.name("properties".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send(copy.execute(&command, ¶ms).map_err(|e| e.to_string()));
|
||||
})
|
||||
.ok()?;
|
||||
Some(rx)
|
||||
}
|
||||
|
||||
/// No threads on the web: measured here.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn measure_beside(_copy: solidcraft_engine::Session, _command: &str, _params: &Value) -> Option<std::sync::mpsc::Receiver<Result<Value, String>>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// While a measurement runs beside the window: a card saying so (Close drops it), and the
|
||||
/// Properties card in its place once the reply is in.
|
||||
fn show_measuring(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(m) = app.menu.props_measuring.take() else { return };
|
||||
match m.rx.try_recv() {
|
||||
Ok(reply) => return opened(app, reply, m.fallback),
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => return app.set_status(app.tr("ui.properties.measuring_failed").to_string(), true),
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => {}
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
let (heading, text) = (app.tr("ui.properties").to_string(), app.tr("ui.properties.measuring").to_string());
|
||||
let (closed, action) = ui_kit::Window::new(egui::Id::new("sc_props"), "settings", &heading).actions("", "Close", None).show(ctx, true, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui_kit::text(ui, &text, ui_kit::Text::Note("measuring"));
|
||||
});
|
||||
});
|
||||
if !(closed || action == ui_kit::Action::Cancel) {
|
||||
app.menu.props_measuring = Some(m);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inspect → Measure → Properties (#8095): the window of the one selected placement, else of the
|
||||
/// active component (at the root, the whole design).
|
||||
pub fn open_selected(app: &mut SolveApp) {
|
||||
let (command, params) = match app.session.selection.as_slice() {
|
||||
[solidcraft_engine::Sel::Occurrence { path }] if !path.is_empty() => {
|
||||
("occurrence.properties", json!({"occurrence": path.last(), "path": path}))
|
||||
}
|
||||
_ => ("component.properties", json!({})),
|
||||
};
|
||||
open_object(app, command, ¶ms, String::new());
|
||||
}
|
||||
|
||||
/// A reply's 3 × 3 tensor, row by row.
|
||||
fn tensor(v: &Value) -> Option<[[f64; 3]; 3]> {
|
||||
let row = |r: &Value| -> Option<[f64; 3]> { Some([r.get(0)?.as_f64()?, r.get(1)?.as_f64()?, r.get(2)?.as_f64()?]) };
|
||||
Some([row(v.get(0)?)?, row(v.get(1)?)?, row(v.get(2)?)?])
|
||||
}
|
||||
|
||||
fn point(v: &Value) -> Option<Vec3> {
|
||||
Some(Vec3::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?, v.get(2)?.as_f64()?))
|
||||
}
|
||||
|
||||
/// `x` with the length decimals, or with more where those would leave fewer than three
|
||||
/// significant digits; never a signed zero.
|
||||
fn number_text(app: &SolveApp, x: f64) -> String {
|
||||
let decimals = i32::from(app.preferences.length_decimals.min(8));
|
||||
let lead = if x == 0.0 || !x.is_finite() { 0 } else { x.abs().log10().floor() as i32 };
|
||||
let digits = decimals.max(2 - lead).clamp(0, 12);
|
||||
let x = if x.abs() < 0.5 * 10f64.powi(-digits) { 0.0 } else { x };
|
||||
format!("{x:.*}", digits as usize)
|
||||
}
|
||||
|
||||
/// The tensor the window shows (#8095): in g × the design's length unit squared, with the
|
||||
/// products of inertia that are round-off against the largest entry as zero.
|
||||
fn shown_tensor(app: &SolveApp, total: &Value) -> Option<[[f64; 3]; 3]> {
|
||||
let k = crate::prefs::unit_scale(&app.session.doc.units).0;
|
||||
let i = tensor(&total["inertia_g_mm2"])?.map(|r| r.map(|x| x * k * k));
|
||||
let largest = i.iter().flatten().fold(0.0f64, |m, x| m.max(x.abs()));
|
||||
Some(i.map(|r| r.map(|x| if x.abs() <= 1e-9 * largest { 0.0 } else { x })))
|
||||
}
|
||||
|
||||
/// What Copy matrix puts on the clipboard: the shown tensor's rows at full precision, the values
|
||||
/// tab-separated, so it pastes as a 3 × 3 grid into a spreadsheet and splits easily in code.
|
||||
fn tensor_text(t: &[[f64; 3]; 3]) -> String {
|
||||
t.iter().map(|r| r.map(|x| x.to_string()).join("\t")).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// The rows under Moment of inertia (#8095): label and cells, the tensor's three first.
|
||||
fn inertia_rows(app: &SolveApp, total: &Value) -> Vec<(String, Vec<String>)> {
|
||||
let k = crate::prefs::unit_scale(&app.session.doc.units).0;
|
||||
let Some(i) = shown_tensor(app, total) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rows: Vec<(String, Vec<String>)> = ["x", "y", "z"]
|
||||
.iter()
|
||||
.zip(i)
|
||||
.map(|(axis, r)| (app.tr(&format!("ui.properties.inertia_{axis}")).to_string(), r.map(|x| number_text(app, x)).to_vec()))
|
||||
.collect();
|
||||
if let Some(moments) = total["principal_moments_g_mm2"].as_array() {
|
||||
let moments = moments.iter().map(|m| number_text(app, m.as_f64().unwrap_or(f64::NAN) * k * k)).collect();
|
||||
rows.push((app.tr("ui.properties.principal_moments").to_string(), moments));
|
||||
}
|
||||
for (n, axis) in total["principal_axes"].as_array().into_iter().flatten().enumerate() {
|
||||
let Some(e) = point(axis) else { continue };
|
||||
let label = crate::i18n::format("ui.properties.principal_axis", &[("number", &(n + 1).to_string())]);
|
||||
rows.push((label, [e.x, e.y, e.z].map(|x| format!("{:.4}", if x.abs() < 5e-5 { 0.0 } else { x })).to_vec()));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// The window's title: just Properties for the whole design (its name would only be the
|
||||
/// file's), else with the name of the component, placement or bodies it measured.
|
||||
fn window_title(title: &str, v: &Value) -> String {
|
||||
if v["component"].as_u64() == Some(0) {
|
||||
crate::i18n::tr("ui.properties").to_string()
|
||||
} else {
|
||||
crate::i18n::format("properties.title", &[("name", title)])
|
||||
}
|
||||
}
|
||||
|
||||
/// The centre of mass the window shows, its principal axes when it has them, and how long to
|
||||
/// draw them: a third of the bounding box's diagonal.
|
||||
fn centre_and_axes(v: &Value) -> Option<(Vec3, Vec<Vec3>, f64)> {
|
||||
let total = &v["total"];
|
||||
let bodies = v["bodies"].as_array();
|
||||
let geometry = if total["center_of_mass"].is_array() { total } else { bodies.filter(|b| b.len() == 1)?.first()? };
|
||||
let centre = point(&geometry["center_of_mass"])?;
|
||||
let axes = total["principal_axes"].as_array().into_iter().flatten().filter_map(point).collect();
|
||||
let reach = point(&geometry["bbox"]["max"]).zip(point(&geometry["bbox"]["min"])).map(|(a, b)| (a - b).len() / 3.0);
|
||||
Some((centre, axes, reach.filter(|r| r.is_finite() && *r > 0.0).unwrap_or(10.0)))
|
||||
}
|
||||
|
||||
/// While the Properties window is open: its centre of mass, with each principal axis standing
|
||||
/// out of it in the X, Y and Z colours in turn and numbered as the window lists them (#8095).
|
||||
pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
let Some((centre, axes, reach)) = app.menu.props.as_ref().and_then(|(_, v)| centre_and_axes(v)) else { return };
|
||||
let Some(at) = proj.to_screen(centre) else { return };
|
||||
let tk = crate::theme::Tokens::view();
|
||||
for (n, (axis, colour)) in axes.iter().zip(tk.axes()).enumerate() {
|
||||
let Some(tip) = proj.to_screen(centre + *axis * reach) else { continue };
|
||||
painter.line_segment([at, tip], Stroke::new(2.0, colour));
|
||||
painter.text(tip, Align2::LEFT_BOTTOM, (n + 1).to_string(), FontId::proportional(tk.font_small), colour);
|
||||
}
|
||||
painter.circle(at, 6.0, tk.panel.gamma_multiply(0.8), Stroke::new(1.6, tk.accent));
|
||||
painter.line_segment([at - egui::vec2(10.0, 0.0), at + egui::vec2(10.0, 0.0)], Stroke::new(1.2, tk.accent));
|
||||
painter.line_segment([at - egui::vec2(0.0, 10.0), at + egui::vec2(0.0, 10.0)], Stroke::new(1.2, tk.accent));
|
||||
}
|
||||
|
||||
/// Draw the Properties window of the body or component the menu asked about (mass, volume,
|
||||
/// area, centre of mass, bounding box and counts), when it is open. Close dismisses it.
|
||||
/// area, centre of mass, bounding box and counts; a component's first moments and moment of
|
||||
/// inertia), when it is open. Close dismisses it.
|
||||
pub fn show_object(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
show_measuring(app, ctx);
|
||||
let Some((title, v)) = app.menu.props.clone() else { return };
|
||||
let number = |x: &Value, k: &str| x.get(k).and_then(Value::as_f64).unwrap_or(f64::NAN);
|
||||
let mut rows: Vec<(&str, String)> = Vec::new();
|
||||
@@ -155,14 +358,64 @@ pub fn show_object(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
rows.push(("Faces / edges / vertices", format!("{} / {} / {}", total["faces"], total["edges"], total["vertices"])));
|
||||
let heading = crate::i18n::format("properties.title", &[("name", &title)]);
|
||||
let (k, unit) = crate::prefs::unit_scale(&app.session.doc.units);
|
||||
let first = point(&total["first_moment_g_mm"]).filter(|_| number(total, "mass_g") > 0.0).map(|m| {
|
||||
let m: Vec<String> = [m.x, m.y, m.z].iter().map(|x| number_text(app, x * k)).collect();
|
||||
format!("({}) g·{unit}", m.join(", "))
|
||||
});
|
||||
let inertia = inertia_rows(app, total);
|
||||
let note = crate::i18n::format("ui.properties.inertia_note", &[("unit", unit)]);
|
||||
// A component's closed bodies have mass, but a mesh that would not close gave no tensor.
|
||||
let unavailable =
|
||||
inertia.is_empty() && total["inertia_g_mm2"].is_null() && number(total, "mass_g") > 0.0 && total.get("first_moment_g_mm").is_some();
|
||||
let (first_label, section, unavailable_text) = (
|
||||
app.tr("ui.properties.first_moments").to_string(),
|
||||
app.tr("ui.properties.inertia").to_string(),
|
||||
app.tr("ui.properties.inertia_unavailable").to_string(),
|
||||
);
|
||||
let copy = shown_tensor(app, total).map(|t| tensor_text(&t));
|
||||
let copy_label = app.tr("ui.properties.copy_matrix").to_string();
|
||||
let heading = window_title(&title, &v);
|
||||
let (closed, action) = ui_kit::Window::new(egui::Id::new("sc_props"), "settings", &heading).actions("", "Close", None).show(ctx, true, |ui| {
|
||||
for (label, value) in &rows {
|
||||
ui_kit::row(ui, crate::i18n::tr_label(label), |ui| ui_kit::text(ui, value, ui_kit::Text::Cell));
|
||||
}
|
||||
if let Some(first) = &first {
|
||||
ui_kit::row(ui, &first_label, |ui| ui_kit::text(ui, first, ui_kit::Text::Cell));
|
||||
}
|
||||
if !inertia.is_empty() || unavailable {
|
||||
ui_kit::section(ui, "sc_props_inertia", §ion, true, |ui| {
|
||||
if unavailable {
|
||||
ui_kit::text(ui, &unavailable_text, ui_kit::Text::Note("inertia_unavailable"));
|
||||
return;
|
||||
}
|
||||
ui_kit::text(ui, ¬e, ui_kit::Text::Note("inertia_note"));
|
||||
for (n, (label, cells)) in inertia.iter().enumerate() {
|
||||
ui_kit::row(ui, label, |ui| {
|
||||
ui.columns(cells.len(), |columns| {
|
||||
for (column, cell) in columns.iter_mut().zip(cells) {
|
||||
ui_kit::text(column, cell, ui_kit::Text::MonoCell);
|
||||
}
|
||||
})
|
||||
});
|
||||
// Under the tensor's last row: copy it whole.
|
||||
if n == 2
|
||||
&& let Some(text) = ©
|
||||
{
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
let button = ui_kit::button(ui, ©_label, ui_kit::Button::Small);
|
||||
crate::scenario::publish_handle("properties:copy_inertia", button.rect.center());
|
||||
if button.clicked() {
|
||||
ui.ctx().copy_text(text.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if closed || action == ui_kit::Action::Cancel {
|
||||
app.menu.props = None;
|
||||
close_object(app);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +441,7 @@ mod tests {
|
||||
h.frames(2);
|
||||
assert!(h.widget("Open surface — no volume").is_some());
|
||||
assert!(h.widget("Area").is_some());
|
||||
for label in ["Volume", "Density", "Mass", "Center of mass"] {
|
||||
for label in ["Volume", "Density", "Mass", "Center of mass", "First moments", "Moment of inertia"] {
|
||||
assert!(h.widget(label).is_none(), "{label} must not be shown for an open surface");
|
||||
}
|
||||
assert!(h.widget("Open bodies (no volume)").is_none());
|
||||
@@ -264,6 +517,106 @@ mod tests {
|
||||
assert!(h.widget("Other tools' data (1)").is_some(), "the unknown key is listed with other tools' data");
|
||||
}
|
||||
|
||||
/// #8095: Inspect → Measure → Properties opens the window (it used to run the command and
|
||||
/// show nothing), with a component's first moments and moment of inertia; the view marks
|
||||
/// its centre of mass with three principal axes a third of the box's diagonal long.
|
||||
#[test]
|
||||
fn the_measure_panels_properties_button_opens_the_window_with_the_moment_of_inertia() {
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "Block"})).unwrap();
|
||||
h.step(&json!({"start": "component.properties"})).unwrap();
|
||||
h.frames(2);
|
||||
let (_, v) = h.app.menu.props.clone().expect("the button opens the window");
|
||||
assert!(v["total"]["inertia_g_mm2"].is_array(), "{v}");
|
||||
for label in ["First moments", "Moment of inertia", "Ixx, Ixy, Ixz", "Izx, Izy, Izz", "Principal moments", "Principal axis 3"] {
|
||||
assert!(h.widget(label).is_some(), "{label}");
|
||||
}
|
||||
let (centre, axes, reach) = super::centre_and_axes(&v).unwrap();
|
||||
assert!((centre - Vec3::new(5.0, 10.0, 15.0)).len() < 1e-9, "{centre:?}");
|
||||
assert_eq!(axes, [Vec3::Z, Vec3::Y, Vec3::X * -1.0]);
|
||||
assert!((reach - 1400f64.sqrt() / 3.0).abs() < 1e-9, "{reach}");
|
||||
assert_eq!(super::window_title("Untitled", &v), "Properties", "the whole design's card has no file name in its title");
|
||||
|
||||
// Copy matrix: the shown tensor, three tab-separated rows at full precision.
|
||||
// The card settles its size over a few frames: click where the button ends up.
|
||||
h.frames(3);
|
||||
let [x, y] = h.widget("Copy matrix").expect("the Copy matrix button");
|
||||
let (request, _reply) = crate::control::ControlRequest::new("ui.click", json!({"x": x, "y": y}));
|
||||
crate::control::handle(&mut h.app, &h.ctx, &request);
|
||||
let mut copied = Vec::new();
|
||||
for _ in 0..4 {
|
||||
h.frame();
|
||||
copied.extend(h.output.commands.iter().filter_map(|c| match c {
|
||||
egui::OutputCommand::CopyText(text) => Some(text.clone()),
|
||||
_ => None,
|
||||
}));
|
||||
}
|
||||
let text = copied.last().expect("Copy matrix puts the tensor on the clipboard");
|
||||
let rows: Vec<Vec<f64>> = text.lines().map(|l| l.split('\t').map(|x| x.parse().unwrap()).collect()).collect();
|
||||
let want = super::shown_tensor(&h.app, &v["total"]).unwrap();
|
||||
assert_eq!(rows, want.map(|r| r.to_vec()).to_vec(), "{text}");
|
||||
let m = v["total"]["mass_g"].as_f64().unwrap();
|
||||
assert!((rows[0][0] - m * (400.0 + 900.0) / 12.0).abs() < 1e-6 * rows[0][0] && rows[0][1] == 0.0, "{text}");
|
||||
h.step(&json!({"widget": "Close"})).unwrap();
|
||||
assert!(h.app.menu.props.is_none());
|
||||
|
||||
// One selected placement: its own properties, by its path.
|
||||
let c = h.app.run("component.create", json!({"name": "Pin", "grounded": false})).unwrap();
|
||||
h.app.run("solid.cylinder", json!({"radius": 4, "height": 10, "base": [50, 0, 0]})).unwrap();
|
||||
let occurrence = c["occurrence"].as_u64().unwrap();
|
||||
h.app.session.selection = vec![solidcraft_engine::Sel::Occurrence { path: vec![occurrence] }];
|
||||
h.step(&json!({"start": "component.properties"})).unwrap();
|
||||
let (title, v) = h.app.menu.props.clone().expect("the window opens on the placement");
|
||||
assert_eq!((title.as_str(), v["occurrence"].as_u64()), ("Pin:1", Some(occurrence)), "{v}");
|
||||
assert_eq!(super::window_title(&title, &v), "Properties: Pin:1", "a placement's card keeps its name");
|
||||
let (_, axes, _) = super::centre_and_axes(&v).unwrap();
|
||||
for (axis, want) in axes.iter().zip([Vec3::Z, Vec3::X, Vec3::Y]) {
|
||||
assert!((*axis - want).len() < 1e-9, "a round pin's axes are the world axes: {axes:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #5203: Properties on a large assembly took seconds on the window thread, which froze it.
|
||||
/// Measured on a copy beside the window (here never waited for), the card says it is
|
||||
/// measuring and shows the reply once it is in; Close drops a measurement still running.
|
||||
#[test]
|
||||
fn properties_measure_beside_the_window_and_show_when_done() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "Block"})).unwrap();
|
||||
let here = h.app.session.execute("component.properties", &json!({})).unwrap();
|
||||
h.app.apply_off_thread = Some(std::time::Duration::ZERO);
|
||||
super::open_object(&mut h.app, "component.properties", &json!({}), String::new());
|
||||
assert!(h.app.menu.props.is_none(), "nothing is waited for on the window thread");
|
||||
let m = h.app.menu.props_measuring.take().expect("measuring beside the window");
|
||||
assert_eq!(m.rx.recv().unwrap().unwrap(), here, "the copy measures what the design here does");
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
h.app.menu.props_measuring = Some(super::Measuring { fallback: String::new(), rx });
|
||||
h.frames(2);
|
||||
assert!(h.widget("Measuring…").is_some() && h.app.menu.props.is_none());
|
||||
tx.send(Ok(here.clone())).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(h.app.menu.props.as_ref().map(|(_, v)| v), Some(&here));
|
||||
assert!(h.widget("Moment of inertia").is_some());
|
||||
|
||||
let (_tx, rx) = std::sync::mpsc::channel::<Result<serde_json::Value, String>>();
|
||||
super::close_object(&mut h.app);
|
||||
h.app.menu.props_measuring = Some(super::Measuring { fallback: String::new(), rx });
|
||||
h.frames(2);
|
||||
h.step(&json!({"widget": "Close"})).unwrap();
|
||||
assert!(h.app.menu.props_measuring.is_none() && h.app.menu.props.is_none(), "Close drops the measurement");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moments_keep_three_significant_digits_and_no_signed_zero() {
|
||||
let h = Harness::new();
|
||||
let decimals = usize::from(h.app.preferences.length_decimals);
|
||||
assert_eq!(super::number_text(&h.app, 1234.5678), format!("{:.decimals$}", 1234.5678));
|
||||
assert_eq!(super::number_text(&h.app, 0.000_012_34), "0.0000123");
|
||||
assert!(!super::number_text(&h.app, -1e-30).starts_with('-'));
|
||||
assert_eq!(super::number_text(&h.app, 0.0), format!("{:.decimals$}", 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_menu_browser_and_root_component_open_the_window() {
|
||||
let mut h = Harness::new();
|
||||
|
||||
@@ -148,7 +148,7 @@ fn swap_in(app: &mut SolveApp, mut slot: DocSlot) -> DocSlot {
|
||||
app.viewport.orbit_free = None;
|
||||
app.preview.reset();
|
||||
app.viewport.hover = None;
|
||||
app.menu.props = None;
|
||||
crate::design_properties::close_object(app);
|
||||
crate::sketch_attachment_ui::clear(app);
|
||||
crate::agent_chat::leave_document(app);
|
||||
app.agent_chat.swap_document(&mut slot.agent_chat);
|
||||
@@ -367,7 +367,7 @@ pub fn adopt(app: &mut SolveApp, session: Session) {
|
||||
reset_chat(app);
|
||||
crate::dialogs::cancel(app);
|
||||
app.tool = None;
|
||||
app.menu.props = None;
|
||||
crate::design_properties::close_object(app);
|
||||
crate::sketch_attachment_ui::clear(app);
|
||||
app.session.replace_design(session);
|
||||
// The blank design's record of its links goes with it; the new one's start now.
|
||||
|
||||
@@ -1299,6 +1299,7 @@ impl SolveApp {
|
||||
self.edit_in_place.active = None;
|
||||
render_workspace::reset_document(self);
|
||||
dim_view::cancel();
|
||||
design_properties::close_object(self);
|
||||
timeline::stop_playback(self);
|
||||
self.menu.props = None;
|
||||
sketch_attachment_ui::clear(self);
|
||||
@@ -1618,6 +1619,11 @@ impl SolveApp {
|
||||
bom_panel::open(self);
|
||||
return;
|
||||
}
|
||||
// Inspect → Measure → Properties: the window, not a reply nobody sees (#8095).
|
||||
if matches!(id, "component.properties" | "occurrence.properties") {
|
||||
design_properties::open_selected(self);
|
||||
return;
|
||||
}
|
||||
if id == "sketch.finish" {
|
||||
self.finish_sketch();
|
||||
return;
|
||||
|
||||
@@ -4801,6 +4801,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
crate::canvas::show(app, ui, &painter, &proj);
|
||||
crate::sketch_tools::show(app, ui, &painter, &proj);
|
||||
crate::design_properties::overlay(app, &painter, &proj);
|
||||
crate::sketch_dims::show(app, ui, &proj);
|
||||
crate::select_other::show(app, ui.ctx());
|
||||
axis_view(app, ui, rect);
|
||||
|
||||
@@ -105,6 +105,8 @@ fn command_dialogs_shrink_to_their_content_within_limits() {
|
||||
h.app.params = None;
|
||||
h.app.design_properties = None;
|
||||
h.app.bom = None;
|
||||
// Properties (Inspect → Measure) opens the Properties card.
|
||||
h.app.menu.props = None;
|
||||
if spec.id == "material.properties.validate" {
|
||||
h.step(&json!({"click":{"handle":"dialog:close"}})).unwrap();
|
||||
}
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
|
||||
The Browser's component **Properties** action measures the selected assembly placement,
|
||||
including its nested components. Select the root design to measure every instance in the
|
||||
assembly. The window shows material-aware mass, volume, surface area, center of mass and
|
||||
the world bounding box. Pending occurrence moves are included without capturing them.
|
||||
assembly. **Inspect → Measure → Properties** opens the same window for the one selected
|
||||
placement, or else for the active component. The window shows material-aware mass, volume,
|
||||
surface area, center of mass, first moments, the moment of inertia and the world bounding box.
|
||||
Pending occurrence moves are included without capturing them. While the window is open, the
|
||||
view marks the center of mass with its principal axes, numbered and colored as the window
|
||||
lists them. **Copy matrix**, under the tensor, puts it on the clipboard: three lines of
|
||||
tab-separated values at full precision, in the units shown, ready to paste into a spreadsheet
|
||||
or code. The window for the whole design is titled just **Properties**; one opened on a
|
||||
component, placement or bodies carries its name.
|
||||
|
||||
The same measurements are available through commands:
|
||||
|
||||
@@ -12,6 +19,7 @@ The same measurements are available through commands:
|
||||
{"command":"component.properties","params":{"component":"root"}}
|
||||
{"command":"component.properties","params":{"component":"Subassembly","descendants":false}}
|
||||
{"command":"occurrence.properties","params":{"occurrence":"Bracket:2"}}
|
||||
{"command":"component.properties","params":{"component":"root","about":[0,0,0]}}
|
||||
```
|
||||
|
||||
`component.properties` defaults to the active component and measures its first placement.
|
||||
@@ -31,3 +39,33 @@ kernel's tessellation-based body measurement. An empty component reports zero to
|
||||
null center of mass and bounding box. A failed body measurement returns an error instead of
|
||||
presenting an incomplete assembly total. These commands leave geometry, pending placements
|
||||
and undo history unchanged.
|
||||
|
||||
## Moment of inertia
|
||||
|
||||
Each closed body's entry has `inertia_g_mm2`: its inertia tensor about its own center of mass,
|
||||
in world axes, in g·mm². The `total` object has:
|
||||
|
||||
- `first_moment_g_mm`: the first moments of mass, Σ m·c (the total mass times the center of
|
||||
mass).
|
||||
- `inertia_g_mm2`: the inertia tensor about the total center of mass, in world axes. Each body's
|
||||
own tensor is moved there with the parallel-axis theorem.
|
||||
- `principal_moments_g_mm2` (smallest first) and `principal_axes` (unit vectors, one per
|
||||
moment). The first two axes point along the world axis each is nearest to; the third is the
|
||||
first × the second, so the three make a right-handed frame. Moments within 0.1% of the largest
|
||||
count as equal, as a round body's two equal moments do. Their axes are then the world axes
|
||||
nearest the plane they share.
|
||||
- `inertia_about`, only when the command has `about: [x, y, z]` (mm, world): `{point,
|
||||
inertia_g_mm2}`, the tensor about that point in world axes.
|
||||
|
||||
The tensor is the matrix that turns an angular velocity into angular momentum. Its diagonal holds
|
||||
the moments of inertia (Ixx = ∫ (y² + z²) dm …), and the off-diagonal terms are the negated
|
||||
products of inertia (Ixy = −∫ x y dm …), the convention URDF and most dynamics libraries take. For
|
||||
kg·m², divide by 10⁹.
|
||||
|
||||
The tensor is integrated over each body's welded export mesh, the one URDF export takes its
|
||||
inertia from. It is then scaled to the body's measured mass, so it agrees with the mass and center
|
||||
of mass shown. Planar bodies are exact. A round body is within about 0.1% at the standard export
|
||||
quality. Each body is tessellated once, however many times the assembly places it; each placement
|
||||
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.
|
||||
|
||||
@@ -400,8 +400,11 @@ joint still describes the intended connection.
|
||||
|
||||
STEP export writes the component tree as an assembly.
|
||||
|
||||
Right-click a component in the Browser and choose **Properties** to inspect
|
||||
material-aware mass, volume, surface area, centre of mass and world bounds. Descendants and
|
||||
Right-click a component in the Browser and choose **Properties**, or use **Inspect → Measure →
|
||||
Properties** for the selected placement or the active component, to inspect material-aware
|
||||
mass, volume, surface area, centre of mass, first moments, moment of inertia and world bounds.
|
||||
While the window is open, the view marks the centre of mass with the three principal axes
|
||||
standing out of it. Descendants and
|
||||
repeated instances contribute to assembly totals; pending moves are included. A component
|
||||
uses its first placement; `occurrence.properties` selects a specific instance. Root Properties
|
||||
measures the complete assembly. Volumes are summed, including overlapping bodies, rather than
|
||||
|
||||
@@ -260,6 +260,22 @@ A material sets mass, centre of gravity and strength values. How the body looks
|
||||
- **Elsewhere:** material, physical material.
|
||||
- **For translators:** not an appearance.
|
||||
|
||||
### moment of inertia
|
||||
|
||||
How a body's mass resists turning about an axis: the mass times the square of its distance from
|
||||
that axis, summed over the body.
|
||||
|
||||
SolidCraft reports the whole inertia tensor about the centre of mass in world axes, with its
|
||||
*principal moments* and *principal axes*: the three axes with no products of inertia between
|
||||
them. The *first moments* are the mass times the centre of mass.
|
||||
|
||||
- **In SolidCraft:** `inertia_g_mm2`, `principal_moments_g_mm2`, `principal_axes`,
|
||||
`first_moment_g_mm` and `about` in `component.properties` and `occurrence.properties`; the
|
||||
Properties window's Moment of inertia section.
|
||||
- **Don't use:** rotational inertia, second moment of mass, mass moment.
|
||||
- **Elsewhere:** moment of inertia, inertia tensor, mass moment of inertia.
|
||||
- **For translators:** not the second moment of area of a cross-section, a different quantity.
|
||||
|
||||
### occurrence
|
||||
|
||||
One placed copy of a component inside its parent component.
|
||||
|
||||
@@ -62,7 +62,7 @@ Written against SolidCraft `main` after 0.11.1, which counts the degrees of free
|
||||
- **Sweep until contact:** `joint.sweep {"joint":..., "from":0, "to":120, "occurrences":[...], "against":[...], "report_gaps":true}` answers how far a hinge, door or linkage opens: `stopped_at` (last clear value, bisected to `refine` mm, default 0.01), `stopped_by` `{a, b, at}` (or `"loop"`, `"limit"`), per-sample `min_gap_mm` and the nearest each part came (`min_gap`). It runs on a copy; `"apply":true` leaves the design at the stop. Pairs touching at `from` (`touching_at_start`) stop it only once they pass into each other.
|
||||
|
||||
## Verify
|
||||
- **Each joint:** `execute occurrence.properties {"occurrence":"Wing:1"}` (under 1 KB) gives the world bbox and centre of mass of one occurrence. Joining must not move them.
|
||||
- **Each joint:** `execute occurrence.properties {"occurrence":"Wing:1"}` (about 2 KB) gives the world bbox, centre of mass and inertia of one occurrence. Joining must not move them.
|
||||
- **Each drive:** compute the expected value of every follower, and read it back from `joint.list` (degrees and mm, with `unit`). For an actuator from root R to tip T on a panel hinged at H: `L(θ) = |T(θ) - R|`, where `T(θ)` is T turned by θ about the hinge axis through H, and the stroke is `L(θ) - L(0)`. A closed loop matches to 1e-6 mm.
|
||||
- **Each stop:** solve for where the limit is reached, and compare with the value the drive stopped at. Stops are found by bisection, so they land within about 0.01 degrees.
|
||||
- **Links:** b must equal r·a + o exactly, unless that is outside b's limits; then b sits at the limit and the mirror stops following.
|
||||
|
||||
Reference in New Issue
Block a user