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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 09:42:34 +00:00
1 parent b9bc75670c
commit 561df89b1d
3 files changed
+292 -6

No files matched your search

+207 -6
View File
@@ -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<Value> {
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<Value> {
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<Vec3>)> = 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<Vec3> = 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::<Result<_>>()?,
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::<Vec<_>>(),
"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<Value> {
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());
}
}
+22
View File
@@ -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<Pos2> = 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);
+63
View File
@@ -28,15 +28,59 @@ thread_local! {
static COMBS: RefCell<Vec<Vec<[Vec3; 2]>>> = const { RefCell::new(Vec::new()) };
/// The image file a Canvas or Decal tool places.
static IMAGE: RefCell<Option<String>> = const { RefCell::new(None) };
/// Analysis markers shown while an analysis tool is active: (where, label).
static MARKS: RefCell<Vec<(Vec3, String)>> = const { RefCell::new(Vec::new()) };
/// Center of Mass has just started: measure everything on the next frame.
static DEFERRED: Cell<bool> = const { Cell::new(false) };
/// The canvas whose panel is open.
static CANVAS_PANEL: Cell<Option<u64>> = const { Cell::new(None) };
/// The opacity slider while it is dragged: (canvas, value).
static OPACITY: Cell<Option<(u64, f64)>> = 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<Mode> {
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<String> {
"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());
}