Files
OneCAD/crates/engine/src/cmd/sweep_options_tests.rs
T
Irakli Kourtis 5fa8e7d60a Sweep: orientation, twist and end scale (#1269)
Part of #771 and #772. solid.sweep and surface.sweep take orientation (perpendicular, parallel or a direction), twist (up to 4 turns) and end scale (0.01 to 100), applied about the profile's normal through the path's start; default options run the old sweep, so existing sweeps are unchanged. New fields are optional, left out when unset and noted under format 21; twist and scale are model parameters. Both sweep dialogs get Orientation, Twist and End Scale rows; labels in every catalog. Checked on main + this branch: cargo check --workspace, kernel/engine/UI sweep tests, the doc suite and the robustness goldens.
2026-10-15 06:24:31 -04:00

164 lines
7.6 KiB
Rust

//! Sweep options through the commands: orientation, twist and end scale on solid.sweep and
//! surface.sweep, checked against analytic values; naming, saving and hostile values.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::f64::consts::PI;
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
/// A circle `C` (radius 10, curve `c`), a square `Q` (side 10, curves `a`…`d`) on XY, and a
/// path `P`: a line 40 up the Z axis (`up`).
fn setup() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "C"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10, "id": "c"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Q"}));
run(&mut s, "sketch.line", json!({"points": [[-5, -5], [5, -5], [5, 5], [-5, 5]], "closed": true, "ids": ["a", "b", "c", "d"]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "P"}));
// The XZ sketch's y runs down world Z.
run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, -40]], "ids": ["up"]}));
run(&mut s, "sketch.finish", json!({}));
s
}
fn measure(s: &Session, body: &str) -> solidcraft_kernel::BodyMeasure {
solidcraft_kernel::measure(&s.world_state().body(body).unwrap().body).unwrap()
}
fn sweep(s: &mut Session, sketch: &str, body: &str, extra: Value) -> Result<Value, String> {
let mut p = json!({"sketch": sketch, "path_sketch": "P", "path": ["up"], "operation": "new", "body_name": body});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
s.execute("solid.sweep", &p).map_err(|e| e.to_string())
}
fn face_names(s: &mut Session, body: &str) -> Vec<String> {
let f = run(s, "model.faces", json!({ "body": body }));
let mut n: Vec<String> = f["faces"]
.as_array()
.into_iter()
.flatten()
.filter_map(|x| x["name"].as_str())
// Without the feature's own prefix (`F7:`), to compare features.
.map(|x| x.split_once(':').map_or(x, |(_, rest)| rest).to_string())
.collect();
n.sort();
n
}
/// A circle swept up a line to end scale 0.5 is a cone frustum; the surface sweep of its curve
/// is the frustum's lateral face. Saved and reopened, the design gives the same solid.
#[test]
fn scaled_sweep_is_a_frustum() {
let mut s = setup();
let (big, small, h) = (10.0, 5.0, 40.0);
let volume = PI * h * (big * big + big * small + small * small) / 3.0;
let lateral = PI * (big + small) * (h * h + (big - small) * (big - small)).sqrt();
sweep(&mut s, "C", "Frustum", json!({"scale": 0.5})).unwrap();
let m = measure(&s, "Frustum");
assert!((m.volume - volume).abs() < 2e-3 * volume, "volume {} vs {volume}", m.volume);
assert!((m.area - PI * (big * big + small * small) - lateral).abs() < 2e-3 * lateral, "area {}", m.area);
run(&mut s, "surface.sweep", json!({"sketch": "C", "curves": ["c"], "path_sketch": "P", "path": ["up"], "scale": "1/2", "body_name": "Cone"}));
let a = measure(&s, "Cone").area;
assert!((a - lateral).abs() < 2e-3 * lateral, "sheet {a} vs {lateral}");
// The options are saved with the feature and evaluate the same when reopened.
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
let reopened = Session::new(doc);
let again = measure(&reopened, "Frustum");
assert!((again.volume - m.volume).abs() < 1e-9 * volume);
}
/// A twisted square keeps its faces' names (each wall after its profile curve), and the twist is
/// a parameter: editing it changes the solid, a plain sweep has the same names.
#[test]
fn twisted_sweep_faces_follow_the_profile_curves() {
let mut s = setup();
sweep(&mut s, "Q", "Plain", json!({})).unwrap();
sweep(&mut s, "Q", "Twisted", json!({"twist": "90 deg", "name": "Twist"})).unwrap();
let (plain, twisted) = (face_names(&mut s, "Plain"), face_names(&mut s, "Twisted"));
assert_eq!(plain.len(), 6, "{plain:?}");
assert_eq!(plain, twisted);
for curve in ["a", "b", "c", "d"] {
assert!(twisted.iter().any(|n| n.ends_with(&format!(":{curve}"))), "{curve} in {twisted:?}");
}
// The end cap turns with the twist: at 90 deg a square's corners are back where they were,
// at 45 deg its top reaches out to the corner radius along the axes.
let reach = |s: &Session| {
let mesh = s.world_state().body("Twisted").unwrap().body.tessellate(0.01).unwrap();
mesh.positions.iter().filter(|p| (p.z - 40.0).abs() < 1e-6).map(|p| p.x).fold(f64::MIN, f64::max)
};
assert!((reach(&s) - 5.0).abs() < 1e-3, "{}", reach(&s));
run(&mut s, "timeline.edit", json!({"feature": "Twist", "set": {"twist": "45 deg"}}));
assert!((reach(&s) - 50f64.sqrt()).abs() < 1e-2, "{}", reach(&s));
let v = measure(&s, "Twisted").volume;
assert!((v - 4000.0).abs() < 0.01 * 4000.0, "{v}");
}
/// Parallel along a straight path leaning out of the profile's plane gives a sheared prism (the
/// volume of the upright one); so does a direction, along a straight path.
#[test]
fn orientations() {
let mut s = setup();
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Lean"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, -40]], "ids": ["lean"]}));
run(&mut s, "sketch.finish", json!({}));
let lean = |s: &mut Session, body: &str, o: Value| {
s.execute(
"solid.sweep",
&json!({"sketch": "Q", "path_sketch": "Lean", "path": ["lean"], "operation": "new", "body_name": body, "orientation": o}),
)
};
lean(&mut s, "Sheared", json!("parallel")).unwrap();
let m = measure(&s, "Sheared");
assert!((m.volume - 4000.0).abs() < 2e-3 * 4000.0, "{}", m.volume);
// Its ends stay level: flat at z = 0 and z = 40.
assert!(m.bbox.min.z.abs() < 1e-6 && (m.bbox.max.z - 40.0).abs() < 1e-6, "{:?}", m.bbox);
// Along a straight path every frame is the first: a direction changes nothing, the profile
// keeps its relation to the path as in a plain sweep.
lean(&mut s, "Directed", json!([0, 1, 0])).unwrap();
let d = measure(&s, "Directed");
assert!((d.volume - 4000.0).abs() < 2e-3 * 4000.0, "{}", d.volume);
assert!(d.bbox.max.y <= 5.0 + 1e-6 && d.bbox.min.y >= -5.0 - 1e-6);
}
/// Hostile options are refused with an error and leave the design as it was.
#[test]
fn hostile_sweep_options_are_refused() {
let mut s = setup();
let before = s.doc.features.len();
for bad in [
json!({"twist": "NaN"}),
json!({"twist": 1e300}),
json!({"twist": "1e9 deg"}),
json!({"twist": "1441 deg"}),
json!({"twist": "nope"}),
json!({"twist": [1, 2]}),
json!({"scale": 0}),
json!({"scale": -1}),
json!({"scale": "0"}),
json!({"scale": "1e9"}),
json!({"scale": "1/0"}),
json!({"orientation": "sideways"}),
json!({"orientation": [0, 0, 0]}),
json!({"orientation": [1e308, 0, 0]}),
json!({"orientation": {}}),
json!({"orientation": 7}),
// Along the path itself: no turn about the path is defined.
json!({"orientation": [0, 0, 1]}),
] {
assert!(sweep(&mut s, "Q", "Bad", bad.clone()).is_err(), "{bad}");
let mut p = json!({"sketch": "Q", "curves": ["a", "b"], "path_sketch": "P", "path": ["up"]});
p.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone());
assert!(s.execute("surface.sweep", &p).is_err(), "surface {bad}");
assert_eq!(s.doc.features.len(), before, "{bad}");
}
}