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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
b9bc75670c
commit
561df89b1d
3 files changed
+292
-6
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user