inspect.compare: where two versions of a part differ, without a boolean of the two (#1423)
New command inspect.compare {a, b, tolerance?}: measures of both and their differences, two-way surface deviation from a bounding-volume tree, regions beyond the tolerance clustered by side (only_in_a / only_in_b) with integrated volumes and faces, a verdict, optional face colouring as one undo step, and warnings for open meshes. Geometry in solidcraft-geom (compare.rs), command in the engine. Closes #1363.
This commit is contained in:
1 parent
2bfec256b7
commit
d0a4443939
16 files changed
+1617
-2
No files matched your search
@@ -0,0 +1,309 @@
|
||||
//! Compare Bodies: two versions of one part laid over each other. Both are measured, each
|
||||
//! surface's distance to the other is sampled, and the places where only one of them has
|
||||
//! material are found, measured and, on request, coloured. It works from the surfaces
|
||||
//! ([`solidcraft_geom::compare`]), not from a boolean of the two bodies, so bodies that share
|
||||
//! almost every face compare as well as any.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_geom::compare::{self, CompareOptions, Side};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Sel, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Compare Bodies", compare_bodies).params(
|
||||
"a, b: body names (default: the two selected bodies, or the design's only two), in world placement; tolerance?: mm (0.001 to 10, default 0.01): \
|
||||
surfaces closer than this agree; spacing?: mm (default 0.5): the largest piece of a face sampled as one point, coarser for a body that would need \
|
||||
more than max_points? (default 8 million, up to 50 million) samples; max_regions? (default 50); color?: true | {only_in_a?, only_in_b?: \"#rrggbb\"} \
|
||||
(colours the faces that hold a region, red where only a has material and green where only b has: the faces mostly beyond the tolerance, else the \
|
||||
one with the largest share; an undo step) → a, b: volume_mm3, area_mm2, center_of_mass, bbox; difference: b − a; \
|
||||
deviation: a_to_b, b_to_a (max_mm, mean_mm, at, area_beyond_mm2); regions, largest first: side (only_in_a | only_in_b), volume_mm3 (integrated along \
|
||||
lines through the region: resolution), area_mm2, max_deviation_mm, at, center, bbox, faces_a, faces_b (face indices, as model.faces lists them); \
|
||||
identical; verdict; warnings (a body whose surface does not enclose what it measures, lines left out). Works from the two surfaces, without a boolean of the bodies, so near-identical bodies compare too",
|
||||
)];
|
||||
|
||||
/// Default colours: red for material only `a` has, green for material only `b` has.
|
||||
const ONLY_IN_A: [u8; 3] = [0xe0, 0x31, 0x31];
|
||||
const ONLY_IN_B: [u8; 3] = [0x2f, 0x9e, 0x44];
|
||||
|
||||
fn side_name(s: Side) -> &'static str {
|
||||
match s {
|
||||
Side::OnlyInA => "only_in_a",
|
||||
Side::OnlyInB => "only_in_b",
|
||||
}
|
||||
}
|
||||
|
||||
/// A number parameter in `range`, `default` when absent.
|
||||
fn number(cmd: &str, p: &Value, key: &str, default: f64, range: std::ops::RangeInclusive<f64>) -> Result<f64> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|x| x.is_finite() && range.contains(x))
|
||||
.ok_or_else(|| bad(cmd, format!("`{key}` must be a number from {} to {}", range.start(), range.end()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The two bodies: `a` and `b`, else the two selected bodies, else the design's only two.
|
||||
fn pair(s: &Session, st: &solidcraft_doc::ModelState, p: &Value, cmd: &str) -> Result<(String, String)> {
|
||||
let given = |k: &str| -> Result<Option<String>> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(n)) => Ok(Some(n.clone())),
|
||||
Some(_) => Err(bad(cmd, format!("`{k}` must be a body name"))),
|
||||
}
|
||||
};
|
||||
let (a, b) = match (given("a")?, given("b")?) {
|
||||
(Some(a), Some(b)) => (a, b),
|
||||
(None, None) => {
|
||||
let selected: Vec<String> =
|
||||
s.selection.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect();
|
||||
let all: Vec<String> = st.bodies.iter().map(|b| b.name.clone()).collect();
|
||||
match (selected.as_slice(), all.as_slice()) {
|
||||
([a, b], _) | (_, [a, b]) => (a.clone(), b.clone()),
|
||||
_ => return Err(bad(cmd, "give `a` and `b` (or select two bodies)")),
|
||||
}
|
||||
}
|
||||
_ => return Err(bad(cmd, "give both `a` and `b`")),
|
||||
};
|
||||
for n in [&a, &b] {
|
||||
if st.body(n).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{n}`")));
|
||||
}
|
||||
}
|
||||
if a == b {
|
||||
return Err(bad(cmd, "`a` and `b` must be two different bodies"));
|
||||
}
|
||||
Ok((a, b))
|
||||
}
|
||||
|
||||
fn colours(cmd: &str, p: &Value) -> Result<Option<[[u8; 3]; 2]>> {
|
||||
match p.get("color") {
|
||||
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
|
||||
Some(Value::Bool(true)) => Ok(Some([ONLY_IN_A, ONLY_IN_B])),
|
||||
Some(Value::Object(o)) => {
|
||||
let pick = |k: &str, d: [u8; 3]| match o.get(k) {
|
||||
None | Some(Value::Null) => Ok(d),
|
||||
Some(v) => {
|
||||
super::inspect::color_param(v).ok_or_else(|| bad(cmd, format!("`color.{k}` must be \"#rrggbb\" or [r, g, b] with values 0–255")))
|
||||
}
|
||||
};
|
||||
Ok(Some([pick("only_in_a", ONLY_IN_A)?, pick("only_in_b", ONLY_IN_B)?]))
|
||||
}
|
||||
Some(_) => Err(bad(cmd, "`color` must be true or {only_in_a, only_in_b}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn v3(v: &Value) -> Option<Vec3> {
|
||||
crate::params::vec3(v)
|
||||
}
|
||||
|
||||
/// b − a of the two bodies' measures.
|
||||
fn difference(ma: &Value, mb: &Value) -> Value {
|
||||
let f = |m: &Value, k: &str| m.get(k).and_then(Value::as_f64);
|
||||
let sub = |x: Option<f64>, y: Option<f64>| x.zip(y).map(|(x, y)| y - x);
|
||||
let (va, vb) = (f(ma, "volume_mm3"), f(mb, "volume_mm3"));
|
||||
let com = v3(&ma["center_of_mass"]).zip(v3(&mb["center_of_mass"])).map(|(a, b)| b - a);
|
||||
let corner = |k: &str| v3(&ma["bbox"][k]).zip(v3(&mb["bbox"][k])).map(|(a, b)| b - a);
|
||||
json!({
|
||||
"volume_mm3": sub(va, vb),
|
||||
"volume_percent": va.zip(vb).filter(|(a, _)| a.abs() > 0.0).map(|(a, b)| 100.0 * (b - a) / a),
|
||||
"area_mm2": sub(f(ma, "area_mm2"), f(mb, "area_mm2")),
|
||||
"center_of_mass": com,
|
||||
"center_of_mass_distance_mm": com.map(Vec3::len),
|
||||
"bbox_min": corner("min"),
|
||||
"bbox_max": corner("max"),
|
||||
})
|
||||
}
|
||||
|
||||
/// The volume a closed mesh encloses (divergence theorem, about its box's centre).
|
||||
fn enclosed_volume(m: &solidcraft_geom::Mesh) -> f64 {
|
||||
let o = m.bounds().center();
|
||||
m.triangles.iter().filter_map(|t| m.tri(t)).map(|[a, b, c]| (a - o).dot((b - o).cross(c - o)) / 6.0).sum()
|
||||
}
|
||||
|
||||
/// What makes the comparison less certain: a body whose surface does not enclose what it
|
||||
/// measures (open, or faces that failed), lines left out of region volumes.
|
||||
fn warnings(bodies: [(&str, &Value, &solidcraft_geom::Mesh); 2], chord: f64, c: &compare::Comparison) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for (name, measure, mesh) in bodies {
|
||||
let (Some(v), area) = (measure["volume_mm3"].as_f64(), measure["area_mm2"].as_f64().unwrap_or(0.0)) else { continue };
|
||||
let enclosed = enclosed_volume(mesh);
|
||||
// A mesh within `chord` of the surface encloses the volume to within chord × area.
|
||||
if !((enclosed - v).abs() <= chord * area.abs() + 1e-6 * v.abs()) {
|
||||
out.push(format!(
|
||||
"`{name}`: its surface encloses {enclosed:.3} mm³ but the body measures {v:.3} mm³: it is open or has faces that failed, so its measures and the regions near the gap are unreliable"
|
||||
));
|
||||
}
|
||||
}
|
||||
let skipped: usize = c.regions.iter().map(|r| r.lines.skipped).sum();
|
||||
if skipped > 0 {
|
||||
out.push(format!("{skipped} line(s) through the regions crossed a gap in a surface and were left out of their volumes"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn deviation_json(d: &compare::Deviation) -> Value {
|
||||
json!({"max_mm": d.max, "mean_mm": d.mean, "at": d.at, "area_beyond_mm2": d.beyond_area, "area_mm2": d.area, "points": d.points})
|
||||
}
|
||||
|
||||
fn compare_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "inspect.compare";
|
||||
let tolerance = number(cmd, p, "tolerance", 0.01, 0.001..=10.0)?;
|
||||
let spacing = number(cmd, p, "spacing", 0.5, 0.01..=1000.0)?;
|
||||
let max_points = number(cmd, p, "max_points", 8e6, 1e4..=5e7)? as usize;
|
||||
let max_regions = number(cmd, p, "max_regions", 50.0, 1.0..=1000.0)? as usize;
|
||||
let colors = colours(cmd, p)?;
|
||||
let st = s.world_state();
|
||||
let (na, nb) = pair(s, &st, p, cmd)?;
|
||||
let (Some(ba), Some(bb)) = (st.body(&na), st.body(&nb)) else { return Err(bad(cmd, "no such body")) };
|
||||
// Meshed within a quarter of the tolerance (the kernel goes no finer than a millionth of a
|
||||
// body's size), so two meshes of one surface agree well within it.
|
||||
let chord = tolerance / 4.0;
|
||||
let mut done = solidcraft_kernel::map_on_threads(&[ba, bb], 2, |b| (super::inspect::measure_json(b), b.body.tessellate_with(chord, true)))
|
||||
.into_iter()
|
||||
.zip([&na, &nb]);
|
||||
let mut next = || -> Result<(Value, solidcraft_geom::Mesh)> {
|
||||
let Some((r, name)) = done.next() else { return Err(bad(cmd, "no such body")) };
|
||||
let (m, mesh) = r.ok_or_else(|| bad(cmd, format!("`{name}`: measuring it failed")))?;
|
||||
let mesh = mesh.map_err(|e| bad(cmd, format!("`{name}`: {e}")))?;
|
||||
if mesh.triangles.is_empty() {
|
||||
return Err(bad(cmd, format!("`{name}` has no surface")));
|
||||
}
|
||||
Ok((m, mesh))
|
||||
};
|
||||
let ((measure_a, mesh_a), (measure_b, mesh_b)) = (next()?, next()?);
|
||||
let options = CompareOptions { tolerance, spacing, max_points, max_regions, ..CompareOptions::default() };
|
||||
let each = |n: usize, job: &(dyn Fn(usize) + Sync)| {
|
||||
let jobs: Vec<usize> = (0..n).collect();
|
||||
solidcraft_kernel::for_each_on_threads(&jobs, 16, |i| job(*i));
|
||||
};
|
||||
let c = compare::compare(&mesh_a, &mesh_b, &options, &each);
|
||||
let axis = |k: usize| ["X", "Y", "Z"].get(k).copied().unwrap_or("?");
|
||||
let regions: Vec<Value> = c
|
||||
.regions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| {
|
||||
json!({
|
||||
"index": i + 1,
|
||||
"side": side_name(r.side),
|
||||
"material_of": if r.side == Side::OnlyInA { &na } else { &nb },
|
||||
"volume_mm3": r.volume,
|
||||
"area_mm2": r.area_a + r.area_b,
|
||||
"area_a_mm2": r.area_a,
|
||||
"area_b_mm2": r.area_b,
|
||||
"max_deviation_mm": r.max_deviation,
|
||||
"at": r.at,
|
||||
"center": r.center,
|
||||
"bbox": {"min": r.bbox.min, "max": r.bbox.max},
|
||||
"faces_a": r.faces_a.iter().map(|x| x.0).collect::<Vec<u32>>(),
|
||||
"faces_b": r.faces_b.iter().map(|x| x.0).collect::<Vec<u32>>(),
|
||||
"resolution": {"along": axis(r.lines.axis), "line_spacing_mm": r.lines.spacing, "lines": r.lines.count, "skipped_lines": r.lines.skipped},
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let total = |side: Side| {
|
||||
let rs: Vec<&compare::Region> = c.regions.iter().filter(|r| r.side == side).collect();
|
||||
// `+ 0.0`: an empty sum is -0.0.
|
||||
(rs.len(), rs.iter().map(|r| r.volume).filter(|v| v.is_finite()).sum::<f64>() + 0.0)
|
||||
};
|
||||
let ((ca, va), (cb, vb)) = (total(Side::OnlyInA), total(Side::OnlyInB));
|
||||
let worst = c.a_to_b.max.max(c.b_to_a.max);
|
||||
let identical = c.regions.is_empty() && c.unlisted == 0;
|
||||
let warnings = warnings([(na.as_str(), &measure_a, &mesh_a), (nb.as_str(), &measure_b, &mesh_b)], chord, &c);
|
||||
let mut verdict = if identical {
|
||||
format!("identical within {tolerance} mm (largest deviation {worst:.4} mm)")
|
||||
} else {
|
||||
let more = if c.unlisted > 0 { format!(" (and {} more, not measured)", c.unlisted) } else { String::new() };
|
||||
format!(
|
||||
"{} {} by more than {tolerance} mm: {ca} with material only in `{na}` ({va:.3} mm³), {cb} only in `{nb}` ({vb:.3} mm³){more}",
|
||||
c.regions.len(),
|
||||
if c.regions.len() == 1 { "region differs" } else { "regions differ" }
|
||||
)
|
||||
};
|
||||
if !warnings.is_empty() {
|
||||
verdict += &format!(" ({} warning{})", warnings.len(), if warnings.len() == 1 { "" } else { "s" });
|
||||
}
|
||||
let mut out = json!({
|
||||
"a": measure_a,
|
||||
"b": measure_b,
|
||||
"difference": difference(&measure_a, &measure_b),
|
||||
"tolerance_mm": tolerance,
|
||||
"deviation": {"a_to_b": deviation_json(&c.a_to_b), "b_to_a": deviation_json(&c.b_to_a), "max_mm": worst},
|
||||
"identical": identical,
|
||||
"verdict": verdict,
|
||||
"regions": regions,
|
||||
"only_in_a": {"regions": ca, "volume_mm3": va},
|
||||
"only_in_b": {"regions": cb, "volume_mm3": vb},
|
||||
"unlisted_regions": c.unlisted,
|
||||
"warnings": warnings,
|
||||
"sampling": {"spacing_mm": c.spacing, "chord_mm": chord, "triangles_a": mesh_a.triangles.len(), "triangles_b": mesh_b.triangles.len()},
|
||||
});
|
||||
if let Some(colors) = colors {
|
||||
out["colored_faces"] = json!(color_regions(s, &c, [&na, &nb], colors)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A face is coloured for a region when at least this share of it deviates there.
|
||||
const MOSTLY: f64 = 0.5;
|
||||
|
||||
/// The faces to colour for a region, as (body: 0 = a, 1 = b; face): those mostly beyond the
|
||||
/// tolerance (a small difference on a large face would colour all of it), or, when it has none,
|
||||
/// the one with the largest share.
|
||||
fn faces_to_color(c: &compare::Comparison, r: &compare::Region) -> Vec<(usize, u32)> {
|
||||
let share = |m: usize, &(f, beyond): &(u32, f64)| {
|
||||
let all = c.face_area.get(m).and_then(|x| x.get(&f)).copied().unwrap_or(0.0);
|
||||
(m, f, if all > 0.0 { beyond / all } else { 0.0 })
|
||||
};
|
||||
let all: Vec<(usize, u32, f64)> = r.faces_a.iter().map(|x| share(0, x)).chain(r.faces_b.iter().map(|x| share(1, x))).collect();
|
||||
let mostly: Vec<(usize, u32)> = all.iter().filter(|x| x.2 >= MOSTLY).map(|x| (x.0, x.1)).collect();
|
||||
if !mostly.is_empty() {
|
||||
return mostly;
|
||||
}
|
||||
all.iter().max_by(|x, y| x.2.total_cmp(&y.2)).map(|x| vec![(x.0, x.1)]).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Give the faces holding each region the colour of its side (a face holding several takes the
|
||||
/// largest one's); returns how many face appearances were set.
|
||||
fn color_regions(s: &mut Session, c: &compare::Comparison, names: [&String; 2], colors: [[u8; 3]; 2]) -> Result<usize> {
|
||||
use solidcraft_doc::appearance::{FaceLook, Look};
|
||||
let st = s.model.state();
|
||||
// Where each face of the two bodies is, as the design file names and finds it. A body placed
|
||||
// by an occurrence is coloured where it is defined, if at all.
|
||||
let found: Vec<_> =
|
||||
names.iter().map(|n| st.body(n).map(|b| (solidcraft_doc::naming::face_spots(b), solidcraft_doc::naming::face_names(b)))).collect();
|
||||
let mut picks: Vec<(String, Vec3, Option<String>, Look)> = Vec::new();
|
||||
// Smallest region first, so the largest one's colour is the one that stays.
|
||||
for r in c.regions.iter().rev() {
|
||||
let colour = if r.side == Side::OnlyInA { colors[0] } else { colors[1] };
|
||||
let mut look = Look::custom(colour, 1.0);
|
||||
look.name = if r.side == Side::OnlyInA { "Only in a".into() } else { "Only in b".into() };
|
||||
for (m, f) in faces_to_color(c, r) {
|
||||
let (Some(Some((spots, face_names))), Some(name)) = (found.get(m), names.get(m)) else { continue };
|
||||
let Some(spot) = spots.get(f as usize).copied().flatten() else { continue };
|
||||
picks.retain(|(b, pt, _, _)| !(b == *name && pt.dist(spot.point) < 1e-9));
|
||||
picks.push(((*name).clone(), spot.point, face_names.get(f as usize).cloned(), look.clone()));
|
||||
}
|
||||
}
|
||||
if picks.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let a = &mut s.doc_mut().appearances;
|
||||
let mut n = 0;
|
||||
for (body, point, name, look) in picks {
|
||||
a.faces.retain(|f| !(f.body == body && f.point.dist(point) < 1e-9));
|
||||
if a.faces.len() >= 100_000 {
|
||||
return Err(bad("inspect.compare", "too many face appearances"));
|
||||
}
|
||||
a.faces.push(FaceLook { body, point, look, name, strict_name: false, certificate: None });
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "compare_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,212 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn regions(r: &Value) -> Vec<Value> {
|
||||
r["regions"].as_array().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn f(v: &Value) -> f64 {
|
||||
v.as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
fn close(got: f64, want: f64, rel: f64) -> bool {
|
||||
(got - want).abs() <= rel * want.abs()
|
||||
}
|
||||
|
||||
/// A 40 x 30 x 10 box `A` and its copy `A (2)`, in the same place.
|
||||
fn two_boxes() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "A"}));
|
||||
run(&mut s, "solid.move", json!({"bodies": ["A"], "translate": [0, 0, 0], "copy": true}));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_and_its_copy_are_identical() {
|
||||
let mut s = two_boxes();
|
||||
let r = run(&mut s, "inspect.compare", json!({"a": "A", "b": "A (2)"}));
|
||||
assert_eq!(r["identical"], true, "{r}");
|
||||
assert_eq!(r["warnings"], json!([]), "{r}");
|
||||
assert!(regions(&r).is_empty());
|
||||
assert!(f(&r["deviation"]["max_mm"]) < 1e-9, "{}", r["deviation"]);
|
||||
assert_eq!(f(&r["difference"]["volume_mm3"]), 0.0);
|
||||
assert!(close(f(&r["a"]["volume_mm3"]), 12_000.0, 1e-9));
|
||||
assert!(r["verdict"].as_str().unwrap_or_default().starts_with("identical within 0.01 mm"), "{}", r["verdict"]);
|
||||
// Without names: the design's only two bodies. Comparing changes nothing.
|
||||
let before = s.doc.clone();
|
||||
let r = run(&mut s, "inspect.compare", json!({}));
|
||||
assert_eq!(r["identical"], true);
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &s.doc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pocket_is_material_only_the_plain_box_has() {
|
||||
// b has a 10 x 8 x 0.5 pocket in its top: 40 mm³ that only a has.
|
||||
let mut s = two_boxes();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 8, "height": 0.5, "corner": [10, 10, 9.5], "operation": "cut", "targets": ["A (2)"]}));
|
||||
let r = run(&mut s, "inspect.compare", json!({"a": "A", "b": "A (2)"}));
|
||||
let rs = regions(&r);
|
||||
assert_eq!(rs.len(), 1, "{r}");
|
||||
let g = &rs[0];
|
||||
assert_eq!(g["side"], "only_in_a");
|
||||
assert_eq!(g["material_of"], "A");
|
||||
assert!(close(f(&g["volume_mm3"]), 40.0, 0.01), "{g}");
|
||||
assert!((f(&g["max_deviation_mm"]) - 0.5).abs() < 1e-6, "{g}");
|
||||
let (lo, hi) = (&g["bbox"]["min"], &g["bbox"]["max"]);
|
||||
for (k, (want_lo, want_hi)) in [(10.0, 20.0), (10.0, 18.0), (9.5, 10.0)].into_iter().enumerate() {
|
||||
assert!((f(&lo[k]) - want_lo).abs() < 0.05 && (f(&hi[k]) - want_hi).abs() < 0.05, "{g}");
|
||||
}
|
||||
let c = &g["center"];
|
||||
assert!((f(&c[0]) - 15.0).abs() < 0.02 && (f(&c[1]) - 14.0).abs() < 0.02 && (f(&c[2]) - 9.75).abs() < 1e-6, "{g}");
|
||||
// The pocket's floor and walls, and a's top.
|
||||
assert_eq!(g["faces_b"].as_array().map(Vec::len), Some(5), "{g}");
|
||||
assert_eq!(g["faces_a"].as_array().map(Vec::len), Some(1), "{g}");
|
||||
assert!(close(f(&r["difference"]["volume_mm3"]), -40.0, 1e-6), "{}", r["difference"]);
|
||||
assert!(close(f(&r["only_in_a"]["volume_mm3"]), 40.0, 0.01));
|
||||
assert_eq!(r["only_in_b"]["regions"], 0);
|
||||
// The other way round, the same material is only in b.
|
||||
let r = run(&mut s, "inspect.compare", json!({"a": "A (2)", "b": "A"}));
|
||||
assert_eq!(regions(&r).len(), 1);
|
||||
assert_eq!(regions(&r)[0]["side"], "only_in_b");
|
||||
assert_eq!(regions(&r)[0]["material_of"], "A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boss_is_material_only_the_box_with_it_has() {
|
||||
// b has a 6 x 5 x 2 boss on its top: 60 mm³ that only b has.
|
||||
let mut s = two_boxes();
|
||||
run(&mut s, "solid.box", json!({"length": 6, "width": 5, "height": 2, "corner": [5, 5, 10], "operation": "join", "targets": ["A (2)"]}));
|
||||
let r = run(&mut s, "inspect.compare", json!({"a": "A", "b": "A (2)", "color": true}));
|
||||
let rs = regions(&r);
|
||||
assert_eq!(rs.len(), 1, "{r}");
|
||||
assert_eq!(rs[0]["side"], "only_in_b");
|
||||
assert!(close(f(&rs[0]["volume_mm3"]), 60.0, 0.01), "{}", rs[0]);
|
||||
assert!((f(&rs[0]["max_deviation_mm"]) - 2.0).abs() < 1e-6);
|
||||
assert!(close(f(&r["difference"]["volume_mm3"]), 60.0, 1e-6));
|
||||
// Coloured green: the boss's top and four sides, all of them beyond the tolerance; not a's top,
|
||||
// of which only the boss's footprint is. One undo step.
|
||||
assert_eq!(r["colored_faces"], 5, "{r}");
|
||||
let green = |s: &Session, body: &str| s.doc.appearances.faces.iter().filter(|x| x.body == body && x.look.color == [0x2f, 0x9e, 0x44]).count();
|
||||
assert_eq!((green(&s, "A (2)"), green(&s, "A")), (5, 0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.appearances.faces.len(), 0);
|
||||
// Own colours.
|
||||
let r = run(&mut s, "inspect.compare", json!({"color": {"only_in_b": "#0000ff"}}));
|
||||
assert_eq!(r["colored_faces"], 5);
|
||||
assert_eq!(s.doc.appearances.faces.iter().filter(|x| x.look.color == [0, 0, 255]).count(), 5);
|
||||
}
|
||||
|
||||
/// A 100 x 60 sheet-metal plate with a 20 mm flange along y = 0 and a 5 mm hole at (x, 40).
|
||||
fn bracket(s: &mut Session, name: &str, x: f64) {
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": format!("{name} base")}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "sheet.flange", json!({"sketch": format!("{name} base"), "body_name": name}));
|
||||
run(s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20, "body": name}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": format!("{name} hole")}));
|
||||
run(s, "sketch.circle.center", json!({"center": [x, 40], "diameter": 5}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"sketch": format!("{name} hole"), "through_all": true, "operation": "cut", "targets": [name]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hole_moved_2_mm_leaves_two_crescents() {
|
||||
let mut s = Session::default();
|
||||
bracket(&mut s, "Before", 50.0);
|
||||
bracket(&mut s, "After", 52.0);
|
||||
let r = run(&mut s, "inspect.compare", json!({"a": "Before", "b": "After"}));
|
||||
let rs = regions(&r);
|
||||
assert_eq!(rs.len(), 2, "{r}");
|
||||
// Each is a disk of 2.5 mm radius less its overlap with the other, centres 2 mm apart, through
|
||||
// the 2.5 mm sheet: (π r² − 2 r² acos(d / 2r) + (d / 2) √(4 r² − d²)) · t.
|
||||
let (rr, d, t) = (2.5f64, 2.0f64, 2.5);
|
||||
let crescent = (std::f64::consts::PI * rr * rr - 2.0 * rr * rr * (d / (2.0 * rr)).acos() + d / 2.0 * (4.0 * rr * rr - d * d).sqrt()) * t;
|
||||
assert!((crescent - 24.316_34).abs() < 1e-4);
|
||||
for g in &rs {
|
||||
assert!(close(f(&g["volume_mm3"]), crescent, 0.02), "{g}");
|
||||
let c = &g["center"];
|
||||
assert!((f(&c[1]) - 40.0).abs() < 0.05 && (f(&c[2]) - 1.25).abs() < 0.01, "{g}");
|
||||
// Material only Before has is where After's hole is (beyond x = 51), and the reverse.
|
||||
match g["side"].as_str() {
|
||||
Some("only_in_a") => assert!(f(&c[0]) > 51.5, "{g}"),
|
||||
Some("only_in_b") => assert!(f(&c[0]) < 50.5, "{g}"),
|
||||
_ => panic!("{g}"),
|
||||
}
|
||||
}
|
||||
assert_ne!(rs[0]["side"], rs[1]["side"]);
|
||||
assert!(f(&r["difference"]["volume_mm3"]).abs() < 1e-3, "{}", r["difference"]);
|
||||
// Coloured: the hole walls, not the plate's faces, of which only the crescents deviate.
|
||||
let r = run(&mut s, "inspect.compare", json!({"a": "Before", "b": "After", "color": true}));
|
||||
let n = r["colored_faces"].as_u64().unwrap_or(0) as usize;
|
||||
assert!((2..=8).contains(&n), "{r}");
|
||||
let looks = &s.doc.appearances.faces;
|
||||
assert_eq!(looks.len(), n);
|
||||
for l in looks {
|
||||
assert!(((l.point.x - 51.0).powi(2) + (l.point.y - 40.0).powi(2)).sqrt() < 4.0, "{:?}", l.point);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_and_its_step_round_trips_are_identical() {
|
||||
// Curved faces too: a hole and a rounded edge.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Part"}));
|
||||
run(&mut s, "solid.cylinder", json!({"diameter": 8, "height": 10, "base": [20, 15, 0], "operation": "cut", "targets": ["Part"]}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[40, 15, 10]], "radius": 3}));
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-compare-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("part.step");
|
||||
run(&mut s, "file.export", json!({"path": path, "bodies": ["Part"]}));
|
||||
let mut inserted = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let r = run(&mut s, "file.insert_step", json!({"path": path}));
|
||||
inserted.push(r["created_bodies"][0].as_str().unwrap().to_string());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
for (a, b) in [("Part", inserted[0].as_str()), (inserted[0].as_str(), inserted[1].as_str())] {
|
||||
let r = run(&mut s, "inspect.compare", json!({"a": a, "b": b}));
|
||||
assert_eq!(r["identical"], true, "{a} vs {b}: {r}");
|
||||
assert!(f(&r["deviation"]["max_mm"]) < 0.005, "{a} vs {b}: {}", r["deviation"]);
|
||||
assert!(f(&r["difference"]["volume_mm3"]).abs() < 0.05, "{a} vs {b}: {}", r["difference"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_parameters_are_refused() {
|
||||
let mut s = two_boxes();
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "body_name": "C"}));
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"a": "A"}),
|
||||
json!({"a": "A", "b": "A"}),
|
||||
json!({"a": "A", "b": "nope"}),
|
||||
json!({"a": "A", "b": 3}),
|
||||
json!({"a": "A", "b": "C", "tolerance": 0}),
|
||||
json!({"a": "A", "b": "C", "tolerance": -1}),
|
||||
json!({"a": "A", "b": "C", "tolerance": "a lot"}),
|
||||
json!({"a": "A", "b": "C", "tolerance": 1e300}),
|
||||
json!({"a": "A", "b": "C", "spacing": 0}),
|
||||
json!({"a": "A", "b": "C", "max_regions": 0}),
|
||||
json!({"a": "A", "b": "C", "max_points": 10}),
|
||||
json!({"a": "A", "b": "C", "color": "blue"}),
|
||||
json!({"a": "A", "b": "C", "color": {"only_in_a": "#12345"}}),
|
||||
] {
|
||||
assert!(s.execute("inspect.compare", &p).is_err(), "{p}");
|
||||
}
|
||||
// Two selected bodies are the pair.
|
||||
s.selection = vec![crate::Sel::Body { name: "A".into() }, crate::Sel::Body { name: "C".into() }];
|
||||
let r = run(&mut s, "inspect.compare", json!({}));
|
||||
assert_eq!(r["a"]["name"], "A");
|
||||
assert_eq!(r["b"]["name"], "C");
|
||||
assert_eq!(r["only_in_a"]["regions"], 1, "{r}");
|
||||
assert!(close(f(&r["only_in_a"]["volume_mm3"]), 12_000.0 - 125.0, 0.01), "{r}");
|
||||
assert!(r["verdict"].as_str().unwrap_or_default().starts_with("1 region differs by more than 0.01 mm"), "{}", r["verdict"]);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ macro_rules! planned {
|
||||
|
||||
mod browser;
|
||||
mod clipboard;
|
||||
mod compare;
|
||||
mod component;
|
||||
mod component_clone;
|
||||
mod component_clone_assembly;
|
||||
@@ -256,6 +257,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(file::COMMANDS.iter());
|
||||
v.extend(metadata::COMMANDS.iter());
|
||||
v.extend(inspect::COMMANDS.iter());
|
||||
v.extend(compare::COMMANDS.iter());
|
||||
v.extend(materials::COMMANDS.iter());
|
||||
v.extend(material_properties::COMMANDS.iter());
|
||||
v.extend(libraries::COMMANDS.iter());
|
||||
|
||||
Reference in new issue
Block a user