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.
This commit is contained in:
1 parent
0872eb959a
commit
5fa8e7d60a
30 files changed
+1120
-71
No files matched your search
@@ -1,7 +1,9 @@
|
||||
//! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, ThinSide, ThinWall, expr::Kind, point_expr};
|
||||
use solidcraft_doc::{
|
||||
AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, SweepOrientation, ThinSide, ThinWall, expr::Kind, point_expr,
|
||||
};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::{CommandSpec, not_in_sketch};
|
||||
@@ -21,7 +23,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.sweep", "Sweep", sweep)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sweep")
|
||||
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?, targets?, name?, body_name?"),
|
||||
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); operation?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"),
|
||||
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, targets?: [body], name?, body_name?"),
|
||||
@@ -1621,6 +1623,7 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if path.is_empty() {
|
||||
return Err(bad(cmd, "`path` must list curve ids"));
|
||||
}
|
||||
let (orientation, twist, scale) = sweep_options(s, p, cmd)?;
|
||||
add_feature(
|
||||
s,
|
||||
p,
|
||||
@@ -1629,12 +1632,58 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
profiles: profiles(p, cmd)?,
|
||||
path_sketch,
|
||||
path,
|
||||
orientation,
|
||||
twist,
|
||||
scale,
|
||||
operation: operation(p, cmd)?,
|
||||
targets: string_list(p, "targets"),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// A sweep's `orientation`, `twist` and `scale` (solid.sweep and surface.sweep), checked: an
|
||||
/// orientation the sweep knows, a twist within four turns, an end scale within 0.01…100.
|
||||
pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepOrientation, Option<String>, Option<String>)> {
|
||||
let orientation = match p.get("orientation") {
|
||||
None | Some(Value::Null) => SweepOrientation::Perpendicular,
|
||||
Some(Value::String(o)) if o.eq_ignore_ascii_case("perpendicular") => SweepOrientation::Perpendicular,
|
||||
Some(Value::String(o)) if o.eq_ignore_ascii_case("parallel") => SweepOrientation::Parallel,
|
||||
Some(v) => {
|
||||
let d = vec3(v)
|
||||
.or_else(|| v.get("direction").and_then(vec3))
|
||||
.ok_or_else(|| bad(cmd, "`orientation` must be perpendicular, parallel or a direction [x, y, z]"))?;
|
||||
if !(d.len() > 1e-9) {
|
||||
return Err(bad(cmd, "the `orientation` direction must not be zero"));
|
||||
}
|
||||
SweepOrientation::Direction(d)
|
||||
}
|
||||
};
|
||||
let twist = match p.get("twist") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => {
|
||||
let t = expr(p, "twist").ok_or_else(|| bad(cmd, "`twist` must be an angle or an expression"))?;
|
||||
let v = s.doc.eval(&t, Kind::Angle).map_err(|e| bad(cmd, format!("twist: {e}")))?;
|
||||
if !(v.is_finite() && v.abs() <= solidcraft_kernel::SweepControl::MAX_TWIST) {
|
||||
return Err(bad(cmd, "`twist` must be within four turns (1440 deg)"));
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
};
|
||||
let scale = match p.get("scale") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => {
|
||||
let k = expr(p, "scale").ok_or_else(|| bad(cmd, "`scale` must be a number or an expression"))?;
|
||||
let v = s.doc.eval(&k, Kind::Unitless).map_err(|e| bad(cmd, format!("scale: {e}")))?;
|
||||
let range = solidcraft_kernel::SweepControl::MIN_SCALE..=solidcraft_kernel::SweepControl::MAX_SCALE;
|
||||
if !(v.is_finite() && range.contains(&v)) {
|
||||
return Err(bad(cmd, "`scale` (the end scale) must be between 0.01 and 100"));
|
||||
}
|
||||
Some(k)
|
||||
}
|
||||
};
|
||||
Ok((orientation, twist, scale))
|
||||
}
|
||||
|
||||
fn loft(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.loft";
|
||||
let list = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must be a list"))?;
|
||||
@@ -1753,6 +1802,10 @@ mod patterns_tests;
|
||||
#[path = "revolve_convention_tests.rs"]
|
||||
mod revolve_convention_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sweep_options_tests.rs"]
|
||||
mod sweep_options_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "offset_topology_tests.rs"]
|
||||
mod offset_topology_tests;
|
||||
|
||||
@@ -41,7 +41,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("surface.sweep", "Surface Sweep", surface_sweep)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_sweep")
|
||||
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); path_sketch: sketch; path: [curve ids in order] (a path with corners, or 3D curves, gives ruled walls mitred at the corners); name?, body_name? (a surface body, no caps)"),
|
||||
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); path_sketch: sketch; path: [curve ids in order] (a path with corners, or 3D curves, gives ruled walls mitred at the corners); orientation?, twist?, scale? (as solid.sweep); name?, body_name? (a surface body, no caps)"),
|
||||
CommandSpec::new("surface.loft", "Surface Loft", surface_loft)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_loft")
|
||||
@@ -192,7 +192,8 @@ fn surface_sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if path.is_empty() || path.len() > 1000 {
|
||||
return Err(bad(cmd, "`path` must list 1…1000 curve ids"));
|
||||
}
|
||||
add_feature(s, p, FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path })
|
||||
let (orientation, twist, scale) = super::features::sweep_options(s, p, cmd)?;
|
||||
add_feature(s, p, FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale })
|
||||
}
|
||||
|
||||
fn surface_loft(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! 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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user