Class-A surfacing 3/3: blend curves, curved-edge fillets, the G2 corner and continuity analysis (#1642)
Thanks @midasdf.
This commit is contained in:
1 parent
3126962cd4
commit
bbd4f29ebb
43 files changed
+4753
-132
No files matched your search
@@ -5,8 +5,19 @@ use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, num, vec3};
|
||||
use crate::{Result, Session, SurfaceAnalysis};
|
||||
use crate::params::{bad, bool_, num, str_, vec3};
|
||||
use crate::{ContinuitySeam, Result, Session, SurfaceAnalysis, kernel};
|
||||
|
||||
/// Stations along each edge when the call does not say (`samples`).
|
||||
const DEFAULT_SAMPLES: usize = 33;
|
||||
/// Tolerances a seam is judged against when the call does not say: a micron of position gap, a
|
||||
/// degree of normal angle, and a curvature jump of 1/100 mm.
|
||||
const DEFAULT_GAP_MM: f64 = 1e-3;
|
||||
const DEFAULT_ANGLE_DEG: f64 = 1.0;
|
||||
const DEFAULT_CURVATURE_PER_MM: f64 = 0.01;
|
||||
/// Most edges one call may pick, and most bodies one call may analyse at once.
|
||||
const MAX_PICKS: usize = 512;
|
||||
const MAX_BODIES: usize = 64;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("inspect.zebra", "Zebra Analysis", zebra)
|
||||
@@ -34,11 +45,14 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("curvature_map")
|
||||
.noundo()
|
||||
.params("radius?: mm shown red (default: a tenth of the model's size); clear?: true turns the analysis off"),
|
||||
planned!("inspect.continuity", "Continuity Analysis", "770")
|
||||
.at("SURFACE", "INSPECT")
|
||||
.icon("continuity")
|
||||
.noundo()
|
||||
.params("planned: body?; edges?: [[x,y,z]] (default: every edge of the body); returns each edge's worst gap (G0), normal angle (G1) and curvature jump (G2); clear?: true turns the analysis off"),
|
||||
CommandSpec::new("inspect.continuity", "Continuity Analysis", continuity).at("SURFACE", "INSPECT").icon("continuity").noundo().params(
|
||||
"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); \
|
||||
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",
|
||||
),
|
||||
];
|
||||
|
||||
fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result<Value> {
|
||||
@@ -94,12 +108,214 @@ fn curvature_map(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
set(s, p, SurfaceAnalysis::Curvature { radius })
|
||||
}
|
||||
|
||||
/// `inspect.continuity`: measure how far the two faces meeting at each picked edge (or at every
|
||||
/// edge of a body) depart from sharing a curve (G0), a tangent plane (G1) and a curvature (G2),
|
||||
/// and keep the seams so the viewport can shade them. View state only: the design and its undo
|
||||
/// history are untouched (`noundo`), as with the other analyses.
|
||||
fn continuity(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "inspect.continuity";
|
||||
if bool_(p, "clear").unwrap_or(false) {
|
||||
s.continuity.clear();
|
||||
s.revision += 1;
|
||||
return Ok(json!({ "analysis": "continuity", "cleared": true }));
|
||||
}
|
||||
let samples = match p.get("samples") {
|
||||
None => DEFAULT_SAMPLES,
|
||||
Some(v) => match v.as_f64().filter(|n| n.is_finite() && n.fract() == 0.0 && (3.0..=257.0).contains(n)) {
|
||||
Some(n) => n as usize,
|
||||
None => return Err(bad(cmd, "`samples` must be 3…257 stations along each edge")),
|
||||
},
|
||||
};
|
||||
let tolerances = kernel::ContinuityTolerances {
|
||||
gap_mm: tolerance(p, "gap_tolerance_mm", DEFAULT_GAP_MM)?,
|
||||
angle_deg: tolerance(p, "angle_tolerance_deg", DEFAULT_ANGLE_DEG)?,
|
||||
curvature_per_mm: tolerance(p, "curvature_tolerance_per_mm", DEFAULT_CURVATURE_PER_MM)?,
|
||||
};
|
||||
let picks = edge_picks(p)?;
|
||||
let st = s.world_state();
|
||||
let named = str_(p, "body");
|
||||
let candidates: Vec<&solidcraft_doc::ModelBody> = match named {
|
||||
Some(name) => vec![st.body(name).ok_or_else(|| bad(cmd, format!("no body `{name}`")))?],
|
||||
None => st.bodies.iter().filter(|b| s.body_visible(&b.name)).collect(),
|
||||
};
|
||||
if candidates.is_empty() {
|
||||
return Err(bad(cmd, "there are no visible bodies to analyse: name a `body`"));
|
||||
}
|
||||
if candidates.len() > MAX_BODIES {
|
||||
return Err(bad(cmd, format!("more than {MAX_BODIES} bodies are shown: name the `body` to analyse")));
|
||||
}
|
||||
let mut targets: Vec<(&solidcraft_doc::ModelBody, Vec<usize>)> = Vec::new();
|
||||
if picks.is_empty() {
|
||||
targets.extend(candidates.into_iter().map(|b| (b, Vec::new())));
|
||||
} else {
|
||||
// Picks mean edges of one body (a design may stack bodies at the same place): a list
|
||||
// that lands on one body's edges only is analysed, and anything else is refused.
|
||||
let mut hits = Vec::new();
|
||||
let mut refused = None;
|
||||
for b in candidates {
|
||||
match kernel::pick_edges(&b.body, &picks) {
|
||||
Ok(edges) => hits.push((b, edges)),
|
||||
Err(e) => refused = refused.or(Some(e)),
|
||||
}
|
||||
}
|
||||
if hits.is_empty() {
|
||||
return Err(match (named, refused) {
|
||||
(Some(_), Some(e)) => e.into(),
|
||||
_ => bad(cmd, "no visible body has an edge matching `edges`"),
|
||||
});
|
||||
}
|
||||
if hits.len() > 1 {
|
||||
return Err(bad(cmd, "`edges` matches edges of more than one body: name the `body` to analyse"));
|
||||
}
|
||||
targets.extend(hits);
|
||||
}
|
||||
let mut seams: Vec<ContinuitySeam> = Vec::new();
|
||||
let mut bodies = Vec::new();
|
||||
let mut summary = Summary::default();
|
||||
for (body, edges) in &targets {
|
||||
let report = kernel::seam_diagnostics(&body.body, edges, samples, tolerances)?;
|
||||
let mut rows = Vec::new();
|
||||
for seam in &report.seams {
|
||||
summary.count(seam.verdict);
|
||||
seams.push(ContinuitySeam { points: seam.points.clone(), verdict: seam.verdict });
|
||||
rows.push(json!({
|
||||
"edge": seam.edge,
|
||||
"length_mm": seam.length,
|
||||
"samples": seam.samples,
|
||||
"gap_mm": seam.gap_mm,
|
||||
"normal_angle_deg": seam.normal_angle_deg,
|
||||
"curvature_jump_per_mm": seam.curvature_jump_per_mm,
|
||||
"verdict": seam.verdict,
|
||||
}));
|
||||
}
|
||||
summary.skipped += report.skipped;
|
||||
summary.unmeasured += report.unmeasured;
|
||||
bodies.push(json!({
|
||||
"body": body.name,
|
||||
"edges": rows,
|
||||
"skipped": report.skipped,
|
||||
"unmeasured": report.unmeasured,
|
||||
}));
|
||||
}
|
||||
s.continuity = seams;
|
||||
s.revision += 1;
|
||||
Ok(json!({
|
||||
"analysis": "continuity",
|
||||
"samples": samples,
|
||||
"tolerances": {
|
||||
"gap_mm": tolerances.gap_mm,
|
||||
"angle_deg": tolerances.angle_deg,
|
||||
"curvature_per_mm": tolerances.curvature_per_mm,
|
||||
},
|
||||
"bodies": bodies,
|
||||
"summary": {
|
||||
"edges": summary.edges,
|
||||
"smooth": summary.smooth,
|
||||
"curvature_jump": summary.curvature_jump,
|
||||
"sharp": summary.sharp,
|
||||
"gap": summary.gap,
|
||||
"skipped": summary.skipped,
|
||||
"unmeasured": summary.unmeasured,
|
||||
"worst": summary.worst(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/// A tolerance parameter. A value that is given but unusable (NaN, infinity, negative, a
|
||||
/// string, a bare `null`) is refused: `num` would fold it into "not given".
|
||||
fn tolerance(p: &Value, key: &str, default: f64) -> Result<f64> {
|
||||
match p.get(key) {
|
||||
None => Ok(default),
|
||||
Some(v) => match v.as_f64() {
|
||||
Some(x) if x.is_finite() && (0.0..1e9).contains(&x) => Ok(x),
|
||||
_ => Err(bad("inspect.continuity", format!("`{key}` must be a finite number, 0 or more"))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The `edges` parameter: points on the edges to measure, or `{"index": i}` entries in the
|
||||
/// display mesh's edge order. Absent (or an empty list) means every edge of the body.
|
||||
fn edge_picks(p: &Value) -> Result<Vec<kernel::EdgePick>> {
|
||||
let Some(value) = p.get("edges") else { return Ok(Vec::new()) };
|
||||
let Some(list) = value.as_array() else {
|
||||
return Err(bad("inspect.continuity", "`edges` must be a list of [x,y,z] points or {\"index\": i} entries"));
|
||||
};
|
||||
if list.len() > MAX_PICKS {
|
||||
return Err(bad("inspect.continuity", format!("pick at most {MAX_PICKS} edges")));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for item in list {
|
||||
if let Some(index) = item.get("index").and_then(Value::as_u64) {
|
||||
let index = usize::try_from(index).map_err(|_| bad("inspect.continuity", "`index` is too large"))?;
|
||||
out.push(kernel::EdgePick::Index(index));
|
||||
} else if let Some(point) = vec3(item) {
|
||||
out.push(kernel::EdgePick::Point(point));
|
||||
} else {
|
||||
return Err(bad("inspect.continuity", "each `edges` entry is a point [x,y,z] or {\"index\": i}"));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Counts of a continuity reply, and the worst verdict over every edge measured.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct Summary {
|
||||
edges: usize,
|
||||
smooth: usize,
|
||||
curvature_jump: usize,
|
||||
sharp: usize,
|
||||
gap: usize,
|
||||
skipped: usize,
|
||||
unmeasured: usize,
|
||||
}
|
||||
|
||||
impl Summary {
|
||||
fn count(&mut self, verdict: kernel::SeamVerdict) {
|
||||
self.edges += 1;
|
||||
match verdict {
|
||||
kernel::SeamVerdict::Smooth => self.smooth += 1,
|
||||
kernel::SeamVerdict::CurvatureJump => self.curvature_jump += 1,
|
||||
kernel::SeamVerdict::Sharp => self.sharp += 1,
|
||||
kernel::SeamVerdict::Gap => self.gap += 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// The worst verdict over the edges measured (`None` when there was nothing to measure).
|
||||
/// [`kernel::SeamVerdict`] runs from the best continuity to the worst.
|
||||
fn worst(&self) -> Option<kernel::SeamVerdict> {
|
||||
[
|
||||
(self.smooth, kernel::SeamVerdict::Smooth),
|
||||
(self.curvature_jump, kernel::SeamVerdict::CurvatureJump),
|
||||
(self.sharp, kernel::SeamVerdict::Sharp),
|
||||
(self.gap, kernel::SeamVerdict::Gap),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(count, _)| *count > 0)
|
||||
.map(|(_, verdict)| verdict)
|
||||
.max()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Session, SurfaceAnalysis};
|
||||
|
||||
fn boxed(s: &mut Session, size: f64) {
|
||||
s.execute("solid.box", &json!({"length": size, "width": size, "height": size})).unwrap();
|
||||
}
|
||||
|
||||
/// The edges of the first body of a continuity reply.
|
||||
fn edges(v: &Value) -> &Vec<Value> {
|
||||
v["bodies"][0]["edges"].as_array().unwrap_or_else(|| panic!("no edges in {v}"))
|
||||
}
|
||||
|
||||
/// The seams that read as tangent (within 0.01° of the neighbouring face).
|
||||
fn tangent(edges: &[Value]) -> Vec<&Value> {
|
||||
edges.iter().filter(|e| e["normal_angle_deg"]["value"].as_f64().unwrap_or(f64::NAN) < 0.01).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_analyses_are_view_state() {
|
||||
let mut s = Session::default();
|
||||
@@ -121,4 +337,126 @@ mod tests {
|
||||
assert!(s.execute("inspect.zebra", &json!({"stripes": 0})).is_err());
|
||||
assert!(s.execute("inspect.draft", &json!({"pull": [0, 0, 0]})).is_err());
|
||||
}
|
||||
|
||||
/// A box edge: the faces meet exactly (G0) and square to each other (G1), so the analysis
|
||||
/// must read 0 gap and 90°, and must keep its seams as view state without an undo step.
|
||||
#[test]
|
||||
fn continuity_reports_a_box_edge_as_a_gap_free_right_angle() {
|
||||
let mut s = Session::default();
|
||||
boxed(&mut s, 20.0);
|
||||
let undo = s.undo.len();
|
||||
let v = s.execute("inspect.continuity", &json!({})).unwrap();
|
||||
assert_eq!(edges(&v).len(), 12);
|
||||
for e in edges(&v) {
|
||||
let gap = e["gap_mm"]["value"].as_f64().unwrap();
|
||||
let angle = e["normal_angle_deg"]["value"].as_f64().unwrap();
|
||||
let jump = e["curvature_jump_per_mm"]["value"].as_f64().unwrap();
|
||||
assert!(gap < 1e-6, "gap {gap} in {e}");
|
||||
assert!((angle - 90.0).abs() < 1e-9, "angle {angle} in {e}");
|
||||
assert!(jump < 1e-9, "curvature jump {jump} in {e}");
|
||||
assert_eq!(e["verdict"], json!("sharp"), "{e}");
|
||||
assert_eq!(e["samples"], json!(33), "{e}");
|
||||
assert!((e["length_mm"].as_f64().unwrap() - 20.0).abs() < 1e-9, "{e}");
|
||||
assert_eq!(e["normal_angle_deg"]["at"].as_array().map(Vec::len), Some(3), "{e}");
|
||||
let f = e["normal_angle_deg"]["edge_fraction"].as_f64().unwrap();
|
||||
assert!((0.0..=1.0).contains(&f), "{e}");
|
||||
}
|
||||
assert_eq!(v["analysis"], json!("continuity"));
|
||||
assert_eq!(v["samples"], json!(33));
|
||||
assert_eq!(v["tolerances"], json!({"gap_mm": 1e-3, "angle_deg": 1.0, "curvature_per_mm": 0.01}));
|
||||
assert_eq!(v["summary"]["edges"], json!(12));
|
||||
assert_eq!(v["summary"]["sharp"], json!(12));
|
||||
assert_eq!(v["summary"]["worst"], json!("sharp"));
|
||||
assert_eq!(s.continuity.len(), 12, "the seams are kept for the viewport");
|
||||
assert_eq!(s.undo.len(), undo, "no undo step");
|
||||
assert_eq!(s.analysis, None, "the other analyses are left alone");
|
||||
}
|
||||
|
||||
/// A rolling-ball fillet is tangent to the faces it joins but not curvature continuous: its
|
||||
/// two seams read ~0° with the fillet's 1 / radius curvature jump against the planes.
|
||||
#[test]
|
||||
fn continuity_finds_the_fillet_seams_tangent_and_their_curvature_jump() {
|
||||
let mut s = Session::default();
|
||||
boxed(&mut s, 20.0);
|
||||
s.execute("solid.fillet", &json!({"edges": [[20, 20, 10]], "radius": 4})).unwrap();
|
||||
let v = s.execute("inspect.continuity", &json!({})).unwrap();
|
||||
assert_eq!(edges(&v).len(), 15, "the rounded edge leaves three new edges behind: {v}");
|
||||
let seams = tangent(edges(&v));
|
||||
assert_eq!(seams.len(), 2, "the fillet meets its two neighbours tangentially: {v}");
|
||||
for e in seams {
|
||||
assert!(e["gap_mm"]["value"].as_f64().unwrap() < 1e-6, "{e}");
|
||||
let jump = e["curvature_jump_per_mm"]["value"].as_f64().unwrap();
|
||||
assert!((jump - 0.25).abs() < 1e-6, "1 / radius 4: {e}");
|
||||
assert_eq!(e["verdict"], json!("curvature_jump"), "{e}");
|
||||
}
|
||||
assert_eq!(v["summary"]["curvature_jump"], json!(2));
|
||||
assert_eq!(v["summary"]["sharp"], json!(13));
|
||||
assert_eq!(v["summary"]["smooth"], json!(0));
|
||||
}
|
||||
|
||||
/// A slot's straight wall runs tangentially into its arc wall: the join is G1 but its
|
||||
/// curvature jumps by the arc's 1 / radius, which is exactly what a Class-A check looks for.
|
||||
#[test]
|
||||
fn continuity_reports_one_over_the_radius_where_a_line_meets_an_arc() {
|
||||
let mut s = Session::default();
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
s.execute("sketch.slot.center_point", &json!({"center": [0, 0], "end": [20, 0], "width": 10})).unwrap();
|
||||
s.execute("solid.extrude", &json!({"distance": 5})).unwrap();
|
||||
let v = s.execute("inspect.continuity", &json!({"angle_tolerance_deg": 0.01})).unwrap();
|
||||
let seams = tangent(edges(&v));
|
||||
assert_eq!(seams.len(), 4, "a slot has four line-to-arc joins: {v}");
|
||||
for e in seams {
|
||||
assert!(e["gap_mm"]["value"].as_f64().unwrap() < 1e-6, "{e}");
|
||||
let jump = e["curvature_jump_per_mm"]["value"].as_f64().unwrap();
|
||||
assert!((jump - 0.2).abs() < 1e-6, "1 / radius 5: {e}");
|
||||
assert_eq!(e["verdict"], json!("curvature_jump"), "{e}");
|
||||
}
|
||||
assert_eq!(v["summary"]["curvature_jump"], json!(4));
|
||||
}
|
||||
|
||||
/// `clear` takes the shading away, and every continuity call stays view state.
|
||||
#[test]
|
||||
fn continuity_is_view_state_and_clears() {
|
||||
let mut s = Session::default();
|
||||
boxed(&mut s, 10.0);
|
||||
let undo = s.undo.len();
|
||||
s.execute("inspect.continuity", &json!({"samples": 5})).unwrap();
|
||||
assert_eq!(s.continuity.len(), 12);
|
||||
assert_eq!(s.undo.len(), undo, "no undo step");
|
||||
let cleared = s.execute("inspect.continuity", &json!({"clear": true})).unwrap();
|
||||
assert_eq!(cleared["cleared"], json!(true));
|
||||
assert!(s.continuity.is_empty(), "clear leaves nothing shaded");
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
|
||||
/// Hostile parameters are refused with an error, never a panic.
|
||||
#[test]
|
||||
fn hostile_continuity_params_are_refused() {
|
||||
let mut s = Session::default();
|
||||
boxed(&mut s, 10.0);
|
||||
for params in [
|
||||
json!({"samples": 0}),
|
||||
json!({"samples": 2}),
|
||||
json!({"samples": 258}),
|
||||
json!({"samples": 3.5}),
|
||||
json!({"samples": "33"}),
|
||||
json!({"samples": null}),
|
||||
json!({"gap_tolerance_mm": -1}),
|
||||
json!({"gap_tolerance_mm": 1e12}),
|
||||
json!({"angle_tolerance_deg": f64::NAN}),
|
||||
json!({"curvature_tolerance_per_mm": "tight"}),
|
||||
json!({"edges": 3}),
|
||||
json!({"edges": [[1, 2]]}),
|
||||
json!({"edges": [[]]}),
|
||||
json!({"edges": [{"index": 99999}]}),
|
||||
json!({"edges": [[500, 500, 500]]}),
|
||||
json!({"body": "Nope"}),
|
||||
] {
|
||||
assert!(s.execute("inspect.continuity", ¶ms).is_err(), "accepted {params}");
|
||||
}
|
||||
let many: Vec<Value> = (0..513).map(|i| json!([i as f64, 0.0, 0.0])).collect();
|
||||
assert!(s.execute("inspect.continuity", &json!({"edges": many})).is_err(), "513 picks");
|
||||
assert!(s.continuity.is_empty(), "a refused call shades nothing");
|
||||
assert_eq!(s.undo.len(), 1, "only the box is an undo step");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user