Files
OneCAD/crates/engine/src/tests.rs
T

3164 lines
176 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::f64::consts::PI;
use serde_json::{Value, json};
use crate::*;
mod mesh_convert;
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 volume(s: &mut Session) -> f64 {
let m = run(s, "inspect.measure", json!({}));
m["total"]["volume_mm3"].as_f64().unwrap()
}
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-12)
}
#[test]
fn round_as_tall_as_a_disc_uses_up_its_wall() {
// #190: a disc 13 in radius and 2 high, its top edge rounded by 2.
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 13, "height": 2}));
run(&mut s, "solid.fillet", json!({"edges": [[0, 13, 2], [0, -13, 2]], "radius": "2 mm"}));
let (a, c) = ((1.0 - PI / 4.0) * 4.0, 2.0 * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)));
let expect = PI * 169.0 * 2.0 - a * 2.0 * PI * (13.0 - c);
assert!(rel(volume(&mut s), expect) < 2e-4, "{} vs {expect}", volume(&mut s));
}
/// #783: an edge pick far from every edge (beside the body, above it, inside it, or on another
/// body than the one named) is refused and adds nothing, instead of rounding the nearest edge;
/// a pick on an edge, or a little off it as a click lands, still works.
#[test]
fn edge_picks_far_from_every_edge_are_refused() {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 40), ("C", 80), ("D", 120)] {
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [x, 0, 0], "operation": "new", "body_name": name}));
}
run(&mut s, "solid.fillet", json!({"body": "A", "edges": [[10, 0, 10]], "radius": 1}));
run(&mut s, "solid.fillet", json!({"body": "B", "edges": [[50, 0.4, 10.3]], "radius": 1}));
let features = s.doc.features.len();
for (id, p, far) in [
("solid.fillet", json!({"body": "B", "edges": [[50, -15, 10]], "radius": 1}), "15.0"),
("solid.chamfer", json!({"body": "C", "edges": [[90, 10, 40]], "distance": 1}), "31.623 mm"),
("solid.fillet", json!({"edges": [[130, 10, 5]], "radius": 1}), "11.180 mm"),
("solid.fillet", json!({"body": "C", "edges": [[10, 20, 10]], "radius": 1}), "mm away"),
("solid.press_pull", json!({"edges": [[50, -15, 10]], "distance": 1}), "15.0"),
] {
let err = s.execute(id, &p).expect_err(&format!("{id} {p}")).to_string();
assert!(err.contains("no edge at") && err.contains(far), "{id} {p}: {err}");
}
assert_eq!(s.doc.features.len(), features);
}
#[test]
fn fillet_as_wide_as_a_face_rounds_it_off() {
// #199: 14 x 6 x 10 box, the four vertical edges at r = 3 (half the 6 mm end faces).
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 14, "width": 6, "height": 10, "corner": [14, 0, 0]}));
run(&mut s, "solid.fillet", json!({"edges": [[14, 0, 5], [28, 0, 5], [14, 6, 5], [28, 6, 5]], "radius": "3"}));
let expect = 840.0 - (4.0 - PI) * 9.0 * 10.0;
assert!(rel(volume(&mut s), expect) < 2e-4, "{} vs {expect}", volume(&mut s));
assert!(s.execute("solid.fillet", &json!({"edges": [[21, 0, 10]], "radius": 3.5})).is_err());
}
#[test]
fn join_extrude_from_a_chamfered_top_face() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name":"height","expression":"20 mm"}));
run(&mut s, "parameters.add", json!({"name":"bevel","expression":"2 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}));
let source = run(&mut s, "solid.extrude", json!({"distance": "height","name":"Stem"}))["feature"].as_u64().unwrap();
run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 20]], "distance": "bevel"}));
let base = volume(&mut s);
let id = run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20]}}))["sketch"].as_u64().unwrap();
let solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { anchor: Some(anchor), .. }, sketch } =
&s.doc.feature(id).unwrap().kind
else {
panic!("the chamfered cap retains its exact original construction support");
};
assert_eq!(anchor.target.origin_feature, source);
assert!(matches!(anchor.target.certificate, Some(solidcraft_doc::config::FaceCertificate::ExtrudeCap { high: true, .. })));
assert!(!sketch.links.is_empty());
assert!(sketch.links.iter().all(|link| link.auto_face && !link.lost));
// The default linked cap outline is radius8; no explicit fixed plane or
// duplicate manually drawn circle is required to join a pad to that face.
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
assert_eq!(s.model.state().bodies.len(), 1);
assert!(rel(volume(&mut s), base + PI * 64.0 * 5.0) < 5e-4);
for (height, bevel) in [(20., 2.), (25., 3.)] {
run(&mut s, "parameters.change", json!({"name":"height","expression":format!("{height} mm")}));
run(&mut s, "parameters.change", json!({"name":"bevel","expression":format!("{bevel} mm")}));
let expected = PI * (100. * height - (10. * bevel * bevel - bevel * bevel * bevel / 3.) + (10. - bevel) * (10. - bevel) * 5.);
let loaded = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
let mut cold = Session::new(loaded);
let warm_volume = volume(&mut s);
let cold_volume = volume(&mut cold);
assert!(rel(warm_volume, expected) < 5e-4, "{warm_volume} vs {expected}");
assert!((warm_volume - cold_volume).abs() < 1e-8);
for current in [&s, &cold] {
assert!(current.model.results.iter().all(|result| result.error.is_none() && result.warning.is_none()), "{:?}", current.model.results);
let state = current.model.state();
assert_eq!(state.bodies.len(), 1);
let solved = state.sketch(id).unwrap();
assert!((solved.plane.origin.z - height).abs() < 1e-8);
assert!(solved.sketch.links.iter().all(|link| link.auto_face && !link.lost));
let body = &state.bodies[0];
assert!(body.body.contains(solidcraft_geom::Vec3::new(0., 0., height + 4.)));
assert!(!body.body.contains(solidcraft_geom::Vec3::new(0., 0., height + 6.)));
assert!(body.body.contains(solidcraft_geom::Vec3::new(10. - bevel - 0.5, 0., height + 4.)));
assert!(!body.body.contains(solidcraft_geom::Vec3::new(10. - bevel + 0.5, 0., height + 4.)));
}
}
}
#[test]
fn a_body_cut_in_pieces_twice_gets_pieces_of_their_own_names() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 5, "height": 5, "body_name": "Bar"}));
for x in [10, 25] {
run(&mut s, "solid.box", json!({"corner": [x, -1, -1], "length": 2, "width": 7, "height": 7, "body_name": "Gap"}));
run(&mut s, "solid.combine", json!({"target": "Bar", "tools": ["Gap"], "operation": "cut"}));
}
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
let unique: std::collections::BTreeSet<&String> = names.iter().collect();
assert_eq!(unique.len(), 3, "{names:?}");
assert!(names.contains(&"Bar (2)".to_string()), "{names:?}");
}
/// A join that bridges two bodies makes them one body, not an overlap of the first and the second.
#[test]
fn extrude_a_face_where_another_body_lies_flush_on_it() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 4, "body_name": "A"}));
run(&mut s, "solid.box", json!({"corner": [10, 0, 4], "length": 10, "width": 20, "height": 1, "body_name": "B"}));
// A's top and B's bottom both lie at the point: which one is meant has to be said.
let e = s.execute("solid.extrude", &json!({"face": [15, 5, 4], "distance": "2 mm", "operation": "new"})).unwrap_err().to_string();
assert!(e.contains("A") && e.contains("B") && e.contains("face_body"), "{e}");
let r = run(&mut s, "solid.extrude", json!({"face": [15, 5, 4], "face_body": "A", "distance": "2 mm", "operation": "new", "body_name": "C"}));
let m = run(&mut s, "inspect.measure", json!({"bodies": ["C"]}));
let v = m["total"]["volume_mm3"].as_f64().unwrap();
assert!((v - 20.0 * 20.0 * 2.0).abs() < 1e-6, "{v} {r}");
let e = s.execute("solid.extrude", &json!({"face": [15, 5, 4], "face_body": "Nope", "distance": "2 mm"})).unwrap_err().to_string();
assert!(e.contains("Nope"), "{e}");
}
#[test]
fn join_bridging_two_bodies_merges_them() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new_body"}));
run(&mut s, "solid.box", json!({"length": 20, "width": 4, "height": 4, "corner": [5, 3, 3], "operation": "join"}));
let st = s.model.state();
assert_eq!(st.bodies.len(), 1, "both boxes and the bar are one body");
assert_eq!(st.bodies[0].name, "Body1");
assert_eq!(st.bodies[0].body.lumps().unwrap().len(), 1);
// 2 boxes + the bar's 10 mm outside them.
assert!(rel(volume(&mut s), 2000.0 + 10.0 * 16.0) < 1e-6);
}
/// A join touching only the second body merges into that body and leaves the first alone.
#[test]
fn join_merges_into_the_body_it_meets() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new_body"}));
// Sits on Body2's top face: a face contact, no shared volume.
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [23, 3, 10], "operation": "join"}));
let st = s.model.state();
assert_eq!(st.bodies.len(), 2);
let b1 = st.bodies.iter().find(|b| b.name == "Body1").unwrap();
let b2 = st.bodies.iter().find(|b| b.name == "Body2").unwrap();
assert_eq!(b1.body.face_count(), 6, "Body1 is untouched");
assert_eq!(b2.body.lumps().unwrap().len(), 1);
assert!(rel(volume(&mut s), 2064.0) < 1e-6);
}
/// A join that meets no body still joins the first body, as a separate lump.
#[test]
fn join_meeting_nothing_keeps_the_first_body() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [40, 0, 0], "operation": "join"}));
let st = s.model.state();
assert_eq!(st.bodies.len(), 1);
assert_eq!(st.bodies[0].body.lumps().unwrap().len(), 2);
assert!(rel(volume(&mut s), 1064.0) < 1e-6);
}
mod boolean_diagnostics;
mod component_frames;
mod mirror_planes;
mod primitive_points;
mod robustness;
mod robustness_goldens;
mod xz_plane;
/// Plan area a round of radius `r` fills at the concave corner `x` where circles (c1, r1) and
/// (c2, r2) cross: the triangle of its contacts and the corner, less the three circular segments.
fn round_gap(c1: [f64; 2], r1: f64, c2: [f64; 2], r2: f64, x: [f64; 2], r: f64) -> f64 {
let (a, b) = (r1 + r, r2 + r);
let d = (c2[0] - c1[0]).hypot(c2[1] - c1[1]);
let u = [(c2[0] - c1[0]) / d, (c2[1] - c1[1]) / d];
let along = (a * a - b * b + d * d) / (2.0 * d);
let side = if (x[0] - c1[0]) * -u[1] + (x[1] - c1[1]) * u[0] < 0.0 { -1.0 } else { 1.0 };
let off = (a * a - along * along).sqrt() * side;
let f = [c1[0] + u[0] * along - u[1] * off, c1[1] + u[1] * along + u[0] * off];
let t1 = [c1[0] + (f[0] - c1[0]) * r1 / a, c1[1] + (f[1] - c1[1]) * r1 / a];
let t2 = [c2[0] + (f[0] - c2[0]) * r2 / b, c2[1] + (f[1] - c2[1]) * r2 / b];
let angle = |c: [f64; 2], p: [f64; 2], q: [f64; 2]| {
let (v, w) = ([p[0] - c[0], p[1] - c[1]], [q[0] - c[0], q[1] - c[1]]);
((v[0] * w[0] + v[1] * w[1]) / (v[0].hypot(v[1]) * w[0].hypot(w[1]))).clamp(-1.0, 1.0).acos()
};
let segment = |r: f64, t: f64| r * r * (t - t.sin()) / 2.0;
let triangle = ((x[0] - t1[0]) * (t2[1] - t1[1]) - (t2[0] - t1[0]) * (x[1] - t1[1])).abs() / 2.0;
triangle - segment(r1, angle(c1, t1, x)) - segment(r2, angle(c2, x, t2)) - segment(r, angle(f, t2, t1))
}
/// A profile of two overlapping arcs, extruded: the vertical edges where the two walls meet are
/// straight lines between cylinders, and their rounds end on the caps across curved edges.
#[test]
fn fillet_the_edges_between_two_overlapping_cylinders() {
let (c1, r1) = ([0.0, 0.0], 17.140595088852663);
let (c2, r2) = ([10.715766135516247, 13.378055020402751], 9.666944358807896);
let (p, q) = ([1.7728134670876865, 17.048669520256194], [16.250324167975673, 5.452243981674495]);
let profile = 1087.5018766864137;
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.arc.center_point", json!({"center": c1, "start": p, "end": q}));
run(&mut s, "sketch.arc.center_point", json!({"center": c2, "start": q, "end": p}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert!(rel(si["profiles"][0]["area_mm2"].as_f64().unwrap(), profile) < 1e-9, "{si}");
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10}));
let counts = |s: &mut Session| {
let m = run(s, "inspect.measure", json!({}));
(m["total"]["faces"].as_u64(), m["total"]["edges"].as_u64(), m["total"]["vertices"].as_u64())
};
assert_eq!(counts(&mut s), (Some(4), Some(6), Some(4)));
let (gp, gq) = (round_gap(c1, r1, c2, r2, p, 6.0), round_gap(c1, r1, c2, r2, q, 6.0));
// One round: a blend face; the edge becomes two contact lines and two end arcs.
run(&mut s, "solid.fillet", json!({"edges": [[p[0], p[1], 5]], "radius": 6, "name": "Round"}));
let (v, expect) = (volume(&mut s), (profile + gp) * 10.0);
assert!(rel(v, expect) < 2e-4, "{v} vs {expect}");
assert_eq!(counts(&mut s), (Some(5), Some(9), Some(6)));
// Both corners in one fillet.
run(
&mut s,
"timeline.redefine",
json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[p[0], p[1], 5], [q[0], q[1], 5]], "radius": 6}}),
);
let (v, expect) = (volume(&mut s), (profile + gp + gq) * 10.0);
assert!(rel(v, expect) < 2e-4, "{v} vs {expect}");
assert_eq!(counts(&mut s), (Some(6), Some(12), Some(8)));
// A larger round, whose contacts are found on the long arcs from their far ends too.
run(&mut s, "timeline.redefine", json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[q[0], q[1], 5]], "radius": 13}}));
let (v, expect) = (volume(&mut s), (profile + round_gap(c1, r1, c2, r2, q, 13.0)) * 10.0);
assert!(rel(v, expect) < 2e-4, "{v} vs {expect}");
}
/// The top outline of two overlapping cylinders whose concave edges were rounded: the walls of
/// the rounds meet the cylinders along the rolling-ball contact lines, straight but not lines.
#[test]
fn fillet_and_chamfer_the_top_loop_of_rounded_overlapping_cylinders() {
use std::f64::consts::{PI, TAU};
let (big, round, h) = (25.0_f64, 5.0_f64, 10.0);
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": big}));
run(&mut s, "sketch.circle.center", json!({"center": [-big, 0], "radius": big}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": h, "profiles": [0, 1, 2]}));
// Plan view: ball centres at x = −12.5, |y| = yb; each round's arc turns by th, each big arc by
// TAU − 2·a1, and the loop turns by TAU in all.
let half = big / 2.0;
let yb = ((big + round).powi(2) - half * half).sqrt();
run(&mut s, "solid.fillet", json!({"edges": [[-half, half * 3f64.sqrt(), 5], [-half, -half * 3f64.sqrt(), 5]], "radius": round}));
let (a1, th) = (yb.atan2(half), 2.0 * (half / (big + round)).asin());
let base = volume(&mut s);
let top = [[big, 0.0, h], [-2.0 * big, 0.0, h], [-half, yb - round, h], [-half, round - yb, h]];
// Removed volume by Pappus: the corner's section swept along the loop at its centroid's offset.
let removed = |area: f64, xb: f64| area * (2.0 * (TAU - 2.0 * a1) * (big - xb) + 2.0 * th * (round + xb));
run(&mut s, "solid.fillet", json!({"edges": top, "radius": 2}));
let r: f64 = 2.0;
let expect = base - removed(r * r * (1.0 - PI / 4.0), (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)) * r);
let v = volume(&mut s);
assert!((v - expect).abs() < 1.5, "{v} vs {expect}");
let m = run(&mut s, "inspect.measure", json!({}));
assert_eq!(m["bodies"][0]["kernel"]["faces"].as_u64(), Some(10), "{m}");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "solid.chamfer", json!({"edges": top, "distance": 2}));
let expect = base - removed(2.0, 2.0 / 3.0);
let v = volume(&mut s);
assert!((v - expect).abs() < 1.5, "{v} vs {expect}");
}
/// Sketch a 40 x 30 rectangle with dimensions, extrude 20, fillet a vertical edge, cut a hole.
#[test]
fn box_fillet_cut_part() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "width", "expression": "40 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [38, 29]}));
let l: Vec<String> = r["curves"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
let corner = s.doc.sketch(s.active_sketch.unwrap()).unwrap().curves[0].clone();
let start = match corner.kind {
solidcraft_sketch::CurveKind::Line { a, .. } => s.doc.sketch(s.active_sketch.unwrap()).unwrap().points[a].id.clone(),
_ => panic!(),
};
run(&mut s, "sketch.constraint.coincident", json!({"a": start, "b": "origin"}));
run(&mut s, "sketch.dimension", json!({"entities": [l[0]], "value": "width"}));
run(&mut s, "sketch.dimension", json!({"entities": [l[1]], "value": 30}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["dof"], 0, "{si}");
assert_eq!(si["profiles"].as_array().unwrap().len(), 1);
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 20}));
assert!(rel(volume(&mut s), 24000.0) < 1e-9);
run(&mut s, "solid.fillet", json!({"edges": [[0, 0, 10]], "radius": 3}));
let filleted = 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0;
assert!(rel(volume(&mut s), filleted) < 2e-4);
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.circle.center", json!({"center": [20, 15], "radius": 5}));
run(&mut s, "solid.extrude", json!({"distance": 20, "operation": "cut"}));
let expect = filleted - PI * 25.0 * 20.0;
let v = volume(&mut s);
assert!(rel(v, expect) < 5e-4, "{v} vs {expect}");
let m = run(&mut s, "inspect.measure", json!({}));
assert_eq!(m["bodies"][0]["faces"], 8, "{m}");
// Parameter edit re-evaluates the timeline from the sketch.
let r = run(&mut s, "parameters.change", json!({"name": "width", "expression": "50"}));
assert_eq!(r["errors"].as_array().unwrap().len(), 0, "{r}");
let expect2 = 30000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0 - PI * 25.0 * 20.0;
assert!(rel(volume(&mut s), expect2) < 5e-4);
// Undo the parameter change, redo it.
run(&mut s, "edit.undo", json!({}));
assert!(rel(volume(&mut s), expect) < 5e-4);
run(&mut s, "edit.redo", json!({}));
assert!(rel(volume(&mut s), expect2) < 5e-4);
// Export STL and STEP.
let dir = std::env::temp_dir().join(format!("solidcraft-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let stl = dir.join("part.stl");
run(&mut s, "file.export", json!({"path": stl.to_string_lossy()}));
assert!(std::fs::metadata(&stl).unwrap().len() > 1000);
let step = dir.join("part.step");
run(&mut s, "file.export", json!({"path": step.to_string_lossy()}));
assert!(std::fs::read_to_string(&step).unwrap().contains("ISO-10303-21"));
let design = dir.join("part.solidcraft");
run(&mut s, "file.save_as", json!({"path": design.to_string_lossy()}));
let mut s2 = Session::default();
run(&mut s2, "doc.open", json!({"path": design.to_string_lossy()}));
assert!(rel(volume(&mut s2), expect2) < 5e-4);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn revolve_and_primitives() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
run(&mut s, "sketch.line", json!({"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true}));
run(&mut s, "solid.revolve", json!({"axis": "y"}));
assert!(rel(volume(&mut s), PI * 125.0 * 5.0) < 5e-4);
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [30, 0, 0]}));
run(&mut s, "solid.cylinder", json!({"diameter": 4, "height": 20, "base": [35, 5, -5]}));
run(&mut s, "solid.combine", json!({"target": "Body2", "tools": ["Body3"], "operation": "cut"}));
let m = run(&mut s, "inspect.measure", json!({}));
assert_eq!(m["body_count"], 2, "{m}");
assert!(rel(m["bodies"][1]["volume_mm3"].as_f64().unwrap(), 1000.0 - PI * 4.0 * 10.0) < 5e-4);
run(&mut s, "solid.chamfer", json!({"edges": [[30, 5, 10]], "distance": 1}));
run(&mut s, "timeline.rollback", json!({"position": 3}));
assert_eq!(run(&mut s, "inspect.measure", json!({}))["body_count"], 2);
run(&mut s, "timeline.rollback", json!({}));
run(&mut s, "timeline.edit", json!({"feature": "Revolve1", "set": {"angle": "180 deg"}}));
let m = run(&mut s, "inspect.measure", json!({"bodies": ["Body1"]}));
assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap(), PI * 125.0 * 5.0 / 2.0) < 5e-4);
}
/// A spherical shell (a half ring between two arcs, both radial lines on the axis) revolved on a
/// sketch plane that does not pass through the origin: the body is the shell, as it is on XY.
/// Before, built in place, it measured a third too large (truck tessellates the degenerate faces
/// on the axis reliably only with the axis through the origin).
#[test]
fn revolve_a_spherical_shell_off_the_origin() {
for (plane, z) in [(json!("XY"), 0.0), (json!({"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}), 10.0)] {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": plane, "name": "Ring"}));
run(&mut s, "sketch.line", json!({"points": [[-40, 0], [40, 0]]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [40, 0], "end": [-40, 0]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [18.5, 0], "end": [-18.5, 0]}));
run(&mut s, "sketch.finish", json!({}));
// The half ring: the larger of the two regions above the axis.
let si = run(&mut s, "sketch.inspect", json!({"sketch": "Ring"}));
let ring = si["profiles"]
.as_array()
.unwrap()
.iter()
.max_by(|a, b| a["area_mm2"].as_f64().unwrap().total_cmp(&b["area_mm2"].as_f64().unwrap()))
.unwrap()["index"]
.clone();
run(&mut s, "solid.revolve", json!({"sketch": "Ring", "profiles": [ring], "axis": "l1", "body_name": "Shell"}));
let m = run(&mut s, "inspect.measure", json!({"bodies": ["Shell"]}));
let want = 4.0 / 3.0 * PI * (40.0f64.powi(3) - 18.5f64.powi(3));
let v = m["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(v, want) < 5e-4, "plane at z {z}: {v} vs {want}");
let b = &m["bodies"][0]["bbox"];
assert!((b["min"][2].as_f64().unwrap() - (z - 40.0)).abs() < 1e-6 && (b["max"][2].as_f64().unwrap() - (z + 40.0)).abs() < 1e-6, "{b}");
}
}
#[test]
fn extrude_of_adjacent_profiles_has_whole_edges() {
// A holder from two regions of one sketch (a disc and the ring around it, both cut off by a
// chord): the chord used to stay split where the inner circle met it, leaving vertices right
// on the surface of a handle of that radius, and cutting the handle out of the holder failed.
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Holder"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 40}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 35}));
let x = (40.0f64 * 40.0 - 33.0 * 33.0).sqrt();
run(&mut s, "sketch.line", json!({"points": [[-x, -33], [x, -33]]}));
run(&mut s, "sketch.finish", json!({}));
let si = run(&mut s, "sketch.inspect", json!({"sketch": "Holder"}));
let below: Vec<Value> = si["profiles"]
.as_array()
.unwrap()
.iter()
.filter(|p| (500.0..4900.0).contains(&p["area_mm2"].as_f64().unwrap()))
.map(|p| p["index"].clone())
.collect();
assert_eq!(below.len(), 2, "{si}");
run(&mut s, "solid.extrude", json!({"sketch": "Holder", "profiles": below, "distance": 23, "body_name": "Holder"}));
let topo = run(&mut s, "model.faces", json!({"body": "Holder"}));
assert_eq!(topo["faces"].as_array().unwrap().len(), 5, "a cut-off cylinder: {topo}");
let lo = run(&mut s, "inspect.measure", json!({"bodies": ["Holder"]}))["bodies"][0]["bbox"]["min"][1].as_f64().unwrap();
run(&mut s, "solid.move", json!({"bodies": ["Holder"], "translate": [0, -5.0 - lo, 0]}));
// The handle: r35 on the Y axis with a bevelled end.
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Handle"}));
for pts in [
[[0.0, 0.0], [0.0, 230.0]],
[[0.0, 0.0], [25.0, 0.0]],
[[25.0, 0.0], [35.0, 11.5]],
[[35.0, 11.5], [35.0, 230.0]],
[[35.0, 230.0], [0.0, 230.0]],
] {
run(&mut s, "sketch.line", json!({ "points": pts }));
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.revolve", json!({"sketch": "Handle", "profiles": [0], "axis": "Y", "body_name": "Handle"}));
let before = run(&mut s, "inspect.measure", json!({"bodies": ["Holder"]}))["total"]["volume_mm3"].as_f64().unwrap();
run(&mut s, "solid.combine", json!({"target": "Holder", "tools": ["Handle"], "operation": "cut", "keep_tools": true}));
let after = run(&mut s, "inspect.measure", json!({"bodies": ["Holder"]}))["total"]["volume_mm3"].as_f64().unwrap();
assert!(after > 1000.0 && after < before * 0.5, "{before} -> {after}");
}
#[test]
fn sketch_tools_and_constraints() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "YZ"}));
run(&mut s, "sketch.rectangle.center", json!({"center": [0, 0], "corner": [10, 5]}));
run(&mut s, "sketch.rectangle.three_point", json!({"p0": [20, 0], "p1": [30, 5], "p2": [28, 10]}));
run(&mut s, "sketch.circle.two_point", json!({"p0": [40, 0], "p1": [44, 0]}));
run(&mut s, "sketch.circle.three_point", json!({"p0": [50, 0], "p1": [52, 2], "p2": [54, 0]}));
run(&mut s, "sketch.arc.three_point", json!({"start": [60, 0], "end": [64, 0], "through": [62, 2]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [70, 0], "start": [72, 0], "end": [70, 2]}));
run(&mut s, "sketch.polygon.inscribed", json!({"center": [80, 0], "radius": 3, "sides": 6}));
run(&mut s, "sketch.polygon.circumscribed", json!({"center": [90, 0], "radius": 3, "sides": 5}));
run(&mut s, "sketch.polygon.edge", json!({"p0": [100, 0], "p1": [103, 0], "sides": 4}));
run(&mut s, "sketch.slot.center_to_center", json!({"p0": [110, 0], "p1": [120, 0], "width": 4}));
run(&mut s, "sketch.slot.overall", json!({"p0": [130, 0], "p1": [140, 0], "width": 4}));
run(&mut s, "sketch.point", json!({"point": [150, 0]}));
let r = run(&mut s, "sketch.line", json!({"points": [[0, 20], [10, 21], [20, 30]], "ids": ["m1", "m2"]}));
assert_eq!(r["curves"], json!(["m1", "m2"]));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "m1"}));
run(&mut s, "sketch.constraint.perpendicular", json!({"a": "m1", "b": "m2"}));
let _ = s.execute("sketch.dimension", &json!({"entities": ["m1", "m2"], "type": "angle", "value": "90 deg"}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["status"], "solved", "{}", si["failing"]);
// Over-constraining is rejected and leaves the sketch unchanged.
let before = s.doc.clone();
assert!(s.execute("sketch.constraint.parallel", &json!({"a": "m1", "b": "m2"})).is_err());
assert_eq!(*s.doc, *before);
// Profiles: rectangles, circles, closed arcs/polygons/slots.
let n = si["profiles"].as_array().unwrap().len();
assert!(n >= 8, "{n} profiles");
run(&mut s, "sketch.construction", json!({"curves": ["m1"]}));
run(&mut s, "sketch.delete", json!({"entities": ["m2"]}));
run(&mut s, "sketch.constraint.fix", json!({"entity": "m1"}));
run(&mut s, "sketch.move_point", json!({"point": "m1.end", "to": [15, 20]}));
run(&mut s, "sketch.finish", json!({}));
assert!(s.execute("sketch.line", &json!({"points": [[0, 0], [1, 1]]})).is_err(), "needs an active sketch");
}
/// The recipe format of the Fusion oracle (01-box) drives the same commands.
#[test]
fn script_runs_commands() {
let mut s = Session::default();
let script = json!({"commands": [
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.line", "params": {"points": [[0, 0], [40, 0], [40, 30], [0, 30]], "closed": true, "ids": ["l1", "l2", "l3", "l4"]}},
{"command": "solid.extrude", "params": {"distance": 20, "profiles": [{"loops": [{"outer": true, "curves": ["l1", "l2", "l3", "l4"]}]}], "body_name": "Box"}},
]});
let out = s.run_script(&script).unwrap();
assert_eq!(out.len(), 3);
let m = run(&mut s, "inspect.measure", json!({}));
assert_eq!(m["bodies"][0]["name"], "Box");
assert_eq!((m["total"]["faces"].as_u64(), m["total"]["edges"].as_u64(), m["total"]["vertices"].as_u64()), (Some(6), Some(12), Some(8)));
assert!(s.run_script(&json!([{"command": "Nope"}])).is_err());
}
fn hostile_values() -> Vec<Value> {
vec![
Value::Null,
json!(true),
json!(-1),
json!(0),
json!(1e308),
json!(-1e308),
json!(""),
json!("nope"),
json!("1/0"),
json!("((((((((((1"),
json!([]),
json!([1e308, -1e308]),
json!([0, 0, 0]),
json!([[0, 0], [0, 0]]),
json!(["origin", "origin"]),
json!({}),
json!({"x": 1}),
json!([{"point": [0, 0]}]),
json!([{"body": "Body1", "index": 999999}]),
]
}
/// Every command, every parameter name it documents, every hostile value: no panic escapes and
/// no command panics internally.
#[test]
fn hostile_params_never_panic() {
let keys = [
"plane",
"offset",
"name",
"sketch",
"points",
"closed",
"construction",
"ids",
"infer",
"p0",
"p1",
"p2",
"center",
"corner",
"radius",
"diameter",
"start",
"end",
"through",
"sides",
"angle",
"width",
"point",
"id",
"entities",
"type",
"value",
"line",
"mode",
"a",
"b",
"entity",
"fixed",
"curves",
"to",
"distance",
"profiles",
"direction",
"distance2",
"start_offset",
"operation",
"targets",
"body_name",
"axis",
"length",
"height",
"base",
"major",
"minor",
"edges",
"body",
"target",
"tools",
"keep_tools",
"bodies",
"translate",
"expression",
"unit",
"comment",
"delete",
"features",
"feature",
"position",
"suppressed",
"set",
"items",
"add",
"path",
"format",
"ascii",
"measure",
"refs",
"link",
"project_edges",
"mid",
"near",
"factor",
"copy",
"count",
"spacing",
"count2",
"spacing2",
"dir",
"side",
"chain",
"at",
"curve",
"minor_radius",
"apex",
"rho",
"text",
"lines",
"driven",
"from",
"datum",
"tolerance",
"face",
"new_name",
"favorite",
"favorites",
"filter",
"kind",
"prefix",
];
// Commands are independent; worker threads take them one at a time (their costs vary a lot).
let ids: Vec<&str> = command_specs()
.iter()
.map(|spec| spec.id)
.filter(|id| !matches!(*id, "doc.open" | "file.save" | "file.save_as" | "file.export" | "file.save_mesh" | "sketch.export_dxf")) // file system side effects are covered by their own tests
.collect();
let next = std::sync::atomic::AtomicUsize::new(0);
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
let internal: Vec<String> = std::thread::scope(|scope| {
let workers: Vec<_> = (0..threads)
.map(|_| {
scope.spawn(|| {
let mut internal = Vec::new();
while let Some(&id) = ids.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
for v in hostile_values() {
for k in keys {
if k == "path" && id.starts_with("parameters.") {
continue; // parameter files: covered by their own tests (no stray files)
}
let mut s = Session::default();
// A model with a sketch, a body and an active sketch, so most commands get far.
let _ = s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10}));
let _ = s.execute("sketch.create", &json!({"plane": "XY"}));
let _ = s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [5, 5]}));
let p = json!({ k: v.clone() });
if let Err(EngineError::Internal(id, msg)) = s.execute(id, &p) {
internal.push(format!("{id} {p}: {msg}"));
}
// Whole-value hostility too.
if let Err(EngineError::Internal(id, msg)) = s.execute(id, &v) {
internal.push(format!("{id} {v}: {msg}"));
}
}
}
}
internal
})
})
.collect();
workers.into_iter().flat_map(|w| w.join().unwrap()).collect()
});
assert!(internal.is_empty(), "commands panicked:\n{}", internal.join("\n"));
}
#[test]
fn registry_is_consistent() {
let specs = command_specs();
let mut ids: Vec<&str> = specs.iter().map(|c| c.id).collect();
ids.sort();
let n = ids.len();
ids.dedup();
assert_eq!(ids.len(), n, "duplicate command ids");
assert_eq!(find_command("Solid.Extrude").map(|c| c.id), Some("solid.extrude"), "case-insensitive lookup");
let s = Session::default();
assert!(!find_command("sketch.line").unwrap().info(&s).enabled);
}
/// Only SolidCraft's own dotted ids name commands; other ids are unknown commands.
#[test]
fn only_own_ids_name_commands() {
for old in ["Extrude", "SketchCreate", "FusionFilletEdgesCommand"] {
assert!(find_command(old).is_none(), "{old}");
let mut s = Session::default();
let err = s.execute(old, &serde_json::json!({})).unwrap_err();
assert!(matches!(err, crate::EngineError::UnknownCommand(_)), "{old}: {err}");
}
}
#[test]
fn sample_design_builds() {
let mut s = Session::default();
s.run_script(&crate::sample::script()).unwrap();
let m = run(&mut s, "inspect.measure", json!({}));
assert_eq!(m["body_count"], 1, "{m}");
let plate = 80.0 * 50.0 * 8.0 - 4.0 * (36.0 - PI * 9.0) * 8.0 - 2.0 * PI * 16.0 * 8.0;
let boss = PI * 144.0 * 14.0;
let bore = PI * 36.0 * 22.0;
let v = m["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(v, plate + boss - bore) < 1e-3, "{v} vs {}", plate + boss - bore);
}
#[test]
fn sample_edges_chain_and_faces() {
let mut s = Session::default();
s.run_script(&crate::sample::script()).unwrap();
let st = s.model.state();
let m = st.bodies[0].mesh();
let e = (0..m.edges.len()).find(|i| m.edges[*i].iter().all(|p| (p.z - 8.0).abs() < 1e-6 && p.y.abs() < 1e-6)).expect("front top edge");
let chain = m.tangent_chain(e, 2f64.to_radians());
// The plate's top outline: 4 lines and 4 corner arcs (a boolean may leave an edge twice,
// once per face; count distinct ones).
let mut mids: Vec<solidcraft_geom::Vec3> = Vec::new();
for i in &chain {
let p = &m.edges[*i];
let mid = p[p.len() / 2];
assert!(p.iter().all(|q| (q.z - 8.0).abs() < 1e-6), "{p:?}");
if !mids.iter().any(|q| q.dist(mid) < 1e-6) {
mids.push(mid);
}
}
assert_eq!(mids.len(), 8, "{chain:?}");
// Every edge bounds a face, and the top face has the outline and the hole rims.
assert!(m.edge_faces.iter().all(|f| !f.is_empty()));
}
/// timeline.inspect returns a feature's definition in the shape timeline.edit's `set` takes, so
/// an agent can read the field names instead of guessing them.
#[test]
fn timeline_inspect_shows_what_timeline_edit_changes() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "Slab"}));
run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 10]], "radius": 2, "name": "Round"}));
let i = run(&mut s, "timeline.inspect", json!({"feature": "Slab"}));
assert_eq!(i["type"], "ExtrudeFeature", "{i}");
assert_eq!(i["definition"]["extent"]["distance"], "10", "{i}");
assert_eq!(i["definition"]["operation"], "new_body", "{i}");
assert!(i["definition"].get("type").is_none() && i["definition"].get("param_names").is_none(), "{i}");
assert_eq!(i["inputs"][0]["label"], "Distance", "{i}");
assert_eq!(i["inputs"][0]["unit"], "mm", "{i}");
let d = i["inputs"][0]["param"].as_str().unwrap().to_string();
// The definition's field names are what timeline.edit takes.
run(&mut s, "timeline.edit", json!({"feature": "Slab", "set": {"extent": {"distance": "12"}}}));
let i = run(&mut s, "timeline.inspect", json!({"feature": i["id"]}));
assert_eq!(i["definition"]["extent"]["distance"], "12", "{i}");
assert_eq!(i["inputs"][0]["param"], d.as_str(), "{i}");
let f = run(&mut s, "timeline.inspect", json!({"feature": "Round"}));
assert_eq!(f["definition"]["radius"], "2", "{f}");
run(&mut s, "timeline.edit", json!({"feature": "Round", "set": {"radius": "3"}}));
assert_eq!(run(&mut s, "timeline.inspect", json!({"feature": "Round"}))["definition"]["radius"], "3");
// A sketch leaves its geometry out (sketch.inspect has it) and says so.
let k = run(&mut s, "timeline.inspect", json!({"feature": "Base"}));
assert!(k["definition"].get("sketch").is_none() && k["definition"].get("plane").is_some(), "{k}");
assert_eq!(k["omitted"][0]["field"], "sketch", "{k}");
// Read-only: no undo step; bad input is refused.
let undo = s.undo.len();
run(&mut s, "timeline.inspect", json!({"feature": "Slab"}));
assert_eq!(s.undo.len(), undo);
for bad in [json!({}), json!({"feature": "Nope"}), json!({"feature": [1]}), json!({"feature": 1e300}), json!({"feature": null})] {
assert!(s.execute("timeline.inspect", &bad).is_err(), "{bad}");
}
}
#[test]
fn timeline_edits_reresolve_references() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "name": "Base"}));
run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 20]], "radius": 3, "name": "Round"}));
run(&mut s, "solid.hole", json!({"position": [20, 15, 20], "diameter": 6, "name": "Bore"}));
let fillet = |l: f64| (9.0 - 9.0 * PI / 4.0) * l;
let expect = |l: f64, h: f64| l * 30.0 * h - fillet(l) - PI * 9.0 * h;
assert!(rel(volume(&mut s), expect(40.0, 20.0)) < 1e-3);
// Taller box: the fillet's edge point is now 5 mm below the edge; the edge is found by its
// name (no warning) and the hole moves up with the top face.
run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"height": "25"}}));
assert!(rel(volume(&mut s), expect(40.0, 25.0)) < 1e-3, "{}", volume(&mut s));
let round = s.doc.find_feature("Round").map(|f| f.id).unwrap();
assert_eq!(s.model.result(round).and_then(|r| r.warning.clone()), None);
// Longer box: the point still lies on the (longer) edge.
run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"length": "60"}}));
assert!(rel(volume(&mut s), expect(60.0, 25.0)) < 1e-3, "{}", volume(&mut s));
// Reorder: the fillet can't go before the box; the hole can go before the fillet.
let e = s.execute("timeline.reorder", &json!({"feature": "Round", "position": 0})).unwrap_err().to_string();
assert!(e.contains("breaks"), "{e}");
run(&mut s, "timeline.reorder", json!({"feature": "Bore", "position": 1}));
let names: Vec<&str> = s.doc.features.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["Base", "Bore", "Round"]);
assert!(rel(volume(&mut s), expect(60.0, 25.0)) < 1e-3);
// Redefine the fillet in place with a new radius; it keeps its name and position.
run(&mut s, "timeline.redefine", json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[30, 0, 25]], "radius": 4}}));
let f4 = (16.0 - 16.0 * PI / 4.0) * 60.0;
assert!(rel(volume(&mut s), 60.0 * 30.0 * 25.0 - f4 - PI * 9.0 * 25.0) < 1e-3);
assert_eq!(s.doc.features.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["Base", "Bore", "Round"]);
// Dependents and roll back.
let d = run(&mut s, "timeline.dependents", json!({"feature": "Base"}));
assert_eq!(d["would_fail"].as_array().map(|a| a.len()), Some(2), "{d}");
run(&mut s, "timeline.roll_to", json!({"feature": "Base"}));
assert!(rel(volume(&mut s), 60.0 * 30.0 * 25.0) < 1e-6);
run(&mut s, "timeline.roll_to", json!({}));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(rel(volume(&mut s), 60.0 * 30.0 * 25.0 - f4 - PI * 9.0 * 25.0) < 1e-3);
}
/// A 40 × 30 × 20 box exported to a STEP file in a fresh temp directory.
fn step_box_file(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-step-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
let p = dir.join("Block.step");
run(&mut s, "file.export", json!({"path": p.to_string_lossy()}));
p
}
/// File → Open of a STEP file gives an Import base feature, and features work on its faces.
#[test]
fn step_open_then_model_on_it() {
let p = step_box_file("open");
let mut s = Session::default();
let r = run(&mut s, "doc.open", json!({"path": p.to_string_lossy()}));
assert_eq!(r["features"], 1, "{r}");
assert_eq!(s.doc.name, "Block");
assert_eq!(s.doc.features[0].kind.type_name(), "BaseFeature");
assert!(matches!(&s.doc.features[0].kind, doc::FeatureKind::Import { file, .. } if file == "Block.step"));
assert!(s.is_dirty() && s.path.is_none() && s.undo.is_empty());
assert!(rel(volume(&mut s), 24000.0) < 1e-6);
let corner = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["min"].clone();
let (x0, y0, z0) = (corner[0].as_f64().unwrap(), corner[1].as_f64().unwrap(), corner[2].as_f64().unwrap());
let at = |x: f64, y: f64, z: f64| json!([x0 + x, y0 + y, z0 + z]);
// Fillet a vertical edge, drill a through hole, cut a pocket sketched on the top face.
run(&mut s, "solid.fillet", json!({"edges": [at(0.0, 0.0, 10.0)], "radius": 3}));
let mut want = 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0;
assert!(rel(volume(&mut s), want) < 2e-4);
run(&mut s, "solid.hole", json!({"position": at(28.0, 15.0, 20.0), "diameter": 6}));
want -= PI * 9.0 * 20.0;
assert!(rel(volume(&mut s), want) < 5e-4, "{} vs {want}", volume(&mut s));
// Modified imported faces no longer have an immutable construction certificate; the top
// attaches by its exact history name instead.
let top = run(&mut s, "sketch.create", json!({"plane": {"face": at(10.0, 15.0, 20.0)}}))["sketch"].as_u64().unwrap();
assert!(matches!(
s.placed_document().sketch_face_attachment(top, &s.model),
doc::FaceAttachmentStatus::Healthy {
target: doc::config::ConfigFaceTarget { certificate: Some(doc::config::FaceCertificate::Lineage), .. },
..
}
));
let c = [x0 + 10.0, y0 + 15.0];
// Sketch coordinates on the face are relative to the model origin projected onto it.
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [c[0] - 4.0, c[1] - 4.0], "p1": [c[0] + 4.0, c[1] + 4.0]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": -5, "operation": "cut"}));
want -= 64.0 * 5.0;
assert!(rel(volume(&mut s), want) < 5e-4, "{} vs {want}", volume(&mut s));
assert_eq!(s.model.results.iter().filter(|r| r.error.is_some()).count(), 0);
// The design saves and reopens without the STEP file.
let before = volume(&mut s);
let design = p.with_extension("solidcraft");
run(&mut s, "file.save_as", json!({"path": design.to_string_lossy()}));
std::fs::remove_file(&p).unwrap();
let mut s2 = Session::default();
run(&mut s2, "doc.open", json!({"path": design.to_string_lossy()}));
assert!(rel(volume(&mut s2), before) < 1e-6);
}
/// Shell an imported body; insert STEP into an existing design; extension case; bad files.
#[test]
fn step_insert_shell_and_errors() {
let p = step_box_file("insert");
let upper = p.with_file_name("BLOCK.STP");
std::fs::copy(&p, &upper).unwrap();
let mut s = Session::default();
run(&mut s, "doc.open", json!({"path": upper.to_string_lossy()}));
let top = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["max"].clone();
let (x1, y1, z1) = (top[0].as_f64().unwrap(), top[1].as_f64().unwrap(), top[2].as_f64().unwrap());
run(&mut s, "solid.shell", json!({"faces": [[x1 - 20.0, y1 - 15.0, z1]], "thickness": 2}));
let walls = 24000.0 - 36.0 * 26.0 * 18.0;
assert!(rel(volume(&mut s), walls) < 5e-4, "{}", volume(&mut s));
let mut d = Session::default();
run(&mut d, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [100, 0, 0]}));
let r = run(&mut d, "file.insert_step", json!({"path": p.to_string_lossy()}));
assert_eq!(r["bodies"].as_array().unwrap().len(), 1, "{r}");
run(&mut d, "file.insert_step", json!({"path": p.to_string_lossy()}));
let names: Vec<String> = d.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names.len(), 3);
let mut uniq = names.clone();
uniq.dedup();
assert_eq!(uniq.len(), 3, "{names:?}");
assert!(rel(volume(&mut d), 125.0 + 48000.0) < 1e-6);
run(&mut d, "edit.undo", json!({}));
assert_eq!(d.model.state().bodies.len(), 2);
let junk = p.with_file_name("junk.step");
std::fs::write(&junk, "ISO-10303-21;\nDATA;\n#1=CARTESIAN_POINT('',(0.,0.,0.));\nENDSEC;\nEND-ISO-10303-21;\n").unwrap();
let before = d.doc.clone();
assert!(d.execute("file.insert_step", &json!({"path": junk.to_string_lossy()})).is_err());
assert!(d.execute("doc.open", &json!({"path": junk.to_string_lossy()})).is_err());
assert!(d.execute("file.insert_step", &json!({"path": "/nonexistent/a.step"})).is_err());
assert!(d.execute("file.insert_step", &json!({"path": p.with_extension("stl").to_string_lossy()})).is_err());
assert_eq!(*d.doc, *before);
}
fn body_volume(st: &solidcraft_doc::ModelState) -> f64 {
st.bodies.iter().map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).sum()
}
/// Previews evaluate on a scratch copy: the document, undo history and model are untouched.
#[test]
fn preview_has_no_side_effects() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(&mut s, "sketch.finish", json!({}));
let doc = s.doc.clone();
let (undo, rev) = (s.undo.len(), s.revision);
let p = s.preview(&[("solid.extrude".into(), json!({"distance": 15}))]).unwrap();
assert!(p.before.bodies.is_empty());
assert!(rel(body_volume(&p.after), 40.0 * 30.0 * 15.0) < 1e-9);
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!((s.undo.len(), s.revision), (undo, rev));
assert!(s.model.state().bodies.is_empty());
// Errors come back as errors and change nothing either.
assert!(s.preview(&[("solid.extrude".into(), json!({"distance": "nonsense"}))]).is_err());
assert!(Arc::ptr_eq(&doc, &s.doc));
// Fillet preview on a real body.
run(&mut s, "solid.extrude", json!({"distance": 20}));
let (doc, undo) = (s.doc.clone(), s.undo.len());
let p = s.preview(&[("solid.fillet".into(), json!({"edges": [[0, 0, 10]], "radius": 3}))]).unwrap();
let filleted = 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0;
assert!(rel(body_volume(&p.after), filleted) < 2e-4);
assert!(rel(body_volume(&p.before), 24000.0) < 1e-9);
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(s.undo.len(), undo);
}
/// Previewing an edit shows the rebuilt feature although the timeline is rolled back to it.
#[test]
fn preview_of_an_edit() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(&mut s, "sketch.finish", json!({}));
let r = run(&mut s, "solid.extrude", json!({"distance": 20}));
let fid = r["feature"].as_u64().unwrap();
let idx = s.doc.feature_index(fid).unwrap();
run(&mut s, "timeline.roll_to", json!({"position": idx}));
assert!(s.model.state().bodies.is_empty());
let doc = s.doc.clone();
let p = s.preview(&[("timeline.redefine".into(), json!({"feature": fid, "command": "solid.extrude", "params": {"distance": 10}}))]).unwrap();
assert!(rel(body_volume(&p.after), 12000.0) < 1e-9);
assert!(Arc::ptr_eq(&doc, &s.doc));
}
/// `operation: "auto"`: into a body cuts; additions default to Join when a solid exists.
#[test]
fn extrude_auto_operation() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
// A face pushed in (negative distance) cuts.
let p = json!({"face": [20, 15, 20], "distance": -5, "operation": "auto"});
assert_eq!(auto_operation(&s, &p), Some("cut"));
run(&mut s, "solid.extrude", p);
assert!(rel(volume(&mut s), 40.0 * 30.0 * 15.0) < 1e-6);
// Pulled out, it joins.
assert_eq!(auto_operation(&s, &json!({"face": [20, 15, 15], "distance": 5})), Some("join"));
// A sketch on XY under the box: up goes into it (cut), down grows out of it (join), and a
// profile beside the box still defaults to Join (New Body remains explicit).
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [15, 15]}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [60, 5], "p1": [70, 15]}));
run(&mut s, "sketch.finish", json!({}));
let at = |x: f64, d: f64| json!({"profiles": [{"point": [x, 10]}], "distance": d});
assert_eq!(auto_operation(&s, &at(10.0, 8.0)), Some("cut"));
assert_eq!(auto_operation(&s, &at(10.0, -8.0)), Some("join"));
assert_eq!(auto_operation(&s, &at(65.0, 8.0)), Some("join"));
run(&mut s, "solid.extrude", json!({"profiles": [{"point": [10, 10]}], "distance": 8, "operation": "auto"}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 15.0 - 100.0 * 8.0) < 1e-6);
}
/// Additive extrusions default to joining even when only a profile side touches material.
#[test]
fn auto_extrude_joins_side_contact_elbow_and_keeps_new_body_explicit() {
for operation in ["auto", "new"] {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 30}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [10, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 20], "p1": [30, 30]}));
run(&mut s, "sketch.finish", json!({}));
// The profile centre is outside the vertical arm. Contact is along its
// y=10 side, not behind the centre along the extrusion's X direction.
let p = json!({"distance": 10, "to": [0, 15, 25], "operation": operation});
assert_eq!(auto_operation(&s, &p), Some("join"));
run(&mut s, "solid.extrude", p);
assert!((volume(&mut s) - 5000.0).abs() < 1e-6);
let st = s.model.state();
assert_eq!(st.bodies.len(), if operation == "auto" { 1 } else { 2 });
if operation == "auto" {
let body = &st.bodies.first().unwrap().body;
assert!(body.validity().is_empty());
assert_eq!(body.lumps().unwrap().len(), 1);
assert_eq!(body.face_count(), 8, "the two flat elbow sides must be single faces");
assert!(body.contains(geom::Vec3::new(5.0, 10.0, 25.0)));
}
}
}
#[test]
fn auto_extrude_joins_corner_screw_boss() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 30}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 30], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 4}));
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 1}));
run(&mut s, "sketch.finish", json!({}));
let p = json!({"profiles": [{"point": [12, 10]}], "distance": 5, "operation": "auto"});
assert_eq!(auto_operation(&s, &p), Some("join"));
run(&mut s, "solid.extrude", p);
assert!(rel(volume(&mut s), 3000.0 + PI * 15.0 * 5.0) < 1e-3);
let st = s.model.state();
assert_eq!(st.bodies.len(), 1);
let body = &st.bodies.first().unwrap().body;
assert_eq!(body.lumps().unwrap().len(), 1);
assert!(body.validity().is_empty());
assert!(!body.contains(geom::Vec3::new(10.0, 10.0, 32.0)), "the screw hole stays open");
}
#[test]
fn auto_extrude_starts_new_body_in_empty_component() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "component.create", json!({"name": "Other"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 2}));
run(&mut s, "sketch.finish", json!({}));
assert_eq!(auto_operation(&s, &json!({"distance": 5})), Some("new"));
assert_eq!(auto_operation(&s, &json!({"distance": 5, "participants": ["missing"]})), Some("new"));
}
/// Measuring picked geometry: distance between faces, angle, edge length.
#[test]
fn measure_items() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
let st = s.model.state();
let m = st.body("Body1").unwrap().mesh();
// Find the faces by their centroid: top (z = 20), bottom (z = 0), front (y = 0).
let face_at = |z: Option<f64>, y: Option<f64>| -> usize {
(0..6)
.find(|f| {
let tris: Vec<_> = m.triangles.iter().zip(&m.tri_face).filter(|(_, tf)| **tf as usize == *f).filter_map(|(t, _)| m.tri(t)).collect();
tris.iter().all(|t| t.iter().all(|p| z.is_none_or(|z| (p.z - z).abs() < 1e-9) && y.is_none_or(|y| (p.y - y).abs() < 1e-9)))
})
.unwrap()
};
let (ft, fb, ff) = (face_at(Some(20.0), None), face_at(Some(0.0), None), face_at(None, Some(0.0)));
let face = |i: usize, p: [f64; 3]| json!({"type": "face", "body": "Body1", "index": i, "point": p});
let r = run(&mut s, "inspect.measure", json!({"items": [face(ft, [20.0, 15.0, 20.0]), face(fb, [20.0, 15.0, 0.0])]}));
assert!((r["distance_mm"].as_f64().unwrap() - 20.0).abs() < 1e-9, "{r}");
assert!((r["angle_deg"].as_f64().unwrap() - 180.0).abs() < 1e-6, "{r}");
let r = run(&mut s, "inspect.measure", json!({"items": [face(ft, [20.0, 15.0, 20.0]), face(ff, [20.0, 0.0, 10.0])]}));
assert!(r["distance_mm"].as_f64().unwrap() < 1e-9);
assert!((r["angle_deg"].as_f64().unwrap() - 90.0).abs() < 1e-6);
let r = run(&mut s, "inspect.measure", json!({"items": [face(ft, [20.0, 15.0, 20.0])]}));
assert!((r["items"][0]["area_mm2"].as_f64().unwrap() - 1200.0).abs() < 1e-6, "{r}");
let v = json!({"type": "vertex", "body": "Body1", "point": [0, 0, 0]});
let w = json!({"type": "vertex", "body": "Body1", "point": [40, 30, 20]});
let r = run(&mut s, "inspect.measure", json!({"items": [v, w]}));
assert!((r["distance_mm"].as_f64().unwrap() - (1600.0f64 + 900.0 + 400.0).sqrt()).abs() < 1e-9);
assert!(s.execute("inspect.measure", &json!({"items": []})).is_err());
assert!(s.execute("inspect.measure", &json!({"items": [{"type": "nonsense"}]})).is_err());
}
/// Section Analysis sets and clears the view cut without touching the design or history.
#[test]
fn section_analysis() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
let (doc, undo) = (s.doc.clone(), s.undo.len());
run(&mut s, "inspect.section", json!({"plane": "XZ", "offset": 15}));
let (o, n) = s.section.unwrap();
assert!((o - n * 15.0).len() < 1e-12 && (n.y.abs() - 1.0).abs() < 1e-12);
run(&mut s, "inspect.section", json!({"plane": {"origin": [0, 0, 5], "normal": [0, 0, 2]}, "flip": true}));
let (o, n) = s.section.unwrap();
assert!((o.z - 5.0).abs() < 1e-12 && (n.z + 1.0).abs() < 1e-12);
assert!(s.execute("inspect.section", &json!({"plane": {"normal": [0, 0, 0]}})).is_err());
run(&mut s, "inspect.section", json!({"clear": true}));
assert!(s.section.is_none());
// XZ's normal is +Y: an offset moves the cut to +Y, `at` places it at a world Y, and the
// result says where the cut landed either way.
let r = run(&mut s, "inspect.section", json!({"plane": "XZ", "offset": 10}));
assert_eq!((r["section"]["axis"].as_str(), r["section"]["at"].as_f64()), (Some("Y"), Some(10.0)), "{r}");
for (plane, at, flip) in [("XZ", -282.5, false), ("XZ", 7.0, true), ("XY", 3.0, false), ("YZ", -4.0, true)] {
let r = run(&mut s, "inspect.section", json!({"plane": plane, "at": at, "flip": flip}));
assert_eq!(r["section"]["at"].as_f64(), Some(at), "{plane} {r}");
let (o, _) = s.section.unwrap();
assert!(((o.x + o.y + o.z) - at).abs() < 1e-12, "{plane} {o:?}");
}
assert!(s.execute("inspect.section", &json!({"plane": "XZ", "at": 1, "offset": 1})).is_err());
assert!(s.execute("inspect.section", &json!({"plane": {"normal": [0, 1, 0]}, "at": 1})).is_err());
run(&mut s, "inspect.section", json!({"clear": true}));
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(s.undo.len(), undo);
}
/// Press Pull: positive pulls a face out, negative pushes it in (a cut), edges get a fillet.
#[test]
fn press_pull() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "solid.press_pull", json!({"face": [20, 15, 20], "distance": 5}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 25.0) < 1e-6);
run(&mut s, "solid.press_pull", json!({"face": [20, 15, 25], "distance": "-10"}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 15.0) < 1e-6);
assert!(s.execute("solid.press_pull", &json!({"face": [20, 15, 15], "distance": 0})).is_err());
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "solid.press_pull", json!({"edges": [[0, 0, 7]], "distance": 3}));
assert!(rel(volume(&mut s), 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0) < 2e-4);
}
/// Extruding a planar body face (press-pull style): the face boundary becomes a sketch profile.
#[test]
fn extrude_a_body_face() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "solid.extrude", json!({"face": [20, 15, 20], "distance": 15, "operation": "join"}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 35.0) < 1e-6);
assert!(s.active_sketch.is_none());
// A face with a round hole: the hole stays open (a new body: joins of curved bodies on a
// shared face are a kernel gap for now).
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.circle.center", json!({"center": [20, 15], "radius": 5}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 20, "operation": "cut"}));
let before = volume(&mut s);
run(&mut s, "solid.extrude", json!({"face": [5, 5, 20], "distance": 10, "operation": "new"}));
let v = volume(&mut s);
assert!(rel(v - before, (1200.0 - PI * 25.0) * 10.0) < 1e-3, "{v} {before}");
assert!(s.execute("solid.extrude", &json!({"face": [500, 5, 20], "distance": 10})).is_err());
}
/// Export 3MF; open and insert it as mesh bodies; mesh bodies move and export but refuse
/// solid features.
#[test]
fn threemf_export_open_insert() {
let dir = std::env::temp_dir().join(format!("solidcraft-3mf-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 10, "base": [100, 0, 0]}));
let p = dir.join("Parts.3MF");
run(&mut s, "file.export", json!({"path": p.to_string_lossy()}));
let want = volume(&mut s);
// With face tags it is still a 3MF any reader opens, with the same bodies.
let tagged = dir.join("tagged.3mf");
let r = run(&mut s, "file.export", json!({"path": tagged.to_string_lossy(), "format": "3mf-tagged"}));
assert_eq!(r["format"], "ThreeMfTagged", "{r}");
let mut t = Session::default();
let r = run(&mut t, "doc.open", json!({"path": tagged.to_string_lossy()}));
assert_eq!(r["bodies"].as_array().unwrap().len(), 2, "{r}");
assert!(rel(volume(&mut t), want) < 2e-3, "{} vs {want}", volume(&mut t));
let mut m = Session::default();
let r = run(&mut m, "doc.open", json!({"path": p.to_string_lossy()}));
assert_eq!(r["bodies"].as_array().unwrap().len(), 2, "{r}");
assert_eq!(m.doc.features[0].kind.type_name(), "MeshFeature");
assert!(rel(volume(&mut m), want) < 2e-3, "{} vs {want}", volume(&mut m));
let meas = run(&mut m, "inspect.measure", json!({}));
assert!(meas["bodies"][0]["face_types"]["mesh"].as_u64().unwrap() >= 12, "{meas}");
let e = m.execute("solid.fillet", &json!({"edges": [[0, 0, 10]], "radius": 2})).unwrap_err().to_string();
assert!(e.contains("mesh"), "{e}");
assert!(m.execute("file.export", &json!({"path": dir.join("x.step").to_string_lossy()})).is_err());
let stl = dir.join("again.stl");
run(&mut m, "file.export", json!({"path": stl.to_string_lossy()}));
// Insert the STL into the solid design; names stay unique.
run(&mut s, "file.insert_mesh", json!({"path": stl.to_string_lossy()}));
assert_eq!(s.model.state().bodies.len(), 3);
assert!(rel(volume(&mut s), 2.0 * want) < 2e-3);
assert!(s.execute("file.insert_mesh", &json!({"path": dir.join("x.step").to_string_lossy()})).is_err());
let junk = dir.join("junk.3mf");
std::fs::write(&junk, b"PK\x03\x04 not really").unwrap();
let before = s.doc.clone();
assert!(s.execute("file.insert_mesh", &json!({"path": junk.to_string_lossy()})).is_err());
assert!(s.execute("doc.open", &json!({"path": junk.to_string_lossy()})).is_err());
assert_eq!(*s.doc, *before);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn holes_at_sketch_points_threads_and_components() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 60, "width": 40, "height": 10, "name": "Plate"}));
run(&mut s, "sketch.create", json!({"plane": {"face": [30, 20, 10]}, "name": "HolePoints"}));
let mut ids = Vec::new();
for (k, (x, y)) in [(10.0, 10.0), (50.0, 10.0), (50.0, 30.0), (10.0, 30.0)].into_iter().enumerate() {
let id = format!("h{k}");
run(&mut s, "sketch.point", json!({ "point": [x, y], "id": id }));
ids.push(id);
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.hole", json!({"sketch": "HolePoints", "points": ids, "diameter": 5, "thread": "M6", "name": "Holes"}));
let v = volume(&mut s);
assert!(rel(v, 60.0 * 40.0 * 10.0 - 4.0 * PI * 6.25 * 10.0) < 1e-3, "{v}");
let t = run(&mut s, "model.threads", json!({}));
let threads = t["threads"].as_array().unwrap();
assert_eq!(threads.len(), 4, "{t}");
assert_eq!(threads[0]["designation"], "M6");
assert_eq!(threads[0]["internal"], true);
// A cosmetic thread on a shaft; designation from its size.
run(&mut s, "solid.cylinder", json!({"base": [100, 0, 0], "radius": 4, "height": 20, "name": "Shaft", "body_name": "Shaft"}));
let th = run(&mut s, "solid.thread", json!({"face": [104, 0, 10], "length": 12}));
assert_eq!(th["thread"]["designation"], "M8", "{th}");
assert!((th["thread"]["end"].as_f64().unwrap() - th["thread"]["start"].as_f64().unwrap() - 12.0).abs() < 1e-6, "{th}");
assert!(s.execute("solid.thread", &json!({"face": [104, 0, 10], "designation": "M20"})).is_err());
// Components: features made while one is active belong to it.
let c = run(&mut s, "component.create", json!({"name": "Bracket"}));
let cid = c["component"].as_u64().unwrap();
run(&mut s, "solid.box", json!({"corner": [0, 60, 0], "length": 10, "width": 10, "height": 10, "name": "Block"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let l = run(&mut s, "component.list", json!({}));
let comps = l["components"].as_array().unwrap();
let bracket = comps.iter().find(|x| x["id"] == cid).unwrap();
assert_eq!(bracket["features"], json!(["Block"]), "{l}");
assert_eq!(bracket["bodies"].as_array().map(|a| a.len()), Some(1), "{l}");
let r = run(&mut s, "component.from_bodies", json!({"bodies": ["Shaft"]}));
assert_eq!(r["components"].as_array().map(|a| a.len()), Some(1));
// Saved and loaded with the components.
let text = s.doc.to_json();
let back = solidcraft_doc::Document::from_json(&text).unwrap();
assert_eq!(back.components.len(), 2);
let assigned = r["components"][0]["component"].as_u64().unwrap();
let transfer = r["components"][0]["feature"].as_u64().unwrap();
assert!(
matches!(&back.feature(transfer).unwrap().kind, solidcraft_doc::FeatureKind::BodyTransfer { bodies, to, .. } if bodies == &["Shaft"] && *to == assigned)
);
let reopened = Session::new(back);
assert_eq!(reopened.model.state().body("Shaft").unwrap().component, assigned);
assert_eq!(reopened.world_state().threads.len(), s.world_state().threads.len());
}
#[test]
fn pipe_scale_offset_bounding_and_materials() {
let mut s = Session::default();
// Pipe along an L path (line, arc, line) on XZ.
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]], "ids": ["l1"]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [30, 10], "start": "l1.end", "end": [40, 10], "id": "a1"}));
run(&mut s, "sketch.line", json!({"points": ["a1.end", [40, 40]], "ids": ["l2"]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.pipe", json!({"path_sketch": "Path", "path": ["l1", "a1", "l2"], "diameter": 4, "wall": 1, "body_name": "Tube"}));
let len = 30.0 + PI * 10.0 / 2.0 + 30.0;
let v = volume(&mut s);
assert!(rel(v, PI * (4.0 - 1.0) * len) < 2e-3, "{v} vs {}", PI * 3.0 * len);
// Bounding solid around it; then scale a box and push one of its faces.
run(&mut s, "solid.bounding_solid", json!({"bodies": ["Tube"], "margin": 1, "body_name": "Stock"}));
let m = run(&mut s, "inspect.measure", json!({"bodies": ["Stock"]}));
let bb = &m["bodies"][0]["bbox"];
assert!((bb["min"][1].as_f64().unwrap() + 3.0).abs() < 1e-6 && (bb["max"][1].as_f64().unwrap() - 41.0).abs() < 1e-6, "{bb}");
run(&mut s, "solid.box", json!({"corner": [100, 0, 0], "length": 10, "width": 10, "height": 10, "body_name": "Cube"}));
run(&mut s, "solid.scale", json!({"bodies": ["Cube"], "factor": 2, "origin": [100, 0, 0]}));
let vc = run(&mut s, "inspect.measure", json!({"bodies": ["Cube"]}))["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(vc, 8000.0) < 1e-9, "{vc}");
run(&mut s, "solid.offset_face", json!({"faces": [[110, 10, 20]], "distance": 5}));
let vc = run(&mut s, "inspect.measure", json!({"bodies": ["Cube"]}))["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(vc, 20.0 * 20.0 * 25.0) < 1e-9, "{vc}");
// Materials and mass.
run(&mut s, "material.assign", json!({"bodies": ["Cube"], "material": "aluminum"}));
let m = run(&mut s, "inspect.measure", json!({"bodies": ["Cube"]}));
assert!((m["bodies"][0]["mass_g"].as_f64().unwrap() - 10_000.0 * 2.7 / 1000.0).abs() < 1e-6, "{m}");
assert_eq!(m["bodies"][0]["material"], "Aluminum");
assert!(s.execute("material.assign", &json!({"bodies": ["Cube"], "material": "unobtainium"})).is_err());
}
/// Regression: deleting a pipe's path sketch left the pipe in the timeline ("unknown sketch").
#[test]
fn deleting_a_path_sketch_deletes_its_pipe() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]], "ids": ["l1"]}));
run(&mut s, "sketch.finish", json!({}));
let pipe = run(&mut s, "solid.pipe", json!({"path_sketch": "Path", "path": ["l1"], "diameter": 4}));
let deps = run(&mut s, "timeline.dependents", json!({"feature": "Path"}));
assert_eq!(deps["deleted_with_it"].as_array().map(Vec::len), Some(1), "{deps} (pipe {pipe})");
let r = run(&mut s, "timeline.delete", json!({"features": ["Path"]}));
assert_eq!(r["deleted"].as_array().map(Vec::len), Some(2), "{r}");
assert!(s.doc.features.is_empty(), "{:?}", s.doc.features.iter().map(|f| &f.name).collect::<Vec<_>>());
assert!(s.model.results.iter().all(|r| r.error.is_none()));
}
/// Regression: batches were rolled back by undoing as many steps as the undo history grew,
/// which is none once the history is full, leaving their sketches behind.
#[test]
fn checkpoint_restores_with_a_full_undo_history() {
let mut s = Session::default();
for i in 0..MAX_UNDO + 5 {
run(&mut s, "parameters.add", json!({"name": format!("p{i}"), "expression": "1 mm"}));
}
assert_eq!(s.undo.len(), MAX_UNDO);
let labels: Vec<String> = s.undo.iter().map(|u| u.label.clone()).collect();
let doc = s.doc.clone();
let id = run(&mut s, "edit.checkpoint", json!({}))["checkpoint"].as_u64().unwrap();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "TapHole"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 2}));
assert!(s.active_sketch.is_some());
run(&mut s, "edit.restore_checkpoint", json!({"checkpoint": id}));
assert!(Arc::ptr_eq(&s.doc, &doc) && s.active_sketch.is_none());
assert!(s.doc.find_feature("TapHole").is_none());
assert_eq!(s.undo.iter().map(|u| u.label.clone()).collect::<Vec<_>>(), labels);
// Used up: a second restore is an error, not a jump somewhere else.
assert!(s.execute("edit.restore_checkpoint", &json!({"checkpoint": id})).is_err());
// Forgetting one leaves the design alone.
let id = run(&mut s, "edit.checkpoint", json!({}))["checkpoint"].as_u64().unwrap();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Kept"}));
run(&mut s, "edit.restore_checkpoint", json!({"checkpoint": id, "forget": true}));
assert!(s.doc.find_feature("Kept").is_some());
assert!(s.execute("edit.restore_checkpoint", &json!({"checkpoint": id})).is_err());
}
#[test]
fn components_occurrences_and_world_placement() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Base"}));
// A component, active: its box is authored in its frame.
let c = run(&mut s, "component.create", json!({"name": "Arm"}));
let (cid, occ) = (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap());
run(&mut s, "solid.box", json!({"corner": [20, 0, 0], "length": 10, "width": 4, "height": 4, "body_name": "ArmBody"}));
run(&mut s, "component.activate", json!({"component": 0}));
let bbox = |s: &mut Session, b: &str| run(s, "inspect.measure", json!({ "bodies": [b] }))["bodies"][0]["bbox"].clone();
assert_eq!(bbox(&mut s, "ArmBody")["min"][0], 20.0);
// Move the occurrence: the world placement follows, the authoring model doesn't change.
run(&mut s, "occurrence.move", json!({"occurrence": occ, "translate": [0, 50, 0]}));
assert!((bbox(&mut s, "ArmBody")["min"][1].as_f64().unwrap() - 50.0).abs() < 1e-9);
assert_eq!(s.model.state().body("ArmBody").unwrap().mesh().bounds().min.y, 0.0);
// A fillet picked in the world, while the component is active, lands on its edge.
run(&mut s, "component.activate", json!({"component": "Arm"}));
run(&mut s, "solid.fillet", json!({"edges": [[25, 54, 4]], "radius": 1}));
let v = run(&mut s, "inspect.measure", json!({"bodies": ["ArmBody"]}))["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(v, 160.0 - (1.0 - PI / 4.0) * 10.0) < 1e-4, "{v}");
run(&mut s, "component.activate", json!({"component": 0}));
// Instances share the contents; a copy is independent.
run(&mut s, "occurrence.copy", json!({"component": cid, "translate": [0, 20, 0]}));
let w = s.world_state();
assert_eq!(w.bodies.len(), 3, "{:?}", w.bodies.iter().map(|b| &b.name).collect::<Vec<_>>());
let pn = run(&mut s, "component.paste_new", json!({"component": cid, "translate": [0, -30, 0]}));
assert_eq!(pn["features"].as_array().map(|a| a.len()), Some(2));
assert_eq!(s.world_state().bodies.len(), 4);
// Ground stops moves; pending moves show until captured or reverted.
run(&mut s, "occurrence.ground", json!({"occurrence": occ}));
assert!(s.execute("occurrence.move", &json!({"occurrence": occ, "translate": [1, 0, 0]})).is_err());
run(&mut s, "occurrence.ground", json!({"occurrence": occ, "grounded": false}));
run(&mut s, "occurrence.move", json!({"occurrence": occ, "translate": [0, 0, 5], "capture": false}));
assert!((bbox(&mut s, "ArmBody")["min"][2].as_f64().unwrap() - 5.0).abs() < 1e-9);
run(&mut s, "component.revert_position", json!({}));
assert!(bbox(&mut s, "ArmBody")["min"][2].as_f64().unwrap().abs() < 1e-9);
run(&mut s, "occurrence.move", json!({"occurrence": occ, "translate": [0, 0, 5], "capture": false}));
run(&mut s, "component.capture_position", json!({}));
assert!(s.pending_moves.is_empty());
assert!((bbox(&mut s, "ArmBody")["min"][2].as_f64().unwrap() - 5.0).abs() < 1e-9);
// A definition placed twice cannot receive one world body without duplicating it.
let document = s.doc.to_json();
let pending = s.pending_moves.clone();
let error = s.execute("component.move_bodies", &json!({"bodies": ["Base"], "component": "Arm"})).unwrap_err();
assert!(error.to_string().contains("one source and destination placement"), "{error}");
assert_eq!(s.doc.to_json(), document);
assert_eq!(s.pending_moves, pending);
// The independent Paste New result has one placement and can receive it.
let copied = pn["component"].as_u64().unwrap();
run(&mut s, "component.move_bodies", json!({"bodies": ["Base"], "component": copied}));
let l = run(&mut s, "component.list", json!({}));
let arm = l["components"].as_array().unwrap().iter().find(|x| x["id"] == cid).unwrap().clone();
assert!(!arm["bodies"].as_array().unwrap().iter().any(|b| b == "Base"), "{arm}");
let receiver = l["components"].as_array().unwrap().iter().find(|x| x["id"] == copied).unwrap();
assert!(receiver["bodies"].as_array().unwrap().iter().any(|b| b == "Base"), "{receiver}");
assert_eq!(bbox(&mut s, "Base")["min"], json!([0.0, 0.0, 0.0]));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S1"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "component.move_sketches", json!({"sketches": ["S1"], "component": cid}));
assert_eq!(s.doc.find_feature("S1").unwrap().component, cid);
// Undo/redo and the timeline work with features in components.
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.find_feature("S1").unwrap().component, 0);
run(&mut s, "edit.redo", json!({}));
run(&mut s, "timeline.roll_to", json!({"position": 1}));
// The ownership boundary is later: before it Base remains the sole root body.
assert_eq!(s.world_state().bodies.len(), 1);
assert_eq!(s.world_state().body("Base").unwrap().component, 0);
run(&mut s, "timeline.roll_to", json!({}));
// Saved and loaded; older designs get occurrences.
let back = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(back.occurrences.len(), s.doc.occurrences.len());
let mut old: serde_json::Value = serde_json::from_str(&s.doc.to_json()).unwrap();
old.as_object_mut().unwrap().remove("occurrences");
let migrated = solidcraft_doc::Document::from_json(&old.to_string()).unwrap();
assert_eq!(migrated.occurrences.len(), migrated.components.len());
}
#[test]
fn feature_inputs_are_parameters() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "w", "expression": "15 mm", "comment": "width"}));
run(&mut s, "solid.box", json!({"length": 10, "width": "w", "height": "1 in", "name": "B"}));
let l = run(&mut s, "parameters.list", json!({}));
let rows = l["parameters"].as_array().unwrap();
let feat: Vec<&Value> = rows.iter().filter(|r| r["source"] == "feature").collect();
assert_eq!(feat.len(), 3, "{l}");
let height = feat.iter().find(|r| r["input"] == "Height").unwrap();
assert!((height["value"].as_f64().unwrap() - 25.4).abs() < 1e-9);
let length_name = feat.iter().find(|r| r["input"] == "Length").unwrap()["name"].as_str().unwrap().to_string();
// A feature parameter changes the feature; other expressions can use it.
run(&mut s, "parameters.change", json!({"name": length_name, "expression": "2 * w"}));
assert!(rel(volume(&mut s), 30.0 * 15.0 * 25.4) < 1e-9);
run(&mut s, "parameters.change", json!({"name": "t", "expression": format!("{length_name} / 3")}));
let t = run(&mut s, "parameters.list", json!({}))["parameters"].as_array().unwrap().iter().find(|r| r["name"] == "t").unwrap()["value"]
.as_f64()
.unwrap();
assert!((t - 10.0).abs() < 1e-9);
// Cycles are refused.
assert!(s.execute("parameters.change", &json!({"name": "w", "expression": "t"})).is_err());
// Favourites, export and import.
run(&mut s, "parameters.favorite", json!({"name": "w"}));
assert_eq!(run(&mut s, "parameters.list", json!({"favorites": true}))["parameters"].as_array().unwrap().len(), 1);
let csv = run(&mut s, "parameters.export", json!({}))["text"].as_str().unwrap().to_string();
assert!(csv.contains("w,mm,15 mm,15,width,true"), "{csv}");
run(&mut s, "parameters.import", json!({"text": "name,unit,expression,comment\nw,mm,20 mm,wider\nnew_p,mm,\"w + 1\","}));
assert!(rel(volume(&mut s), 40.0 * 20.0 * 25.4) < 1e-9);
run(&mut s, "parameters.import", json!({"text": "[{\"name\": \"w\", \"expression\": \"12\"}]"}));
assert!(rel(volume(&mut s), 24.0 * 12.0 * 25.4) < 1e-9);
assert!(s.execute("parameters.import", &json!({"text": "name,expression\nw,1/"})).is_err());
// Saved and reloaded, the names stay.
let back = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(back.features[0].param_names, s.doc.features[0].param_names);
}
#[test]
fn rolling_back_and_forward_leaves_no_projection_warnings() {
let mut s = Session::default();
s.run_script(&crate::sample::script()).unwrap();
let warnings = |s: &Session| -> Vec<String> {
s.doc.features.iter().filter_map(|f| s.model.result(f.id).and_then(|r| r.warning.clone()).map(|w| format!("{}: {w}", f.name))).collect()
};
assert!(warnings(&s).is_empty(), "{:?}", warnings(&s));
for pos in 0..s.doc.features.len() {
run(&mut s, "timeline.roll_to", json!({ "position": pos }));
run(&mut s, "timeline.roll_to", json!({}));
assert!(warnings(&s).is_empty(), "after rolling to {pos}: {:?}", warnings(&s));
}
}
/// Export STEP, validate the file, read it back: no warnings, same volume, area and faces.
fn step_round_trip(s: &mut Session, tag: &str) -> solidcraft_kernel::StepImport {
let p = std::env::temp_dir().join(format!("solidcraft-steprt-{tag}-{}.step", std::process::id()));
run(s, "file.export", json!({"path": p.to_string_lossy()}));
let text = std::fs::read_to_string(&p).unwrap();
let _ = std::fs::remove_file(&p);
solidcraft_kernel::step_validate(&text).unwrap();
assert_eq!(solidcraft_kernel::step_orientation_errors(&text).unwrap(), Vec::<String>::new());
assert!(text.contains("AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF"));
let imp = solidcraft_kernel::step_import(&text).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
imp
}
#[test]
fn sample_plate_step_round_trip() {
let mut s = Session::default();
s.run_script(&crate::sample::script()).unwrap();
let imp = step_round_trip(&mut s, "sample");
let st = s.world_state();
assert_eq!(imp.bodies.len(), st.bodies.len());
for (a, b) in st.bodies.iter().zip(&imp.bodies) {
assert_eq!(a.name, b.name);
let (ma, mb) = (solidcraft_kernel::measure(&a.body).unwrap(), solidcraft_kernel::measure(&b.body).unwrap());
assert!(rel(mb.volume, ma.volume) < 1e-4, "{} vs {}", mb.volume, ma.volume);
assert!(rel(mb.area, ma.area) < 1e-4, "{} vs {}", mb.area, ma.area);
assert!(b.file_faces <= a.body.face_count() && b.file_faces >= ma.merged.faces.min(a.body.face_count()));
assert_eq!(mb.merged.faces, ma.merged.faces);
}
}
/// Bodies export in the colour they are shown in: a material's look, an appearance over it,
/// none for the default; and colours read from a STEP file survive the next export.
#[test]
fn material_and_appearance_colours_export_to_step() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "BrassBox"}));
run(&mut s, "solid.box", json!({"corner": [20, 0, 0], "length": 10, "width": 10, "height": 10, "body_name": "RedAbs"}));
run(&mut s, "solid.box", json!({"corner": [40, 0, 0], "length": 10, "width": 10, "height": 10, "body_name": "Plain"}));
run(&mut s, "material.assign", json!({"bodies": ["BrassBox"], "material": "Brass"}));
run(&mut s, "material.assign", json!({"bodies": ["RedAbs"], "material": "ABS Plastic"}));
run(&mut s, "appearance.assign", json!({"bodies": ["RedAbs"], "color": "#d01c1c"}));
assert!(s.execute("appearance.assign", &json!({"bodies": ["RedAbs"], "color": "#12"})).is_err());
let rgb = |c: [u8; 3]| c.map(|x| x as f32 / 255.0);
let want =
[("BrassBox", Some(rgb(solidcraft_doc::material_color("Brass").unwrap()))), ("RedAbs", Some(rgb([0xd0, 0x1c, 0x1c]))), ("Plain", None)];
let same = |a: Option<[f32; 3]>, b: Option<[f32; 3]>| match (a, b) {
(Some(a), Some(b)) => a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3),
(None, None) => true,
_ => false,
};
let check = |imp: &solidcraft_kernel::StepImport| {
for (name, c) in want {
let b = imp.bodies.iter().find(|b| b.name == name).unwrap_or_else(|| panic!("no body {name}"));
assert!(same(b.body.color(), c), "{name}: {:?} vs {c:?}", b.body.color());
}
};
check(&step_round_trip(&mut s, "colours"));
// Open the export and export again: the file's colours are kept.
let p = std::env::temp_dir().join(format!("solidcraft-colours-{}.step", std::process::id()));
run(&mut s, "file.export", json!({"path": p.to_string_lossy()}));
let mut s2 = Session::default();
run(&mut s2, "doc.open", json!({"path": p.to_string_lossy()}));
let _ = std::fs::remove_file(&p);
check(&step_round_trip(&mut s2, "colours2"));
}
/// Face appearances and see-through bodies export: STEP styles on the faces with a
/// transparency, read back as face colours.
#[test]
fn face_colours_and_opacity_export() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Cube"}));
run(&mut s, "appearance.assign", json!({"bodies": ["Cube"], "color": "#2040c0", "opacity": 0.5}));
run(&mut s, "appearance.assign", json!({"faces": [[5, 5, 10]], "color": "#ff0000"}));
let imp = step_round_trip(&mut s, "facecol");
let b = &imp.bodies[0];
let paint = b.body.paint().expect("paint read back");
assert!((paint.opacity - 0.5).abs() < 1e-3, "{paint:?}");
let faces = b.body.faces(0.01).unwrap();
let top = faces.iter().position(|f| (f.centroid.z - 10.0).abs() < 1e-6).unwrap();
assert_eq!(paint.faces.len(), 1, "{paint:?}");
assert_eq!(paint.faces[0].face, top);
assert!((paint.faces[0].color[0] - 1.0).abs() < 1e-3 && paint.faces[0].color[1].abs() < 1e-3, "{paint:?}");
// Opened again, the face keeps its colour (shown and exported).
let p = std::env::temp_dir().join(format!("solidcraft-facecol-{}.step", std::process::id()));
run(&mut s, "file.export", json!({"path": p.to_string_lossy()}));
let mut s2 = Session::default();
run(&mut s2, "doc.open", json!({"path": p.to_string_lossy()}));
let _ = std::fs::remove_file(&p);
let st = s2.world_state();
let looks = s2.doc.face_colors(&st.bodies[0]);
assert_eq!(looks.len(), 1);
assert_eq!(looks[0].1.color, [255, 0, 0]);
}
/// IGES: a design exports as IGES and the file opens again as an Import feature with the same
/// bodies, names and colours.
#[test]
fn iges_export_and_open() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 5, "body_name": "Plate"}));
run(&mut s, "solid.cylinder", json!({"radius": 4, "height": 20, "base": [10, 10, 5], "body_name": "Pin"}));
run(&mut s, "appearance.assign", json!({"bodies": ["Pin"], "color": "#ff0000"}));
let want: Vec<f64> = s.world_state().bodies.iter().map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).collect();
let p = std::env::temp_dir().join(format!("solidcraft-iges-{}.igs", std::process::id()));
run(&mut s, "file.export", json!({"path": p.to_string_lossy()}));
let mut s2 = Session::default();
run(&mut s2, "doc.open", json!({"path": p.to_string_lossy()}));
let _ = std::fs::remove_file(&p);
let st = s2.world_state();
let names: Vec<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect();
assert_eq!(names, ["Plate", "Pin"]);
for (b, v) in st.bodies.iter().zip(&want) {
assert!(rel(solidcraft_kernel::measure(&b.body).unwrap().volume, *v) < 1e-4);
}
assert_eq!(st.bodies[1].body.color(), Some([1.0, 0.0, 0.0]));
}
/// Split Body by a face of another body (its surface extended): a box cut by a cylinder's
/// side into the core and the rest; volumes add up exactly.
#[test]
fn split_body_by_a_face() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "Block"}));
run(&mut s, "solid.cylinder", json!({"radius": 4, "height": 40, "base": [5, 10, -5], "body_name": "Tool"}));
run(&mut s, "solid.split_body", json!({"body": "Block", "tool": {"body": "Tool", "point": [9, 10, 10]}}));
let st = s.world_state();
let mut v: Vec<f64> =
st.bodies.iter().filter(|b| b.name.starts_with("Block")).map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).collect();
v.sort_by(f64::total_cmp);
let core = PI * 16.0 * 30.0;
assert!(v.len() == 2 && rel(v[0], core) < 1e-3 && rel(v[1], 6000.0 - core) < 1e-3, "{v:?}");
assert!(s.execute("solid.split_body", &json!({"body": "Block", "tool": {"body": "Tool", "point": [100, 0, 0]}})).is_err());
}
/// Fillet types: chord length (radius from the face angle) and a radius varying along the
/// edge; volumes from the removed material.
#[test]
fn chord_and_variable_fillets() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "B"}));
run(&mut s, "solid.fillet", json!({"edges": [[10, 10, 30]], "radius": 4, "type": "chord"}));
let r = 4.0 / 2f64.sqrt();
let v = solidcraft_kernel::measure(&s.world_state().body("B").unwrap().body).unwrap().volume;
assert!(rel(v, 6000.0 - (1.0 - PI / 4.0) * r * r * 20.0) < 1e-4, "{v}");
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "B"}));
run(&mut s, "solid.fillet", json!({"edges": [[10, 10, 30]], "radius": 2, "type": "variable", "radius2": 5, "start": [10, 0, 30]}));
let v = solidcraft_kernel::measure(&s.world_state().body("B").unwrap().body).unwrap().volume;
let removed = (1.0 - PI / 4.0) * 20.0 * (4.0 + 10.0 + 25.0) / 3.0;
assert!(rel(v, 6000.0 - removed) < 1e-4, "{v}");
assert!(s.execute("solid.fillet", &json!({"edges": [[10, 10, 30]], "radius": 2, "type": "variable"})).is_err());
// Down to zero at the far end: the round closes to the corner.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "B"}));
run(&mut s, "solid.fillet", json!({"edges": [[10, 10, 30]], "radius": 4, "type": "variable", "radius2": 0, "start": [10, 0, 30]}));
let v = solidcraft_kernel::measure(&s.world_state().body("B").unwrap().body).unwrap().volume;
assert!(rel(v, 6000.0 - (1.0 - PI / 4.0) * 20.0 * 16.0 / 3.0) < 1e-4, "{v}");
// A radius through a middle point (a user parameter), straight between: 2 → 4 → 2.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "B"}));
run(&mut s, "parameters.add", json!({"name": "waist", "expression": "4 mm"}));
run(
&mut s,
"solid.fillet",
json!({"edges": [[10, 10, 30]], "radius": 2, "type": "variable", "radius2": 2, "start": [10, 0, 30], "mid": [[0.5, "waist"]]}),
);
let vol = |s: &Session| solidcraft_kernel::measure(&s.world_state().body("B").unwrap().body).unwrap().volume;
let removed = 2.0 * (1.0 - PI / 4.0) * 10.0 * (4.0 + 8.0 + 16.0) / 3.0;
assert!(rel(vol(&s), 6000.0 - removed) < 1e-4, "{}", vol(&s));
// The middle radius follows its parameter.
run(&mut s, "parameters.change", json!({"name": "waist", "expression": "3 mm"}));
let removed = 2.0 * (1.0 - PI / 4.0) * 10.0 * (4.0 + 6.0 + 9.0) / 3.0;
assert!(rel(vol(&s), 6000.0 - removed) < 1e-4, "{}", vol(&s));
assert!(
s.execute(
"solid.fillet",
&json!({"edges": [[10, 10, 30]], "radius": 2, "type": "variable", "radius2": 2, "start": [10, 0, 30], "mid": [[1.5, 3]]})
)
.is_err()
);
}
/// Components export as a STEP assembly: products, occurrences, placements.
#[test]
fn components_export_as_step_assembly() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 5, "name": "Plate"}));
run(&mut s, "component.create", json!({"name": "Pin"}));
run(&mut s, "solid.cylinder", json!({"radius": 2, "height": 10, "base": [5, 5, 5]}));
run(&mut s, "occurrence.copy", json!({"component": "Pin", "translate": [20, 0, 0]}));
let want: f64 = s.world_state().bodies.iter().map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).sum();
let imp = step_round_trip(&mut s, "asm");
assert_eq!(imp.bodies.len(), 3, "{:?}", imp.bodies.iter().map(|b| &b.name).collect::<Vec<_>>());
let got: f64 = imp.bodies.iter().map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).sum();
assert!(rel(got, want) < 1e-4, "{got} vs {want}");
let root = &imp.tree[0];
assert_eq!(root.children.len(), 2);
assert!(root.children.iter().all(|c| c.name == "Pin"));
// The second pin sits 20 mm along X from the first.
let xs: Vec<f64> = imp
.bodies
.iter()
.filter(|b| b.path.last().is_some_and(|p| p == "Pin"))
.map(|b| solidcraft_kernel::measure(&b.body).unwrap().centroid.x)
.collect();
assert_eq!(xs.len(), 2);
assert!(((xs[0] - xs[1]).abs() - 20.0).abs() < 1e-6, "{xs:?}");
}
/// A face extrude follows its face when earlier features change.
#[test]
fn face_extrude_follows_edits() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "name": "Base"}));
run(&mut s, "solid.extrude", json!({"face": [20, 15, 20], "distance": 10, "operation": "join", "name": "Pad"}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 30.0) < 1e-6);
run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"height": "25", "length": "50"}}));
assert!(rel(volume(&mut s), 50.0 * 30.0 * 35.0) < 1e-6, "{}", volume(&mut s));
// A face revolved about a line: Pappus (area × centroid path).
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
let before = volume(&mut s);
run(
&mut s,
"solid.revolve",
json!({"face": [40, 15, 10], "axis": {"origin": [40, 0, 30], "dir": [0, 1, 0]}, "angle": "90 deg", "operation": "new"}),
);
let added = volume(&mut s) - before;
let want = 30.0 * 20.0 * 20.0 * PI / 2.0;
assert!(rel(added, want) < 1e-4, "{added} vs {want}");
}
/// Appearances on faces, bodies and components: face > body > component > material.
#[test]
fn appearances_by_face_body_and_component() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Root"}));
run(&mut s, "component.create", json!({"name": "Case"}));
run(&mut s, "solid.box", json!({"corner": [20, 0, 0], "length": 10, "width": 10, "height": 10, "body_name": "Shell"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let lib = run(&mut s, "appearance.library", json!({}));
assert!(lib["appearances"].as_array().is_some_and(|a| a.len() >= 15 && a.iter().any(|x| x["name"] == "Glass - Clear")));
let look = |s: &mut Session, b: &str| run(s, "appearance.list", json!({"body": b}))["body"].clone();
// Material, then component, then body.
run(&mut s, "material.assign", json!({"bodies": ["Shell"], "material": "Brass"}));
assert_eq!(look(&mut s, "Shell")["appearance"]["name"], "Brass");
run(&mut s, "appearance.assign", json!({"components": ["Case"], "appearance": "ABS - Blue"}));
assert_eq!(look(&mut s, "Shell")["appearance"]["name"], "ABS - Blue");
assert!(look(&mut s, "Root")["appearance"].is_null(), "root bodies have no component look");
run(&mut s, "appearance.assign", json!({"bodies": ["Shell"], "color": [255, 0, 0], "opacity": 0.5}));
let l = look(&mut s, "Shell");
assert_eq!((l["appearance"]["color"].as_str(), l["appearance"]["opacity"].as_f64()), (Some("#ff0000"), Some(0.5)), "{l}");
// A face: the top of Shell.
run(&mut s, "appearance.assign", json!({"faces": [[25, 5, 10]], "appearance": "Glass - Clear"}));
let l = look(&mut s, "Shell");
let faces = l["faces"].as_array().cloned().unwrap_or_default();
assert_eq!(faces.len(), 1, "{l}");
assert_eq!(faces[0]["appearance"]["name"], "Glass - Clear");
// Clearing goes back down the chain.
run(&mut s, "appearance.assign", json!({"faces": [[26, 6, 10]]}));
assert_eq!(look(&mut s, "Shell")["faces"].as_array().map(Vec::len), Some(0), "cleared by another point on the same face");
run(&mut s, "appearance.assign", json!({"bodies": ["Shell"], "color": null}));
assert_eq!(look(&mut s, "Shell")["appearance"]["name"], "ABS - Blue");
run(&mut s, "appearance.assign", json!({"components": ["Case"]}));
assert_eq!(look(&mut s, "Shell")["appearance"]["name"], "Brass");
// Saved and opened again.
run(&mut s, "appearance.assign", json!({"faces": [[5, 5, 10]], "color": "#00ff00"}));
let back = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(back.appearances, s.doc.appearances);
assert!(s.execute("appearance.assign", &json!({"bodies": ["Shell"], "appearance": "Unobtainium"})).is_err());
assert!(s.execute("appearance.assign", &json!({"color": "#ffffff"})).is_err(), "no target");
assert!(s.execute("appearance.assign", &json!({"faces": [[500, 500, 500]], "color": "#ffffff"})).is_err(), "no face there");
}
/// The evaluation cache gives exactly what evaluating from scratch gives, through edits,
/// suppression, roll-back, undo and redo.
#[test]
fn cached_evaluation_matches_from_scratch() {
let mut s = Session::default();
s.run_script(&crate::sample::script()).unwrap();
run(
&mut s,
"solid.pattern.rectangular",
json!({"bodies": ["Plate"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 100, "dir2": [0, 1, 0], "count2": 2, "spacing2": 70}),
);
run(&mut s, "solid.box", json!({"corner": [-50, 0, 0], "length": 10, "width": 10, "height": 10, "body_name": "Side"}));
let same = |s: &Session, what: &str| {
let mut fresh = solidcraft_doc::Model::new();
fresh.no_cache = true;
fresh.evaluate(&s.doc);
let errs = |m: &solidcraft_doc::Model| m.results.iter().map(|r| (r.id, r.error.clone(), r.skipped)).collect::<Vec<_>>();
assert_eq!(errs(&s.model), errs(&fresh), "{what}: feature results");
let shape = |st: &solidcraft_doc::ModelState| {
st.bodies
.iter()
.map(|b| {
let m = solidcraft_kernel::measure(&b.body).unwrap();
// Volume and area to 1e-9: two evaluations from scratch differ in the last
// bits too (the kernel's tessellation is not bit-for-bit repeatable).
(b.name.clone(), b.feature, (m.volume * 1e6).round(), (m.area * 1e6).round(), m.faces, m.edges, m.vertices, m.merged.faces)
})
.collect::<Vec<_>>()
};
assert_eq!(shape(&s.model.state()), shape(&fresh.state()), "{what}: bodies");
assert_eq!(s.model.state().sketches.len(), fresh.state().sketches.len(), "{what}: sketches");
};
// Two evaluations from scratch agree to the same precision.
let mut a = solidcraft_doc::Model::new();
a.no_cache = true;
a.evaluate(&s.doc);
let mut b = solidcraft_doc::Model::new();
b.no_cache = true;
b.evaluate(&s.doc);
let vols = |m: &solidcraft_doc::Model| {
m.state().bodies.iter().map(|x| (solidcraft_kernel::measure(&x.body).unwrap().volume * 1e6).round()).collect::<Vec<_>>()
};
assert_eq!(vols(&a), vols(&b));
same(&s, "built");
let hits = |s: &Session| s.model.cache.lock().map(|c| c.hits).unwrap_or(0);
for (step, (id, p)) in [
("parameters.change", json!({"name": "width", "expression": "90 mm"})),
("parameters.change", json!({"name": "width", "expression": "80 mm"})),
("parameters.change", json!({"name": "thickness", "expression": "10 mm"})),
("edit.undo", json!({})),
("edit.redo", json!({})),
("timeline.suppress", json!({"feature": "Holes", "suppressed": true})),
("timeline.suppress", json!({"feature": "Holes", "suppressed": false})),
("timeline.roll_to", json!({"position": 6})),
("timeline.roll_to", json!({})),
("edit.undo", json!({})),
("edit.undo", json!({})),
]
.into_iter()
.enumerate()
{
let before = hits(&s);
run(&mut s, id, p.clone());
same(&s, &format!("step {step}: {id} {p}"));
// Setting the width back solves the sketch again from its current state; the
// features after it see the same geometry and reuse their results. So does undo.
if step == 1 || step == 3 {
assert!(hits(&s) > before, "step {step} reuses the earlier results");
}
}
}
/// A fillet keeps its edge when an earlier sketch dimension resizes the body (QA s06).
#[test]
fn fillet_follows_its_edge_after_an_upstream_resize() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
let bottom = r["curves"][0].as_str().unwrap_or_default().to_string();
let d = run(&mut s, "sketch.dimension", json!({"entities": [bottom], "value": 40}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10}));
run(&mut s, "solid.fillet", json!({"edges": [[40, 15, 10]], "radius": 2}));
let v = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(0.0);
let strip = (4.0 - std::f64::consts::PI) * 30.0;
assert!((v(&mut s) - (12000.0 - strip)).abs() < 0.05, "{}", v(&mut s));
let name = d["param"].as_str().or_else(|| d["name"].as_str()).map(str::to_string).unwrap_or_else(|| "d1".into());
run(&mut s, "parameters.change", json!({"name": name, "expression": "60"}));
// The right top edge, now at x = 60: 18000 less the same 30 mm strip.
assert!((v(&mut s) - (18000.0 - strip)).abs() < 0.05, "{}", v(&mut s));
let fillet = s.doc.features.last().map(|f| f.id).unwrap_or(0);
assert!(s.model.result(fillet).and_then(|r| r.warning.clone()).is_none(), "followed without a warning");
}
mod naming {
use serde_json::{Value, json};
use super::run;
use crate::Session;
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(0.0)
}
fn warning(s: &Session, name: &str) -> Option<String> {
let id = s.doc.find_feature(name).map(|f| f.id)?;
s.model.result(id).and_then(|r| r.warning.clone())
}
/// A 40 x 30 x 10 block (sketch S: l1…l4 counter-clockwise from the origin), a hole H, a
/// fillet on the right top edge.
fn block() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 40}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "Block"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "H"}));
run(&mut s, "sketch.circle.center", json!({"center": [12, 15], "radius": 4}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "H", "distance": 10, "operation": "cut", "name": "Hole"}));
run(&mut s, "solid.fillet", json!({"edges": [[40, 15, 10]], "radius": 2, "name": "Round"}));
s
}
const STRIP: f64 = (4.0 - std::f64::consts::PI) * 30.0;
#[test]
fn faces_and_edges_are_named_from_their_history() {
let mut s = block();
let f = run(&mut s, "model.faces", json!({"body": "Body1"}));
let names: Vec<&str> = f["faces"].as_array().into_iter().flatten().filter_map(|x| x["name"].as_str()).collect();
for want in ["F2:start", "F2:top", "F2:side:l1", "F2:side:l2", "F2:side:l3", "F2:side:l4"] {
assert!(names.contains(&want), "{want} in {names:?}");
}
assert!(names.iter().any(|n| n.starts_with("F4:side:c1")), "{names:?}");
assert!(names.iter().any(|n| n.starts_with("F5:blend:")), "{names:?}");
let round = s.doc.find_feature("Round").cloned().unwrap();
assert_eq!(round.edge_names, vec!["F2:side:l2|F2:top".to_string()]);
}
#[test]
fn a_resize_keeps_the_edge() {
let mut s = block();
let d = s.doc.find_feature("S").and_then(|f| f.param_names.first().cloned()).unwrap_or_else(|| "d1".into());
run(&mut s, "parameters.change", json!({"name": d, "expression": "60"}));
assert!((volume(&mut s) - (18000.0 - std::f64::consts::PI * 16.0 * 10.0 - STRIP)).abs() < 0.1, "{}", volume(&mut s));
assert_eq!(warning(&s, "Round"), None);
}
#[test]
fn suppress_and_reorder_upstream_keep_the_edge() {
let mut s = block();
let v = volume(&mut s);
run(&mut s, "timeline.suppress", json!({"feature": "Hole", "suppressed": true}));
assert!((volume(&mut s) - (12000.0 - STRIP)).abs() < 0.1);
assert_eq!(warning(&s, "Round"), None);
run(&mut s, "timeline.suppress", json!({"feature": "Hole", "suppressed": false}));
assert!((volume(&mut s) - v).abs() < 1e-6);
// The hole after the fillet: same part, same edge.
run(&mut s, "timeline.reorder", json!({"feature": "Hole", "position": 4}));
assert!((volume(&mut s) - v).abs() < 0.1, "{} {v}", volume(&mut s));
assert_eq!(warning(&s, "Round"), None);
}
#[test]
fn a_split_edge_warns_and_takes_the_nearest_piece() {
let mut s = block();
// Upstream of the fillet, a notch through the middle of the filleted edge.
run(&mut s, "timeline.roll_to", json!({"feature": "Hole"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "N"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [36, 12], "p1": [44, 18]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "N", "distance": 10, "operation": "cut", "name": "Notch"}));
run(&mut s, "timeline.roll_to", json!({}));
let w = warning(&s, "Round").unwrap_or_default();
assert!(w.contains("split"), "{w}");
}
#[test]
fn a_removed_segment_warns() {
let mut s = block();
// Replace the right side (l2) of the rectangle with a new line: the face it made is gone.
let sk = s.doc.find_feature("S").map(|f| f.id).unwrap();
run(&mut s, "sketch.edit", json!({"sketch": sk}));
run(&mut s, "sketch.delete", json!({"entities": ["l2"]}));
run(&mut s, "sketch.line", json!({"points": [[40, 0], [40, 30]], "ids": ["right"]}));
let r: Result<Value, _> = s.execute("sketch.finish", &json!({}));
assert!(r.is_ok() || r.is_err());
let w = warning(&s, "Round").unwrap_or_default();
assert!(w.contains("no longer exists"), "{w}");
}
fn face_names(s: &mut Session, body: &str) -> Vec<String> {
let f = run(s, "model.faces", json!({ "body": body }));
f["faces"].as_array().into_iter().flatten().filter_map(|x| x["name"].as_str().map(str::to_string)).collect()
}
#[test]
fn primitives_revolves_and_pattern_copies_are_named() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"}));
let n = face_names(&mut s, "B");
for want in ["F1:+x", "F1:-x", "F1:+y", "F1:-y", "F1:+z", "F1:-z"] {
assert!(n.contains(&want.to_string()), "{want} in {n:?}");
}
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["B"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 20}));
let bodies: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
let copy = face_names(&mut s, &bodies[2]);
assert!(copy.iter().all(|x| x.starts_with("F1:") && x.contains("@F2.")), "{copy:?}");
// A revolved ring: the section's four lines name the four faces.
let mut r = Session::default();
run(&mut r, "sketch.create", json!({"plane": "XZ"}));
run(&mut r, "sketch.line", json!({"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true, "ids": ["a", "b", "c", "d"]}));
run(&mut r, "sketch.finish", json!({}));
run(&mut r, "solid.revolve", json!({"axis": "y", "angle": "270 deg"}));
let body = r.model.state().bodies[0].name.clone();
let n = face_names(&mut r, &body);
for want in ["F2:side:a", "F2:side:b", "F2:side:c", "F2:side:d", "F2:start", "F2:end"] {
assert!(n.iter().any(|x| x.starts_with(want)), "{want} in {n:?}");
}
}
#[test]
fn a_sketch_on_a_face_stays_on_that_face() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "Block"}));
run(&mut s, "sketch.create", json!({"plane": {"face": [30, 15, 10]}, "name": "Top"}));
run(&mut s, "sketch.circle.center", json!({"center": [30, 15], "radius": 3}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Top", "distance": 5, "operation": "join", "name": "Peg"}));
let top = |s: &mut Session| run(s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["max"][2].as_f64().unwrap_or(0.0);
assert!((top(&mut s) - 15.0).abs() < 1e-6);
let attached = |s: &Session| s.model.state().sketch(s.doc.find_feature("Top").unwrap().id).map(|sketch| sketch.plane.origin.z);
assert_eq!(attached(&s), Some(10.));
run(&mut s, "timeline.edit", json!({"feature":"Block","set":{"extent":{"distance":"12"}}}));
assert_eq!(attached(&s), Some(12.));
assert!((top(&mut s) - 17.).abs() < 1e-6);
run(&mut s, "edit.undo", json!({}));
assert_eq!(attached(&s), Some(10.));
assert!((top(&mut s) - 15.).abs() < 1e-6);
let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
let mut model = solidcraft_doc::Model::new();
model.evaluate(&reopened);
assert_eq!(model.state().sketch(reopened.find_feature("Top").unwrap().id).unwrap().plane.origin.z, 10.);
assert!((model.state().bodies[0].mesh().bounds().max.z - 15.).abs() < 1e-6);
// A historical format-10 attachment uses its saved generic face name and no strong anchor.
let mut old = (*s.doc).clone();
let solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { anchor, name, .. }, sketch } =
&mut old.features.iter_mut().find(|f| f.name == "Top").unwrap().kind
else {
panic!("fixture must contain a face sketch");
};
*anchor = None;
for link in &mut sketch.links {
if link.auto_face {
link.auto_face = false;
link.face_name = name.clone();
}
}
old.format = "solidcraft/10".into();
let mut legacy = Session::new(solidcraft_doc::Document::from_json(&old.to_json()).unwrap());
assert_eq!(attached(&legacy), Some(10.));
assert!((top(&mut legacy) - 15.).abs() < 1e-6);
// An upstream join that trims the certified face: the attachment stays on what is left
// of it and never follows the substitute roof (see face_tests for a face covered whole).
for target in [&mut s, &mut legacy] {
run(target, "timeline.roll_to", json!({"feature": "Block"}));
run(target, "sketch.create", json!({"plane": "XY", "name": "B"}));
run(target, "sketch.rectangle.two_point", json!({"p0": [8, -5], "p1": [45, 35]}));
run(target, "sketch.finish", json!({}));
run(target, "solid.extrude", json!({"sketch": "B", "distance": 20, "operation": "join", "name": "Boss"}));
run(target, "timeline.roll_to", json!({}));
}
assert_eq!(attached(&legacy), Some(10.), "historical weak naming retains its saved geometry behavior");
assert!((top(&mut legacy) - 20.).abs() < 1e-6);
let legacy_reopened = Session::new(solidcraft_doc::Document::from_json(&legacy.doc.to_json()).unwrap());
assert_eq!(attached(&legacy_reopened), Some(10.));
assert!((legacy_reopened.model.state().bodies[0].mesh().bounds().max.z - 20.).abs() < 1e-6);
let solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { anchor, .. }, .. } =
&legacy_reopened.doc.find_feature("Top").unwrap().kind
else {
panic!("saved historical face reference must remain present");
};
assert!(anchor.is_none(), "opening old face references must not infer a strict attachment");
let failed = |s: &Session, name: &str| s.model.results.iter().find(|r| r.id == s.doc.find_feature(name).unwrap().id).unwrap().error.is_some();
assert!(!failed(&s, "Top"));
assert!(!failed(&s, "Peg"));
assert_eq!(attached(&s), Some(10.), "the strip of Block's top left beside the boss is still that face");
assert!((top(&mut s) - 20.0).abs() < 1e-6, "{}", top(&mut s));
run(&mut s, "timeline.suppress", json!({"feature":"Boss","suppressed":true}));
assert!(!failed(&s, "Top"));
assert_eq!(attached(&s), Some(10.));
assert!((top(&mut s) - 15.).abs() < 1e-6);
}
#[test]
fn shell_inner_faces_are_named_after_the_walls() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "B"}));
run(&mut s, "solid.shell", json!({"faces": [[20, 15, 20]], "thickness": 2}));
let n = face_names(&mut s, "B");
for want in ["F2:inner:F1:-z", "F2:inner:F1:+x", "F2:inner:F1:-x", "F2:inner:F1:+y", "F2:inner:F1:-y"] {
assert!(n.contains(&want.to_string()), "{want} in {n:?}");
}
}
#[test]
fn shell_and_hole_faces_follow_by_name() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "name": "Base"}));
run(&mut s, "solid.shell", json!({"faces": [[20, 15, 20]], "thickness": 2, "name": "Hollow"}));
let shell = |h: f64| 40.0 * 30.0 * h - 36.0 * 26.0 * (h - 2.0);
assert!((volume(&mut s) - shell(20.0)).abs() < 0.5, "{}", volume(&mut s));
// Taller: the picked point is 5 mm under the open face now; its name still finds it.
run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"height": "25"}}));
assert!((volume(&mut s) - shell(25.0)).abs() < 0.5, "{}", volume(&mut s));
assert_eq!(warning(&s, "Hollow"), None);
let h = s.doc.find_feature("Hollow").map(|f| f.face_names.clone()).unwrap_or_default();
assert_eq!(h, vec!["F1:+z".to_string()]);
}
}
/// Tapped holes take the tap drill (major − pitch); clearance holes the ISO 273 diameter.
#[test]
fn tapped_and_clearance_holes() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10}));
let v0 = volume(&mut s);
run(&mut s, "solid.hole", json!({"position": [10, 15, 10], "thread": "M6"}));
let v1 = volume(&mut s);
assert!(rel(v0 - v1, PI * 2.5 * 2.5 * 10.0) < 1e-3, "{}", v0 - v1);
run(&mut s, "solid.hole", json!({"position": [30, 15, 10], "clearance": "M6"}));
let v2 = volume(&mut s);
assert!(rel(v1 - v2, PI * 3.3 * 3.3 * 10.0) < 1e-3, "{}", v1 - v2);
run(&mut s, "solid.hole", json!({"position": [20, 5, 10], "clearance": "M4", "fit": "close"}));
assert!(rel(v2 - volume(&mut s), PI * 2.15 * 2.15 * 10.0) < 1e-3);
assert!(s.execute("solid.hole", &json!({"position": [20, 25, 10], "clearance": "M7"})).is_err());
assert!(s.execute("solid.hole", &json!({"position": [20, 25, 10]})).is_err());
}
/// In an inch design, a bare length typed into a feature means inches (stored with the unit);
/// lengths with units and angles are left alone.
#[test]
fn bare_lengths_take_the_design_units() {
let mut s = Session::default();
s.doc_mut().units = "in".into();
run(&mut s, "solid.box", json!({"length": 2, "width": "10 mm", "height": 1}));
let m = run(&mut s, "inspect.measure", json!({}));
let max = |i: usize| m["bodies"][0]["bbox"]["max"][i].as_f64().unwrap_or(0.0);
assert!((max(0) - 50.8).abs() < 1e-9 && (max(1) - 10.0).abs() < 1e-9 && (max(2) - 25.4).abs() < 1e-9, "{m}");
let d = s.doc.features[0].param_names[2].clone();
run(&mut s, "parameters.change", json!({"name": d, "expression": "2"}));
let m = run(&mut s, "inspect.measure", json!({}));
assert!((m["bodies"][0]["bbox"]["max"][2].as_f64().unwrap_or(0.0) - 50.8).abs() < 1e-9);
assert_eq!(s.doc.features[0].kind.inputs()[2].1, "2 in");
}
/// Press Pull on a fillet's face changes its radius; on another curved face it offsets it.
#[test]
fn press_pull_on_curved_faces() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "solid.fillet", json!({"edges": [[40, 0, 10]], "radius": 3, "name": "Round"}));
let corner = |r: f64| (4.0 - PI) * r * r / 4.0 * 20.0;
assert!(rel(volume(&mut s), 24000.0 - corner(3.0)) < 1e-4);
// A point on the fillet: the arc's middle.
let c = Vec3::new(37.0, 3.0, 10.0);
let on = c + Vec3::new(1.0, -1.0, 0.0).normalized().unwrap() * 3.0;
let out = run(&mut s, "solid.press_pull", json!({"face": [on.x, on.y, on.z], "distance": 2}));
assert_eq!(out["edited"], "Round", "{out}");
assert!(rel(volume(&mut s), 24000.0 - corner(5.0)) < 1e-4, "{}", volume(&mut s));
// A hole's wall: pulled along its normal (into the hole), the hole gets smaller.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "solid.hole", json!({"position": [20, 15, 20], "diameter": 10}));
let v0 = volume(&mut s);
run(&mut s, "solid.press_pull", json!({"face": [25, 15, 10], "distance": 1}));
let v1 = volume(&mut s);
assert!(rel(v1 - v0, PI * (25.0 - 16.0) * 20.0) < 1e-3, "{v0} {v1}");
}
/// A cut in one component leaves other components' bodies alone unless they are named as
/// participants; auto join/cut looks at the active component only.
#[test]
fn booleans_stay_in_their_component() {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 20)] {
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 10, "corner": [x, 0, 0], "body_name": format!("{name}Box")}));
}
// A's box spans x 0…20, B's x 20…40 (in their own frames, both occurrences in place).
run(&mut s, "component.activate", json!({"component": "A"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Slot"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [8, 2], "p1": [28, 8]}));
run(&mut s, "sketch.finish", json!({}));
let vol = |s: &mut Session, b: &str| run(s, "inspect.measure", json!({"bodies": [b]}))["total"]["volume_mm3"].as_f64().unwrap_or(0.0);
assert_eq!(auto_operation(&s, &json!({"sketch": "Slot", "distance": 10})), Some("cut"));
run(&mut s, "solid.extrude", json!({"sketch": "Slot", "distance": 10, "operation": "cut", "name": "Cut"}));
assert!((vol(&mut s, "ABox") - (2000.0 - 12.0 * 6.0 * 10.0)).abs() < 1e-6, "{}", vol(&mut s, "ABox"));
assert!((vol(&mut s, "BBox") - 2000.0).abs() < 1e-6, "B untouched: {}", vol(&mut s, "BBox"));
// Named as a participant, B is cut too.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Slot", "distance": 10, "operation": "cut", "participants": ["ABox", "BBox"]}));
assert!((vol(&mut s, "BBox") - (2000.0 - 8.0 * 6.0 * 10.0)).abs() < 1e-6, "{}", vol(&mut s, "BBox"));
assert!(s.execute("solid.extrude", &json!({"sketch": "Slot", "distance": 1, "operation": "cut", "participants": ["Nope"]})).is_err());
}
/// A unit-less parameter used as a length means the design's units.
#[test]
fn unitless_parameters_used_as_lengths_take_the_design_units() {
let mut s = Session::default();
s.doc_mut().units = "in".into();
run(&mut s, "parameters.add", json!({"name": "w", "expression": "2", "unit": ""}));
run(&mut s, "solid.box", json!({"length": "w", "width": "w * 1", "height": "10 mm"}));
let m = run(&mut s, "inspect.measure", json!({}));
let max = |i: usize| m["bodies"][0]["bbox"]["max"][i].as_f64().unwrap_or(0.0);
assert!((max(0) - 50.8).abs() < 1e-9 && (max(1) - 50.8).abs() < 1e-9 && (max(2) - 10.0).abs() < 1e-9, "{m}");
}
/// A chamfer on a tapered pocket survives parameter edits and undo/redo.
#[test]
fn chamfer_sloped_basin_rim_recomputes() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 30}));
for (name, z, p0, p1) in [("Rim", 30, [20, 4], [80, 50]), ("Floor", 10, [30, 20], [70, 40])] {
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": z, "name": name}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": p0, "p1": p1}));
run(&mut s, "sketch.finish", json!({}));
}
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "Rim"}, {"sketch": "Floor"}], "operation": "cut"}));
run(&mut s, "parameters.add", json!({"name": "rim_chamfer", "expression": "1 mm"}));
let base = volume(&mut s);
run(&mut s, "solid.chamfer", json!({"edges": [[50, 4, 30]], "distance": "rim_chamfer"}));
let expected = |size: f64| {
let wall_slope: f64 = (20.0 - 4.0) / (30.0 - 10.0); // horizontal run / vertical drop
let drop = size / (1.0 + wall_slope.powi(2)).sqrt();
base - 0.5 * size * drop * (60.0 - drop / 3.0)
};
assert!(rel(volume(&mut s), expected(1.0)) < 1e-8);
let edited = run(&mut s, "parameters.change", json!({"name": "rim_chamfer", "expression": "2 mm"}));
assert_eq!(edited["errors"].as_array().unwrap().len(), 0, "{edited}");
assert!(rel(volume(&mut s), expected(2.0)) < 1e-8);
run(&mut s, "edit.undo", json!({}));
assert!(rel(volume(&mut s), expected(1.0)) < 1e-8);
run(&mut s, "edit.redo", json!({}));
assert!(rel(volume(&mut s), expected(2.0)) < 1e-8);
}
/// Chamfers with two distances or a distance and an angle.
#[test]
fn unequal_chamfers() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
// Top front edge: 2 along the top (the face facing up), 4 down the front.
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 20]], "distance": 2, "distance2": 4}));
assert!(rel(volume(&mut s), 24000.0 - 0.5 * 2.0 * 4.0 * 40.0) < 1e-6, "{}", volume(&mut s));
// Bottom back edge: 3 along the bottom, at 30° from it (down the back face 3·tan 30°).
run(&mut s, "solid.chamfer", json!({"edges": [[20, 30, 0]], "distance": 3, "angle": "30 deg", "flip": true}));
let d2 = 3.0 * (30f64).to_radians().tan();
assert!(rel(volume(&mut s), 24000.0 - 160.0 - 0.5 * 3.0 * d2 * 40.0) < 1e-6, "{}", volume(&mut s));
assert!(s.execute("solid.chamfer", &json!({"edges": [[0, 15, 20]], "distance": 1, "distance2": 2, "angle": "30 deg"})).is_err());
}
/// An enclosure built in the usual order: shell, bosses, then the lip on the rim.
#[test]
fn lip_after_boss() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 30}));
run(&mut s, "solid.shell", json!({"faces": [[40, 25, 30]], "thickness": 2}));
run(&mut s, "plastic.boss", json!({"position": [10, 10, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}));
run(&mut s, "plastic.boss", json!({"position": [70, 40, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}));
let v0 = volume(&mut s);
run(&mut s, "plastic.lip", json!({"face": [1, 25, 30], "width": 1, "height": 2}));
let v = volume(&mut s);
assert!(v > v0 + 1.0, "{v0} {v}");
// Bosses up to the rim, with ribs, against a wall; lips, grooves and outside rims.
let bosses = [
json!({"position": [15, 15, 2], "diameter": 8, "height": 28, "hole_diameter": 3}),
// Ribs out to the inner walls (15 − 4 − 9 = 2), and short of them.
json!({"position": [15, 15, 2], "diameter": 8, "height": 12, "ribs": 4, "rib_thickness": 1.5, "rib_length": 9}),
json!({"position": [15, 15, 2], "diameter": 8, "height": 12, "ribs": 4, "rib_thickness": 1.5, "rib_length": 6}),
json!({"position": [5.5, 25, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}),
json!({"position": [40, 25, 2], "diameter": 8, "height": 20, "draft": "1 deg", "fillet": 1}),
];
let lips = [
json!({"face": [1, 25, 30], "width": 1, "height": 2}),
json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove"}),
json!({"face": [1, 25, 30], "width": 1, "height": 2, "side": "outside"}),
];
for (i, b) in bosses.iter().enumerate() {
for (j, l) in lips.iter().enumerate() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 30}));
run(&mut s, "solid.shell", json!({"faces": [[40, 25, 30]], "thickness": 2}));
let r = s.execute("plastic.boss", b);
assert!(r.is_ok(), "boss {i}: {r:?}");
let v0 = volume(&mut s);
let r = s.execute("plastic.lip", l);
assert!(r.is_ok(), "boss {i} lip {j}: {r:?}");
assert!((volume(&mut s) - v0).abs() > 1.0, "boss {i} lip {j}");
}
}
}
/// A drilled hole as deep as the plate: the drill point breaks through the bottom.
#[test]
fn hole_as_deep_as_the_plate() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 50, "width": 25, "height": 10}));
let v0 = volume(&mut s);
run(&mut s, "solid.hole", json!({"position": [25, 12.5, 10], "diameter": 6, "depth": 10, "type": "drilled"}));
let v = volume(&mut s);
assert!(v < v0 - PI * 9.0 * 9.0 && v > v0 - PI * 9.0 * 10.0 - 1.0, "{v0} {v}");
// Other sizes and places, the defaults, and a tapped hole.
for (i, p) in [
json!({"position": [10, 10, 10], "diameter": 5, "depth": 10, "type": "drilled"}),
json!({"position": [40, 15, 10], "diameter": 5, "depth": 10}),
json!({"position": [12, 6, 10], "diameter": 8.5, "depth": 10}),
json!({"position": [30, 7, 10], "thread": "M6", "depth": 10}),
json!({"position": [20, 18, 10], "diameter": 4, "depth": 10, "tip_angle": 90}),
]
.into_iter()
.enumerate()
{
let before = volume(&mut s);
let r = s.execute("solid.hole", &p);
assert!(r.is_ok(), "{i} {p}: {r:?}");
assert!(volume(&mut s) < before - 1.0, "{i} {p}");
}
}
/// Appearances outlive later features: a coloured face keeps its colour after a fillet on a
/// neighbouring edge; an imported STEP's body and face colours survive a hole.
#[test]
fn appearances_survive_later_features() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "B"}));
run(&mut s, "appearance.assign", json!({"faces": [[20, 15, 20]], "color": "#ff0000"}));
run(&mut s, "solid.fillet", json!({"edges": [[40, 15, 0]], "radius": 3}));
let red = |s: &mut Session, body: &str| {
let l = run(s, "appearance.list", json!({ "body": body }));
l["body"]["faces"].as_array().into_iter().flatten().any(|f| f["appearance"]["color"] == "#ff0000")
};
assert!(red(&mut s, "B"), "the top face is still red after the fillet");
// Exported with a body colour and the red face, then opened: the colours come in as the
// body's own; a hole afterwards keeps them.
run(&mut s, "appearance.assign", json!({"bodies": ["B"], "color": "#2050c0"}));
let dir = std::env::temp_dir().join(format!("solidcraft-paint-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let step = dir.join("painted.step");
run(&mut s, "file.export", json!({"path": step.to_string_lossy()}));
let mut t = Session::default();
run(&mut t, "doc.open", json!({"path": step.to_string_lossy()}));
let body = t.model.state().bodies[0].name.clone();
let before = t.doc.body_color(&t.model.state().bodies[0]);
assert!(before.is_some());
run(&mut t, "solid.hole", json!({"position": [10, 10, 20], "diameter": 4}));
let st = t.model.state();
let b = st.body(&body).unwrap();
assert!(b.body.paint().is_none(), "the hole made a new kernel body");
assert_eq!(t.doc.body_color(b), before, "the imported body colour carries on");
assert!(red(&mut t, &body), "the imported red face carries on");
let _ = std::fs::remove_dir_all(&dir);
}
/// The original #2 RGB import acceptance, including later topology and placement edits.
#[test]
fn imported_rgb_appearances_survive_edits_reopen_and_step_export() {
let dir = std::env::temp_dir().join(format!("solidcraft-imported-rgb-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let input = dir.join("rgb.step");
let saved = dir.join("edited.solidcraft");
let output = dir.join("edited.step");
let mut source = Session::default();
for (name, x, color) in [("Red", 0, "#ff0000"), ("Green", 40, "#00ff00"), ("Blue", 80, "#0000ff")] {
run(&mut source, "solid.box", json!({"corner":[x,0,0],"length":30,"width":20,"height":20,"body_name":name}));
run(&mut source, "appearance.assign", json!({"bodies":[name],"color":color}));
}
run(&mut source, "appearance.assign", json!({"faces":[[15,10,20]],"color":"#ffff00"}));
run(&mut source, "file.export", json!({"path":input}));
let check = |session: &Session| {
let state = session.model.state();
assert_eq!(state.bodies.len(), 3);
for (name, color) in [("Red", [1., 0., 0.]), ("Green", [0., 1., 0.]), ("Blue", [0., 0., 1.])] {
let body = state.body(name).unwrap();
assert_eq!(session.doc.body_color(body), Some(color), "{name} retains its imported body colour");
}
let red = state.body("Red").unwrap();
assert!(session.doc.face_colors(red).iter().any(|(_, look)| look.color == [255, 255, 0]), "Red retains its yellow top face");
assert!(session.model.results.iter().all(|result| result.error.is_none()));
};
let mut edited = Session::default();
run(&mut edited, "doc.open", json!({"path":input}));
check(&edited);
let initial_volume = volume(&mut edited);
run(&mut edited, "solid.fillet", json!({"bodies":["Red"],"edges":[[0,10,20]],"radius":2}));
check(&edited);
run(&mut edited, "solid.move", json!({"bodies":["Red"],"translate":[0,0,5]}));
check(&edited);
run(&mut edited, "solid.box", json!({"corner":[85,5,15],"length":10,"width":10,"height":10,"body_name":"Cutter"}));
run(&mut edited, "solid.combine", json!({"target":"Blue","tools":["Cutter"],"operation":"cut"}));
check(&edited);
run(&mut edited, "solid.box", json!({"corner":[65,0,0],"length":10,"width":20,"height":20,"body_name":"Joiner"}));
run(&mut edited, "solid.combine", json!({"target":"Green","tools":["Joiner"],"operation":"join"}));
check(&edited);
let edited_volume = volume(&mut edited);
// The join adds 5*20*20, the pocket removes 10*10*5, and the
// radius-2 quarter-round replaces a 2*2 square along a 20 mm edge.
let expected_volume = initial_volume + 2000. - 500. - (4. - PI) * 20.;
assert!((edited_volume - expected_volume).abs() < 0.1, "{edited_volume} vs {expected_volume}");
run(&mut edited, "edit.undo", json!({}));
run(&mut edited, "edit.redo", json!({}));
check(&edited);
run(&mut edited, "file.save_as", json!({"path":saved}));
let mut reopened = Session::default();
run(&mut reopened, "doc.open", json!({"path":saved}));
check(&reopened);
assert!(rel(volume(&mut reopened), edited_volume) < 1e-8);
run(&mut reopened, "file.export", json!({"path":output}));
let mut roundtrip = Session::default();
run(&mut roundtrip, "doc.open", json!({"path":output}));
check(&roundtrip);
assert!(rel(volume(&mut roundtrip), edited_volume) < 1e-3);
let _ = std::fs::remove_dir_all(dir);
}
/// Extrude To Object (a parallel face, with an offset; a slanted face) and From Object.
#[test]
fn extrude_to_and_from_objects() {
let mut s = Session::default();
// A 10 mm plate at z 30…40 to extrude up to, and a profile on XY.
run(&mut s, "solid.box", json!({"corner": [0, 0, 30], "length": 40, "width": 40, "height": 10, "body_name": "Roof"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "P"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [15, 15]}));
run(&mut s, "sketch.finish", json!({}));
let top = |s: &mut Session, b: &str| run(s, "inspect.measure", json!({"bodies": [b]}))["bodies"][0]["bbox"]["max"][2].as_f64().unwrap_or(0.0);
run(&mut s, "solid.extrude", json!({"sketch": "P", "to": [20, 20, 30], "body_name": "Post"}));
assert!((top(&mut s, "Post") - 30.0).abs() < 1e-6, "{}", top(&mut s, "Post"));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "P", "to": [20, 20, 30], "to_offset": -5, "body_name": "Post"}));
assert!((top(&mut s, "Post") - 25.0).abs() < 1e-6);
run(&mut s, "edit.undo", json!({}));
// From the roof's top face (z 40) to 10 mm above it.
run(&mut s, "solid.extrude", json!({"sketch": "P", "from": [20, 20, 40], "distance": 10, "body_name": "Post"}));
let m = run(&mut s, "inspect.measure", json!({"bodies": ["Post"]}));
assert!((m["bodies"][0]["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 40.0).abs() < 1e-6 && (top(&mut s, "Post") - 50.0).abs() < 1e-6, "{m}");
// The To face is kept by its persistent name, so it is followed when it moves.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "P", "to": [20, 20, 30], "body_name": "Post"}));
let name = s.doc.features.last().map(|f| f.face_names.clone()).unwrap_or_default();
assert_eq!(name, vec!["F1:-z".to_string()], "the To face is stored by name");
assert!(s.execute("solid.extrude", &json!({"sketch": "P", "to": "nope"})).is_err());
}
/// Revolve Two Sides, Symmetric and To Object.
#[test]
fn revolve_two_sides_symmetric_and_to_object() {
let ring = |extra: Value| {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "S"}));
run(&mut s, "sketch.line", json!({"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true}));
run(&mut s, "sketch.finish", json!({}));
let mut p = json!({"sketch": "S", "axis": "y", "angle": "90 deg"});
for (k, v) in extra.as_object().into_iter().flatten() {
p[k] = v.clone();
}
run(&mut s, "solid.revolve", p);
let m = run(&mut s, "inspect.measure", json!({}));
(m["total"]["volume_mm3"].as_f64().unwrap_or(0.0), m["bodies"][0]["bbox"].clone())
};
let full = PI * (225.0 - 100.0) * 5.0;
let (v, _) = ring(json!({}));
assert!(rel(v, full / 4.0) < 1e-3);
let (v, _) = ring(json!({"angle2": "45 deg"}));
assert!(rel(v, full * 135.0 / 360.0) < 1e-3, "{v}");
let (v, b) = ring(json!({"direction": "symmetric"}));
assert!(rel(v, full / 4.0) < 1e-3);
// Split evenly: the extent either side of the sketch plane (y = 0) is the same.
assert!((b["max"][1].as_f64().unwrap_or(0.0) + b["min"][1].as_f64().unwrap_or(0.0)).abs() < 1e-3, "{b}");
// To a point a quarter turn round: the same as 90°.
let (v90, b90) = ring(json!({}));
let corner = if b90["max"][1].as_f64().unwrap_or(0.0) > 1.0 { json!([0, 12, 0]) } else { json!([0, -12, 0]) };
let (v, _) = ring(json!({"to": corner}));
assert!(rel(v, v90) < 1e-3, "{v} {v90}");
}
/// Shell Inside, Outside and Both, and a tangent chain of open faces.
#[test]
fn shell_directions_and_tangent_chain() {
let shell = |extra: Value| {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
let mut p = json!({"faces": [[20, 15, 20]], "thickness": 2});
for (k, v) in extra.as_object().into_iter().flatten() {
p[k] = v.clone();
}
run(&mut s, "solid.shell", p);
let m = run(&mut s, "inspect.measure", json!({}));
(m["total"]["volume_mm3"].as_f64().unwrap_or(0.0), m["bodies"][0]["bbox"].clone())
};
let (v, _) = shell(json!({}));
assert!(rel(v, 40.0 * 30.0 * 20.0 - 36.0 * 26.0 * 18.0) < 1e-6, "{v}");
// Outside: the walls grow out; the original box is the cavity.
let (v, b) = shell(json!({"direction": "outside"}));
assert!(rel(v, 44.0 * 34.0 * 22.0 - 40.0 * 30.0 * 20.0) < 1e-6, "{v} {b}");
// Both: 1 mm out, 1 mm in.
let (v, _) = shell(json!({"direction": "both"}));
assert!(rel(v, 42.0 * 32.0 * 21.0 - 38.0 * 28.0 * 19.0) < 1e-6, "{v}");
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
assert!(s.execute("solid.shell", &json!({"faces": [[20, 15, 20]], "thickness": 2, "direction": "sideways"})).is_err());
}
/// Hole To Object (a face it meets, or a point) and holes at every point of a sketch.
#[test]
fn hole_to_object_and_at_sketch_points() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
// A pocket from below: its ceiling is at z = 10.
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Pocket"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [30, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Pocket", "distance": 10, "operation": "cut"}));
let v0 = volume(&mut s);
run(&mut s, "solid.hole", json!({"position": [20, 15, 20], "diameter": 4, "to": [25, 15, 10]}));
assert!(rel(v0 - volume(&mut s), PI * 4.0 * 10.0) < 1e-3, "{}", v0 - volume(&mut s));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "solid.hole", json!({"position": [5, 5, 20], "diameter": 4, "to": [5, 5, 12]}));
assert!(rel(v0 - volume(&mut s), PI * 4.0 * 8.0) < 1e-3, "{}", v0 - volume(&mut s));
// Every point of a sketch: three points (the rectangle's corners are not hole points).
let mut t = Session::default();
run(&mut t, "solid.box", json!({"length": 40, "width": 30, "height": 10}));
run(&mut t, "sketch.create", json!({"plane": {"face": [20, 15, 10]}, "name": "Spots"}));
for x in [10, 20, 30] {
run(&mut t, "sketch.point", json!({"point": [x, 15]}));
}
run(&mut t, "sketch.rectangle.two_point", json!({"p0": [2, 2], "p1": [6, 6]}));
run(&mut t, "sketch.finish", json!({}));
let w0 = volume(&mut t);
let r = run(&mut t, "solid.hole", json!({"sketch": "Spots", "diameter": 3}));
assert!(rel(w0 - volume(&mut t), 3.0 * PI * 2.25 * 10.0) < 1e-3, "{r} {}", w0 - volume(&mut t));
}
/// Combine keeps its tools on request and can put its result in a new component.
#[test]
fn combine_keep_tools_and_new_component() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "body_name": "A"}));
run(&mut s, "solid.box", json!({"corner": [10, 0, 0], "length": 20, "width": 20, "height": 20, "body_name": "B"}));
let r = run(&mut s, "solid.combine", json!({"target": "A", "tools": ["B"], "operation": "cut", "keep_tools": true, "new_component": true}));
let comp = r["component"].as_u64().unwrap_or(0);
assert!(comp > 0, "{r}");
let st = s.model.state();
assert!(st.body("B").is_some(), "the tool is kept");
let a = st.body("A").unwrap();
assert_eq!(s.doc.body_component("A", a.feature), comp);
assert!(rel(solidcraft_kernel::measure(&a.body).unwrap().volume, 4000.0) < 1e-9);
}
/// Align with snap points: a box's vertex onto another's face centre, an edge midpoint onto a
/// hole's circle centre.
#[test]
fn align_with_snap_points() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 10, "body_name": "Base"}));
run(&mut s, "solid.box", json!({"corner": [100, 0, 0], "length": 10, "width": 10, "height": 10, "body_name": "Cube"}));
// Cube's corner (100, 0, 0) onto Base's top face centre (20, 20, 10).
run(
&mut s,
"solid.align",
json!({"bodies": ["Cube"], "from": {"snap": "vertex", "at": [100.4, 0.3, 0.2]}, "to": {"snap": "face_center", "at": [5, 5, 10]}}),
);
let m = run(&mut s, "inspect.measure", json!({"bodies": ["Cube"]}));
let min = |i: usize| m["bodies"][0]["bbox"]["min"][i].as_f64().unwrap_or(f64::NAN);
assert!((min(0) - 20.0).abs() < 1e-6 && (min(1) - 20.0).abs() < 1e-6 && (min(2) - 10.0).abs() < 1e-6, "{m}");
assert!(s.execute("solid.align", &json!({"bodies": ["Cube"], "from": {"snap": "nope", "at": [0, 0, 0]}, "to": [0, 0, 0]})).is_err());
}
/// Boundary Fill: the lens two overlapping boxes share, and one box less the other.
#[test]
fn boundary_fill_cells() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "body_name": "A"}));
run(&mut s, "solid.box", json!({"corner": [10, 0, 0], "length": 20, "width": 20, "height": 20, "body_name": "B"}));
let r = run(&mut s, "solid.boundary_fill", json!({"tools": ["A", "B"], "cells": [[15, 10, 10]], "remove_tools": true}));
let m = run(&mut s, "inspect.measure", json!({}));
assert_eq!(m["body_count"], 1, "{r} {m}");
assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap_or(0.0), 10.0 * 20.0 * 20.0) < 1e-9);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "solid.boundary_fill", json!({"tools": ["A", "B"], "cells": [[5, 10, 10], [25, 10, 10]], "remove_tools": true}));
let m = run(&mut s, "inspect.measure", json!({}));
assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap_or(0.0), 2.0 * 10.0 * 20.0 * 20.0) < 1e-9, "{m}");
assert!(s.execute("solid.boundary_fill", &json!({"tools": ["A"], "cells": [[100, 100, 100]]})).is_err());
}
/// Rectangles name their sides and corners, so they can be dimensioned without inspecting the
/// sketch first, whichever way round the corners were given.
#[test]
fn rectangle_names_sides_and_corners() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [40, 30], "p1": [0, 0]}));
let pos = |s: &Session, id: &Value| {
let sk = s.doc.sketch(s.active_sketch.unwrap()).unwrap();
sk.point(sk.resolve_point(id.as_str().unwrap()).unwrap()).unwrap()
};
for (corner, at) in [("bottom_left", [0.0, 0.0]), ("bottom_right", [40.0, 0.0]), ("top_right", [40.0, 30.0]), ("top_left", [0.0, 30.0])] {
let p = pos(&s, &r["corners"][corner]);
assert!((p.x - at[0]).abs() < 1e-9 && (p.y - at[1]).abs() < 1e-9, "{corner}: {p:?} in {r}");
}
for (side, from, to) in [
("bottom", "bottom_left", "bottom_right"),
("right", "bottom_right", "top_right"),
("top", "top_right", "top_left"),
("left", "top_left", "bottom_left"),
] {
let line = r["sides"][side].as_str().unwrap();
assert!(r["curves"].as_array().unwrap().iter().any(|c| c == line), "{r}");
assert_eq!(pos(&s, &json!(format!("{line}.start"))), pos(&s, &r["corners"][from]), "{side} in {r}");
assert_eq!(pos(&s, &json!(format!("{line}.end"))), pos(&s, &r["corners"][to]), "{side} in {r}");
}
run(&mut s, "sketch.constraint.coincident", json!({"a": r["corners"]["bottom_left"], "b": "origin"}));
run(&mut s, "sketch.dimension", json!({"entities": [r["sides"]["bottom"]], "value": 50}));
run(&mut s, "sketch.dimension", json!({"entities": [r["sides"]["left"]], "value": 20}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["dof"], 0, "{si}");
let p = pos(&s, &r["corners"]["top_right"]);
assert!((p.x - 50.0).abs() < 1e-6 && (p.y - 20.0).abs() < 1e-6, "{p:?}");
let c = run(&mut s, "sketch.rectangle.center", json!({"center": [100, 0], "corner": [90, 5]}));
let p = pos(&s, &c["corners"]["bottom_left"]);
assert!((p.x - 90.0).abs() < 1e-9 && (p.y + 5.0).abs() < 1e-9, "{c}");
assert_eq!(pos(&s, &json!(format!("{}.start", c["sides"]["top"].as_str().unwrap()))), pos(&s, &c["corners"]["top_right"]), "{c}");
}
/// Points drawn exactly onto an existing point (the origin, a corner) are connected to it;
/// `connect: false` leaves them free, and tying them again by hand is a no-op, not a conflict.
#[test]
fn drawing_on_existing_points_connects_to_them() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let n = |v: &Value| v["constraints"].as_array().map_or(0, Vec::len);
// Horizontal/vertical x4, plus the corner on the origin.
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
assert_eq!(n(&r), 5, "{r}");
run(&mut s, "sketch.dimension", json!({"entities": [r["curves"][0]], "value": 40}));
run(&mut s, "sketch.dimension", json!({"entities": [r["curves"][1]], "value": 30}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["dof"], 0, "fully constrained without a hand-made coincident: {si}");
let again = run(&mut s, "sketch.constraint.coincident", json!({"a": "origin", "b": "l1.start"}));
assert_eq!(again["existing"], true, "{again}");
// A line from a corner, a circle on a corner and a point on the origin connect too.
assert_eq!(n(&run(&mut s, "sketch.line", json!({"points": [[40, 30], [60, 30]]}))), 1);
assert_eq!(n(&run(&mut s, "sketch.circle.center", json!({"center": [0, 30], "radius": 3}))), 1);
assert_eq!(n(&run(&mut s, "sketch.point", json!({"point": [0, 0]}))), 1);
// Close is not on it, and `connect: false` opts out.
assert_eq!(n(&run(&mut s, "sketch.point", json!({"point": [40.001, 0]}))), 0);
assert_eq!(n(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, -10]], "connect": false}))), 0);
let si = run(&mut s, "sketch.inspect", json!({}));
assert_ne!(si["status"], "conflict", "{si}");
}
#[test]
fn computed_import_preserves_file_and_earlier_undo_history() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
s.path = Some("existing.solidcraft".into());
s.mark_saved();
let saved = s.doc.clone();
let mut computed = s.scratch();
run(&mut computed, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [30, 0, 0]}));
s.accept_computed(computed);
assert_eq!(s.path.as_deref(), Some("existing.solidcraft"));
assert!(s.is_dirty());
assert_eq!(s.model.state().bodies.len(), 2);
run(&mut s, "edit.undo", json!({}));
assert!(std::sync::Arc::ptr_eq(&saved, &s.doc));
assert!(!s.is_dirty());
run(&mut s, "edit.undo", json!({}));
assert!(s.model.state().bodies.is_empty());
}
#[test]
fn adopting_a_computed_component_keeps_its_warm_world_geometry() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Part"}));
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10}));
let mut computed = s.scratch();
run(&mut computed, "solid.box", json!({"length":5,"width":5,"height":5,"corner":[30,0,0]}));
let prepared = computed.world_state();
for b in &prepared.bodies {
let _ = b.mesh();
}
s.accept_computed(computed);
assert!(std::sync::Arc::ptr_eq(&prepared, &s.world_state()));
s.invalidate_view(100);
assert!(std::sync::Arc::ptr_eq(&prepared, &s.world_state()));
}
/// Every file command checked the size with the metadata and then read to the end. A character
/// device reports a length of 0, so the cap passed and the read never finished; a FIFO blocked
/// the calling thread, which over the control channel is the thread drawing the window.
#[cfg(unix)]
#[test]
fn special_files_are_refused_by_every_file_command() {
let mut s = Session::default();
for cmd in ["doc.open", "parameters.import"] {
let r = s.execute(cmd, &json!({"path": "/dev/zero"}));
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("not a regular file")), "{cmd}: {r:?}");
}
// The commands that check the extension first need a special file with the right name.
let dir = std::env::temp_dir().join(format!("solidcraft-special-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
for (cmd, name) in [("file.insert_step", "x.step"), ("file.insert_mesh", "x.3mf"), ("sketch.insert_dxf", "x.dxf")] {
let p = dir.join(name);
let _ = std::fs::remove_file(&p);
if !std::process::Command::new("mkfifo").arg(&p).status().map(|st| st.success()).unwrap_or(false) {
continue;
}
let r = s.execute(cmd, &json!({"path": p.to_string_lossy()}));
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("not a regular file")), "{cmd}: {r:?}");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A cut targeted at one body leaves a non-target body alone, even one the tool covers completely (#260).
#[test]
fn targeted_cut_keeps_a_covered_body_that_is_not_a_target() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "Frame"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new_body", "body_name": "Other"}));
// Exactly Other's extent; it does not touch Frame.
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "cut", "targets": ["Frame"]}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["Frame", "Other"]);
assert!(rel(volume(&mut s), 2000.0) < 1e-6);
}
/// A frame's opening cut over a separate mesh body that sits in it: the mesh survives (#260).
#[test]
fn targeted_cut_keeps_an_overlapping_body_that_is_not_a_target() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 50, "width": 50, "height": 5, "operation": "new_body", "body_name": "Frame"}));
run(&mut s, "solid.box", json!({"length": 30, "width": 30, "height": 1, "corner": [10, 10, 2], "operation": "new_body", "body_name": "Mesh"}));
run(&mut s, "solid.box", json!({"length": 30, "width": 30, "height": 5, "corner": [10, 10, 0], "operation": "cut", "targets": ["Frame"]}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["Frame", "Mesh"]);
assert!(rel(volume(&mut s), 50.0 * 50.0 * 5.0 - 30.0 * 30.0 * 5.0 + 900.0) < 1e-6);
}
/// A join with explicit `targets` merges into those bodies only; a touched non-target stays (#259).
#[test]
fn join_with_targets_leaves_other_touched_bodies_alone() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [10, 0, 0], "operation": "new_body", "body_name": "B"}));
// Straddles the face between A and B; only A is a target.
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [8, 3, 3], "operation": "join", "targets": ["A"]}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["A", "B"], "B is not a target and must stay a body");
let b = run(&mut s, "inspect.measure", json!({"bodies": ["B"]}))["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(b, 1000.0) < 1e-6, "B is unchanged: {b}");
// A gains the half of the tool outside it.
let a = run(&mut s, "inspect.measure", json!({"bodies": ["A"]}))["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(a, 1032.0) < 1e-6, "A: {a}");
}
/// Deleting a sketch takes its extrude with it; a later combine that names the extrude's body must
/// not be left failing silently: it is deleted too, or the reply reports it (#267).
#[test]
fn deleting_a_sketch_leaves_no_silently_failing_feature() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": "10", "body_name": "P"}));
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [18, 5, 0], "operation": "new_body", "body_name": "Q"}));
run(&mut s, "solid.combine", json!({"target": "P", "tools": ["Q"], "name": "JoinPQ"}));
let reply = run(&mut s, "timeline.delete", json!({"features": ["S"]}));
if let Ok(join) = s.execute("timeline.inspect", &json!({"feature": "JoinPQ"})) {
assert!(
join["error"].is_null() || reply.to_string().contains("JoinPQ"),
"JoinPQ now fails ({}) but the reply only says {reply}",
join["error"]
);
}
}
/// Every body a multi-profile `new` extrude creates carries its `body_name`, not a generic BodyN (#266).
#[test]
fn multi_profile_extrude_names_every_body_after_body_name() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "R"}));
for x in [0, 10, 20] {
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x, 0], "p1": [x + 5, 30]}));
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": "10", "operation": "new_body", "body_name": "Ribs"}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert!(names.iter().all(|n| n.starts_with("Ribs")), "{names:?}");
assert!(rel(volume(&mut s), 4500.0) < 1e-6);
}
/// Renaming one pattern copy leaves the other copies' names alone (#265).
#[test]
fn renaming_a_pattern_copy_keeps_the_other_copies_names() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "X"}));
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["X"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 20}));
run(&mut s, "body.rename", json!({"body": "X (2)", "name": "Second"}));
let third = run(&mut s, "inspect.measure", json!({"bodies": ["X (3)"]}));
assert_eq!(third["bodies"][0]["bbox"]["min"][0].as_f64().map(f64::round), Some(40.0), "X (3) is still the copy at x = 40: {third}");
run(&mut s, "body.rename", json!({"body": "X (3)", "name": "Third"}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["X", "Second", "Third"]);
}
/// Parameters that work are listed in the command's params doc, which is all agents see (#271).
#[test]
fn params_docs_list_working_parameters() {
let mut s = Session::default();
let cmds = run(&mut s, "engine.commands", json!({}));
let params =
|id: &str| cmds.as_array().unwrap().iter().find(|c| c["id"] == id).and_then(|c| c["params"].as_str()).unwrap_or_default().to_string();
// Both work today: a named base flange, and a loft cut limited to its targets.
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "A"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [50, 50]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "A", "body_name": "Panel A"}));
assert!(s.model.state().body("Panel A").is_some());
assert!(params("sheet.flange").contains("body_name"), "sheet.flange: {}", params("sheet.flange"));
assert!(params("solid.loft").contains("targets"), "solid.loft: {}", params("solid.loft"));
}
/// Every body-creating feature documents the keys `add_feature` reads for all of them (`name`,
/// `body_name`), and those that take `targets` say so (#271).
#[test]
fn feature_params_docs_list_shared_keys() {
let mut s = Session::default();
let cmds = run(&mut s, "engine.commands", json!({}));
let params =
|id: &str| cmds.as_array().unwrap().iter().find(|c| c["id"] == id).and_then(|c| c["params"].as_str()).unwrap_or_default().to_string();
let with_targets = ["solid.extrude", "solid.revolve", "solid.sweep", "solid.loft", "solid.pipe", "solid.coil"];
let without_targets = ["solid.box", "solid.cylinder", "solid.sphere", "solid.torus", "sheet.flange"];
for id in with_targets.iter().chain(&without_targets) {
let doc = params(id);
assert!(doc.contains("name?") && doc.contains("body_name?"), "{id}: {doc}");
}
for id in with_targets {
assert!(params(id).contains("targets?"), "{id}: {}", params(id));
}
}
#[test]
fn a_pattern_repeats_a_fillet_on_the_edges_it_found() {
// #219: a filleted pocket patterned along the plate, its height from a parameter. A taller
// pocket moves its far corner edges away from the points picked for the fillet; the pattern
// repeats the edges the fillet found (by name), not those points.
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "n", "expression": "14", "unit": ""}));
run(&mut s, "parameters.add", json!({"name": "h", "expression": "(419.1 mm - 3.175 mm - (n - 1) * 3.175 mm) / n", "unit": "mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [150, 422.275]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 19.05, "name": "Plate"}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 19.05, "name": "PocketPlane"}));
run(&mut s, "sketch.create", json!({"plane": "PocketPlane"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [3.175, 3.175], "p1": [146.825, 29.9357]}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "p1"], "type": "horizontal", "value": "3.175"}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "p1"], "type": "vertical", "value": "3.175"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "type": "length", "value": "143.65"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l4"], "type": "length", "value": "h"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 12.7, "direction": "negative", "operation": "cut", "name": "Pocket"}));
run(
&mut s,
"solid.fillet",
json!({"edges": [[3.175, 3.175, 12.700000000000001], [146.825, 3.175, 12.700000000000001], [146.825, 29.9357, 12.700000000000001], [3.175, 29.9357, 12.700000000000001]], "radius": 3.2}),
);
run(
&mut s,
"solid.pattern.rectangular",
json!({"features": ["Pocket", "Fillet1"], "dir1": [0, 1, 0], "count1": "n", "spacing1": "h + 3.175 mm"}),
);
let r = run(&mut s, "parameters.change", json!({"name": "n", "expression": "10"}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
let (n, h) = (10.0, (419.1 - 3.175 - 9.0 * 3.175) / 10.0);
let pocket = 143.65 * h * 12.7 - 4.0 * (1.0 - PI / 4.0) * 3.2 * 3.2 * 12.7;
let expect = 150.0 * 422.275 * 19.05 - n * pocket;
assert!(rel(volume(&mut s), expect) < 1e-5, "{} vs {expect}", volume(&mut s));
}
/// A copy added by raising the count after a rename takes the next number, not the freed one (#372).
#[test]
fn raising_a_pattern_count_after_a_rename_numbers_the_new_copy_after_the_others() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "X"}));
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["X"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 20, "name": "Pat"}));
run(&mut s, "body.rename", json!({"body": "X (2)", "name": "Second"}));
run(&mut s, "timeline.edit", json!({"feature": "Pat", "set": {"pattern": {"count1": "4"}}}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["X", "Second", "X (3)", "X (4)"]);
let fourth = run(&mut s, "inspect.measure", json!({"bodies": ["X (4)"]}));
assert_eq!(fourth["bodies"][0]["bbox"]["min"][0].as_f64().map(f64::round), Some(60.0), "{fourth}");
}
/// A profile added to the sketch after a named multi-profile extrude is named after `body_name`
/// too (#373).
#[test]
fn a_profile_added_later_is_named_after_body_name() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "R"}));
for x in [0, 10, 20] {
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x, 0], "p1": [x + 5, 30]}));
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "R", "distance": "10", "operation": "new_body", "body_name": "Ribs"}));
run(&mut s, "sketch.edit", json!({"sketch": "R"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [30, 0], "p1": [35, 30]}));
run(&mut s, "sketch.finish", json!({}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["Ribs", "Ribs (2)", "Ribs (3)", "Ribs (4)"]);
}
/// The reply names the bodies the feature created, apart from the ones already there (#374).
#[test]
fn extrude_reply_lists_the_bodies_it_created() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [0, 50, 0], "operation": "new_body", "body_name": "Base"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "R"}));
for x in [0, 10, 20] {
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x, 0], "p1": [x + 5, 30]}));
}
run(&mut s, "sketch.finish", json!({}));
let r = run(&mut s, "solid.extrude", json!({"sketch": "R", "distance": "10", "operation": "new_body", "body_name": "Ribs"}));
assert_eq!(r["created_bodies"], json!(["Ribs", "Ribs (2)", "Ribs (3)"]), "{r}");
}
/// A design saved before format 14 names the bodies past its stored names `BodyN`, as it did when
/// it was saved, so later features that name them still find them (#373).
#[test]
fn a_design_saved_before_format_14_keeps_generic_names_past_body_name() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "R"}));
for x in [0, 10, 20] {
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x, 0], "p1": [x + 5, 30]}));
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "R", "distance": "10", "operation": "new_body", "body_name": "Ribs"}));
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
legacy["format"] = json!("solidcraft/13");
let extrude = legacy["features"].as_array_mut().unwrap().iter_mut().find(|f| f["type"] == "extrude").unwrap();
extrude["body_names"] = json!(["Ribs"]);
let dir = std::env::temp_dir().join(format!("solidcraft-373-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let design = dir.join("ribs.solidcraft");
std::fs::write(&design, legacy.to_string()).unwrap();
let mut old = Session::default();
run(&mut old, "doc.open", json!({"path": design.to_string_lossy()}));
let names: Vec<String> = old.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["Ribs", "Body1", "Body2"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn fillet_fit_reduce_uses_the_largest_radius_that_fits() {
// #293: 3.5 is too large for the 6 mm end faces of a 14 x 6 x 10 box; reduce gives 3 (a full
// round), says so, and refits when the box changes.
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "w", "expression": "6 mm"}));
run(&mut s, "solid.box", json!({"length": 14, "width": "w", "height": 10, "corner": [14, 0, 0]}));
let mids = json!([[14, 0, 5], [28, 0, 5], [14, 6, 5], [28, 6, 5]]);
assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 3.5})).is_err());
let out = run(&mut s, "solid.fillet", json!({"edges": mids, "radius": 3.5, "fit": "reduce", "name": "Round"}));
assert_eq!(out["radius_applied"].as_f64(), Some(3.0), "{out}");
let full = 840.0 - (4.0 - PI) * 9.0 * 10.0;
assert!(rel(volume(&mut s), full) < 2e-4, "{} vs {full}", volume(&mut s));
let id = out["feature"].as_u64().unwrap();
let warning = s.model.result(id).and_then(|r| r.warning.clone()).unwrap_or_default();
assert!(warning.contains("reduced to 3 mm"), "{warning}");
// Wider: the requested 3.5 fits, nothing is reduced.
let r = run(&mut s, "parameters.change", json!({"name": "w", "expression": "8 mm"}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
assert_eq!(s.model.result(id).and_then(|r| r.state.fitted_radius(id)), None);
assert!(s.model.result(id).and_then(|r| r.warning.clone()).is_none());
// Saved only when set.
let json = serde_json::to_string(&s.doc.features).unwrap();
assert_eq!(json.matches("\"fit\":\"reduce\"").count(), 1, "{json}");
// An unknown mode is refused, and so are reduce and taper with a radius that is not constant.
assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 1, "fit": "stretch"})).is_err());
assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 1, "fit": "reduce", "type": "chord"})).is_err());
assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 1, "fit": "taper", "type": "chord"})).is_err());
}
#[test]
fn fillet_fit_taper_follows_the_room_along_the_edge() {
// #293: the top front edge of a plate whose top narrows from 20 to `tip` mm along 40 mm.
// At a 2 mm tip it can't take 3 mm; taper keeps 3 where it fits and comes down to 0.9 of the
// 2 mm there. With an 8 mm tip 3 fits and stays constant; a plate closing to a point
// tapers to 0. The warning says what was done.
let (k, edge) = (1.0 - PI / 4.0, json!([[20, 0, 15]]));
let plate = |tip: f64| {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let pts = if tip > 0.0 { json!([[0, 0], [40, 0], [40, tip], [0, 20]]) } else { json!([[0, 0], [40, 0], [0, 20]]) };
run(&mut s, "sketch.line", json!({"points": pts, "closed": true}));
run(&mut s, "solid.extrude", json!({"distance": 15}));
s
};
let warning = |s: &Session, out: &Value| out["feature"].as_u64().and_then(|id| s.model.result(id)).and_then(|r| r.warning.clone());
let mut s = plate(2.0);
assert!(s.execute("solid.fillet", &json!({"edges": edge, "radius": 3})).is_err());
let out = run(&mut s, "solid.fillet", json!({"edges": edge, "radius": 3, "fit": "taper"}));
let w = warning(&s, &out).unwrap_or_default();
assert!(w.contains("tapers to the room on 1 of 1 edges, down to 1.8 mm"), "{w}");
// Between the material a constant 1.8 and a constant 3 would remove.
let (v, full) = (volume(&mut s), 40.0 * 22.0 / 2.0 * 15.0);
assert!(v < full - k * 1.8 * 1.8 * 40.0 && v > full - k * 9.0 * 40.0, "{v}");
let json = serde_json::to_string(&s.doc.features).unwrap();
assert_eq!(json.matches("\"fit\":\"taper\"").count(), 1, "{json}");
let mut s = plate(8.0);
let out = run(&mut s, "solid.fillet", json!({"edges": edge, "radius": 3, "fit": "taper"}));
assert_eq!(warning(&s, &out), None);
assert!(rel(volume(&mut s), 40.0 * 28.0 / 2.0 * 15.0 - k * 9.0 * 40.0) < 2e-4);
let mut s = plate(0.0);
let out = run(&mut s, "solid.fillet", json!({"edges": edge, "radius": 16, "fit": "taper"}));
let w = warning(&s, &out).unwrap_or_default();
assert!(w.contains("on 1 of 1 edges, down to 0 mm"), "{w}");
}
#[test]
fn spline_fillet_takes_over_a_taper() {
// A tapered fillet keeps each edge's radius spline; given back as a `spline` fillet it makes
// the same part, and its control radii are then the user's to change.
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, 2], [0, 20]], "closed": true}));
run(&mut s, "solid.extrude", json!({"distance": 15}));
let edge = json!([[20, 0, 15]]);
let out = run(&mut s, "solid.fillet", json!({"edges": edge, "radius": 3, "fit": "taper"}));
let id = out["feature"].as_u64().unwrap();
let tapered = s.model.result(id).and_then(|r| r.state.tapered(id).map(<[_]>::to_vec)).unwrap();
let [(_, t)] = &tapered[..] else { panic!("{tapered:?}") };
let tapered_volume = volume(&mut s);
let radii: Vec<Value> = t.spline.radii.iter().skip(1).map(|r| json!(r)).collect();
let spline = json!({"edges": edge, "radius": t.spline.radii[0], "type": "spline", "start": [t.start.x, t.start.y, t.start.z], "knots": t.spline.knots, "radii": radii});
run(&mut s, "timeline.redefine", json!({"feature": id, "command": "solid.fillet", "params": spline}));
assert!(rel(volume(&mut s), tapered_volume) < 1e-9, "{} vs {tapered_volume}", volume(&mut s));
let json = serde_json::to_string(&s.doc.features).unwrap();
assert!(json.contains("\"type\":\"spline\""), "{json}");
// A control radius raised in the middle: more material goes.
let mut raised = spline.clone();
raised["radii"][2] = json!("4 mm");
run(&mut s, "timeline.redefine", json!({"feature": id, "command": "solid.fillet", "params": raised}));
assert!(volume(&mut s) < tapered_volume);
// Knots that don't match the radii are refused.
let mut bad = spline;
bad["knots"] = json!([0, 1]);
assert!(s.execute("solid.fillet", &bad).is_err());
}
#[test]
fn fillet_fit_reduce_keeps_the_radius_where_it_fits() {
// A 2 mm fin on a block: its top edge can't take 4, a corner of the block far from it can.
// Only the fin's edge is reduced; the reply gives each edge's radius.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "solid.box", json!({"length": 2, "width": 30, "height": 10, "corner": [19, 0, 20], "operation": "join"}));
assert_eq!(s.model.state().bodies.len(), 1);
let edges = json!([[21, 15, 30], [40, 0, 10]]);
let out = run(&mut s, "solid.fillet", json!({"edges": edges, "radius": 4, "fit": "reduce"}));
let fin = out["radius_applied"].as_f64().unwrap();
assert!(fin < 4.0, "{out}");
let each: Vec<(Vec<f64>, f64)> = out["radii_applied"]
.as_array()
.unwrap()
.iter()
.map(|x| (x["edge"].as_array().unwrap().iter().filter_map(Value::as_f64).collect(), x["radius"].as_f64().unwrap()))
.collect();
assert_eq!(each, [(vec![21.0, 15.0, 30.0], fin), (vec![40.0, 0.0, 10.0], 4.0)], "{out}");
let removed = (1.0 - PI / 4.0) * (fin * fin * 30.0 + 16.0 * 20.0);
let want = 40.0 * 30.0 * 20.0 + 2.0 * 30.0 * 10.0 - removed;
assert!(rel(volume(&mut s), want) < 2e-4, "{} vs {want}", volume(&mut s));
let id = out["feature"].as_u64().unwrap();
let warning = s.model.result(id).and_then(|r| r.warning.clone()).unwrap_or_default();
assert!(warning.contains("on 1 of 2 edges"), "{warning}");
}
#[test]
fn a_pattern_of_a_reduced_fillet_rounds_each_copy_alike() {
// #293: a 6 mm wide post on a plate, its four vertical edges at 3.5 reduced to 3, patterned
// three times: every copy is a full round.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 60, "width": 20, "height": 2}));
run(&mut s, "solid.box", json!({"length": 14, "width": 6, "height": 10, "corner": [2, 7, 2], "operation": "join", "name": "Post"}));
let mids = json!([[2, 7, 7], [16, 7, 7], [2, 13, 7], [16, 13, 7]]);
run(&mut s, "solid.fillet", json!({"edges": mids, "radius": 3.5, "fit": "reduce", "name": "Round"}));
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Post", "Round"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 20}));
let expect = 60.0 * 20.0 * 2.0 + 3.0 * (840.0 - (4.0 - PI) * 9.0 * 10.0);
assert!(rel(volume(&mut s), expect) < 2e-4, "{} vs {expect}", volume(&mut s));
}
/// A taper on a profile of lines and an arc (#546). The D profile (back 9.2, sides 7.4, a
/// half-round front R 4.6 tangent to them) grows by d = z · tan 5° with mitred corners:
/// A(d) = A₀ + P·d + (2 + π/2)·d², integrated over the 10 mm height.
#[test]
fn tapered_extrude_of_a_profile_mixing_lines_and_an_arc() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "D"}));
run(&mut s, "sketch.line", json!({"points": [[0, 4.6], [-7.4, 4.6], [-7.4, -4.6], [0, -4.6]], "closed": false}));
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [0, -4.6], "end": [0, 4.6]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "D", "distance": 10, "taper": "5 deg"}));
let (t, h) = (5f64.to_radians().tan(), 10.0);
let (a0, p, k) = (7.4 * 9.2 + PI * 4.6 * 4.6 / 2.0, 2.0 * 7.4 + 9.2 + PI * 4.6, 2.0 + PI / 2.0);
let expect = a0 * h + p * t * h * h / 2.0 + k * t * t * h.powi(3) / 3.0;
assert!(rel(volume(&mut s), expect) < 1e-3, "{} vs {expect}", volume(&mut s));
}
/// A feature that joins into a body creates none, and its reply says so; a primitive's reply
/// names the body it made (#374).
#[test]
fn feature_replies_list_only_the_bodies_they_created() {
let mut s = Session::default();
let r = run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Base"}));
assert_eq!(r["created_bodies"], json!(["Base"]), "{r}");
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Boss"}));
run(&mut s, "sketch.circle.center", json!({"center": [5, 5], "radius": 2}));
run(&mut s, "sketch.finish", json!({}));
let r = run(&mut s, "solid.extrude", json!({"sketch": "Boss", "distance": "15", "operation": "join"}));
assert_eq!(r["created_bodies"], json!([]), "{r}");
assert_eq!(r["bodies"], json!(["Base"]), "{r}");
}
/// A feature reply also says which existing bodies it changed and which are gone, so a client
/// that skips the full body list (MCP `batch` with `verbose: false`, #530) still sees every effect.
#[test]
fn feature_replies_list_the_bodies_they_changed_and_removed() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new", "body_name": "B"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 0, 0], "operation": "new", "body_name": "C"}));
// A pocket in A changes A only.
let r = run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 10, "corner": [3, 3, 5], "operation": "cut", "targets": ["A"]}));
assert_eq!((&r["created_bodies"], &r["changed_bodies"], &r["removed_bodies"]), (&json!([]), &json!(["A"]), &json!([])), "{r}");
// Joining C into B changes B and removes C.
let r = run(&mut s, "solid.combine", json!({"target": "B", "tools": ["C"], "operation": "join"}));
assert_eq!((&r["changed_bodies"], &r["removed_bodies"]), (&json!(["B"]), &json!(["C"])), "{r}");
// A new body changes nothing else.
let r = run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [0, 50, 0], "operation": "new", "body_name": "D"}));
assert_eq!((&r["created_bodies"], &r["changed_bodies"], &r["removed_bodies"]), (&json!(["D"]), &json!([]), &json!([])), "{r}");
}
/// Drafting a cylinder's side face 5° from the base plane makes a cone frustum (#547): radius 10
/// at the base, 10 − 20 · tan 5° at the top.
#[test]
fn draft_on_a_cylinder_side_makes_a_frustum() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 20}));
run(&mut s, "solid.draft", json!({"faces": [[10, 0, 10]], "angle": "5 deg", "neutral": "XY"}));
let (big, h) = (10.0, 20.0);
let small = big - h * 5f64.to_radians().tan();
let expect = PI * h / 3.0 * (big * big + big * small + small * small);
assert!(rel(volume(&mut s), expect) < 1e-3, "{} vs {expect}", volume(&mut s));
}
/// The copies added after a rename keep their numbers through undo/redo, save/reopen and a
/// further count change; lowering the count drops the last copies (#372).
#[test]
fn pattern_copies_added_after_a_rename_keep_their_numbers() {
let mut s = Session::default();
let names = |s: &Session| -> Vec<String> { s.model.state().bodies.iter().map(|b| b.name.clone()).collect() };
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "X"}));
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["X"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 20, "name": "Pat"}));
run(&mut s, "body.rename", json!({"body": "X (2)", "name": "Second"}));
run(&mut s, "timeline.edit", json!({"feature": "Pat", "set": {"pattern": {"count1": "5"}}}));
assert_eq!(names(&s), ["X", "Second", "X (3)", "X (4)", "X (5)"]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(names(&s), ["X", "Second", "X (3)"]);
run(&mut s, "edit.redo", json!({}));
assert_eq!(names(&s), ["X", "Second", "X (3)", "X (4)", "X (5)"]);
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert_eq!(names(&reopened), ["X", "Second", "X (3)", "X (4)", "X (5)"]);
run(&mut s, "timeline.edit", json!({"feature": "Pat", "set": {"pattern": {"count1": "2"}}}));
assert_eq!(names(&s), ["X", "Second"]);
}