From b7eac9220b3d85f9ddce2bd917a5cd522f0b1871 Mon Sep 17 00:00:00 2001 From: franco michetti Date: Sat, 17 Oct 2026 07:56:25 -0300 Subject: [PATCH] inspect.silhouette: the outline of bodies seen from a view, as a mask (#1743) Thanks @onembyte. --- assets/locales/en.json | 1 + assets/locales/ja.json | 1 + assets/locales/zh.json | 1 + crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/silhouette.rs | 234 ++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 94 +++++++++++ 6 files changed, 333 insertions(+) create mode 100644 crates/engine/src/cmd/silhouette.rs diff --git a/assets/locales/en.json b/assets/locales/en.json index de3d68aa..79d7925d 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -420,6 +420,7 @@ "cmd.inspect_interference": "Interference", "cmd.inspect_isocurve": "Isocurve Analysis", "cmd.inspect_minimum_radius": "Minimum Radius Analysis", + "cmd.inspect_silhouette": "Silhouette", "cmd.inspect_zebra": "Zebra Analysis", "cmd.joint_as_built": "As-Built Joint", "cmd.joint_cam": "Cam", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 8a6f8a57..87bdba2f 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -420,6 +420,7 @@ "cmd.inspect_interference": "干渉", "cmd.inspect_isocurve": "アイソカーブ解析", "cmd.inspect_minimum_radius": "最小半径解析", + "cmd.inspect_silhouette": "シルエット", "cmd.inspect_zebra": "ゼブラ解析", "cmd.joint_as_built": "現位置ジョイント", "cmd.joint_cam": "カム", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 7cbf9d21..053e83a7 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -420,6 +420,7 @@ "cmd.inspect_interference": "干涉", "cmd.inspect_isocurve": "等参线分析", "cmd.inspect_minimum_radius": "最小半径分析", + "cmd.inspect_silhouette": "轮廓", "cmd.inspect_zebra": "斑马纹分析", "cmd.joint_as_built": "现状接头", "cmd.joint_cam": "凸轮", diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 1a78ddae..4d8626b5 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -60,6 +60,7 @@ mod render; #[cfg(test)] mod script_tests; mod sheet; +mod silhouette; mod sketch; pub(crate) use sketch::author_path_for_rebind; pub(crate) use sketch::created as sketch_created; @@ -267,6 +268,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(metadata::COMMANDS.iter()); v.extend(inspect::COMMANDS.iter()); v.extend(compare::COMMANDS.iter()); + v.extend(silhouette::COMMANDS.iter()); v.extend(materials::COMMANDS.iter()); v.extend(material_properties::COMMANDS.iter()); v.extend(libraries::COMMANDS.iter()); diff --git a/crates/engine/src/cmd/silhouette.rs b/crates/engine/src/cmd/silhouette.rs new file mode 100644 index 00000000..97d34544 --- /dev/null +++ b/crates/engine/src/cmd/silhouette.rs @@ -0,0 +1,234 @@ +//! A tightly framed, view-aligned union of the projected body meshes. + +use serde_json::{Value, json}; +use solidcraft_geom::Vec3; + +use super::CommandSpec; +use crate::params::bad; +use crate::render::{Camera, StandardView}; +use crate::{Result, Session}; + +const CMD: &str = "inspect.silhouette"; +const MAX_PIXELS: usize = 4 * 1024 * 1024; + +// For agents comparing the model with a picture; nothing to show a person, so no toolbar button. +pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Silhouette", run).noundo().params( + "view?: front|back|left|right|top|bottom|iso (default front); or camera?: {target: [x,y,z], yaw, pitch, distance, fov?} (mm, radians; fov 0 means parallel); bodies?: [world body names] (default visible bodies, named hidden bodies included); rows?: mask height in pixels (default 240, clamped to 16..1024) → tightly framed run-length mask, box_mm, mm_per_px, right/up, projection, body and triangle counts", +)]; + +fn run(s: &mut Session, p: &Value) -> Result { + let camera = camera(p)?; + let rows = rows(p)?; + let wanted = body_names(p)?; + let state = s.world_state(); + if let Some(names) = &wanted { + for name in names { + if !state.bodies.iter().any(|b| b.name == *name) { + return Err(bad(CMD, format!("unknown body name `{name}`"))); + } + } + } + let bodies: Vec<_> = state + .bodies + .iter() + .filter(|b| wanted.as_ref().map_or_else(|| s.body_visible(&b.name), |names| names.iter().any(|name| name == &b.name))) + .collect(); + if bodies.is_empty() { + return Err(bad(CMD, "nothing visible to outline")); + } + + let (right, up, back) = camera.basis(); + let perspective = camera.fov > 0.0; + let origin_u = camera.target.dot(right); + let origin_v = camera.target.dot(up); + let project = |point: Vec3| -> Result<[f64; 2]> { + let delta = point - camera.target; + let scale = if perspective { + let depth = camera.distance - delta.dot(back); + if !depth.is_finite() || depth <= 0.0 { + return Err(bad( + CMD, + "part of the model is at or behind the camera: move the camera back, or name the bodies in front of it in `bodies`", + )); + } + camera.distance / depth + } else { + 1.0 + }; + let uv = [origin_u + delta.dot(right) * scale, origin_v + delta.dot(up) * scale]; + if uv.iter().any(|v| !v.is_finite()) { + return Err(bad(CMD, "projected model coordinates are not finite")); + } + Ok(uv) + }; + + let mut lo = [f64::INFINITY; 2]; + let mut hi = [f64::NEG_INFINITY; 2]; + let mut meshes = Vec::with_capacity(bodies.len()); + let mut triangle_count = 0usize; + for body in &bodies { + let mesh = body.mesh(); + triangle_count = triangle_count.checked_add(mesh.triangles.len()).ok_or_else(|| bad(CMD, "too many triangles"))?; + let mut projected = Vec::with_capacity(mesh.positions.len()); + for &point in &mesh.positions { + let uv = project(point)?; + for axis in 0..2 { + lo[axis] = lo[axis].min(uv[axis]); + hi[axis] = hi[axis].max(uv[axis]); + } + projected.push(uv); + } + meshes.push((mesh, projected)); + } + let u_span = hi[0] - lo[0]; + let v_span = hi[1] - lo[1]; + if !u_span.is_finite() || !v_span.is_finite() || (u_span <= 0.0 && v_span <= 0.0) { + return Err(bad(CMD, "nothing visible to outline")); + } + let mm_per_px = (v_span / rows as f64).max(u_span / (4 * rows) as f64); + if !mm_per_px.is_finite() || mm_per_px <= 0.0 { + return Err(bad(CMD, "outline scale is invalid")); + } + let width = dimension(u_span, mm_per_px)?; + let height = dimension(v_span, mm_per_px)?; + let pixels = width.checked_mul(height).filter(|n| *n <= MAX_PIXELS).ok_or_else(|| bad(CMD, "outline exceeds the pixel limit"))?; + if width == 0 || height == 0 { + return Err(bad(CMD, "nothing visible to outline")); + } + + let mut covered = vec![false; pixels]; + for (mesh, projected) in &meshes { + for indices in &mesh.triangles { + // Meshes can come from imported data; never trust triangle indices. + let (Some(a), Some(b), Some(c)) = + (projected.get(indices[0] as usize), projected.get(indices[1] as usize), projected.get(indices[2] as usize)) + else { + continue; + }; + let tri = [*a, *b, *c].map(|q| [(q[0] - lo[0]) / mm_per_px, (hi[1] - q[1]) / mm_per_px]); + if tri.iter().flat_map(|p| p.iter()).any(|v| !v.is_finite()) { + return Err(bad(CMD, "projected triangle coordinates are not finite")); + } + let [a, b, c] = tri; + let area = edge(a, b, c); + if area == 0.0 || !area.is_finite() { + continue; + } + let x0 = a[0].min(b[0]).min(c[0]).floor().clamp(0.0, width as f64) as usize; + let x1 = a[0].max(b[0]).max(c[0]).ceil().clamp(0.0, width as f64) as usize; + let y0 = a[1].min(b[1]).min(c[1]).floor().clamp(0.0, height as f64) as usize; + let y1 = a[1].max(b[1]).max(c[1]).ceil().clamp(0.0, height as f64) as usize; + for y in y0..y1 { + for x in x0..x1 { + let centre = [x as f64 + 0.5, y as f64 + 0.5]; + let e = [edge(a, b, centre), edge(b, c, centre), edge(c, a, centre)]; + if (e.iter().all(|v| *v >= -1e-9) || e.iter().all(|v| *v <= 1e-9)) + && let Some(i) = y.checked_mul(width).and_then(|base| base.checked_add(x)) + && let Some(pixel) = covered.get_mut(i) + { + *pixel = true; + } + } + } + } + } + + let mask: Vec> = covered.chunks_exact(width).map(spans).collect(); + Ok(json!({ + "width": width, + "height": height, + "mm_per_px": round4(mm_per_px), + "box_mm": [[round4(lo[0]), round4(lo[1])], [round4(hi[0]), round4(hi[1])]], + "right": [clean(right.x), clean(right.y), clean(right.z)], + "up": [clean(up.x), clean(up.y), clean(up.z)], + "projection": if perspective { "perspective" } else { "parallel" }, + "bodies": bodies.len(), + "triangles": triangle_count, + "mask": mask, + })) +} + +fn camera(p: &Value) -> Result { + if p.get("view").is_some() && p.get("camera").is_some() { + return Err(bad(CMD, "give `view` or `camera`, not both")); + } + let camera = if let Some(value) = p.get("camera") { + let named: solidcraft_doc::NamedView = serde_json::from_value(value.clone()).map_err(|e| bad(CMD, format!("bad camera: {e}")))?; + Camera { target: named.target, yaw: named.yaw, pitch: named.pitch, distance: named.distance, fov: named.fov, ..Default::default() } + } else { + let name = match p.get("view") { + None => "front", + Some(Value::String(name)) => name, + _ => return Err(bad(CMD, "`view` must be a standard view name")), + }; + let view = StandardView::parse(name).ok_or_else(|| bad(CMD, format!("unknown standard view `{name}`")))?; + let (yaw, pitch) = view.angles(); + Camera { target: Vec3::ZERO, yaw, pitch, distance: 200.0, fov: 0.0, ..Default::default() } + }; + if !camera.is_valid() || !camera.fov.is_finite() || camera.fov < 0.0 { + return Err(bad(CMD, "the camera must be finite with a positive distance")); + } + Ok(camera) +} + +fn body_names(p: &Value) -> Result>> { + match p.get("bodies") { + None => Ok(None), + Some(Value::String(name)) => Ok(Some(vec![name.clone()])), + Some(Value::Array(names)) if names.len() <= 10_000 => names + .iter() + .map(|v| v.as_str().map(str::to_string).ok_or_else(|| bad(CMD, "`bodies` must contain names"))) + .collect::>>() + .map(Some), + _ => Err(bad(CMD, "`bodies` must be a list of names")), + } +} + +fn rows(p: &Value) -> Result { + match p.get("rows") { + None => Ok(240), + Some(v) => { + let n = v.as_i64().or_else(|| v.as_u64().map(|n| n.min(i64::MAX as u64) as i64)).ok_or_else(|| bad(CMD, "`rows` must be an integer"))?; + Ok(n.clamp(16, 1024) as usize) + } + } +} + +fn dimension(span: f64, mm_per_px: f64) -> Result { + let ratio = span / mm_per_px; + if !ratio.is_finite() || ratio > MAX_PIXELS as f64 { + return Err(bad(CMD, "outline exceeds the pixel limit")); + } + // Exact axis views pick up a few ulps from sin(π) and cos(π/2). + let nearest = ratio.round(); + let stable = if (ratio - nearest).abs() < 1e-9 { nearest } else { ratio }; + Ok(stable.ceil() as usize) +} + +fn edge(a: [f64; 2], b: [f64; 2], p: [f64; 2]) -> f64 { + (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]) +} + +fn spans(row: &[bool]) -> Vec<[usize; 2]> { + let mut out = Vec::new(); + let mut start = None; + for (x, &on) in row.iter().enumerate() { + if on && start.is_none() { + start = Some(x); + } else if !on && let Some(first) = start.take() { + out.push([first, x]); + } + } + if let Some(first) = start { + out.push([first, row.len()]); + } + out +} + +fn round4(value: f64) -> f64 { + if value.abs() < 1e12 { (value * 10_000.0).round() / 10_000.0 } else { value } +} + +fn clean(value: f64) -> f64 { + if value.abs() < 1e-12 { 0.0 } else { value } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 98f0b986..777f9498 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -902,6 +902,100 @@ fn sample_design_builds() { assert!(rel(v, plate + boss - bore) < 1e-3, "{v} vs {}", plate + boss - bore); } +fn silhouette_box(s: &mut Session, name: &str, x0: f64, x1: f64, z: f64) { + let sketch = format!("{name}Sketch"); + run(s, "sketch.create", json!({"plane": "XY", "name": sketch})); + run(s, "sketch.rectangle.two_point", json!({"p0": [x0, 0], "p1": [x1, 10]})); + run(s, "sketch.finish", json!({})); + run(s, "solid.extrude", json!({"sketch": sketch, "distance": z, "operation": "new_body", "body_name": name})); +} + +fn silhouette_pixels(v: &Value) -> usize { + v["mask"] + .as_array() + .unwrap() + .iter() + .map(|row| row.as_array().unwrap().iter().map(|span| (span[1].as_u64().unwrap() - span[0].as_u64().unwrap()) as usize).sum::()) + .sum() +} + +#[test] +fn silhouette_box_standard_views_and_full_mask() { + let mut s = Session::default(); + silhouette_box(&mut s, "Box", 0.0, 20.0, 30.0); + let front = run(&mut s, "inspect.silhouette", json!({"rows": 60})); + assert_eq!(front["box_mm"], json!([[0.0, 0.0], [20.0, 30.0]])); + assert_eq!(front["mm_per_px"], 0.5); + assert_eq!((front["width"].as_u64(), front["height"].as_u64()), (Some(40), Some(60))); + assert_eq!(silhouette_pixels(&front), 2400); + assert_eq!(front["bodies"], 1); + let right = run(&mut s, "inspect.silhouette", json!({"view": "right", "rows": 60})); + assert_eq!(right["box_mm"], json!([[0.0, 0.0], [10.0, 30.0]])); + assert_eq!((right["width"].as_u64(), right["height"].as_u64()), (Some(20), Some(60))); + let top = run(&mut s, "inspect.silhouette", json!({"view": "top", "rows": 60})); + assert_eq!(top["box_mm"], json!([[0.0, 0.0], [20.0, 10.0]])); + assert_eq!((top["width"].as_u64(), top["height"].as_u64()), (Some(120), Some(60))); +} + +#[test] +fn silhouette_front_and_back_mirror_tall_body() { + let mut s = Session::default(); + silhouette_box(&mut s, "Tall", 0.0, 10.0, 30.0); + silhouette_box(&mut s, "Short", 30.0, 40.0, 10.0); + let front = run(&mut s, "inspect.silhouette", json!({"view": "front", "rows": 60})); + let back = run(&mut s, "inspect.silhouette", json!({"view": "back", "rows": 60})); + assert_eq!(front["mask"][0], json!([[0, 20]])); + assert_eq!(back["mask"][0], json!([[60, 80]])); + assert_eq!(front["right"], json!([1.0, 0.0, 0.0])); + assert_eq!(back["right"], json!([-1.0, 0.0, 0.0])); +} + +#[test] +fn silhouette_visibility_selection_and_bad_input() { + let mut s = Session::default(); + silhouette_box(&mut s, "Shown", 0.0, 20.0, 30.0); + silhouette_box(&mut s, "Hidden", 40.0, 50.0, 10.0); + run(&mut s, "browser.visibility", json!({"items": ["Hidden"], "visible": false})); + assert_eq!(run(&mut s, "inspect.silhouette", json!({}))["bodies"], 1); + let named = run(&mut s, "inspect.silhouette", json!({"bodies": ["Hidden"]})); + assert_eq!(named["bodies"], 1); + assert_eq!(named["box_mm"], json!([[40.0, 0.0], [50.0, 10.0]])); + assert!(s.execute("inspect.silhouette", &json!({"bodies": ["Unknown"]})).unwrap_err().to_string().contains("Unknown")); + assert!(s.execute("inspect.silhouette", &json!({"view": "front", "camera": {}})).is_err()); + assert!(s.execute("inspect.silhouette", &json!({"camera": {"target": [0,0,0], "yaw": 0, "pitch": 0, "distance": 0}})).is_err()); +} + +#[test] +fn silhouette_perspective_scales_the_near_face_and_refuses_bodies_behind_the_eye() { + let mut s = Session::default(); + silhouette_box(&mut s, "Box", 0.0, 20.0, 30.0); + // Looking along +Y at the box's centre from 100 mm: the near face (Y 0) is 95 mm from the + // eye, so it is drawn 100/95 times its size on the plane through the target. + let camera = json!({"target": [10, 5, 15], "yaw": 0, "pitch": 0, "distance": 100, "fov": 0.6}); + let perspective = run(&mut s, "inspect.silhouette", json!({"camera": camera, "rows": 60})); + assert_eq!(perspective["projection"], "perspective"); + assert_eq!(perspective["box_mm"], json!([[-0.5263, -0.7895], [20.5263, 30.7895]])); + let flat = json!({"target": [10, 5, 15], "yaw": 0, "pitch": 0, "distance": 100}); + let parallel = run(&mut s, "inspect.silhouette", json!({"camera": flat, "rows": 60})); + assert_eq!(parallel["projection"], "parallel"); + assert_eq!(parallel["box_mm"], json!([[0.0, 0.0], [20.0, 30.0]])); + let inside = json!({"target": [10, 5, 15], "yaw": 0, "pitch": 0, "distance": 1, "fov": 0.6}); + assert!(s.execute("inspect.silhouette", &json!({"camera": inside})).unwrap_err().to_string().contains("behind the camera")); + // Between two bodies, looking -X from X 28: Short (X 30..40) is behind the eye. + silhouette_box(&mut s, "Short", 30.0, 40.0, 10.0); + let between = json!({"target": [25, 5, 15], "yaw": -std::f64::consts::FRAC_PI_2, "pitch": 0, "distance": 3, "fov": 0.6}); + assert!(s.execute("inspect.silhouette", &json!({"camera": between})).unwrap_err().to_string().contains("behind the camera")); + assert_eq!(run(&mut s, "inspect.silhouette", json!({"camera": between, "bodies": ["Box"]}))["bodies"], 1); +} + +#[test] +fn silhouette_is_an_agent_command_not_a_toolbar_button() { + let specs = command_specs(); + let spec = specs.iter().find(|c| c.id == "inspect.silhouette").unwrap(); + assert!(spec.tab.is_empty(), "{} › {}", spec.tab, spec.panel); + assert!(!spec.undoable); +} + #[test] fn sample_edges_chain_and_faces() { let mut s = Session::default();