Sweep guide rails and two-rail sweep (#771) (#1806)

Thanks @jyviko.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-17 04:07:46 -04:00
1 parent 7ba2d5db47
commit 98ce6a4ee8
22 files changed
+804 -55

No files matched your search

+48 -2
View File
@@ -2,7 +2,8 @@
use serde_json::{Value, json};
use solidcraft_doc::{
AxisRef, Direction, Expr, Extent, FeatureKind, Operation, PointExpr, ProfileSel, SweepOrientation, ThinSide, ThinWall, expr::Kind, point_expr,
AxisRef, Direction, Expr, Extent, FeatureKind, Operation, PointExpr, ProfileSel, RailFit, SweepOrientation, SweepRail, ThinSide, ThinWall,
expr::Kind, point_expr,
};
use solidcraft_doc::{BodyName, FeatureName, SketchId};
use solidcraft_doc::{Dir, FacePick, Position};
@@ -25,7 +26,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]; 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?"),
.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); rails?: [{sketch?: sketch (default path_sketch), curves: [curve ids in order]}] (guide rails, one or two, each meeting every plane square to the path once along it, from the end nearest the path's start: one rail turns the profile so its point where the rail starts stays on the rail and scales it with the rail's distance from the path; two rails carry it between them, its points where they start staying on both; rails take the default orientation, no twist and no scale, and a path without sharp corners); rail_fit?: scale (default: the profile grows and shrinks evenly) | stretch (only across to the rail, between the rails); 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?"),
@@ -1850,6 +1851,7 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "`path` must list curve ids"));
}
let (orientation, twist, scale) = sweep_options(s, p, cmd)?;
let (rails, rail_fit) = sweep_rails(s, p, cmd, path_sketch)?;
add_feature(
s,
p,
@@ -1861,6 +1863,8 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
orientation,
twist,
scale,
rails,
rail_fit,
operation: operation(p, cmd)?,
targets: string_list(p, "targets").into_iter().map(BodyName).collect(),
},
@@ -1910,6 +1914,44 @@ pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepO
Ok((orientation, twist, scale))
}
/// A sweep's guide `rails` and `rail_fit` (solid.sweep and surface.sweep), checked: one or two
/// rails, each a sketch (default: the path's) and 1…1000 of its curves; a fit of scale or
/// stretch; and no orientation, twist or scale besides, since the rails set those.
pub(super) fn sweep_rails(s: &Session, p: &Value, cmd: &str, path_sketch: u64) -> Result<(Vec<SweepRail>, RailFit)> {
let rail_fit = match p.get("rail_fit") {
None | Some(Value::Null) => RailFit::Scale,
Some(Value::String(f)) if f.eq_ignore_ascii_case("scale") => RailFit::Scale,
Some(Value::String(f)) if f.eq_ignore_ascii_case("stretch") => RailFit::Stretch,
Some(_) => return Err(bad(cmd, "`rail_fit` must be scale or stretch")),
};
let given = match p.get("rails") {
None | Some(Value::Null) => return Ok((Vec::new(), rail_fit)),
Some(Value::Array(a)) => a,
Some(_) => return Err(bad(cmd, "`rails` must list one or two rails: [{sketch?, curves: [ids]}]")),
};
if !(1..=2).contains(&given.len()) {
return Err(bad(cmd, "a sweep takes one or two guide rails"));
}
for key in ["orientation", "twist", "scale"] {
if p.get(key).is_some_and(|v| !v.is_null()) {
return Err(bad(cmd, format!("guide rails set the profile's turn and size along the path: leave out `{key}`")));
}
}
let mut rails = Vec::with_capacity(given.len());
for rail in given {
let sketch = match rail.get("sketch") {
None | Some(Value::Null) => path_sketch,
Some(_) => sketch_id(s, rail.get("sketch"), cmd, "rails[].sketch")?,
};
let curves = string_list(rail, "curves");
if curves.is_empty() || curves.len() > 1000 {
return Err(bad(cmd, "each rail's `curves` must list 1…1000 curve ids in order"));
}
rails.push(SweepRail { sketch: SketchId(sketch), curves });
}
Ok((rails, rail_fit))
}
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"))?;
@@ -2059,6 +2101,10 @@ mod revolve_convention_tests;
#[path = "sweep_options_tests.rs"]
mod sweep_options_tests;
#[cfg(test)]
#[path = "sweep_rails_tests.rs"]
mod sweep_rails_tests;
#[cfg(test)]
#[path = "offset_topology_tests.rs"]
mod offset_topology_tests;
+14 -2
View File
@@ -47,7 +47,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); orientation?, twist?, scale? (as solid.sweep); 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?, rails?, rail_fit? (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")
@@ -354,10 +354,22 @@ fn surface_sweep(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "`path` must list 1…1000 curve ids"));
}
let (orientation, twist, scale) = super::features::sweep_options(s, p, cmd)?;
let (rails, rail_fit) = super::features::sweep_rails(s, p, cmd, path_sketch)?;
add_feature(
s,
p,
FeatureKind::SurfaceSweep { sketch: SketchId(sketch), profiles, curves, path_sketch: SketchId(path_sketch), path, orientation, twist, scale },
FeatureKind::SurfaceSweep {
sketch: SketchId(sketch),
profiles,
curves,
path_sketch: SketchId(path_sketch),
path,
orientation,
twist,
scale,
rails,
rail_fit,
},
)
}
+143
View File
@@ -0,0 +1,143 @@
//! Guide rails through the commands (issue #771): solid.sweep and surface.sweep with one rail
//! or two, scaling or stretching, checked against analytic values; editing, 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) on XY; a path `P` on XZ, a line 40 up the Z axis (`up`), with a rail
/// beside it (`in`) from the circle's edge at x = 10 in to x = 5 at the top; a line `L` across
/// XY from x = −10 to 10 (`l`); and in `R` on XZ two arcs from x = ±10 at the bottom to x = ±10
/// at the top, bulging out to x = ±20 halfway (radius 25 about (∓5, 0, 20)).
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": "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.line", json!({"points": [[10, 0], [5, -40]], "ids": ["in"]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "L"}));
run(&mut s, "sketch.line", json!({"points": [[-10, 0], [10, 0]], "ids": ["l"]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "R"}));
run(&mut s, "sketch.arc.three_point", json!({"start": [10, 0], "end": [10, -40], "through": [20, -20], "id": "right"}));
run(&mut s, "sketch.arc.three_point", json!({"start": [-10, 0], "end": [-10, -40], "through": [-20, -20], "id": "left"}));
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, body: &str, extra: Value) -> Result<Value, String> {
let mut p = json!({"sketch": "C", "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())
.map(|x| x.split_once(':').map_or(x, |(_, rest)| rest).to_string())
.collect();
n.sort();
n
}
/// A rail closing in from radius 10 to 5 sweeps the circle into a frustum (and its curve into
/// the frustum's side); the faces keep the plain sweep's names; switching the fit to stretch is
/// an edit (the circle then widens only toward the rail: an elliptic cone, sections πr²k); the
/// rails are saved with the feature.
#[test]
fn a_rail_sweeps_a_frustum_and_stretching_is_an_edit() {
let mut s = setup();
let volume = PI * 40.0 / 3.0 * (100.0 + 50.0 + 25.0);
let lateral = PI * 15.0 * (40.0f64 * 40.0 + 25.0).sqrt();
sweep(&mut s, "Plain", json!({})).unwrap();
sweep(&mut s, "Frustum", json!({"rails": [{"curves": ["in"]}], "name": "Railed"})).unwrap();
let m = measure(&s, "Frustum");
assert!((m.volume - volume).abs() < 2e-3 * volume, "volume {} vs {volume}", m.volume);
assert_eq!(face_names(&mut s, "Plain"), face_names(&mut s, "Frustum"));
run(
&mut s,
"surface.sweep",
json!({"sketch": "C", "curves": ["c"], "path_sketch": "P", "path": ["up"], "rails": [{"sketch": "P", "curves": ["in"]}], "body_name": "Side"}),
);
let a = measure(&s, "Side").area;
assert!((a - lateral).abs() < 2e-3 * lateral, "side {a} vs {lateral}");
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
let reopened = Session::new(doc);
assert!((measure(&reopened, "Frustum").volume - m.volume).abs() < 1e-9 * volume);
// Stretched: k = 1 − z/80 across (x) only, so sections are π · 100 · k.
run(&mut s, "timeline.edit", json!({"feature": "Railed", "set": {"rail_fit": "stretch"}}));
let stretched = measure(&s, "Frustum");
let elliptic = PI * 100.0 * 40.0 * 0.75;
assert!((stretched.volume - elliptic).abs() < 2e-3 * elliptic, "stretched {} vs {elliptic}", stretched.volume);
assert!((stretched.bbox.max.y - 10.0).abs() < 1e-3 && (stretched.bbox.min.y + 10.0).abs() < 1e-3, "{:?}", stretched.bbox);
}
/// Two arcs bulging apart carry a line between them: the sheet fills the region between the
/// arcs in the XZ plane, area 2(a√(R² − a²) + R² asin(a/R)) − 400 with R = 25, a = 20.
#[test]
fn two_rails_carry_a_line_between_arcs() {
let mut s = setup();
run(
&mut s,
"surface.sweep",
json!({"sketch": "L", "curves": ["l"], "path_sketch": "P", "path": ["up"], "rails": [{"sketch": "R", "curves": ["left"]}, {"sketch": "R", "curves": ["right"]}], "body_name": "Between"}),
);
let m = measure(&s, "Between");
let area = 2.0 * (20.0 * 15.0 + 625.0 * 0.8f64.asin()) - 400.0;
assert!((m.area - area).abs() < 1e-3 * area, "area {} vs {area}", m.area);
assert!((m.bbox.max.x - 20.0).abs() < 1e-2 && (m.bbox.min.x + 20.0).abs() < 1e-2, "{:?}", m.bbox);
}
/// Hostile rails are refused with an error and leave the design as it was.
#[test]
fn hostile_rails_are_refused() {
let mut s = setup();
let before = s.doc.features.len();
let rail = json!([{"curves": ["in"]}]);
for bad in [
json!({"rails": "in"}),
json!({"rails": []}),
json!({"rails": [{"curves": ["in"]}, {"curves": ["in"]}, {"curves": ["in"]}]}),
json!({"rails": [{"curves": []}]}),
json!({"rails": [{}]}),
json!({"rails": [{"curves": ["nope"]}]}),
json!({"rails": [{"sketch": "Nowhere", "curves": ["in"]}]}),
json!({"rails": rail, "rail_fit": "wobble"}),
json!({"rails": rail, "twist": "10 deg"}),
json!({"rails": rail, "scale": 2}),
json!({"rails": rail, "orientation": "parallel"}),
// A rail along the path itself: nothing to scale against.
json!({"rails": [{"curves": ["up"]}]}),
// Two rails starting at one point.
json!({"rails": [{"curves": ["in"]}, {"curves": ["in"]}]}),
] {
assert!(sweep(&mut s, "Bad", bad.clone()).is_err(), "{bad}");
let mut p = json!({"sketch": "C", "curves": ["c"], "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}");
}
}