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()); }