From 561df89b1dd92dbfa5506c2413ab9dd70fc80c0c Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 07:33:35 +0000 Subject: [PATCH] Minimum Radius Analysis (sketch curves exactly, edges, faces from the mesh normals; every face when nothing is picked) and Center of Mass (all bodies or picked ones, uniform density) in the sketch INSPECT panel, with markers and labels in the viewport and their own icons Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/engine/src/cmd/sketch_inspect.rs | 213 +++++++++++++++++++++++- crates/ui-egui/src/icons.rs | 22 +++ crates/ui-egui/src/sketch_tools.rs | 63 +++++++ 3 files changed, 292 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/sketch_inspect.rs b/crates/engine/src/cmd/sketch_inspect.rs index af104386..02e7e102 100644 --- a/crates/engine/src/cmd/sketch_inspect.rs +++ b/crates/engine/src/cmd/sketch_inspect.rs @@ -1,4 +1,5 @@ -//! Sketch inspection: curvature combs on sketch curves and model edges. +//! Inspection: curvature combs and minimum radius on sketch curves, model edges and faces; +//! centre of mass of bodies. use serde_json::{Value, json}; use solvecraft_geom::{Seg2, Vec2, Vec3}; @@ -7,11 +8,23 @@ use super::CommandSpec; use crate::params::{bad, num, string_list, vec3}; use crate::{EngineError, Result, Session}; -pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("FusionCurvatureCombAnalysisCommand", "Curvature Comb Analysis", curvature_comb) - .at("SKETCH", "INSPECT") - .icon("curvature_comb") - .noundo() - .params("curves?: [sketch curve ids] (sketch?: id|name, default active) | edges?: [[x,y,z] points on body edges]; density?: teeth per curve (default 40), scale?: comb length per unit curvature (default auto)")]; +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("FusionCurvatureCombAnalysisCommand", "Curvature Comb Analysis", curvature_comb) + .at("SKETCH", "INSPECT") + .icon("curvature_comb") + .noundo() + .params("curves?: [sketch curve ids] (sketch?: id|name, default active) | edges?: [[x,y,z] points on body edges]; density?: teeth per curve (default 40), scale?: comb length per unit curvature (default auto)"), + CommandSpec::new("FusionMinimumRadiusAnalysisCommand", "Minimum Radius Analysis", minimum_radius) + .at("SKETCH", "INSPECT") + .icon("min_radius") + .noundo() + .params("curves?: [sketch curve ids] (sketch?) | edges?: [[x,y,z]…] | faces?: [[x,y,z]…] (default: every face of every body); the smallest radius of curvature of each and where it is"), + CommandSpec::new("FusionCenterOfMassCommand", "Center of Mass", center_of_mass) + .at("SKETCH", "INSPECT") + .icon("center_of_mass") + .noundo() + .params("bodies?: [names] (default all): the centre of mass (uniform density) of each and of all together"), +]; /// Curvature (signed, 1/mm) and unit normal of a segment at `t`. fn seg_curvature(s: &Seg2, t: f64) -> (f64, Vec2) { @@ -123,6 +136,168 @@ fn curvature_comb(s: &mut Session, p: &Value) -> Result { Ok(json!({"combs": out, "scale": scale})) } +/// Smallest radius along teeth: (radius, where), or None when straight. +fn min_of(teeth: &[(Vec3, f64, Vec3)]) -> Option<(f64, Vec3)> { + teeth.iter().filter(|t| t.1.abs() > 1e-9).max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())).map(|t| (1.0 / t.1.abs(), t.0)) +} + +/// The face of a body at `at` (nearest triangle): (body index, face index). +fn face_at(st: &solvecraft_doc::ModelState, at: Vec3) -> Option<(usize, u32)> { + let mut best: Option<(f64, usize, u32)> = None; + for (bi, b) in st.bodies.iter().enumerate() { + let m = b.mesh(); + for (ti, t) in m.triangles.iter().enumerate() { + let [a, bb, c] = t.map(|i| m.positions.get(i as usize).copied().unwrap_or_default()); + let d = at.dist(closest_on_triangle(at, a, bb, c)); + if best.is_none_or(|x| d < x.0) { + best = Some((d, bi, m.tri_face.get(ti).copied().unwrap_or(0))); + } + } + } + best.filter(|b| b.0 < 1.0).map(|b| (b.1, b.2)) +} + +fn closest_on_triangle(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 { + // Project onto the plane, then clamp to the edges if outside. + let n = (b - a).cross(c - a); + let nn = n.dot(n); + if nn < 1e-300 { + return a; + } + let q = p - n * ((p - a).dot(n) / nn); + let inside = |u: Vec3, v: Vec3| (v - u).cross(q - u).dot(n) >= 0.0; + if inside(a, b) && inside(b, c) && inside(c, a) { + return q; + } + let seg = |u: Vec3, v: Vec3| { + let d = v - u; + let t = if d.dot(d) > 1e-300 { ((p - u).dot(d) / d.dot(d)).clamp(0.0, 1.0) } else { 0.0 }; + u + d * t + }; + [seg(a, b), seg(b, c), seg(c, a)].into_iter().min_by(|x, y| x.dist(p).total_cmp(&y.dist(p))).unwrap_or(a) +} + +/// Smallest radius of curvature over a face of a mesh, from the turn of the vertex normals +/// along each triangle side: (radius, where). +fn face_min_radius(m: &solvecraft_geom::Mesh, face: u32) -> Option<(f64, Vec3)> { + let mut best: Option<(f64, Vec3)> = None; + for (ti, t) in m.triangles.iter().enumerate() { + if m.tri_face.get(ti) != Some(&face) { + continue; + } + for k in 0..3 { + let (i, j) = (t[k] as usize, t[(k + 1) % 3] as usize); + let (Some(pi), Some(pj), Some(ni), Some(nj)) = (m.positions.get(i), m.positions.get(j), m.normals.get(i), m.normals.get(j)) else { + continue; + }; + let d = *pj - *pi; + let dd = d.dot(d); + if dd < 1e-18 { + continue; + } + let kappa = ((*nj - *ni).dot(d) / dd).abs(); + if kappa > 1e-6 && best.is_none_or(|b| 1.0 / kappa < b.0) { + best = Some((1.0 / kappa, (*pi + *pj) * 0.5)); + } + } + } + best +} + +fn minimum_radius(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionMinimumRadiusAnalysisCommand"; + let st = s.model.state(); + let mut items: Vec<(String, Option<(f64, Vec3)>)> = Vec::new(); + let curves = string_list(p, "curves"); + if !curves.is_empty() { + let id = match p.get("sketch") { + Some(Value::Number(n)) => n.as_u64(), + Some(Value::String(x)) => s.doc.find_feature(x).map(|f| f.id), + _ => s.active_sketch, + } + .ok_or_else(|| bad(cmd, "no such sketch (and none is active)"))?; + let ss = st.sketch(id).ok_or_else(|| EngineError::Other("that sketch is not evaluated".into()))?; + for c in curves.iter().take(1000) { + let ci = ss.sketch.curve_index(c).ok_or_else(|| bad(cmd, format!("unknown curve `{c}`")))?; + let segs = ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)); + let teeth: Vec<(Vec3, f64, Vec3)> = + teeth_2d(&segs, 400).into_iter().map(|(q, k, n)| (ss.plane.to_world(q), k, ss.plane.dir_to_world(n))).collect(); + items.push((c.clone(), min_of(&teeth))); + } + } + if let Some(edges) = p.get("edges").and_then(Value::as_array) { + for e in edges.iter().take(1000) { + let at = vec3(e).ok_or_else(|| bad(cmd, "`edges` must be points [x,y,z]"))?; + let mut best: Option<(f64, Vec)> = None; + for b in &st.bodies { + let m = b.mesh(); + for (i, pts) in m.edges.iter().enumerate() { + if m.seams.get(i).copied().unwrap_or(false) { + continue; + } + let d = pts.windows(2).map(|w| at.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min); + if best.as_ref().is_none_or(|x| d < x.0) { + best = Some((d, pts.clone())); + } + } + } + let (_, pts) = best.ok_or_else(|| bad(cmd, "no edge there"))?; + items.push((format!("edge@{},{},{}", at.x, at.y, at.z), min_of(&teeth_3d(&pts)))); + } + } + let faces: Vec = match p.get("faces").and_then(Value::as_array) { + Some(a) => a.iter().take(1000).map(|v| vec3(v).ok_or_else(|| bad(cmd, "`faces` must be points [x,y,z]"))).collect::>()?, + None => Vec::new(), + }; + for at in &faces { + let (bi, f) = face_at(&st, *at).ok_or_else(|| bad(cmd, "no face there"))?; + let m = st.bodies.get(bi).map(|b| b.mesh()).ok_or_else(|| bad(cmd, "body"))?; + items.push((format!("face@{},{},{}", at.x, at.y, at.z), face_min_radius(&m, f))); + } + if items.is_empty() { + // Every face of every body. + for b in &st.bodies { + let m = b.mesh(); + let n = m.tri_face.iter().copied().max().map(|x| x + 1).unwrap_or(0); + for f in 0..n.min(100_000) { + items.push((format!("{}:face{f}", b.name), face_min_radius(&m, f))); + } + } + } + let overall = items.iter().filter_map(|i| i.1).min_by(|a, b| a.0.total_cmp(&b.0)); + let fmt = |x: Option<(f64, Vec3)>| match x { + Some((r, at)) => json!({"min_radius": r, "at": [at.x, at.y, at.z]}), + None => json!({"min_radius": null}), + }; + Ok(json!({ + "items": items.iter().map(|(id, x)| { let mut v = fmt(*x); v["item"] = json!(id); v }).collect::>(), + "min_radius": overall.map(|o| o.0), + "at": overall.map(|o| [o.1.x, o.1.y, o.1.z]), + })) +} + +fn center_of_mass(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionCenterOfMassCommand"; + let names = string_list(p, "bodies"); + let st = s.model.state(); + let mut out = Vec::new(); + let (mut vol, mut moment) = (0.0, Vec3::ZERO); + for b in st.bodies.iter().filter(|b| names.is_empty() || names.contains(&b.name)) { + let m = b.mesh().measure(); + if !(m.volume.is_finite() && m.centroid.is_finite()) { + continue; + } + vol += m.volume; + moment = moment + m.centroid * m.volume; + out.push(json!({"body": b.name, "center": [m.centroid.x, m.centroid.y, m.centroid.z], "volume": m.volume})); + } + if out.is_empty() { + return Err(bad(cmd, if names.is_empty() { "there are no bodies" } else { "no such bodies" })); + } + let c = if vol.abs() > 1e-300 { moment / vol } else { Vec3::ZERO }; + Ok(json!({"center": [c.x, c.y, c.z], "volume": vol, "bodies": out})) +} + #[cfg(test)] mod tests { use serde_json::json; @@ -149,4 +324,30 @@ mod tests { assert!((r["combs"][0]["min_radius"].as_f64().unwrap() - 8.0).abs() < 0.5, "{r}"); assert!(s.execute("FusionCurvatureCombAnalysisCommand", &json!({})).is_err()); } + + #[test] + fn minimum_radius_and_center_of_mass() { + let mut s = Session::default(); + s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap(); + let e = s.execute("CircleElipse", &json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4})).unwrap()["curves"][0].clone(); + // An ellipse's tightest bend is at the major axis ends: b²/a. + let r = s.execute("FusionMinimumRadiusAnalysisCommand", &json!({"curves": [e]})).unwrap(); + assert!((r["min_radius"].as_f64().unwrap() - 1.6).abs() < 1e-3, "{r}"); + assert!((r["at"][0].as_f64().unwrap().abs() - 10.0).abs() < 0.1, "{r}"); + s.execute("SketchStop", &json!({})).unwrap(); + s.execute("PrimitiveCylinder", &json!({"base": [50, 0, 0], "radius": 8, "height": 10})).unwrap(); + // The cylinder's side face: radius 8 (the mesh estimate is close). + let r = s.execute("FusionMinimumRadiusAnalysisCommand", &json!({"faces": [[58, 0, 5]]})).unwrap(); + assert!((r["min_radius"].as_f64().unwrap() - 8.0).abs() < 0.4, "{r}"); + let r = s.execute("FusionMinimumRadiusAnalysisCommand", &json!({})).unwrap(); + assert!((r["min_radius"].as_f64().unwrap() - 8.0).abs() < 0.4, "{r}"); + // Centre of mass: a box beside the cylinder. + s.execute("PrimitiveBox", &json!({"corner": [0, 0, 0], "length": 10, "width": 10, "height": 10})).unwrap(); + let r = s.execute("FusionCenterOfMassCommand", &json!({})).unwrap(); + let (vb, vc) = (1000.0, std::f64::consts::PI * 640.0); + let want = (5.0 * vb + 50.0 * vc) / (vb + vc); + assert!((r["center"][0].as_f64().unwrap() - want).abs() < 0.1, "{r}"); + assert!((r["center"][2].as_f64().unwrap() - 5.0).abs() < 0.05, "{r}"); + assert!(s.execute("FusionCenterOfMassCommand", &json!({"bodies": ["nope"]})).is_err()); + } } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 51ee36b5..b8a8921c 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -638,6 +638,28 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce let pts: Vec<(f32, f32)> = (0..=24).map(|i| 3.0 + 18.0 * i as f32 / 24.0).map(|x| (x, f(x))).collect(); pen.line(&pts, ink); } + "min_radius" => { + pen.line(&[(3.0, 20.0), (7.0, 9.0), (12.0, 5.5), (17.0, 9.0), (21.0, 20.0)], ink); + pen.circle(12.0, 10.5, 5.0, Color32::TRANSPARENT, a); + pen.arrow((12.0, 10.5), (15.5, 7.0), a); + pen.dot(12.0, 10.5, ink); + } + "center_of_mass" => { + pen.circle(12.0, 12.0, 8.0, Color32::TRANSPARENT, ink); + let q = |a0: f32| { + let pts: Vec = std::iter::once(pen.at(12.0, 12.0)) + .chain( + (0..=8) + .map(|i| a0 + i as f32 / 8.0 * std::f32::consts::FRAC_PI_2) + .map(|t| pen.at(12.0 + 8.0 * t.cos(), 12.0 - 8.0 * t.sin())), + ) + .collect(); + p.add(Shape::convex_polygon(pts, a, Stroke::NONE)); + }; + q(0.0); + q(std::f32::consts::PI); + pen.circle(12.0, 12.0, 8.0, Color32::TRANSPARENT, ink); + } "sketch_fillet" => { pen.line(&[(4.0, 21.0), (4.0, 12.0)], ink); pen.line(&[(12.0, 4.0), (21.0, 4.0)], ink); diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 52e24fcb..c5c8d04a 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -28,15 +28,59 @@ thread_local! { static COMBS: RefCell>> = const { RefCell::new(Vec::new()) }; /// The image file a Canvas or Decal tool places. static IMAGE: RefCell> = const { RefCell::new(None) }; + /// Analysis markers shown while an analysis tool is active: (where, label). + static MARKS: RefCell> = const { RefCell::new(Vec::new()) }; + /// Center of Mass has just started: measure everything on the next frame. + static DEFERRED: Cell = const { Cell::new(false) }; /// The canvas whose panel is open. static CANVAS_PANEL: Cell> = const { Cell::new(None) }; /// The opacity slider while it is dragged: (canvas, value). static OPACITY: Cell> = const { Cell::new(None) }; } +/// Run an analysis and keep its result as a marker. +fn analysis(app: &mut SolveApp, cmd: &str, p: Value) { + let Ok(v) = app.run(cmd, p) else { return }; + let mark = match cmd { + "FusionCenterOfMassCommand" => v3(&v["center"]).map(|c| (c, "Center of mass".to_string())), + _ => v3(&v["at"]).zip(v["min_radius"].as_f64()).map(|(c, r)| (c, format!("R min {r:.3} mm"))), + }; + if let Some(m) = mark { + MARKS.with(|k| k.borrow_mut().push(m)); + } else if cmd == "FusionMinimumRadiusAnalysisCommand" { + app.set_status("Minimum radius: it is straight (no curvature)", false); + } +} + +/// Analysis markers: a target with its label. +fn marks(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { + if !app.tool.as_ref().is_some_and(|t| matches!(t.cmd, "FusionMinimumRadiusAnalysisCommand" | "FusionCenterOfMassCommand")) { + MARKS.with(|m| m.borrow_mut().clear()); + return; + } + let tk = crate::theme::Tokens::get(); + MARKS.with(|m| { + for (at, label) in m.borrow().iter() { + let Some(q) = proj.to_screen(*at) else { continue }; + painter.circle(q, 6.0, tk.panel.gamma_multiply(0.8), Stroke::new(1.6, tk.accent)); + painter.line_segment([q - egui::vec2(10.0, 0.0), q + egui::vec2(10.0, 0.0)], Stroke::new(1.2, tk.accent)); + painter.line_segment([q - egui::vec2(0.0, 10.0), q + egui::vec2(0.0, 10.0)], Stroke::new(1.2, tk.accent)); + let r = painter.text(q + egui::vec2(12.0, -12.0), egui::Align2::LEFT_BOTTOM, label, egui::FontId::proportional(12.0), tk.text); + painter.rect_filled(r.expand(3.0), 3.0, tk.panel.gamma_multiply(0.85)); + painter.text(q + egui::vec2(12.0, -12.0), egui::Align2::LEFT_BOTTOM, label, egui::FontId::proportional(12.0), tk.text); + } + }); +} + /// Commands that start with a step of their own: Canvas and Decal ask for the image first, /// Edit Canvas opens the canvas panel. False when the command should not go on. pub fn start_hook(app: &SolveApp, id: &str) -> bool { + if id == "FusionCenterOfMassCommand" { + // The whole model at once; the tool stays for picking single bodies. + MARKS.with(|m| m.borrow_mut().clear()); + DEFERRED.with(|d| d.set(true)); + return true; + } if id == "canvas.edit" { let first = app.session.doc.canvases.first().map(|c| c.id); CANVAS_PANEL.with(|c| c.set(first)); @@ -294,6 +338,7 @@ fn mode(id: &str) -> Option { Some(match id { "ProjectNewCmd" | "IntersectCmd" | "Include3DGeometry" | "FitCurvesToSectionCommand" | "SketchIsoparametricCurve" => Mode::ModelRef, "FusionCurvatureCombAnalysisCommand" | "FusionAddCanvasCommand" | "FusionAddEditDecalCommand" => Mode::ModelRef, + "FusionMinimumRadiusAnalysisCommand" | "FusionCenterOfMassCommand" => Mode::ModelRef, "TrimSketchCmd" | "ExtendSketchCmd" | "BreakSketchCmd" => Mode::CurveAt, "SketchMidpointLine" => Mode::Points(2), "ArcTangent" => Mode::Points(2), @@ -339,6 +384,8 @@ pub fn hint(id: &str) -> Option { "FitCurvesToSectionCommand" => "Fit Curves to Mesh Section: click a body", "SketchIsoparametricCurve" => "Isoparametric Curve: click a point on a face (Shift: along)", "FusionCurvatureCombAnalysisCommand" => "Curvature comb: click sketch curves or model edges", + "FusionMinimumRadiusAnalysisCommand" => "Minimum radius: click sketch curves, edges or faces", + "FusionCenterOfMassCommand" => "Center of mass of all bodies; click a body for its own", "FusionAddCanvasCommand" => "Canvas: click a plane or a planar face where the image's centre goes", "FusionAddEditDecalCommand" => "Decal: click a planar face where the image's centre goes", "TrimSketchCmd" => "Trim: click the piece of a curve to remove", @@ -479,6 +526,18 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { } } } + Mode::ModelRef if matches!(cmd, "FusionMinimumRadiusAnalysisCommand" | "FusionCenterOfMassCommand") => { + let params = hits.iter().find_map(|h| match (cmd, h) { + ("FusionMinimumRadiusAnalysisCommand", Hit::SketchCurve { sketch, id, .. }) => Some(json!({"sketch": sketch, "curves": [id]})), + ("FusionMinimumRadiusAnalysisCommand", Hit::Edge { mid, .. }) => Some(json!({"edges": [[mid.x, mid.y, mid.z]]})), + ("FusionMinimumRadiusAnalysisCommand", Hit::Face { point, .. }) => Some(json!({"faces": [[point.x, point.y, point.z]]})), + ("FusionCenterOfMassCommand", Hit::Face { body, .. }) => Some(json!({"bodies": [body]})), + _ => None, + }); + if let Some(p) = params { + analysis(app, cmd, p); + } + } Mode::ModelRef if matches!(cmd, "FusionAddCanvasCommand" | "FusionAddEditDecalCommand") => { let Some(path) = IMAGE.with(|i| i.borrow().clone()) else { return }; let decal = cmd == "FusionAddEditDecalCommand"; @@ -926,6 +985,10 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P glyphs(app, painter, proj); snap_hint(app, painter, proj); combs(app, painter, proj); + if DEFERRED.with(|d| d.replace(false)) { + analysis(app, "FusionCenterOfMassCommand", json!({})); + } + marks(app, painter, proj); text_entry(app, ui.ctx()); canvas_panel(app, ui.ctx()); }