diff --git a/assets/locales/pending/properties-moment-of-inertia.json b/assets/locales/pending/properties-moment-of-inertia.json new file mode 100644 index 0000000000..9294106192 --- /dev/null +++ b/assets/locales/pending/properties-moment-of-inertia.json @@ -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": "主惯性矩" + } +} diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 0ef06e078b..04145baa81 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -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( diff --git a/crates/engine/src/cmd/component_properties.rs b/crates/engine/src/cmd/component_properties.rs index dd8033474d..023f7b7d48 100644 --- a/crates/engine/src/cmd/component_properties.rs +++ b/crates/engine/src/cmd/component_properties.rs @@ -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 { } } +/// 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> { + 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 { } 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 { _ => 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 { +fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, about: Option, cmd: &str) -> Result { let state = s.model.state(); let mut bodies = Vec::new(); let (mut volume, mut area, mut mass) = (0.0, 0.0, 0.0); @@ -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 = selected_placements.iter().map(|(component, _, _)| *component).collect(); + let spread_bodies: Vec = (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 = 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)> = 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)] diff --git a/crates/engine/src/cmd/component_properties_tests.rs b/crates/engine/src/cmd/component_properties_tests.rs index e533ec37a4..f291014ea3 100644 --- a/crates/engine/src/cmd/component_properties_tests.rs +++ b/crates/engine/src/cmd/component_properties_tests.rs @@ -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] diff --git a/crates/geom/src/inertia.rs b/crates/geom/src/inertia.rs new file mode 100644 index 0000000000..3ff6db48d1 --- /dev/null +++ b/crates/geom/src/inertia.rs @@ -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 { + // 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 { + 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]]] +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index bef540a360..791270245d 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -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; diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 6ff94beefd..ab25971b4e 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -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::())); + 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::*; diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index ff4e149582..b172a6467a 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -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> { + 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 { let n: usize = meshes.iter().map(|m| m.triangles.len()).sum(); diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 66711152c6..f6af4d5532 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -231,6 +231,8 @@ pub struct MenuState { pub link_copies: Option, /// The Properties window: title and measurements. pub props: Option<(String, Value)>, + /// Its measurement while it runs beside the window (#5203). + pub props_measuring: Option, /// 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" => { diff --git a/crates/ui-egui/src/design_properties.rs b/crates/ui-egui/src/design_properties.rs index f845fb3464..92a66eb5c8 100644 --- a/crates/ui-egui/src/design_properties.rs +++ b/crates/ui-egui/src/design_properties.rs @@ -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>, +} + +/// 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, 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>> { + 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>> { + 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 { + 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::>().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)> { + 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)> = ["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, 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 = [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> = 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::>(); + 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(); diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 68dea98b70..05743aaa5d 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -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. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f5fe316675..632833d4e6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 73a2514a84..203273b51b 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -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); diff --git a/crates/ui-egui/tests/window_widths.rs b/crates/ui-egui/tests/window_widths.rs index 6a65e0ec50..1e3d1c1fda 100644 --- a/crates/ui-egui/tests/window_widths.rs +++ b/crates/ui-egui/tests/window_widths.rs @@ -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(); } diff --git a/docs/assembly-properties.md b/docs/assembly-properties.md index 588f5cc4a3..0358b336c1 100644 --- a/docs/assembly-properties.md +++ b/docs/assembly-properties.md @@ -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. diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index 859b387326..215f486d88 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -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 diff --git a/docs/book/src/reference/glossary.md b/docs/book/src/reference/glossary.md index 3af6bfd4be..8db9be95f2 100644 --- a/docs/book/src/reference/glossary.md +++ b/docs/book/src/reference/glossary.md @@ -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. diff --git a/skills/solidcraft-mechanisms/SKILL.md b/skills/solidcraft-mechanisms/SKILL.md index 9e9fc6aec5..53b7e3894c 100644 --- a/skills/solidcraft-mechanisms/SKILL.md +++ b/skills/solidcraft-mechanisms/SKILL.md @@ -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.