Continuity Analysis tool, legend and theme colours on #1642's inspect.continuity; curvature combs on faces (#770) (#1247)

Thanks @jyviko.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-17 08:38:55 -04:00
1 parent a000bd69cf
commit 0a66697da8
16 files changed
+400 -17

No files matched your search

+28 -2
View File
@@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.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)"),
.params("curves?: [sketch curve ids] (sketch?: id|name, default active) | edges?: [[x,y,z] points on body edges] | faces?: [[x,y,z] points on faces] with count?: isoparametric curves each way (default 4; combs along the face's iso grid); density?: teeth per curve (default 40), scale?: comb length per unit curvature (default auto)"),
CommandSpec::new("inspect.minimum_radius", "Minimum Radius Analysis", minimum_radius)
.at("SKETCH", "INSPECT")
.icon("min_radius")
@@ -113,8 +113,25 @@ fn curvature_comb(s: &mut Session, p: &Value) -> Result<Value> {
combs.push((format!("edge@{},{},{}", at.x, at.y, at.z), teeth_3d(&pts)));
}
}
if let Some(faces) = p.get("faces") {
let faces = faces.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(cmd, "`faces` must list points [x,y,z]"))?;
let count = match p.get("count") {
None | Some(Value::Null) => 4,
Some(_) => {
num(p, "count").filter(|c| c.is_finite()).map(|c| c.clamp(1.0, 50.0) as usize).ok_or_else(|| bad(cmd, "`count` must be a number"))?
}
};
for v in faces.iter().take(100) {
let at = vec3(v).ok_or_else(|| bad(cmd, "`faces` must list points [x,y,z]"))?;
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, "that face's body is not evaluated"))?;
for (k, w) in solidcraft_doc::iso_grid(&m, f, at, count).iter().enumerate() {
combs.push((format!("face@{},{},{}#{k}", at.x, at.y, at.z), teeth_3d(w)));
}
}
}
if combs.is_empty() {
return Err(bad(cmd, "give `curves` or `edges`"));
return Err(bad(cmd, "give `curves`, `edges` or `faces`"));
}
// One scale for all combs: the longest tooth is a fifth of the largest curve's size.
let kmax = combs.iter().flat_map(|c| c.1.iter().map(|t| t.1.abs())).fold(0.0_f64, f64::max);
@@ -348,6 +365,15 @@ mod tests {
let r = s.execute("inspect.curvature_comb", &json!({"edges": [[58, 0, 10]]})).unwrap();
assert!((r["combs"][0]["min_radius"].as_f64().unwrap() - 8.0).abs() < 0.5, "{r}");
assert!(s.execute("inspect.curvature_comb", &json!({})).is_err());
// Combs along the side face's isocurves: rings of radius 8 and straight lines along it.
let r = s.execute("inspect.curvature_comb", &json!({"faces": [[58, 0, 5]], "count": 3})).unwrap();
let combs = r["combs"].as_array().unwrap();
assert!(combs.len() >= 6, "{}", combs.len());
let rings = combs.iter().filter(|c| c["min_radius"].as_f64().is_some_and(|m| (m - 8.0).abs() < 0.5)).count();
let straight = combs.iter().filter(|c| c["min_radius"].is_null() || c["min_radius"].as_f64().is_some_and(|m| m > 1e3)).count();
assert!(rings >= 3 && straight >= 3, "{rings} rings, {straight} straight: {r}");
assert!(s.execute("inspect.curvature_comb", &json!({"faces": []})).is_err());
assert!(s.execute("inspect.curvature_comb", &json!({"faces": [[58, 0, 5]], "count": "x"})).is_err());
}
#[test]
+30 -2
View File
@@ -49,6 +49,7 @@ pub static COMMANDS: &[CommandSpec] = &[
"body?: name (default: the visible bodies); edges?: [x,y,z] points or {\"index\": i} (default: every edge of the body; \
a pick must match one body); samples?: 3…257 stations along each edge, ends included (default 33); \
gap_tolerance_mm? (default 0.001), angle_tolerance_deg? (default 1), curvature_tolerance_per_mm? (default 0.01); \
add?: true keeps the seams already shaded and adds these (measuring an edge again replaces it); \
clear?: true turns the analysis off. → each edge's worst gap (G0), normal angle (G1) and curvature jump (G2) as \
{value, at: [x,y,z], edge_fraction}, its verdict (smooth, curvature_jump, sharp or gap) and a summary; \
the seams are shaded in the viewport",
@@ -177,7 +178,7 @@ fn continuity(s: &mut Session, p: &Value) -> Result<Value> {
let mut rows = Vec::new();
for seam in &report.seams {
summary.count(seam.verdict);
seams.push(ContinuitySeam { points: seam.points.clone(), verdict: seam.verdict });
seams.push(ContinuitySeam { body: body.name.clone(), edge: seam.edge, points: seam.points.clone(), verdict: seam.verdict });
rows.push(json!({
"edge": seam.edge,
"length_mm": seam.length,
@@ -197,7 +198,13 @@ fn continuity(s: &mut Session, p: &Value) -> Result<Value> {
"unmeasured": report.unmeasured,
}));
}
s.continuity = seams;
if bool_(p, "add").unwrap_or(false) {
// The seams shown stay; an edge measured again takes its new reading.
s.continuity.retain(|kept| !seams.iter().any(|new| new.body == kept.body && new.edge == kept.edge));
s.continuity.extend(seams);
} else {
s.continuity = seams;
}
s.revision += 1;
Ok(json!({
"analysis": "continuity",
@@ -429,6 +436,27 @@ mod tests {
assert_eq!(s.undo.len(), undo);
}
/// `add` keeps what is shaded and adds the picked edges: an edge measured again replaces its
/// seam instead of drawing it twice, and a call without `add` starts over.
#[test]
fn continuity_add_keeps_the_seams_shown() {
let mut s = Session::default();
boxed(&mut s, 10.0);
s.execute("inspect.continuity", &json!({"edges": [[5, 0, 0]]})).unwrap();
assert_eq!(s.continuity.len(), 1);
let v = s.execute("inspect.continuity", &json!({"edges": [[0, 5, 0]], "add": true})).unwrap();
assert_eq!(edges(&v).len(), 1, "the reply covers the edges this call measured: {v}");
assert_eq!(s.continuity.len(), 2, "the first seam stays");
s.execute("inspect.continuity", &json!({"edges": [[5, 0, 0]], "add": true, "samples": 5})).unwrap();
assert_eq!(s.continuity.len(), 2, "measured again, not added twice");
assert!(s.continuity.iter().any(|seam| seam.points.len() == 5), "with its new reading");
assert!(s.continuity.iter().all(|seam| seam.body == "Body1"), "{:?}", s.continuity);
s.execute("inspect.continuity", &json!({"edges": [[10, 5, 0]]})).unwrap();
assert_eq!(s.continuity.len(), 1, "without `add` the shading starts over");
s.execute("inspect.continuity", &json!({"add": true})).unwrap();
assert_eq!(s.continuity.len(), 12, "every edge once");
}
/// Hostile parameters are refused with an error, never a panic.
#[test]
fn hostile_continuity_params_are_refused() {
+4
View File
@@ -309,6 +309,10 @@ pub enum SurfaceAnalysis {
/// sampled at, in world coordinates, and how it reads. View state only: the design is untouched.
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
pub struct ContinuitySeam {
/// The body the seam is an edge of.
pub body: String,
/// The edge's index in the body's display mesh edge order.
pub edge: usize,
/// Sample points along the seam, in world coordinates.
pub points: Vec<Vec3>,
/// The seam's worst verdict along its length.
+1
View File
@@ -806,6 +806,7 @@ fn hostile_params_never_panic() {
"scale",
"continuity",
"crown",
"faces",
];
// Commands are independent; worker threads take them one at a time (their costs vary a lot).
let ids: Vec<&str> = command_specs()