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

+4 -1
View File
@@ -53,7 +53,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two boundaries exact, three/four refused pending a Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), and every remaining #757–#772 item | deferred | — |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two boundaries exact, three/four refused pending a Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), loft guide rails (#201; sweep guide rails and two-rail sweeps are done, #771), and every remaining #757–#772 item | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
| M11 | 2D drawings | deferred | — |
@@ -383,6 +383,9 @@ completed drawings publish atomically. The browser still exports synchronously.
A sweep with a twist, an end scale or a parallel or direction orientation (#771) follows its
path that way too, even along sketch lines and arcs: B-spline walls through stations, ruled
walls through denser stations where the path has corners or the profile has holes.
Guide rails (one or two, #771) do the same: each station meets every rail once, and the rail
turns the profile and scales it (or stretches it across to the rail) against the start; two
rails carry it between them. Rails along a path with sharp corners are refused for now.
- Extend intersects exact ellipses and rational/high-degree splines without moving the line or
circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance
certificate and refine within bounded node limits while retaining existing IDs.
+4
View File
@@ -1077,6 +1077,8 @@
"ui.quit": "Beenden",
"ui.radius": "Radius",
"ui.radius_type": "Radius-Typ",
"ui.rail": "Leitkurve",
"ui.rail_fit": "Anpassung an Leitkurve",
"ui.reachable": "Erreichbar",
"ui.rectangle": "Rechteck",
"ui.rectangular_pattern": "Rechteckmuster",
@@ -1100,6 +1102,7 @@
"ui.save_mesh_temp_failed": "{folder} konnte nicht angelegt werden: {error}",
"ui.saved_to": "Gespeichert: {path}",
"ui.scale": "Skalieren",
"ui.second_rail": "Zweite Leitkurve",
"ui.section_analysis": "Querschnittsanalyse",
"ui.select": "Auswählen",
"ui.selectOther": "Andere auswählen…",
@@ -1144,6 +1147,7 @@
"ui.split_body": "Körper trennen",
"ui.start": "Start",
"ui.start_radius": "Startradius",
"ui.stretch": "Strecken",
"ui.supersampling": "Supersampling (SSAA)",
"ui.supersampling_adaptive": "Nur bei ruhender Ansicht",
"ui.supersampling_off": "Aus (1×)",
+4
View File
@@ -1330,6 +1330,8 @@
"ui.radial_context_menu": "Radial context menu",
"ui.radius": "Radius",
"ui.radius_type": "Radius Type",
"ui.rail": "Rail",
"ui.rail_fit": "Rail Fit",
"ui.reachable": "Reachable",
"ui.rectangle": "Rectangle",
"ui.rectangular_pattern": "Rectangular Pattern",
@@ -1356,6 +1358,7 @@
"ui.save_mesh_temp_failed": "Couldn't make {folder}: {error}",
"ui.saved_to": "Saved {path}",
"ui.scale": "Scale",
"ui.second_rail": "Second Rail",
"ui.section_analysis": "Section Analysis",
"ui.select": "Select",
"ui.selectOther": "Select Other…",
@@ -1406,6 +1409,7 @@
"ui.stand_up_tip": "After a STEP or IGES file opens with Z up, offer a quarter turn about X",
"ui.start": "Start",
"ui.start_radius": "Start Radius",
"ui.stretch": "Stretch",
"ui.supersampling": "Supersampling (SSAA)",
"ui.supersampling_adaptive": "Only when the view is still",
"ui.supersampling_off": "Off (1×)",
+4
View File
@@ -1138,6 +1138,8 @@
"ui.radial_context_menu": "Menu contextuel radial",
"ui.radius": "Rayon",
"ui.radius_type": "Type de rayon",
"ui.rail": "Rail",
"ui.rail_fit": "Ajustement au rail",
"ui.reachable": "Accessible",
"ui.rectangle": "Rectangle",
"ui.rectangular_pattern": "Réseau rectangulaire",
@@ -1164,6 +1166,7 @@
"ui.save_mesh_temp_failed": "Impossible de créer {folder} : {error}",
"ui.saved_to": "Enregistré : {path}",
"ui.scale": "Échelle",
"ui.second_rail": "Second rail",
"ui.section_analysis": "Analyse de coupe",
"ui.select": "Sélectionner",
"ui.selectOther": "Sélectionner un autre…",
@@ -1214,6 +1217,7 @@
"ui.stand_up_tip": "Après l'ouverture d'un fichier STEP ou IGES avec Z vers le haut, proposer un quart de tour autour de X",
"ui.start": "Début",
"ui.start_radius": "Rayon initial",
"ui.stretch": "Étirer",
"ui.supersampling": "Suréchantillonnage (SSAA)",
"ui.supersampling_adaptive": "Seulement quand la vue est immobile",
"ui.supersampling_off": "Désactivé (1×)",
+4
View File
@@ -1330,6 +1330,8 @@
"ui.radial_context_menu": "パイ メニュー",
"ui.radius": "半径",
"ui.radius_type": "半径タイプ",
"ui.rail": "レール",
"ui.rail_fit": "レールの合わせ方",
"ui.reachable": "到達可能",
"ui.rectangle": "長方形",
"ui.rectangular_pattern": "矩形パターン",
@@ -1356,6 +1358,7 @@
"ui.save_mesh_temp_failed": "{folder} を作成できません: {error}",
"ui.saved_to": "保存しました: {path}",
"ui.scale": "尺度",
"ui.second_rail": "第2レール",
"ui.section_analysis": "断面解析",
"ui.select": "選択",
"ui.selectOther": "その他を選択…",
@@ -1406,6 +1409,7 @@
"ui.stand_up_tip": "Z軸上向きで開いたSTEPまたはIGESファイルに対し、X軸周りに90度回転させるか確認します",
"ui.start": "開始",
"ui.start_radius": "開始半径",
"ui.stretch": "伸縮",
"ui.supersampling": "スーパーサンプリング (SSAA)",
"ui.supersampling_adaptive": "ビューが静止しているときのみ",
"ui.supersampling_off": "オフ (1×)",
+4
View File
@@ -1330,6 +1330,8 @@
"ui.radial_context_menu": "环形上下文菜单",
"ui.radius": "半径",
"ui.radius_type": "半径类型",
"ui.rail": "导轨",
"ui.rail_fit": "导轨适配方式",
"ui.reachable": "可到达",
"ui.rectangle": "矩形",
"ui.rectangular_pattern": "矩形阵列",
@@ -1356,6 +1358,7 @@
"ui.save_mesh_temp_failed": "无法创建 {folder}:{error}",
"ui.saved_to": "已保存:{path}",
"ui.scale": "缩放",
"ui.second_rail": "第二导轨",
"ui.section_analysis": "截面分析",
"ui.select": "选择",
"ui.selectOther": "选择其他…",
@@ -1406,6 +1409,7 @@
"ui.stand_up_tip": "当 Z 朝上的 STEP 或 IGES 文件打开后,提示绕 X 轴旋转四分之一周",
"ui.start": "开始",
"ui.start_radius": "起始半径",
"ui.stretch": "伸展",
"ui.supersampling": "超级采样 (SSAA)",
"ui.supersampling_adaptive": "仅在视图静止时",
"ui.supersampling_off": "关 (1×)",
+37
View File
@@ -299,6 +299,31 @@ impl SweepOrientation {
}
}
/// A sweep's guide rail: a chain of curves of a sketch, followed from the end nearest the path's
/// start.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct SweepRail {
pub sketch: SketchId,
pub curves: Vec<String>,
}
/// How a sweep's guide rails reshape its profile.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RailFit {
/// Evenly, with the distance across to the rail (between the rails).
#[default]
Scale,
/// Only across to the rail (between the rails).
Stretch,
}
impl RailFit {
pub fn is_scale(&self) -> bool {
*self == RailFit::Scale
}
}
/// 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).
@@ -729,6 +754,12 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(unitless = "Scale")]
scale: Option<Expr>,
/// Guide rails (one or two) the profile follows, instead of orientation, twist and scale.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
rails: Vec<SweepRail>,
/// How the rails reshape the profile.
#[serde(default, skip_serializing_if = "RailFit::is_scale")]
rail_fit: RailFit,
#[serde(default)]
operation: Operation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -1316,6 +1347,12 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(unitless = "Scale")]
scale: Option<Expr>,
/// Guide rails (one or two) the curves follow, instead of orientation, twist and scale.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
rails: Vec<SweepRail>,
/// How the rails reshape the curves.
#[serde(default, skip_serializing_if = "RailFit::is_scale")]
rail_fit: RailFit,
},
/// A surface body lofted through sections in order, each one open or closed chain of
/// sketch curves: walls only, no caps.
+64 -11
View File
@@ -962,17 +962,25 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 {
| FeatureKind::Pattern { pattern: crate::PatternKind::Path { path_sketch, .. }, .. } => Some(*path_sketch),
_ => None,
};
let wired = |id: &crate::SketchId| doc.sketch(id.0).is_ok_and(|sketch| !sketch.wires.is_empty());
let rails: &[crate::SweepRail] = match &f.kind {
FeatureKind::Sweep { rails, .. } | FeatureKind::SurfaceSweep { rails, .. } => rails,
_ => &[],
};
if let Some(path) = path
&& doc.sketch(path.0).is_ok_and(|sketch| !sketch.wires.is_empty())
&& (wired(&path) || rails.iter().any(|r| wired(&r.sketch)))
{
let mut hash = std::collections::hash_map::DefaultHasher::new();
let owner = doc.feature(path.0).map(|feature| feature.component).unwrap_or(0);
for frame in [doc.component_transform(f.component), doc.component_transform(owner)] {
frame.iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
}
if let FeatureKind::Sweep { sketch, .. } | FeatureKind::SurfaceSweep { sketch, .. } = &f.kind {
let owner = doc.feature(sketch.0).map(|feature| feature.component).unwrap_or(0);
doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
if let FeatureKind::Sweep { sketch, rails, .. } | FeatureKind::SurfaceSweep { sketch, rails, .. } = &f.kind {
// The profile's and the rails' components place them too.
for sketch in std::iter::once(sketch).chain(rails.iter().map(|r| &r.sketch)) {
let owner = doc.feature(sketch.0).map(|feature| feature.component).unwrap_or(0);
doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
}
}
return hash.finish();
}
@@ -3271,8 +3279,7 @@ 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, orientation, twist, scale, .. } => {
let ctl = sweep_control(vals, orientation, twist, scale)?;
FeatureKind::Sweep { sketch, profiles, path_sketch, path, orientation, twist, scale, rails, rail_fit, .. } => {
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
let ps =
st.sketch(path_sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
@@ -3288,11 +3295,25 @@ fn feature_tools_with_constructor(
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
let start = regions.first().map(|r| ss.plane.to_world(r.centroid())).unwrap_or_default();
let tool = |r: &Region2, body: Body| crate::lineage::ProfileRoles::swept(Some(ss), Some(r)).tool(body, None);
let points = path_points(ps, path, start)?;
let segs = match points {
Some(_) => Vec::new(),
None => path_segments(ps, path, start)?,
};
let path_start = points
.as_ref()
.and_then(|p| p.first().copied())
.or_else(|| match segs.first() {
Some(kernel::PathSeg::Line { a, .. } | kernel::PathSeg::Arc { a, .. }) => Some(*a),
None => None,
})
.unwrap_or(start);
let rails = rail_polylines(doc, st, rails, f.component, path_start)?;
let ctl = sweep_control(vals, orientation, twist, scale, rails, *rail_fit)?;
// A path through 3D sketch curves follows them as points.
if let Some(pts) = path_points(ps, path, start)? {
if let Some(pts) = points {
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_with(&ss.plane, r, &segs, &ctl)?))).collect()
}
FeatureKind::Pipe { path_sketch, path, diameter, wall, .. } => {
@@ -3341,13 +3362,16 @@ 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.
/// A sweep's options from its stored orientation, its twist and end scale expressions (none: no
/// twist, scale 1) and its guide rails as polylines (see [`rail_polylines`]). Out-of-range values
/// are refused by the kernel.
pub(crate) fn sweep_control(
vals: &BTreeMap<String, Value>,
orientation: &crate::SweepOrientation,
twist: &Option<crate::Expr>,
scale: &Option<crate::Expr>,
rails: Vec<Vec<Vec3>>,
rail_fit: crate::RailFit,
) -> Result<kernel::SweepControl> {
let orientation = match *orientation {
crate::SweepOrientation::Perpendicular => kernel::SweepOrientation::Perpendicular,
@@ -3362,11 +3386,40 @@ pub(crate) fn sweep_control(
Some(k) => val(vals, k, Kind::Unitless)?,
None => 1.0,
};
let ctl = kernel::SweepControl { orientation, twist, scale };
let rail_fit = match rail_fit {
crate::RailFit::Scale => kernel::RailFit::Scale,
crate::RailFit::Stretch => kernel::RailFit::Stretch,
};
let ctl = kernel::SweepControl { orientation, twist, scale, rails, rail_fit };
ctl.check()?;
Ok(ctl)
}
/// A sweep's guide rails as polylines in the feature's frame, each a chain of its sketch's curves
/// (planar or 3D) from the end nearest `start`, the start of the path.
pub(crate) fn rail_polylines(doc: &Document, st: &ModelState, rails: &[crate::SweepRail], consumer: u64, start: Vec3) -> Result<Vec<Vec<Vec3>>> {
if rails.len() > 2 {
return Err(DocError::Invalid("a sweep takes one or two guide rails".into()));
}
rails
.iter()
.map(|rail| {
if rail.curves.is_empty() {
return Err(DocError::Invalid("a guide rail needs its curves".into()));
}
let rs = st
.sketch(rail.sketch.0)
.ok_or_else(|| DocError::Unknown(format!("rail sketch {} (it must come earlier in the timeline)", rail.sketch)))?;
let mapped = path_in_frame(doc, rs, consumer)?;
let rs = mapped.as_ref();
match path_points(rs, &rail.curves, start)? {
Some(pts) => Ok(pts),
None => Ok(kernel::path_polyline(&path_segments(rs, &rail.curves, start)?)?),
}
})
.collect()
}
/// 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<crate::Expr>) -> Result<Region2> {
let holes = match wall {
+16 -4
View File
@@ -286,8 +286,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
Ok(())
}
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale } => {
let ctl = crate::eval::sweep_control(vals, orientation, twist, scale)?;
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale, rails, rail_fit } => {
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
let ps =
st.sketch(path_sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
@@ -306,8 +305,21 @@ 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_with(&ss.plane, &chains, &pts, &ctl)?,
None => kernel::sweep_sheet_with(&ss.plane, &chains, &path_segments(ps, path, start)?, &ctl)?,
Some(pts) => {
let rails = crate::eval::rail_polylines(doc, st, rails, f.component, pts.first().copied().unwrap_or(start))?;
let ctl = crate::eval::sweep_control(vals, orientation, twist, scale, rails, *rail_fit)?;
kernel::sweep_sheet_points_with(&ss.plane, &chains, &pts, &ctl)?
}
None => {
let segs = path_segments(ps, path, start)?;
let path_start = match segs.first() {
Some(kernel::PathSeg::Line { a, .. } | kernel::PathSeg::Arc { a, .. }) => *a,
None => start,
};
let rails = crate::eval::rail_polylines(doc, st, rails, f.component, path_start)?;
let ctl = crate::eval::sweep_control(vals, orientation, twist, scale, rails, *rail_fit)?;
kernel::sweep_sheet_with(&ss.plane, &chains, &segs, &ctl)?
}
};
let traced = crate::lineage::ChainRoles { sketch: ss, chains: &chains }.tool(body);
push_sheet(st, f, traced);
@@ -0,0 +1,5 @@
//! A `Sweep` and a `SurfaceSweep` can carry guide `rails` (one or two, each a `sketch` and its
//! `curves` in order) and `rail_fit` (`scale`, the default, or `stretch`): the profile follows
//! the rails instead of an orientation, twist and end scale (#771). A sweep without them saves as
//! it always did, so older designs need no transformation; 0.13.x asks for an update instead of
//! sweeping a railed profile without its rails.
+1
View File
@@ -53,6 +53,7 @@ impl Walk for crate::document::FilletFit {}
impl Walk for crate::document::FilletCorner {}
impl Walk for crate::document::MeshData {}
impl Walk for crate::document::RevolveConvention {}
impl Walk for crate::document::RailFit {}
impl Walk for crate::document::CoilSection {}
impl Walk for crate::document::ThinSide {}
impl Walk for crate::document::ExtendMode {}
+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}");
}
}
+1 -1
View File
@@ -1835,7 +1835,7 @@ pub(crate) fn rotate_about(p: Vec3, center: Vec3, k: Vec3, angle: f64) -> Vec3 {
/// The path as a polyline: its first point, then each segment's points (arcs within 0.01 mm of
/// their chords).
pub(crate) fn path_polyline(path: &[PathSeg]) -> Result<Vec<Vec3>> {
pub fn path_polyline(path: &[PathSeg]) -> Result<Vec<Vec3>> {
let first = match path.first() {
Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a,
None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())),
+5 -3
View File
@@ -114,6 +114,8 @@ mod surface_ruled;
mod surface_untrim;
mod surfaces;
mod sweep_path;
#[cfg(test)]
mod sweep_rails_tests;
mod tangent_cylinders;
mod tess;
#[cfg(test)]
@@ -134,8 +136,8 @@ pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrate
pub use boundary::{Boundary, boundary_patch};
pub use bridge::{Continuity, Frame, blend_between, bridge, chain, chain_closes, frames, frames_across, sample_grid};
pub use build::{
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, revolve_whole, sphere,
sweep, sweep_with, torus,
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, path_polyline, revolve,
revolve_whole, sphere, sweep, sweep_with, torus,
};
pub use continuity::{
ContinuityReport, ContinuityTolerances, EdgePick, MAX_SAMPLES, MIN_SAMPLES, SeamReport, SeamVerdict, SeamWorst, pick_edges, seam_diagnostics,
@@ -180,7 +182,7 @@ pub use surfaces::{
copy_faces, extend, extrude_sheet, loft_sheet, patch_edges, patch_region, remove_faces, reverse_faces, revolve_sheet, stitch, sweep_sheet,
sweep_sheet_points, sweep_sheet_points_with, sweep_sheet_with, thicken, trim, unstitch,
};
pub use sweep_path::{SweepControl, SweepOrientation, sweep_path, sweep_path_with};
pub use sweep_path::{RailFit, SweepControl, SweepOrientation, sweep_path, sweep_path_with};
pub use topo::{CylinderFace, cylinder_end_at, cylinder_face_at};
pub use topo::{TopoCounts, merged_topology, seam_flags};
pub use tspline::tspline_sheet;
+156 -9
View File
@@ -1,8 +1,9 @@
//! Sweeps along 3D point paths (3D sketch curves: projected, intersection and included curves).
//! The profile follows the path with a rotation-minimising frame. Smooth paths get one B-spline
//! wall per profile segment; paths with sharp corners (or profiles with holes) get ruled walls
//! between stations, mitred at the corners. Sweep options (orientation, twist, end scale; see
//! [`SweepControl`]) reshape the stations; their default leaves them exactly as described.
//! between stations, mitred at the corners. Sweep options (orientation, twist, end scale, guide
//! rails; see [`SweepControl`]) reshape the stations; their default leaves them exactly as
//! described.
use mt::builder;
use solidcraft_geom::{Plane, Region2, Seg2, Vec2, Vec3};
@@ -48,21 +49,44 @@ pub enum SweepOrientation {
Direction(Vec3),
}
/// How guide rails reshape the profile at each station.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RailFit {
/// The profile grows or shrinks evenly with its distance across to the rail (between the
/// two rails).
#[default]
Scale,
/// The profile stretches only across to the rail (between the rails), keeping its size the
/// other way.
Stretch,
}
/// Sweep options beyond following the path. `twist` turns the profile about the path by that
/// many radians over the path's length (right hand about the direction of travel, in proportion
/// to the length run); `scale` is the profile's size at the end, reached linearly along the
/// path's length, about the path's start point. The default (perpendicular, no twist, scale 1)
/// is a plain sweep.
#[derive(Clone, Copy, Debug, PartialEq)]
/// path's length, about the path's start point. The default (perpendicular, no twist, scale 1,
/// no rails) is a plain sweep.
///
/// `rails` are guide rails, as polylines from the end nearest the profile. Each station of the
/// path is square to it; a rail meets the station's plane at one point. With one rail, the
/// profile turns about the path so that its point where the rail starts stays on the rail, and
/// is scaled (or stretched, see [`RailFit`]) by how far the rail is from the path compared with
/// the start. With two rails, the profile's points where the rails start stay on both: it moves
/// with the first rail, turns with the line between them and scales (stretches) with their
/// distance apart. Rails decide the profile's turn and size, so they take the default
/// orientation, no twist and scale 1, and a path without sharp corners.
#[derive(Clone, Debug, PartialEq)]
pub struct SweepControl {
pub orientation: SweepOrientation,
pub twist: f64,
pub scale: f64,
pub rails: Vec<Vec<Vec3>>,
pub rail_fit: RailFit,
}
impl Default for SweepControl {
fn default() -> Self {
SweepControl { orientation: SweepOrientation::Perpendicular, twist: 0.0, scale: 1.0 }
SweepControl { orientation: SweepOrientation::Perpendicular, twist: 0.0, scale: 1.0, rails: Vec::new(), rail_fit: RailFit::Scale }
}
}
@@ -79,13 +103,15 @@ impl SweepControl {
*self == SweepControl::default()
}
/// Whether the profile changes along the path (it twists or scales).
/// Whether the profile changes along the path (it twists, scales or follows rails).
fn varies(&self) -> bool {
self.twist != 0.0 || self.scale != 1.0
self.twist != 0.0 || self.scale != 1.0 || !self.rails.is_empty()
}
/// Refuses options out of range: a twist beyond [`Self::MAX_TWIST`], a scale outside
/// [`Self::MIN_SCALE`]…[`Self::MAX_SCALE`], a direction that is not finite or has no length.
/// [`Self::MIN_SCALE`]…[`Self::MAX_SCALE`], a direction that is not finite or has no length,
/// more than two rails, a rail that is not 2…100000 finite points, and rails with any other
/// orientation, a twist or an end scale.
pub fn check(&self) -> Result<()> {
if !(self.twist.is_finite() && self.twist.abs() <= Self::MAX_TWIST) {
return Err(KernelError::Invalid("the sweep twist must be within four turns (1440 deg)".into()));
@@ -98,6 +124,17 @@ impl SweepControl {
{
return Err(KernelError::Invalid("the sweep orientation direction must be a finite, non-zero vector".into()));
}
if self.rails.len() > 2 {
return Err(KernelError::Invalid("a sweep takes one or two guide rails".into()));
}
for rail in &self.rails {
clean(rail).map_err(|_| KernelError::Invalid("a guide rail needs 2…100000 finite points along its length".into()))?;
}
if !self.rails.is_empty() && (self.orientation != SweepOrientation::Perpendicular || self.twist != 0.0 || self.scale != 1.0) {
return Err(KernelError::Invalid(
"guide rails set the profile's turn and size along the path: leave the orientation, twist and end scale at their defaults".into(),
));
}
Ok(())
}
}
@@ -228,6 +265,9 @@ fn frames_toward(tangents: &[Vec3], d: Vec3) -> Result<Vec<[Vec3; 3]>> {
/// turned by the orientation's frames), optionally stretched across a corner (mitre). Twist and
/// scale grow with the length run along `pts`.
fn controlled_stations(plane: &Plane, pts: &[Vec3], tangents: &[Vec3], mitre: &[Option<(Vec3, f64)>], ctl: &SweepControl) -> Result<Vec<Station>> {
if !ctl.rails.is_empty() {
return railed_stations(pts, tangents, mitre, ctl);
}
let bad = || KernelError::Invalid("sweep path".into());
let t0 = *tangents.first().ok_or_else(bad)?;
let f: Vec<[Vec3; 3]> = match ctl.orientation {
@@ -284,6 +324,113 @@ fn controlled_stations(plane: &Plane, pts: &[Vec3], tangents: &[Vec3], mitre: &[
Ok(out)
}
/// Where a rail polyline crosses the plane through `p` square to `t`: the first crossing at or
/// after `from` (segment index and fraction along it), with the position it was found at, so a
/// walk along the path finds each rail point after the last. Within `tol` of the plane a rail's
/// first and last points count as on it (a rail drawn to end where the path ends).
fn rail_crossing(rail: &[Vec3], p: Vec3, t: Vec3, from: (usize, f64), tol: f64) -> Option<(Vec3, (usize, f64))> {
let f = |q: Vec3| (q - p).dot(t);
let (k0, u0) = from;
if k0 == 0 && u0 == 0.0 && rail.first().is_some_and(|q| f(*q).abs() <= tol) {
return rail.first().map(|q| (*q, (0, 0.0)));
}
for k in k0..rail.len().saturating_sub(1) {
let (a, b) = (*rail.get(k)?, *rail.get(k + 1)?);
let a = if k == k0 { a.lerp(b, u0) } else { a };
let (fa, fb) = (f(a), f(b));
if fa == 0.0 && (k != k0 || u0 == 0.0) {
return Some((a, (k, if k == k0 { u0 } else { 0.0 })));
}
if (fa < 0.0) != (fb < 0.0) || fb == 0.0 {
let s = fa / (fa - fb);
let q = a.lerp(b, s);
// The fraction along the whole segment, for the next walk.
let base = if k == k0 { u0 } else { 0.0 };
return Some((q, (k, base + (1.0 - base) * s)));
}
}
let last = rail.last()?;
(f(*last).abs() <= tol).then(|| (*last, (rail.len().saturating_sub(2), 1.0)))
}
/// Stations shaped by guide rails (see [`SweepControl`]): each is square to the path, turned
/// about it to follow the rail (the line between the rails) and scaled or stretched by the
/// rail's distance across (the rails' distance apart) against the start's. A rail has to meet
/// the plane of every station, in order along its length.
fn railed_stations(pts: &[Vec3], tangents: &[Vec3], mitre: &[Option<(Vec3, f64)>], ctl: &SweepControl) -> Result<Vec<Station>> {
// A polyline path's points bend it a little (its arcs' chords); a corner is a bend past
// [`CORNER`], where the stations would have to be mitred.
let corner = 1.0 / (CORNER / 2.0).cos();
if mitre.iter().flatten().any(|(_, s)| *s > corner + 1e-12) {
return Err(KernelError::Invalid("not supported yet: guide rails along a path with sharp corners".into()));
}
let rails: Vec<Vec<Vec3>> = ctl.rails.iter().map(|r| clean(r)).collect::<Result<_>>()?;
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
let tol = 1e-3f64.max(1e-6 * len);
let mut cursors = vec![(0usize, 0.0f64); rails.len()];
// At each station: the anchor the profile moves with, and the vector across that turns and
// sizes it (to the rail, or from the first rail to the second).
let mut across = Vec::with_capacity(pts.len());
for (i, (p, t)) in pts.iter().zip(tangents).enumerate() {
let mut hits = Vec::with_capacity(rails.len());
for (rail, cursor) in rails.iter().zip(cursors.iter_mut()) {
let Some((q, at)) = rail_crossing(rail, *p, *t, *cursor, tol) else {
let done = 100.0 * i as f64 / pts.len().saturating_sub(1).max(1) as f64;
return Err(KernelError::Invalid(format!(
"a guide rail does not reach the path's plane {done:.0}% of the way along it: the rail has to run the whole length of the path"
)));
};
*cursor = at;
hits.push(q);
}
let (anchor, v) = match hits[..] {
[r] => (*p, r - *p),
[a, b] => (a, b - a),
_ => return Err(KernelError::Invalid("a sweep takes one or two guide rails".into())),
};
across.push((anchor, v - *t * v.dot(*t)));
}
let (Some(&(a0, d0)), Some(&t0)) = (across.first(), tangents.first()) else { return Err(KernelError::Invalid("sweep path".into())) };
let s0 = *pts.first().ok_or_else(|| KernelError::Invalid("sweep path".into()))?;
let near = |d: Vec3| d.len() <= 1e-6 * (1.0 + len);
if near(d0) {
return Err(KernelError::Invalid(if rails.len() == 1 {
"the guide rail starts on the path: it has to start away from the path, at the point of the profile it guides".into()
} else {
"the two guide rails start at the same point".into()
}));
}
let (u0, w0) = {
let u = d0.normalized().ok_or_else(|| KernelError::Invalid("guide rail".into()))?;
(u, t0.cross(u))
};
let mut out = Vec::with_capacity(pts.len());
for ((anchor, d), t) in across.iter().zip(tangents) {
if near(*d) {
return Err(KernelError::Invalid(if rails.len() == 1 {
format!("the guide rail meets the path near ({:.3}, {:.3}, {:.3}): the profile would shrink to nothing", anchor.x, anchor.y, anchor.z)
} else {
format!("the guide rails meet near ({:.3}, {:.3}, {:.3}): the profile would shrink to nothing", anchor.x, anchor.y, anchor.z)
}));
}
let k = d.len() / d0.len();
let k_other = match ctl.rail_fit {
RailFit::Scale => k,
RailFit::Stretch => 1.0,
};
let u = d.normalized().ok_or_else(|| KernelError::Invalid("guide rail".into()))?;
let w = t.cross(u);
// Along the path stays along it; across to the rail turns onto the rail and sizes with
// it; the third way follows, scaled evenly or kept.
let map = |v: Vec3| *t * v.dot(t0) + u * (k * v.dot(u0)) + w * (k_other * v.dot(w0));
let m = [map(Vec3::X), map(Vec3::Y), map(Vec3::Z)];
let st = Station { at: Vec3::default(), m };
// The anchor's start position lands on the anchor.
out.push(Station { at: *anchor - st.apply(a0 - s0), m });
}
Ok(out)
}
/// The volume a profile of `area` on `plane` sweeps through the stations: each station's
/// section area times how squarely the path crosses it, summed along the path (trapezoids).
fn swept_volume(plane: &Plane, area: f64, st: &[Station], tangents: &[Vec3]) -> f64 {
+166
View File
@@ -0,0 +1,166 @@
//! Guide rails on path sweeps (issue #771), checked against analytic solids: a rail that closes
//! in on the path scales a circle into a cone frustum, a stretching rail widens a square one way
//! only, a helical rail twists a square, two rails carry a line between them; and rails the
//! sweep cannot follow are refused.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::f64::consts::PI;
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use crate::{RailFit, SweepControl, measure, sweep_path_with};
fn circle(r: f64) -> Region2 {
Region2 { outer: Loop2::circle(Vec2::ZERO, r), holes: Vec::new() }
}
fn square(h: f64) -> Region2 {
Region2 { outer: Loop2::polygon(&[Vec2::new(-h, -h), Vec2::new(h, -h), Vec2::new(h, h), Vec2::new(-h, h)]), holes: Vec::new() }
}
/// The Z axis from 0 to 40.
fn up() -> Vec<Vec3> {
vec![Vec3::ZERO, Vec3::new(0.0, 0.0, 40.0)]
}
fn railed(rails: Vec<Vec<Vec3>>, fit: RailFit) -> SweepControl {
SweepControl { rails, rail_fit: fit, ..SweepControl::default() }
}
fn close(a: f64, b: f64, rel: f64) -> bool {
(a - b).abs() <= rel * b.abs()
}
/// A rail from the circle's edge at radius 10 in to radius 5 at the top scales the circle into
/// a frustum: volume πh(R² + Rr + r²)/3, lateral area π(R + r) times the slant height.
#[test]
fn a_rail_closing_in_scales_a_circle_into_a_frustum() {
let rail = vec![Vec3::new(10.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 40.0)];
let b = sweep_path_with(&Plane::XY, &circle(10.0), &up(), &railed(vec![rail], RailFit::Scale)).unwrap();
let m = measure(&b).unwrap();
let volume = PI * 40.0 / 3.0 * (100.0 + 50.0 + 25.0);
let area = PI * 15.0 * (40.0f64 * 40.0 + 25.0).sqrt() + PI * 100.0 + PI * 25.0;
assert!(close(m.volume, volume, 2e-3), "volume {} vs {volume}", m.volume);
assert!(close(m.area, area, 2e-3), "area {} vs {area}", m.area);
assert!(close(m.bbox.max.z, 40.0, 1e-9), "{:?}", m.bbox);
}
/// A stretching rail from x = 5 out to x = 10 doubles the square's width along X by the top and
/// leaves its depth along Y: sections 10 × 10k with k = 1 + z/40, volume 100 · 40 · 1.5.
#[test]
fn a_stretching_rail_widens_a_square_one_way() {
let rail = vec![Vec3::new(5.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 40.0)];
let b = sweep_path_with(&Plane::XY, &square(5.0), &up(), &railed(vec![rail], RailFit::Stretch)).unwrap();
let m = measure(&b).unwrap();
assert!(close(m.volume, 6000.0, 1e-3), "volume {}", m.volume);
assert!((m.bbox.max.x - 10.0).abs() < 1e-3 && (m.bbox.min.x + 10.0).abs() < 1e-3, "{:?}", m.bbox);
assert!((m.bbox.max.y - 5.0).abs() < 1e-3 && (m.bbox.min.y + 5.0).abs() < 1e-3, "{:?}", m.bbox);
}
/// The same rail scaling evenly: the square grows both ways, sections 100k², volume
/// 100 ∫ (1 + z/40)² dz = 100 · 40 · 7/3.
#[test]
fn a_scaling_rail_grows_a_square_both_ways() {
let rail = vec![Vec3::new(5.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 40.0)];
let b = sweep_path_with(&Plane::XY, &square(5.0), &up(), &railed(vec![rail], RailFit::Scale)).unwrap();
let m = measure(&b).unwrap();
let volume = 100.0 * 40.0 * 7.0 / 3.0;
assert!(close(m.volume, volume, 2e-3), "volume {} vs {volume}", m.volume);
assert!((m.bbox.max.y - 10.0).abs() < 1e-3, "{:?}", m.bbox);
}
/// A helical rail from the square's corner, a quarter turn about the path at the corner's
/// radius, twists the square a quarter turn: the volume stays 4000, halfway up the corners stand
/// on the axes (the box reaches 5√2), and at the top the corner is at (−5, 5).
#[test]
fn a_helical_rail_twists_a_square() {
let r = 50f64.sqrt();
let rail: Vec<Vec3> = (0..=200)
.map(|i| {
let u = i as f64 / 200.0;
let a = PI / 4.0 + u * PI / 2.0;
Vec3::new(r * a.cos(), r * a.sin(), 40.0 * u)
})
.collect();
let b = sweep_path_with(&Plane::XY, &square(5.0), &up(), &railed(vec![rail], RailFit::Scale)).unwrap();
let m = measure(&b).unwrap();
assert!(close(m.volume, 4000.0, 2e-3), "volume {}", m.volume);
assert!((m.bbox.max.x - r).abs() < 0.05 && (m.bbox.max.y - r).abs() < 0.05, "{:?}", m.bbox);
// The edge running up from the corner (5, 5) ends a quarter turn on, at (−5, 5).
let edges = b.edges(0.01).unwrap();
let ends = |e: &crate::EdgeInfo| (e.points.first().copied().unwrap(), e.points.last().copied().unwrap());
let joins = |p: Vec3, q: Vec3| {
edges.iter().any(|e| {
let (a, b) = ends(e);
(a.dist(p) < 1e-3 && b.dist(q) < 1e-3) || (a.dist(q) < 1e-3 && b.dist(p) < 1e-3)
})
};
assert!(joins(Vec3::new(5.0, 5.0, 0.0), Vec3::new(-5.0, 5.0, 40.0)), "{:?}", edges.iter().map(ends).collect::<Vec<_>>());
assert!(!joins(Vec3::new(5.0, 5.0, 0.0), Vec3::new(5.0, 5.0, 40.0)));
}
/// Along a quarter circle of radius 30, a rail keeping 5 mm out from the path (the circle's
/// outer point, on a quarter circle of radius 35) leaves the circle as it is: a quarter torus,
/// volume πr² · R · π/2 (Pappus).
#[test]
fn a_parallel_rail_along_a_curved_path_keeps_the_section() {
let arc = |radius: f64| -> Vec<Vec3> {
(0..=180)
.map(|i| {
let a = PI / 2.0 * i as f64 / 180.0;
Vec3::new(30.0 - radius * a.cos(), 0.0, radius * a.sin())
})
.collect()
};
let b = sweep_path_with(&Plane::XY, &circle(5.0), &arc(30.0), &railed(vec![arc(35.0)], RailFit::Scale)).unwrap();
let m = measure(&b).unwrap();
let volume = PI * 25.0 * 30.0 * PI / 2.0;
assert!(close(m.volume, volume, 2e-3), "volume {} vs {volume}", m.volume);
}
/// Two rails bulging apart along arcs (radius 25 about (∓5, 0, 20), through x = ±10 at z = 0
/// and z = 40) carry a line between them: the sheet lies in the XZ plane between the arcs, area
/// 2(a√(R² − a²) + R² asin(a/R)) − 400 with a = 20.
#[test]
fn two_rails_carry_a_line_between_arcs() {
let arc = |side: f64| -> Vec<Vec3> {
(0..=400)
.map(|i| {
let z = 40.0 * i as f64 / 400.0;
let x = -5.0 + (625.0 - (z - 20.0) * (z - 20.0)).sqrt();
Vec3::new(side * x, 0.0, z)
})
.collect()
};
let line = vec![vec![Seg2::Line { a: Vec2::new(-10.0, 0.0), b: Vec2::new(10.0, 0.0) }]];
for fit in [RailFit::Scale, RailFit::Stretch] {
let ctl = railed(vec![arc(-1.0), arc(1.0)], fit);
let b = crate::sweep_sheet_points_with(&Plane::XY, &line, &up(), &ctl).unwrap();
let m = measure(&b).unwrap();
let area = 2.0 * (20.0 * 15.0 + 625.0 * (0.8f64).asin()) - 400.0;
assert!(close(m.area, area, 1e-3), "{fit:?}: area {} vs {area}", m.area);
assert!(m.bbox.min.y.abs() < 1e-6 && m.bbox.max.y.abs() < 1e-6, "{fit:?}: {:?}", m.bbox);
}
}
/// Rails the sweep cannot follow are refused with a reason, never a panic: three rails, a rail
/// with a bad point, a rail with a twist or scale besides, a rail starting on the path, a rail
/// that stops short, rails along a path with a corner.
#[test]
fn rails_the_sweep_cannot_follow_are_refused() {
let rail = vec![Vec3::new(10.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 40.0)];
let refuse = |ctl: SweepControl, path: &[Vec3], why: &str| {
let e = sweep_path_with(&Plane::XY, &circle(10.0), path, &ctl).map(|_| ()).unwrap_err().to_string();
assert!(e.contains(why), "expected `{why}` in `{e}`");
};
refuse(railed(vec![rail.clone(); 3], RailFit::Scale), &up(), "one or two guide rails");
refuse(railed(vec![vec![Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 1.0)]], RailFit::Scale), &up(), "finite points");
refuse(SweepControl { twist: 1.0, ..railed(vec![rail.clone()], RailFit::Scale) }, &up(), "leave the orientation");
refuse(SweepControl { scale: 2.0, ..railed(vec![rail.clone()], RailFit::Scale) }, &up(), "leave the orientation");
refuse(railed(vec![vec![Vec3::ZERO, Vec3::new(5.0, 0.0, 40.0)]], RailFit::Scale), &up(), "starts on the path");
refuse(railed(vec![vec![Vec3::new(10.0, 0.0, 0.0), Vec3::new(8.0, 0.0, 20.0)]], RailFit::Scale), &up(), "whole length");
refuse(railed(vec![rail.clone(), rail.clone()], RailFit::Scale), &up(), "start at the same point");
let bent = vec![Vec3::ZERO, Vec3::new(0.0, 0.0, 20.0), Vec3::new(20.0, 0.0, 20.0)];
refuse(railed(vec![vec![Vec3::new(10.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 30.0)]], RailFit::Scale), &bent, "sharp corners");
}
+16 -7
View File
@@ -565,10 +565,16 @@ impl Dialog {
vec![SelInput::new("Objects", BODIES, true), SelInput::new("Axis", AXES, false)],
),
"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), opts: crate::dialogs_sweep::SweepOpts::default() },
vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true), crate::dialogs_sweep::direction_input()],
),
"solid.sweep" => Dialog::new(Kind::Sweep { operation: usize::from(has_bodies), opts: crate::dialogs_sweep::SweepOpts::default() }, {
let [rail, rail2] = crate::dialogs_sweep::rail_inputs();
vec![
SelInput::new("Profile", PROFILES, true),
SelInput::new("Path", CURVES, true),
crate::dialogs_sweep::direction_input(),
rail,
rail2,
]
}),
"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)]),
"solid.box" => Dialog::new(
@@ -3900,7 +3906,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
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")?;
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)?;
crate::dialogs_sweep::params(app, opts, sels(d, 2).first(), [sels(d, 3), sels(d, 4)], &mut p)?;
("solid.sweep", p)
}
Kind::OffsetPlane { offset } => {
@@ -5005,13 +5011,16 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
}
d
}
FeatureKind::Sweep { sketch, profiles, path, operation, targets, orientation, twist, scale, .. } => {
FeatureKind::Sweep { sketch, profiles, path, operation, targets, orientation, twist, scale, rails, rail_fit, .. } => {
let mut d = start("solid.sweep")?;
let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, &feature_frame(s, f.component));
let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, *rail_fit, &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();
}
for (inp, items) in d.inputs.iter_mut().skip(3).zip(crate::dialogs_sweep::rail_sels(rails)) {
inp.items = items;
}
let items = st.sketch(sketch.0).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.0, index }).collect();
+16 -8
View File
@@ -117,11 +117,16 @@ 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),
crate::dialogs_sweep::direction_input(),
],
Surf::Sweep(_) => {
let [rail, rail2] = crate::dialogs_sweep::rail_inputs();
vec![
SelInput::new("Profiles or curves", PROFILES | CURVES, true),
SelInput::new("Path", CURVES, true),
crate::dialogs_sweep::direction_input(),
rail,
rail2,
]
}
Surf::Loft => vec![SelInput::new("Sections (one sketch each)", PROFILES | CURVES, true)],
Surf::Patch { .. } => vec![SelInput::new("Boundary", PROFILES | EDGES, true), SelInput::new("Pass through", VERTICES | FACES, true)],
Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)],
@@ -371,7 +376,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)?;
crate::dialogs_sweep::params(app, opts, items(inputs, 2).first(), [items(inputs, 3), items(inputs, 4)], &mut p)?;
("surface.sweep", p)
}
Surf::Loft => {
@@ -610,14 +615,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, orientation, twist, scale, .. } => {
let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, frame);
FeatureKind::SurfaceSweep { sketch, profiles, curves, path, orientation, twist, scale, rails, rail_fit, .. } => {
let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, *rail_fit, frame);
let [rail, rail2] = crate::dialogs_sweep::rail_sels(rails);
(
Surf::Sweep(opts),
vec![
sheet_sels(app, sketch.0, profiles.as_ref(), curves),
path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect(),
direction.into_iter().collect(),
rail,
rail2,
],
)
}
+53 -7
View File
@@ -1,14 +1,15 @@
//! 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.
//! twist over the path's length, the profile's scale at the end, and guide rails with how they
//! reshape the profile.
use serde_json::{Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::doc::{Expr, Mat, SweepOrientation};
use solidcraft_engine::doc::{Expr, Mat, RailFit, SweepOrientation, SweepRail};
use solidcraft_engine::geom::Vec3;
use crate::SolveApp;
use crate::dialogs::{combo, field, row_label};
use crate::selection::{AXES, EDGES, SelInput};
use crate::selection::{AXES, CURVES, EDGES, SelInput};
/// The orientation choices, in combo order: perpendicular, parallel, a direction.
const ORIENTATIONS: [&str; 3] = ["Turn with Path", "Keep as Drawn", "Follow Direction"];
@@ -18,6 +19,8 @@ const DIRECTION: usize = 2;
const NO_TWIST: &str = "0 deg";
/// An end scale field that means no scaling.
const NO_SCALE: &str = "1";
/// The rail fits, in combo order: scale, stretch.
const RAIL_FITS: [&str; 2] = ["Scale", "Stretch"];
/// A sweep dialog's options.
#[derive(Clone, Debug, PartialEq)]
@@ -28,11 +31,13 @@ pub struct SweepOpts {
pub scale: String,
/// A stored direction (world), used while no direction is picked.
pub direction: Option<Vec3>,
/// An index into [`RAIL_FITS`].
pub rail_fit: usize,
}
impl Default for SweepOpts {
fn default() -> Self {
SweepOpts { orientation: 0, twist: NO_TWIST.into(), scale: NO_SCALE.into(), direction: None }
SweepOpts { orientation: 0, twist: NO_TWIST.into(), scale: NO_SCALE.into(), direction: None, rail_fit: 0 }
}
}
@@ -40,10 +45,17 @@ 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<Expr>, scale: &Option<Expr>, frame: &Mat) -> (SweepOpts, Option<Sel>) {
pub fn of(
orientation: &SweepOrientation,
twist: &Option<Expr>,
scale: &Option<Expr>,
rail_fit: RailFit,
frame: &Mat,
) -> (SweepOpts, Option<Sel>) {
let mut o = SweepOpts {
twist: twist.as_ref().map_or_else(|| NO_TWIST.into(), |t| t.to_string()),
scale: scale.as_ref().map_or_else(|| NO_SCALE.into(), |k| k.to_string()),
rail_fit: usize::from(rail_fit == RailFit::Stretch),
..SweepOpts::default()
};
let mut pick = None;
@@ -74,6 +86,18 @@ pub fn direction_input() -> SelInput {
SelInput::new("Direction", AXES | EDGES, false)
}
/// The inputs that pick a sweep's guide rails: the first rail's curves and the second's, in order
/// along each rail; both optional.
pub fn rail_inputs() -> [SelInput; 2] {
[SelInput::new("Rail", CURVES, true), SelInput::new("Second Rail", CURVES, true)]
}
/// A feature's stored rails as the rail inputs' selections.
pub fn rail_sels(rails: &[SweepRail]) -> [Vec<Sel>; 2] {
let sels = |i: usize| rails.get(i).map(|r| r.curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect()).unwrap_or_default();
[sels(0), sels(1)]
}
/// 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;
@@ -86,12 +110,34 @@ pub fn rows(ui: &mut egui::Ui, o: &mut SweepOpts, id: &str) -> bool {
row_label(ui, "End Scale");
enter |= field(ui, &mut o.scale);
ui.end_row();
row_label(ui, "Rail Fit");
combo(ui, &format!("{id}_rail_fit"), &RAIL_FITS, &mut o.rail_fit);
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> {
/// scale when they change anything; with rails picked, the rails (each in the sketch its curves
/// are in) and the fit instead. `picked` is the direction input's selection, `rails` the rail
/// inputs'.
pub fn params(app: &SolveApp, o: &SweepOpts, picked: Option<&Sel>, rails: [&[Sel]; 2], p: &mut Value) -> Result<(), String> {
let mut given = Vec::new();
for sels in rails {
let curves: Vec<String> = sels.iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect();
if curves.is_empty() {
continue;
}
let sketch = crate::dialogs::curves_sketch(app, &curves, None).ok_or("each rail's curves must be in one sketch")?;
given.push(json!({"sketch": sketch, "curves": curves}));
}
if !given.is_empty() {
// Rails set the profile's turn and size: the other options don't apply.
p["rails"] = json!(given);
if o.rail_fit == 1 {
p["rail_fit"] = json!("stretch");
}
return Ok(());
}
match o.orientation {
0 => {}
1 => p["orientation"] = json!("parallel"),
+39
View File
@@ -99,3 +99,42 @@ fn editing_sweeps_keeps_their_options() {
assert_eq!(after.param_names, before.param_names, "{name}");
}
}
/// A rail picked in the dialog reaches the command with its sketch, the fit with it; editing the
/// sweep brings both back and keeps them.
#[test]
fn a_rail_picked_in_the_dialog_is_kept_through_an_edit() {
let mut h = Harness::new();
let sketch = setup(&mut h);
h.app.run("sketch.create", json!({"plane":"XZ","name":"R"})).unwrap();
h.app.run("sketch.line", json!({"points":[[5,0],[10,-40]],"ids":["out"]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
let rail_sketch = h.app.session.doc.find_feature("R").unwrap().id;
h.step(&json!({"call":"ui.start","params":{"command":"solid.sweep"}})).unwrap();
h.frames(3);
assert!(h.widget("Rail Fit").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[3].items = vec![Sel::SketchCurve { id: "out".into() }];
let Kind::Sweep { opts, .. } = &mut d.kind else { panic!("{:?}", d.kind) };
opts.rail_fit = 1;
h.frames(2);
h.step(&json!({"widget":"OK"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
let name = h.app.session.doc.features.last().unwrap().name.clone();
let rails = |h: &Harness| match &h.app.session.doc.find_feature(&name).unwrap().kind {
FeatureKind::Sweep { rails, rail_fit, .. } => (rails.clone(), *rail_fit),
k => panic!("{k:?}"),
};
let (stored, fit) = rails(&h);
assert_eq!(stored, vec![solidcraft_engine::doc::SweepRail { sketch: solidcraft_engine::doc::SketchId(rail_sketch), curves: vec!["out".into()] }]);
assert_eq!(fit, solidcraft_engine::doc::RailFit::Stretch);
h.step(&json!({"call":"ui.editFeature","params":{"feature":name}})).unwrap();
h.frames(8);
let d = h.app.dialog.as_ref().unwrap();
assert_eq!(d.inputs[3].items, vec![Sel::SketchCurve { id: "out".into() }]);
h.step(&json!({"widget":"OK"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
assert_eq!(rails(&h), (stored, fit));
}