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

+15 -10
View File
@@ -108,6 +108,7 @@ pub enum Kind {
/// Sweep a profile along a path of sketch curves.
Sweep {
operation: usize,
opts: crate::dialogs_sweep::SweepOpts,
},
/// A construction plane offset from a plane.
OffsetPlane {
@@ -544,8 +545,8 @@ impl Dialog {
),
"solid.loft" => Dialog::new(Kind::Loft { operation: usize::from(has_bodies) }, vec![SelInput::new("Profiles", PROFILES, true)]),
"solid.sweep" => Dialog::new(
Kind::Sweep { operation: usize::from(has_bodies) },
vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true)],
Kind::Sweep { operation: usize::from(has_bodies), opts: crate::dialogs_sweep::SweepOpts::default() },
vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true), crate::dialogs_sweep::direction_input()],
),
"solid.move" => Dialog::new(crate::dialogs_move::kind(0), crate::dialogs_move::inputs(0)),
"solid.hole" => Dialog::new(hole_defaults(), vec![SelInput::new("Face", HOLE_FACES, true)]),
@@ -2537,7 +2538,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
combo(ui, "lf_op", &OP_LABELS, operation);
ui.end_row();
}
Kind::Sweep { operation } => {
Kind::Sweep { operation, opts } => {
enter |= crate::dialogs_sweep::rows(ui, opts, "sw");
row_label(ui, "Operation");
combo(ui, "sw_op", &OP_LABELS, operation);
ui.end_row();
@@ -3548,16 +3550,15 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
let sections: Vec<Value> = sections.into_iter().map(|(sk, v)| json!({"sketch": sk, "profiles": v})).collect();
("solid.loft", json!({"sections": sections, "operation": OPS.get(*operation).copied().unwrap_or("new")}))
}
Kind::Sweep { operation } => {
Kind::Sweep { operation, opts } => {
need(0, "a profile")?;
need(1, "the path")?;
let (sketch, idx) = profiles();
let path: Vec<String> = sels(d, 1).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect();
let path_sketch = curves_sketch(app, &path, sketch.as_u64()).ok_or("the path curves must be in one sketch")?;
(
"solid.sweep",
json!({"sketch": sketch, "profiles": idx, "path_sketch": path_sketch, "path": path, "operation": OPS.get(*operation).copied().unwrap_or("new")}),
)
let mut p = json!({"sketch": sketch, "profiles": idx, "path_sketch": path_sketch, "path": path, "operation": OPS.get(*operation).copied().unwrap_or("new")});
crate::dialogs_sweep::params(app, opts, sels(d, 2).first(), &mut p)?;
("solid.sweep", p)
}
Kind::OffsetPlane { offset } => {
need(0, "a plane")?;
@@ -4614,9 +4615,13 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
}
d
}
FeatureKind::Sweep { sketch, profiles, path, operation, targets, .. } => {
FeatureKind::Sweep { sketch, profiles, path, operation, targets, orientation, twist, scale, .. } => {
let mut d = start("solid.sweep")?;
d.kind = Kind::Sweep { operation: op_index(operation) };
let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, &feature_frame(s, f.component));
d.kind = Kind::Sweep { operation: op_index(operation), opts };
if let Some(inp) = d.inputs.get_mut(2) {
inp.items = direction.into_iter().collect();
}
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
if let Some(inp) = d.inputs.get_mut(0) {
inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
+23 -10
View File
@@ -25,7 +25,7 @@ pub enum Surf {
symmetric: bool,
axis: Option<Value>,
},
Sweep,
Sweep(crate::dialogs_sweep::SweepOpts),
Loft,
Patch,
Unstitch,
@@ -68,7 +68,7 @@ impl Surf {
match self {
Surf::Extrude { .. } => "SURFACE EXTRUDE",
Surf::Revolve { .. } => "SURFACE REVOLVE",
Surf::Sweep => "SURFACE SWEEP",
Surf::Sweep(_) => "SURFACE SWEEP",
Surf::Loft => "SURFACE LOFT",
Surf::Patch => "PATCH",
Surf::Unstitch => "UNSTITCH",
@@ -110,7 +110,11 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
match k {
Surf::Extrude { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true)],
Surf::Revolve { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Axis", AXES, false)],
Surf::Sweep => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Path", CURVES, true)],
Surf::Sweep(_) => vec![
SelInput::new("Profiles or curves", PROFILES | CURVES, true),
SelInput::new("Path", CURVES, true),
crate::dialogs_sweep::direction_input(),
],
Surf::Loft => vec![SelInput::new("Sections (one sketch each)", PROFILES | CURVES, true)],
Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)],
Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)],
@@ -137,7 +141,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
let k = match id {
"surface.extrude" => Surf::Extrude { distance: "10 mm".into(), direction: 0 },
"surface.revolve" => Surf::Revolve { angle: "360 deg".into(), symmetric: false, axis: None },
"surface.sweep" => Surf::Sweep,
"surface.sweep" => Surf::Sweep(crate::dialogs_sweep::SweepOpts::default()),
"surface.loft" => Surf::Loft,
"surface.patch" => Surf::Patch,
"surface.unstitch" => Surf::Unstitch,
@@ -243,7 +247,8 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
ui.checkbox(in_place, "");
ui.end_row();
}
Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Sweep | Surf::Loft => {}
Surf::Sweep(opts) => enter |= crate::dialogs_sweep::rows(ui, opts, "ss"),
Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Loft => {}
}
enter
}
@@ -345,7 +350,7 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
}
("surface.revolve", p)
}
Surf::Sweep => {
Surf::Sweep(opts) => {
need(0, "profiles or curves to sweep")?;
need(1, "the path")?;
let mut p = sheet_picks(app, items(inputs, 0))?;
@@ -354,6 +359,7 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
p["path_sketch"] =
json!(crate::dialogs::curves_sketch(app, &path, p["sketch"].as_u64()).ok_or("the path curves must be in one other sketch")?);
p["path"] = json!(path);
crate::dialogs_sweep::params(app, opts, items(inputs, 2).first(), &mut p)?;
("surface.sweep", p)
}
Surf::Loft => {
@@ -578,10 +584,17 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
}
return Some((Kind::Surface(k), ins, extra));
}
FeatureKind::SurfaceSweep { sketch, profiles, curves, path, .. } => (
Surf::Sweep,
vec![sheet_sels(app, *sketch, profiles.as_ref(), curves), path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect()],
),
FeatureKind::SurfaceSweep { sketch, profiles, curves, path, orientation, twist, scale, .. } => {
let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, frame);
(
Surf::Sweep(opts),
vec![
sheet_sels(app, *sketch, profiles.as_ref(), curves),
path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect(),
direction.into_iter().collect(),
],
)
}
FeatureKind::SurfaceLoft { sections } => {
(Surf::Loft, vec![sections.iter().flat_map(|sec| sheet_sels(app, sec.sketch, sec.profiles.as_ref(), &sec.curves)).collect()])
}
+116
View File
@@ -0,0 +1,116 @@
//! The options Sweep and Surface Sweep share (#771): how the profile turns along the path, a
//! twist over the path's length and the profile's scale at the end.
use serde_json::{Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::doc::{Mat, SweepOrientation};
use solidcraft_engine::geom::Vec3;
use crate::SolveApp;
use crate::dialogs::{combo, field, row_label};
use crate::selection::{AXES, EDGES, SelInput};
/// The orientation choices, in combo order: perpendicular, parallel, a direction.
const ORIENTATIONS: [&str; 3] = ["Turn with Path", "Keep as Drawn", "Follow Direction"];
/// The orientation choice that takes a picked direction.
const DIRECTION: usize = 2;
/// A twist field that means no twist.
const NO_TWIST: &str = "0 deg";
/// An end scale field that means no scaling.
const NO_SCALE: &str = "1";
/// A sweep dialog's options.
#[derive(Clone, Debug, PartialEq)]
pub struct SweepOpts {
/// An index into [`ORIENTATIONS`].
pub orientation: usize,
pub twist: String,
pub scale: String,
/// A stored direction (world), used while no direction is picked.
pub direction: Option<Vec3>,
}
impl Default for SweepOpts {
fn default() -> Self {
SweepOpts { orientation: 0, twist: NO_TWIST.into(), scale: NO_SCALE.into(), direction: None }
}
}
impl SweepOpts {
/// A feature's stored options as dialog values; `frame` maps the feature's component frame to
/// the world (a stored direction is in the component's frame). A direction along an origin
/// axis comes back as a pick of that axis.
pub fn of(orientation: &SweepOrientation, twist: &Option<String>, scale: &Option<String>, frame: &Mat) -> (SweepOpts, Option<Sel>) {
let mut o = SweepOpts {
twist: twist.clone().unwrap_or_else(|| NO_TWIST.into()),
scale: scale.clone().unwrap_or_else(|| NO_SCALE.into()),
..SweepOpts::default()
};
let mut pick = None;
match orientation {
SweepOrientation::Perpendicular => {}
SweepOrientation::Parallel => o.orientation = 1,
SweepOrientation::Direction(d) => {
o.orientation = DIRECTION;
let world = solidcraft_engine::doc::apply_vector(frame, *d);
match world.normalized().and_then(world_axis) {
Some(name) => pick = Some(Sel::Axis { name: name.into() }),
None => o.direction = Some(world),
}
}
}
(o, pick)
}
}
/// The origin axis a direction runs along (its way), if any.
fn world_axis(d: Vec3) -> Option<&'static str> {
[("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)].into_iter().find(|(_, a)| d.cross(*a).len() < 1e-9 && d.dot(*a) > 0.0).map(|(n, _)| n)
}
/// The input that picks a Follow Direction sweep's direction: an axis, a sketch line or a
/// straight edge.
pub fn direction_input() -> SelInput {
SelInput::new("Direction", AXES | EDGES, false)
}
/// The option rows; true when Enter was pressed in a field.
pub fn rows(ui: &mut egui::Ui, o: &mut SweepOpts, id: &str) -> bool {
let mut enter = false;
row_label(ui, "Orientation");
combo(ui, &format!("{id}_orient"), &ORIENTATIONS, &mut o.orientation);
ui.end_row();
row_label(ui, "Twist");
enter |= field(ui, &mut o.twist);
ui.end_row();
row_label(ui, "End Scale");
enter |= field(ui, &mut o.scale);
ui.end_row();
enter
}
/// The options as command parameters, added to `p`: the orientation, and the twist and end
/// scale when they change anything. `picked` is the direction input's selection.
pub fn params(app: &SolveApp, o: &SweepOpts, picked: Option<&Sel>, p: &mut Value) -> Result<(), String> {
match o.orientation {
0 => {}
1 => p["orientation"] = json!("parallel"),
_ => {
let d = match (picked, o.direction) {
(Some(x), _) => crate::dialogs::axis_of(app, x).ok_or("the direction must be an axis, a sketch line or a straight edge")?.1,
(None, Some(d)) => d,
(None, None) => return Err("select a direction (an axis, a sketch line or a straight edge) for the profile to follow".into()),
};
p["orientation"] = json!([d.x, d.y, d.z]);
}
}
let twist = o.twist.trim();
if !twist.is_empty() && twist != NO_TWIST {
p["twist"] = json!(twist);
}
let scale = o.scale.trim();
if !scale.is_empty() && scale != NO_SCALE {
p["scale"] = json!(scale);
}
Ok(())
}
+3
View File
@@ -32,6 +32,7 @@ pub mod dialogs_plastic;
pub mod dialogs_save_mesh;
pub mod dialogs_sheet;
pub mod dialogs_surface;
pub mod dialogs_sweep;
pub mod dim_view;
#[cfg(test)]
mod dimension_create_ui_tests;
@@ -141,6 +142,8 @@ pub mod smart_extrude;
#[cfg(test)]
mod smart_extrude_ui_tests;
pub mod stand_up;
#[cfg(test)]
mod sweep_dialog_tests;
pub mod theme;
#[cfg(test)]
mod thin_extrude_tests;
+101
View File
@@ -0,0 +1,101 @@
//! The Sweep and Surface Sweep dialogs' options (#771): orientation, twist and end scale reach
//! the commands, and editing a sweep keeps them.
use crate::dialogs::Kind;
use crate::dialogs_surface::Surf;
use crate::scenario::Harness;
use serde_json::json;
use solidcraft_engine::Sel;
use solidcraft_engine::doc::{FeatureKind, SweepOrientation};
use solidcraft_engine::geom::Vec3;
/// A square `Q` on XY and a path `P` 40 up the Z axis; the square's sketch id.
fn setup(h: &mut Harness) -> u64 {
h.app.run("sketch.create", json!({"plane":"XY","name":"Q"})).unwrap();
h.app.run("sketch.rectangle.two_point", json!({"p0":[-5,-5],"p1":[5,5]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.run("sketch.create", json!({"plane":"XZ","name":"P"})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,0],[0,-40]],"ids":["up"]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.session.doc.find_feature("Q").unwrap().id
}
#[test]
fn a_new_sweep_takes_its_options_from_the_dialog() {
let mut h = Harness::new();
let sketch = setup(&mut h);
h.step(&json!({"call":"ui.start","params":{"command":"solid.sweep"}})).unwrap();
h.frames(3);
assert!(h.widget("Twist").is_some() && h.widget("End Scale").is_some() && h.widget("Orientation").is_some());
let d = h.app.dialog.as_mut().unwrap();
d.inputs[0].items = vec![Sel::Profile { sketch, index: 0 }];
d.inputs[1].items = vec![Sel::SketchCurve { id: "up".into() }];
d.inputs[2].items = vec![Sel::Axis { name: "X".into() }];
let Kind::Sweep { opts, .. } = &mut d.kind else { panic!("{:?}", d.kind) };
opts.orientation = 2;
opts.twist = "90 deg".into();
opts.scale = "0.5".into();
h.frames(2);
h.step(&json!({"widget":"OK"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
let f = h.app.session.doc.features.last().unwrap();
let FeatureKind::Sweep { orientation, twist, scale, .. } = &f.kind else { panic!("{:?}", f.kind) };
assert_eq!(*orientation, SweepOrientation::Direction(Vec3::X));
assert_eq!(twist.as_deref(), Some("90 deg"));
assert_eq!(scale.as_deref(), Some("0.5"));
}
#[test]
fn a_plain_sweep_from_the_dialog_stores_no_options() {
let mut h = Harness::new();
let sketch = setup(&mut h);
h.step(&json!({"call":"ui.start","params":{"command":"solid.sweep"}})).unwrap();
h.frames(3);
let d = h.app.dialog.as_mut().unwrap();
d.inputs[0].items = vec![Sel::Profile { sketch, index: 0 }];
d.inputs[1].items = vec![Sel::SketchCurve { id: "up".into() }];
h.step(&json!({"widget":"OK"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
let f = h.app.session.doc.features.last().unwrap();
let FeatureKind::Sweep { orientation, twist, scale, .. } = &f.kind else { panic!("{:?}", f.kind) };
assert!(orientation.is_perpendicular() && twist.is_none() && scale.is_none());
}
#[test]
fn editing_sweeps_keeps_their_options() {
let mut h = Harness::new();
setup(&mut h);
h.app
.run(
"solid.sweep",
json!({"sketch":"Q","path_sketch":"P","path":["up"],"orientation":[1,1,0],"twist":"30 deg","scale":"2","operation":"new","name":"Solid"}),
)
.unwrap();
h.app
.run(
"surface.sweep",
json!({"sketch":"Q","path_sketch":"P","path":["up"],"orientation":"parallel","twist":"45 deg","scale":"0.5","name":"Sheet"}),
)
.unwrap();
h.frames(4);
for name in ["Solid", "Sheet"] {
let before = h.app.session.doc.find_feature(name).unwrap().clone();
h.step(&json!({"call":"ui.editFeature","params":{"feature":name}})).unwrap();
h.frames(8);
match &h.app.dialog.as_ref().unwrap().kind {
Kind::Sweep { opts, .. } | Kind::Surface(Surf::Sweep(opts)) => assert_eq!(opts.twist, if name == "Solid" { "30 deg" } else { "45 deg" }),
k => panic!("{k:?}"),
}
h.step(&json!({"widget":"OK"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
// The options come back as they were (the profile pick is stored by its curves).
let options = |k: &FeatureKind| match k {
FeatureKind::Sweep { orientation, twist, scale, .. } | FeatureKind::SurfaceSweep { orientation, twist, scale, .. } => {
(*orientation, twist.clone(), scale.clone())
}
k => panic!("{k:?}"),
};
let after = h.app.session.doc.find_feature(name).unwrap();
assert_eq!(options(&after.kind), options(&before.kind), "{name}");
assert_eq!(after.param_names, before.param_names, "{name}");
}
}