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:
Irakli Kourtis authored and GitHub committed 2026-10-15 06:24:31 -04:00
1 parent 0872eb959a
commit 5fa8e7d60a
30 files changed
+1120 -71

No files matched your search

+42
View File
@@ -262,6 +262,26 @@ pub struct ThinWall {
pub thickness2: Option<String>,
}
/// How a swept profile turns as it follows its path.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
#[serde(rename_all = "snake_case")]
pub enum SweepOrientation {
/// Square to the path, turning with it (a rotation-minimising frame).
#[default]
Perpendicular,
/// As drawn: the profile moves along the path without turning.
Parallel,
/// Square to the path, its turn about the path fixed by this direction (in the feature's
/// frame): the direction's projection onto the profile stays where it was at the start.
Direction(#[walk(dir)] Vec3),
}
impl SweepOrientation {
pub fn is_perpendicular(&self) -> bool {
*self == SweepOrientation::Perpendicular
}
}
/// Where a thin wall lies against its curves. A closed loop's inside is the area it encloses;
/// an open chain's is the side it bends toward (a straight one's, the left of its first
/// curve as drawn).
@@ -701,6 +721,17 @@ pub enum FeatureKind {
#[walk(sketch)]
path_sketch: u64,
path: Vec<String>,
/// How the profile turns along the path (default: perpendicular to it).
#[serde(default, skip_serializing_if = "SweepOrientation::is_perpendicular")]
orientation: SweepOrientation,
/// Angle the profile turns about the path over its length.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(angle = "Twist")]
twist: Option<String>,
/// The profile's scale at the end of the path (linear along its length).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(unitless = "Scale")]
scale: Option<String>,
#[serde(default)]
operation: Operation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -1327,6 +1358,17 @@ pub enum FeatureKind {
#[walk(sketch)]
path_sketch: u64,
path: Vec<String>,
/// How the profile turns along the path (default: perpendicular to it).
#[serde(default, skip_serializing_if = "SweepOrientation::is_perpendicular")]
orientation: SweepOrientation,
/// Angle the profile turns about the path over its length.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(angle = "Twist")]
twist: Option<String>,
/// The profile's scale at the end of the path (linear along its length).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(unitless = "Scale")]
scale: Option<String>,
},
/// A surface body lofted through sections in order, each one open or closed chain of
/// sketch curves: walls only, no caps.
+30 -3
View File
@@ -3043,7 +3043,8 @@ fn feature_tools_with_constructor(
let roles = crate::lineage::SectionRoles { sections: sketches.into_iter().zip(secs.iter().map(|(_, lp)| lp)).collect() };
Ok(vec![roles.tool(body)])
}
FeatureKind::Sweep { sketch, profiles, path_sketch, path, .. } => {
FeatureKind::Sweep { sketch, profiles, path_sketch, path, orientation, twist, scale, .. } => {
let ctl = sweep_control(vals, orientation, twist, scale)?;
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
let ps =
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
@@ -3061,10 +3062,10 @@ fn feature_tools_with_constructor(
let tool = |r: &Region2, body: Body| crate::lineage::ProfileRoles::swept(Some(ss), Some(r)).tool(body, None);
// A path through 3D sketch curves follows them as points.
if let Some(pts) = path_points(ps, path, start)? {
return regions.iter().map(|r| Ok(tool(r, kernel::sweep_path(&ss.plane, r, &pts)?))).collect();
return regions.iter().map(|r| Ok(tool(r, kernel::sweep_path_with(&ss.plane, r, &pts, &ctl)?))).collect();
}
let segs = path_segments(ps, path, start)?;
regions.iter().map(|r| Ok(tool(r, kernel::sweep(&ss.plane, r, &segs)?))).collect()
regions.iter().map(|r| Ok(tool(r, kernel::sweep_with(&ss.plane, r, &segs, &ctl)?))).collect()
}
FeatureKind::Pipe { path_sketch, path, diameter, wall, .. } => {
let ps =
@@ -3112,6 +3113,32 @@ fn feature_tools_with_constructor(
}
/// A chain of sketch curves as 3D path segments, starting at the end nearest `start`.
/// A sweep's options from its stored orientation and its twist and end scale expressions
/// (none: no twist, scale 1). Out-of-range values are refused by the kernel.
pub(crate) fn sweep_control(
vals: &BTreeMap<String, Value>,
orientation: &crate::SweepOrientation,
twist: &Option<String>,
scale: &Option<String>,
) -> Result<kernel::SweepControl> {
let orientation = match *orientation {
crate::SweepOrientation::Perpendicular => kernel::SweepOrientation::Perpendicular,
crate::SweepOrientation::Parallel => kernel::SweepOrientation::Parallel,
crate::SweepOrientation::Direction(d) => kernel::SweepOrientation::Direction(d),
};
let twist = match twist {
Some(t) => val(vals, t, Kind::Angle)?,
None => 0.0,
};
let scale = match scale {
Some(k) => val(vals, k, Kind::Unitless)?,
None => 1.0,
};
let ctl = kernel::SweepControl { orientation, twist, scale };
ctl.check()?;
Ok(ctl)
}
/// A pipe's section: a circle of radius `r`, hollow when it has a wall.
fn pipe_region(vals: &BTreeMap<String, Value>, r: f64, wall: &Option<String>) -> Result<Region2> {
let holes = match wall {
+4 -3
View File
@@ -176,7 +176,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
Ok(())
}
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path } => {
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale } => {
let ctl = crate::eval::sweep_control(vals, orientation, twist, scale)?;
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
let ps =
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
@@ -195,8 +196,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
let pts: Vec<Vec2> = chains.iter().flatten().flat_map(|s| s.polyline(1e-2)).collect();
let start = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, p| a + *p) * (1.0 / pts.len().max(1) as f64));
let body = match path_points(ps, path, start)? {
Some(pts) => kernel::sweep_sheet_points(&ss.plane, &chains, &pts)?,
None => kernel::sweep_sheet(&ss.plane, &chains, &path_segments(ps, path, start)?)?,
Some(pts) => kernel::sweep_sheet_points_with(&ss.plane, &chains, &pts, &ctl)?,
None => kernel::sweep_sheet_with(&ss.plane, &chains, &path_segments(ps, path, start)?, &ctl)?,
};
let traced = crate::lineage::ChainRoles { sketch: ss, chains: &chains }.tool(body);
push_sheet(st, f, traced);
+11 -1
View File
@@ -75,6 +75,11 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
("thread", json!({"type": "thread", "face": o, "length": "thread.length"})),
("loft", json!({"type": "loft", "sections": []})),
("sweep", json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"]})),
(
"sweep_options",
json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"],
"orientation": {"direction": [0, 1, 0]}, "twist": "sweep.twist", "scale": "sweep.scale"}),
),
("shell", json!({"type": "shell", "faces": [o], "thickness": "shell.thickness"})),
("draft", json!({"type": "draft", "faces": [o], "angle": "draft.angle", "neutral": plane("draft_neutral"), "pull": [0, 0, 1]})),
("construction_plane", json!({"type": "construction_plane", "plane": plane("cplane")})),
@@ -147,6 +152,11 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
json!({"type": "surface_revolve", "sketch": 1, "axis": {"type": "world", "axis": "X"}, "angle": "srev.angle", "angle2": "srev.angle2"}),
),
("surface_sweep", json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"]})),
(
"surface_sweep_options",
json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"],
"orientation": "parallel", "twist": "surface_sweep.twist", "scale": "surface_sweep.scale"}),
),
("surface_loft", json!({"type": "surface_loft", "sections": []})),
("surface_offset", json!({"type": "surface_offset", "faces": [o], "distance": "soff.distance", "tolerance": "soff.tolerance"})),
("unstitch", json!({"type": "unstitch", "body": "B"})),
@@ -181,7 +191,7 @@ pub(super) fn with_refs(name: &str, mut v: Value) -> Value {
let o = [0, 0, 0];
let tool = json!({"body": "B", "point": o});
let extra = match name {
"extrude" | "extrude_thin" | "revolve" | "sweep" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}),
"extrude" | "extrude_thin" | "revolve" | "sweep" | "sweep_options" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}),
"fillet_constant" | "fillet_chord" | "fillet_curvature" | "fillet_variable" | "fillet_spline" | "chamfer" | "shell" | "draft"
| "offset_face" | "delete_face" | "replace_face" | "sheet_flange" | "sheet_hem" | "sheet_perforate" | "sheet_unfold" | "boss" | "lip"
| "snap_fit" | "rest" | "surface_offset" => {
@@ -136,6 +136,11 @@ thread
expressions: thread.length
loft
sweep
sweep_options
inputs:
Twist = sweep.twist (Angle)
Scale = sweep.scale (Unitless)
expressions: sweep.twist, sweep.scale
shell
inputs:
Thickness = shell.thickness (Length)
@@ -317,6 +322,11 @@ surface_revolve
Angle 2 = srev.angle2 (Angle)
expressions: srev.angle, srev.angle2
surface_sweep
surface_sweep_options
inputs:
Twist = surface_sweep.twist (Angle)
Scale = surface_sweep.scale (Unitless)
expressions: surface_sweep.twist, surface_sweep.scale
surface_loft
surface_offset
inputs:
@@ -63,6 +63,9 @@ loft
profiles: 4 all, 5 all
sweep
profiles: 1 all
sweep_options
moves: orientation.direction[1], orientation.direction[2]
profiles: 1 all
shell
moves: faces[0][0], faces[0][1], faces[0][2]
face points: [0, 0, 0]
@@ -142,6 +145,7 @@ surface_extrude
surface_revolve
moves: axis.dir[0], axis.dir[1], axis.dir[2], axis.origin[0], axis.origin[1], axis.origin[2]
surface_sweep
surface_sweep_options
surface_loft
surface_offset
moves: faces[0][0], faces[0][1], faces[0][2]
@@ -67,6 +67,10 @@ sweep
sketches: [1, 2]
remap_refs: path_sketch, sketch
remap_bodies: targets[0]
sweep_options
sketches: [1, 2]
remap_refs: path_sketch, sketch
remap_bodies: targets[0]
shell
remap_bodies: body
draft
@@ -159,6 +163,9 @@ surface_revolve
surface_sweep
sketches: [1, 2]
remap_refs: path_sketch, sketch
surface_sweep_options
sketches: [1, 2]
remap_refs: path_sketch, sketch
surface_loft
sketches: [4, 5]
remap_refs: sections[0].sketch, sections[1].sketch
+28
View File
@@ -191,6 +191,10 @@
//! A bend point can also lie on a bend modelled with its radius (a cylinder between two flat
//! faces tangent to it), which then is one bend at the two faces' virtual sharp with the
//! rule's radius; 0.11.0 found no edge there or refused the curved face.
//! Also under 21, a Sweep and a Surface Sweep can carry `orientation` (`parallel`, or
//! `{direction: [x, y, z]}`; absent: perpendicular), `twist` (an angle expression) and `scale`
//! (the end scale, an expression). A sweep without them saves as before; 0.11.x refuses
//! format 21, so no released build sweeps a twisted or scaled profile straight.
use serde_json::Value;
@@ -1763,4 +1767,28 @@ mod tests {
let json = edited(&d, |v| v["components"][1]["id"] = u64::MAX.into());
assert!(read(&json).unwrap_err().to_string().contains("damaged design"));
}
/// A sweep saved before its options existed loads as a plain sweep and saves as it was; the
/// options round-trip.
#[test]
fn sweep_options_are_optional_and_round_trip() {
use crate::{FeatureKind, SweepOrientation};
let old =
serde_json::json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"], "operation": "new_body"});
let k: FeatureKind = serde_json::from_value(old.clone()).unwrap();
let FeatureKind::Sweep { orientation, twist, scale, .. } = &k else { panic!("{k:?}") };
assert!(orientation.is_perpendicular() && twist.is_none() && scale.is_none());
assert_eq!(serde_json::to_value(&k).unwrap(), old);
let old = serde_json::json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"]});
let k: FeatureKind = serde_json::from_value(old.clone()).unwrap();
assert_eq!(serde_json::to_value(&k).unwrap(), old);
let new = serde_json::json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"],
"orientation": {"direction": [0.0, 1.0, 0.0]}, "twist": "90 deg", "scale": "0.5"});
let k: FeatureKind = serde_json::from_value(new.clone()).unwrap();
let FeatureKind::SurfaceSweep { orientation, .. } = &k else { panic!("{k:?}") };
assert_eq!(*orientation, SweepOrientation::Direction(solidcraft_geom::Vec3::Y));
assert_eq!(serde_json::to_value(&k).unwrap(), new);
let parallel: SweepOrientation = serde_json::from_value(serde_json::json!("parallel")).unwrap();
assert_eq!(parallel, SweepOrientation::Parallel);
}
}