1 parent
7ba2d5db47
commit
98ce6a4ee8
22 files changed
+804
-55
No files matched your search
+4
-1
@@ -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.
|
||||
|
||||
@@ -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×)",
|
||||
|
||||
@@ -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×)",
|
||||
|
||||
@@ -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×)",
|
||||
|
||||
@@ -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×)",
|
||||
|
||||
@@ -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×)",
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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())),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user