Sweep: orientation, twist and end scale (#1269)

Part of #771 and #772. solid.sweep and surface.sweep take orientation (perpendicular, parallel or a direction), twist (up to 4 turns) and end scale (0.01 to 100), applied about the profile's normal through the path's start; default options run the old sweep, so existing sweeps are unchanged. New fields are optional, left out when unset and noted under format 21; twist and scale are model parameters. Both sweep dialogs get Orientation, Twist and End Scale rows; labels in every catalog. Checked on main + this branch: cargo check --workspace, kernel/engine/UI sweep tests, the doc suite and the robustness goldens.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-15 06:24:31 -04:00
1 parent 0872eb959a
commit 5fa8e7d60a
30 files changed
+1120 -71

No files matched your search

+4 -1
View File
@@ -51,7 +51,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 | | 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 | | 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 | | 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), 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. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep rails, twist and scale (#771, loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — | | M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), 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. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | 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), 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), 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 and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | | 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), 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), 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 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 | | 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 | — | | M11 | 2D drawings | deferred | — |
@@ -336,6 +336,9 @@ retain their saved two-finger scroll preference; camera motion is available thro
and conics; analytic trim/break also sees free-form cutters. Free-form curves cut at a tangent and conics; analytic trim/break also sees free-form cutters. Free-form curves cut at a tangent
touch (no clean crossing) enter that profile as polylines. touch (no clean crossing) enter that profile as polylines.
Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth). Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth).
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.
- Extend intersects exact ellipses and rational/high-degree splines without moving the line or - 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 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. certificate and refine within bounded node limits while retaining existing IDs.
+6
View File
@@ -333,6 +333,12 @@
"ui.save_as": "Speichern unter", "ui.save_as": "Speichern unter",
"ui.save_as_ellipsis": "Speichern unter...", "ui.save_as_ellipsis": "Speichern unter...",
"ui.scale": "Skalieren", "ui.scale": "Skalieren",
"ui.orientation": "Ausrichtung",
"ui.twist": "Verdrehung",
"ui.end_scale": "Endskalierung",
"ui.sweep_turn_with_path": "Mit Pfad drehen",
"ui.sweep_keep_as_drawn": "Wie gezeichnet",
"ui.sweep_follow_direction": "Richtung folgen",
"ui.section_analysis": "Querschnittsanalyse", "ui.section_analysis": "Querschnittsanalyse",
"ui.select": "Auswählen", "ui.select": "Auswählen",
"ui.selection": "Auswahl", "ui.selection": "Auswahl",
+6
View File
@@ -636,6 +636,12 @@
"ui.save_as": "Save As", "ui.save_as": "Save As",
"ui.save_as_ellipsis": "Save As...", "ui.save_as_ellipsis": "Save As...",
"ui.scale": "Scale", "ui.scale": "Scale",
"ui.orientation": "Orientation",
"ui.twist": "Twist",
"ui.end_scale": "End Scale",
"ui.sweep_turn_with_path": "Turn with Path",
"ui.sweep_keep_as_drawn": "Keep as Drawn",
"ui.sweep_follow_direction": "Follow Direction",
"ui.section_analysis": "Section Analysis", "ui.section_analysis": "Section Analysis",
"ui.select": "Select", "ui.select": "Select",
"ui.select_other_hint": "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there.", "ui.select_other_hint": "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there.",
+6
View File
@@ -593,6 +593,12 @@
"ui.save_as": "Enregistrer sous", "ui.save_as": "Enregistrer sous",
"ui.save_as_ellipsis": "Enregistrer sous...", "ui.save_as_ellipsis": "Enregistrer sous...",
"ui.scale": "Échelle", "ui.scale": "Échelle",
"ui.orientation": "Orientation",
"ui.twist": "Torsion",
"ui.end_scale": "Échelle finale",
"ui.sweep_turn_with_path": "Tourner avec la trajectoire",
"ui.sweep_keep_as_drawn": "Garder tel que dessiné",
"ui.sweep_follow_direction": "Suivre une direction",
"ui.section_analysis": "Analyse de coupe", "ui.section_analysis": "Analyse de coupe",
"ui.select": "Sélectionner", "ui.select": "Sélectionner",
"ui.select_other_hint": "Sélectionner un autre élément : maintenir le bouton gauche immobile sur le modèle (ou clic droit › Sélectionner un autre…) pour lister tout ce qui se trouve sous le curseur ; cliquer de nouveau au même endroit pour sélectionner l'élément suivant.", "ui.select_other_hint": "Sélectionner un autre élément : maintenir le bouton gauche immobile sur le modèle (ou clic droit › Sélectionner un autre…) pour lister tout ce qui se trouve sous le curseur ; cliquer de nouveau au même endroit pour sélectionner l'élément suivant.",
+6
View File
@@ -636,6 +636,12 @@
"ui.save_as": "名前を付けて保存", "ui.save_as": "名前を付けて保存",
"ui.save_as_ellipsis": "名前を付けて保存…", "ui.save_as_ellipsis": "名前を付けて保存…",
"ui.scale": "尺度", "ui.scale": "尺度",
"ui.orientation": "向き",
"ui.twist": "ねじれ",
"ui.end_scale": "終端スケール",
"ui.sweep_turn_with_path": "パスに沿って回転",
"ui.sweep_keep_as_drawn": "描いた向きを保持",
"ui.sweep_follow_direction": "方向に従う",
"ui.section_analysis": "断面解析", "ui.section_analysis": "断面解析",
"ui.select": "選択", "ui.select": "選択",
"ui.select_other_hint": "その他を選択: モデル上で左ボタンを長押し(または右クリック › その他を選択…)するとカーソル下の要素が一覧表示されます。同じ場所を再度クリックすると次の要素が選択されます。", "ui.select_other_hint": "その他を選択: モデル上で左ボタンを長押し(または右クリック › その他を選択…)するとカーソル下の要素が一覧表示されます。同じ場所を再度クリックすると次の要素が選択されます。",
+6
View File
@@ -333,6 +333,12 @@
"ui.save_as": "다른 이름으로 저장", "ui.save_as": "다른 이름으로 저장",
"ui.save_as_ellipsis": "다른 이름으로 저장...", "ui.save_as_ellipsis": "다른 이름으로 저장...",
"ui.scale": "축척", "ui.scale": "축척",
"ui.orientation": "배향",
"ui.twist": "비틀림",
"ui.end_scale": "끝 배율",
"ui.sweep_turn_with_path": "경로를 따라 회전",
"ui.sweep_keep_as_drawn": "그린 대로 유지",
"ui.sweep_follow_direction": "방향 따르기",
"ui.section_analysis": "단면 분석", "ui.section_analysis": "단면 분석",
"ui.select": "선택", "ui.select": "선택",
"ui.selection": "선택", "ui.selection": "선택",
+6
View File
@@ -607,6 +607,12 @@
"ui.save_as": "Сохранить как", "ui.save_as": "Сохранить как",
"ui.save_as_ellipsis": "Сохранить как...", "ui.save_as_ellipsis": "Сохранить как...",
"ui.scale": "Масштаб", "ui.scale": "Масштаб",
"ui.orientation": "Ориентация",
"ui.twist": "Закрутка",
"ui.end_scale": "Масштаб в конце",
"ui.sweep_turn_with_path": "Поворачивать по траектории",
"ui.sweep_keep_as_drawn": "Как нарисовано",
"ui.sweep_follow_direction": "Следовать направлению",
"ui.section_analysis": "Анализ сечения", "ui.section_analysis": "Анализ сечения",
"ui.select": "Выбрать", "ui.select": "Выбрать",
"ui.select_other_hint": "Выбрать другое: удерживайте левую кнопку на модели (или правый щелчок › Выбрать другое…), чтобы увидеть всё под курсором; повторный щелчок в том же месте выбирает следующий объект.", "ui.select_other_hint": "Выбрать другое: удерживайте левую кнопку на модели (или правый щелчок › Выбрать другое…), чтобы увидеть всё под курсором; повторный щелчок в том же месте выбирает следующий объект.",
+6
View File
@@ -578,6 +578,12 @@
"ui.save_as": "Зберегти як", "ui.save_as": "Зберегти як",
"ui.save_as_ellipsis": "Зберегти як...", "ui.save_as_ellipsis": "Зберегти як...",
"ui.scale": "Масштаб", "ui.scale": "Масштаб",
"ui.orientation": "Орієнтація",
"ui.twist": "Закручування",
"ui.end_scale": "Масштаб у кінці",
"ui.sweep_turn_with_path": "Повертати за траєкторією",
"ui.sweep_keep_as_drawn": "Як намальовано",
"ui.sweep_follow_direction": "Слідувати напрямку",
"ui.section_analysis": "Аналіз перерізу", "ui.section_analysis": "Аналіз перерізу",
"ui.select": "Вибрати", "ui.select": "Вибрати",
"ui.select_other_hint": "Вибір іншого: утримуйте ліву кнопку нерухомо на моделі (або клацніть правою › Вибрати інше…), щоб побачити список усього під курсором; клацніть ще раз у тому ж місці, щоб вибрати наступний об'єкт там.", "ui.select_other_hint": "Вибір іншого: утримуйте ліву кнопку нерухомо на моделі (або клацніть правою › Вибрати інше…), щоб побачити список усього під курсором; клацніть ще раз у тому ж місці, щоб вибрати наступний об'єкт там.",
+6
View File
@@ -634,6 +634,12 @@
"ui.save_as": "另存为", "ui.save_as": "另存为",
"ui.save_as_ellipsis": "另存为...", "ui.save_as_ellipsis": "另存为...",
"ui.scale": "缩放", "ui.scale": "缩放",
"ui.orientation": "定向",
"ui.twist": "扭转",
"ui.end_scale": "末端缩放",
"ui.sweep_turn_with_path": "随路径转动",
"ui.sweep_keep_as_drawn": "保持绘制方向",
"ui.sweep_follow_direction": "跟随方向",
"ui.section_analysis": "截面分析", "ui.section_analysis": "截面分析",
"ui.select": "选择", "ui.select": "选择",
"ui.select_other_hint": "“选择其他”:在模型上按住左键(或右键单击 › 选择其他…)列出光标下的所有对象;在同一位置再次单击可选择该处的下一个对象。", "ui.select_other_hint": "“选择其他”:在模型上按住左键(或右键单击 › 选择其他…)列出光标下的所有对象;在同一位置再次单击可选择该处的下一个对象。",
+42
View File
@@ -262,6 +262,26 @@ pub struct ThinWall {
pub thickness2: Option<String>, pub thickness2: Option<String>,
} }
/// How a swept profile turns as it follows its path.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
#[serde(rename_all = "snake_case")]
pub enum SweepOrientation {
/// Square to the path, turning with it (a rotation-minimising frame).
#[default]
Perpendicular,
/// As drawn: the profile moves along the path without turning.
Parallel,
/// Square to the path, its turn about the path fixed by this direction (in the feature's
/// frame): the direction's projection onto the profile stays where it was at the start.
Direction(#[walk(dir)] Vec3),
}
impl SweepOrientation {
pub fn is_perpendicular(&self) -> bool {
*self == SweepOrientation::Perpendicular
}
}
/// Where a thin wall lies against its curves. A closed loop's inside is the area it encloses; /// 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 /// an open chain's is the side it bends toward (a straight one's, the left of its first
/// curve as drawn). /// curve as drawn).
@@ -701,6 +721,17 @@ pub enum FeatureKind {
#[walk(sketch)] #[walk(sketch)]
path_sketch: u64, path_sketch: u64,
path: Vec<String>, path: Vec<String>,
/// How the profile turns along the path (default: perpendicular to it).
#[serde(default, skip_serializing_if = "SweepOrientation::is_perpendicular")]
orientation: SweepOrientation,
/// Angle the profile turns about the path over its length.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(angle = "Twist")]
twist: Option<String>,
/// The profile's scale at the end of the path (linear along its length).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(unitless = "Scale")]
scale: Option<String>,
#[serde(default)] #[serde(default)]
operation: Operation, operation: Operation,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -1327,6 +1358,17 @@ pub enum FeatureKind {
#[walk(sketch)] #[walk(sketch)]
path_sketch: u64, path_sketch: u64,
path: Vec<String>, path: Vec<String>,
/// How the profile turns along the path (default: perpendicular to it).
#[serde(default, skip_serializing_if = "SweepOrientation::is_perpendicular")]
orientation: SweepOrientation,
/// Angle the profile turns about the path over its length.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(angle = "Twist")]
twist: Option<String>,
/// The profile's scale at the end of the path (linear along its length).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(unitless = "Scale")]
scale: Option<String>,
}, },
/// A surface body lofted through sections in order, each one open or closed chain of /// A surface body lofted through sections in order, each one open or closed chain of
/// sketch curves: walls only, no caps. /// sketch curves: walls only, no caps.
+30 -3
View File
@@ -3043,7 +3043,8 @@ fn feature_tools_with_constructor(
let roles = crate::lineage::SectionRoles { sections: sketches.into_iter().zip(secs.iter().map(|(_, lp)| lp)).collect() }; let roles = crate::lineage::SectionRoles { sections: sketches.into_iter().zip(secs.iter().map(|(_, lp)| lp)).collect() };
Ok(vec![roles.tool(body)]) Ok(vec![roles.tool(body)])
} }
FeatureKind::Sweep { sketch, profiles, path_sketch, path, .. } => { FeatureKind::Sweep { sketch, profiles, path_sketch, path, orientation, twist, scale, .. } => {
let ctl = sweep_control(vals, orientation, twist, scale)?;
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?; let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
let ps = let ps =
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?; st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
@@ -3061,10 +3062,10 @@ fn feature_tools_with_constructor(
let tool = |r: &Region2, body: Body| crate::lineage::ProfileRoles::swept(Some(ss), Some(r)).tool(body, None); let tool = |r: &Region2, body: Body| crate::lineage::ProfileRoles::swept(Some(ss), Some(r)).tool(body, None);
// A path through 3D sketch curves follows them as points. // A path through 3D sketch curves follows them as points.
if let Some(pts) = path_points(ps, path, start)? { if let Some(pts) = path_points(ps, path, start)? {
return regions.iter().map(|r| Ok(tool(r, kernel::sweep_path(&ss.plane, r, &pts)?))).collect(); return regions.iter().map(|r| Ok(tool(r, kernel::sweep_path_with(&ss.plane, r, &pts, &ctl)?))).collect();
} }
let segs = path_segments(ps, path, start)?; let segs = path_segments(ps, path, start)?;
regions.iter().map(|r| Ok(tool(r, kernel::sweep(&ss.plane, r, &segs)?))).collect() regions.iter().map(|r| Ok(tool(r, kernel::sweep_with(&ss.plane, r, &segs, &ctl)?))).collect()
} }
FeatureKind::Pipe { path_sketch, path, diameter, wall, .. } => { FeatureKind::Pipe { path_sketch, path, diameter, wall, .. } => {
let ps = let ps =
@@ -3112,6 +3113,32 @@ fn feature_tools_with_constructor(
} }
/// A chain of sketch curves as 3D path segments, starting at the end nearest `start`. /// A chain of sketch curves as 3D path segments, starting at the end nearest `start`.
/// A sweep's options from its stored orientation and its twist and end scale expressions
/// (none: no twist, scale 1). Out-of-range values are refused by the kernel.
pub(crate) fn sweep_control(
vals: &BTreeMap<String, Value>,
orientation: &crate::SweepOrientation,
twist: &Option<String>,
scale: &Option<String>,
) -> Result<kernel::SweepControl> {
let orientation = match *orientation {
crate::SweepOrientation::Perpendicular => kernel::SweepOrientation::Perpendicular,
crate::SweepOrientation::Parallel => kernel::SweepOrientation::Parallel,
crate::SweepOrientation::Direction(d) => kernel::SweepOrientation::Direction(d),
};
let twist = match twist {
Some(t) => val(vals, t, Kind::Angle)?,
None => 0.0,
};
let scale = match scale {
Some(k) => val(vals, k, Kind::Unitless)?,
None => 1.0,
};
let ctl = kernel::SweepControl { orientation, twist, scale };
ctl.check()?;
Ok(ctl)
}
/// A pipe's section: a circle of radius `r`, hollow when it has a wall. /// A pipe's section: a circle of radius `r`, hollow when it has a wall.
fn pipe_region(vals: &BTreeMap<String, Value>, r: f64, wall: &Option<String>) -> Result<Region2> { fn pipe_region(vals: &BTreeMap<String, Value>, r: f64, wall: &Option<String>) -> Result<Region2> {
let holes = match wall { let holes = match wall {
+4 -3
View File
@@ -176,7 +176,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
} }
Ok(()) Ok(())
} }
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path } => { FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale } => {
let ctl = crate::eval::sweep_control(vals, orientation, twist, scale)?;
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?; let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
let ps = let ps =
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?; st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
@@ -195,8 +196,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
let pts: Vec<Vec2> = chains.iter().flatten().flat_map(|s| s.polyline(1e-2)).collect(); let 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 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)? { let body = match path_points(ps, path, start)? {
Some(pts) => kernel::sweep_sheet_points(&ss.plane, &chains, &pts)?, Some(pts) => kernel::sweep_sheet_points_with(&ss.plane, &chains, &pts, &ctl)?,
None => kernel::sweep_sheet(&ss.plane, &chains, &path_segments(ps, path, start)?)?, None => kernel::sweep_sheet_with(&ss.plane, &chains, &path_segments(ps, path, start)?, &ctl)?,
}; };
let traced = crate::lineage::ChainRoles { sketch: ss, chains: &chains }.tool(body); let traced = crate::lineage::ChainRoles { sketch: ss, chains: &chains }.tool(body);
push_sheet(st, f, traced); push_sheet(st, f, traced);
+11 -1
View File
@@ -75,6 +75,11 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
("thread", json!({"type": "thread", "face": o, "length": "thread.length"})), ("thread", json!({"type": "thread", "face": o, "length": "thread.length"})),
("loft", json!({"type": "loft", "sections": []})), ("loft", json!({"type": "loft", "sections": []})),
("sweep", json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"]})), ("sweep", json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"]})),
(
"sweep_options",
json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"],
"orientation": {"direction": [0, 1, 0]}, "twist": "sweep.twist", "scale": "sweep.scale"}),
),
("shell", json!({"type": "shell", "faces": [o], "thickness": "shell.thickness"})), ("shell", json!({"type": "shell", "faces": [o], "thickness": "shell.thickness"})),
("draft", json!({"type": "draft", "faces": [o], "angle": "draft.angle", "neutral": plane("draft_neutral"), "pull": [0, 0, 1]})), ("draft", json!({"type": "draft", "faces": [o], "angle": "draft.angle", "neutral": plane("draft_neutral"), "pull": [0, 0, 1]})),
("construction_plane", json!({"type": "construction_plane", "plane": plane("cplane")})), ("construction_plane", json!({"type": "construction_plane", "plane": plane("cplane")})),
@@ -147,6 +152,11 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
json!({"type": "surface_revolve", "sketch": 1, "axis": {"type": "world", "axis": "X"}, "angle": "srev.angle", "angle2": "srev.angle2"}), json!({"type": "surface_revolve", "sketch": 1, "axis": {"type": "world", "axis": "X"}, "angle": "srev.angle", "angle2": "srev.angle2"}),
), ),
("surface_sweep", json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"]})), ("surface_sweep", json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"]})),
(
"surface_sweep_options",
json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"],
"orientation": "parallel", "twist": "surface_sweep.twist", "scale": "surface_sweep.scale"}),
),
("surface_loft", json!({"type": "surface_loft", "sections": []})), ("surface_loft", json!({"type": "surface_loft", "sections": []})),
("surface_offset", json!({"type": "surface_offset", "faces": [o], "distance": "soff.distance", "tolerance": "soff.tolerance"})), ("surface_offset", json!({"type": "surface_offset", "faces": [o], "distance": "soff.distance", "tolerance": "soff.tolerance"})),
("unstitch", json!({"type": "unstitch", "body": "B"})), ("unstitch", json!({"type": "unstitch", "body": "B"})),
@@ -181,7 +191,7 @@ pub(super) fn with_refs(name: &str, mut v: Value) -> Value {
let o = [0, 0, 0]; let o = [0, 0, 0];
let tool = json!({"body": "B", "point": o}); let tool = json!({"body": "B", "point": o});
let extra = match name { let extra = match name {
"extrude" | "extrude_thin" | "revolve" | "sweep" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}), "extrude" | "extrude_thin" | "revolve" | "sweep" | "sweep_options" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}),
"fillet_constant" | "fillet_chord" | "fillet_curvature" | "fillet_variable" | "fillet_spline" | "chamfer" | "shell" | "draft" "fillet_constant" | "fillet_chord" | "fillet_curvature" | "fillet_variable" | "fillet_spline" | "chamfer" | "shell" | "draft"
| "offset_face" | "delete_face" | "replace_face" | "sheet_flange" | "sheet_hem" | "sheet_perforate" | "sheet_unfold" | "boss" | "lip" | "offset_face" | "delete_face" | "replace_face" | "sheet_flange" | "sheet_hem" | "sheet_perforate" | "sheet_unfold" | "boss" | "lip"
| "snap_fit" | "rest" | "surface_offset" => { | "snap_fit" | "rest" | "surface_offset" => {
@@ -136,6 +136,11 @@ thread
expressions: thread.length expressions: thread.length
loft loft
sweep sweep
sweep_options
inputs:
Twist = sweep.twist (Angle)
Scale = sweep.scale (Unitless)
expressions: sweep.twist, sweep.scale
shell shell
inputs: inputs:
Thickness = shell.thickness (Length) Thickness = shell.thickness (Length)
@@ -317,6 +322,11 @@ surface_revolve
Angle 2 = srev.angle2 (Angle) Angle 2 = srev.angle2 (Angle)
expressions: srev.angle, srev.angle2 expressions: srev.angle, srev.angle2
surface_sweep surface_sweep
surface_sweep_options
inputs:
Twist = surface_sweep.twist (Angle)
Scale = surface_sweep.scale (Unitless)
expressions: surface_sweep.twist, surface_sweep.scale
surface_loft surface_loft
surface_offset surface_offset
inputs: inputs:
@@ -63,6 +63,9 @@ loft
profiles: 4 all, 5 all profiles: 4 all, 5 all
sweep sweep
profiles: 1 all profiles: 1 all
sweep_options
moves: orientation.direction[1], orientation.direction[2]
profiles: 1 all
shell shell
moves: faces[0][0], faces[0][1], faces[0][2] moves: faces[0][0], faces[0][1], faces[0][2]
face points: [0, 0, 0] face points: [0, 0, 0]
@@ -142,6 +145,7 @@ surface_extrude
surface_revolve surface_revolve
moves: axis.dir[0], axis.dir[1], axis.dir[2], axis.origin[0], axis.origin[1], axis.origin[2] moves: axis.dir[0], axis.dir[1], axis.dir[2], axis.origin[0], axis.origin[1], axis.origin[2]
surface_sweep surface_sweep
surface_sweep_options
surface_loft surface_loft
surface_offset surface_offset
moves: faces[0][0], faces[0][1], faces[0][2] moves: faces[0][0], faces[0][1], faces[0][2]
@@ -67,6 +67,10 @@ sweep
sketches: [1, 2] sketches: [1, 2]
remap_refs: path_sketch, sketch remap_refs: path_sketch, sketch
remap_bodies: targets[0] remap_bodies: targets[0]
sweep_options
sketches: [1, 2]
remap_refs: path_sketch, sketch
remap_bodies: targets[0]
shell shell
remap_bodies: body remap_bodies: body
draft draft
@@ -159,6 +163,9 @@ surface_revolve
surface_sweep surface_sweep
sketches: [1, 2] sketches: [1, 2]
remap_refs: path_sketch, sketch remap_refs: path_sketch, sketch
surface_sweep_options
sketches: [1, 2]
remap_refs: path_sketch, sketch
surface_loft surface_loft
sketches: [4, 5] sketches: [4, 5]
remap_refs: sections[0].sketch, sections[1].sketch remap_refs: sections[0].sketch, sections[1].sketch
+28
View File
@@ -191,6 +191,10 @@
//! A bend point can also lie on a bend modelled with its radius (a cylinder between two flat //! A bend point can also lie on a bend modelled with its radius (a cylinder between two flat
//! faces tangent to it), which then is one bend at the two faces' virtual sharp with the //! faces tangent to it), which then is one bend at the two faces' virtual sharp with the
//! rule's radius; 0.11.0 found no edge there or refused the curved face. //! rule's radius; 0.11.0 found no edge there or refused the curved face.
//! Also under 21, a Sweep and a Surface Sweep can carry `orientation` (`parallel`, or
//! `{direction: [x, y, z]}`; absent: perpendicular), `twist` (an angle expression) and `scale`
//! (the end scale, an expression). A sweep without them saves as before; 0.11.x refuses
//! format 21, so no released build sweeps a twisted or scaled profile straight.
use serde_json::Value; use serde_json::Value;
@@ -1763,4 +1767,28 @@ mod tests {
let json = edited(&d, |v| v["components"][1]["id"] = u64::MAX.into()); let json = edited(&d, |v| v["components"][1]["id"] = u64::MAX.into());
assert!(read(&json).unwrap_err().to_string().contains("damaged design")); assert!(read(&json).unwrap_err().to_string().contains("damaged design"));
} }
/// A sweep saved before its options existed loads as a plain sweep and saves as it was; the
/// options round-trip.
#[test]
fn sweep_options_are_optional_and_round_trip() {
use crate::{FeatureKind, SweepOrientation};
let old =
serde_json::json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"], "operation": "new_body"});
let k: FeatureKind = serde_json::from_value(old.clone()).unwrap();
let FeatureKind::Sweep { orientation, twist, scale, .. } = &k else { panic!("{k:?}") };
assert!(orientation.is_perpendicular() && twist.is_none() && scale.is_none());
assert_eq!(serde_json::to_value(&k).unwrap(), old);
let old = serde_json::json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"]});
let k: FeatureKind = serde_json::from_value(old.clone()).unwrap();
assert_eq!(serde_json::to_value(&k).unwrap(), old);
let new = serde_json::json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"],
"orientation": {"direction": [0.0, 1.0, 0.0]}, "twist": "90 deg", "scale": "0.5"});
let k: FeatureKind = serde_json::from_value(new.clone()).unwrap();
let FeatureKind::SurfaceSweep { orientation, .. } = &k else { panic!("{k:?}") };
assert_eq!(*orientation, SweepOrientation::Direction(solidcraft_geom::Vec3::Y));
assert_eq!(serde_json::to_value(&k).unwrap(), new);
let parallel: SweepOrientation = serde_json::from_value(serde_json::json!("parallel")).unwrap();
assert_eq!(parallel, SweepOrientation::Parallel);
}
} }
+55 -2
View File
@@ -1,7 +1,9 @@
//! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move. //! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move.
use serde_json::{Value, json}; use serde_json::{Value, json};
use solidcraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, ThinSide, ThinWall, expr::Kind, point_expr}; use solidcraft_doc::{
AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, SweepOrientation, ThinSide, ThinWall, expr::Kind, point_expr,
};
use solidcraft_geom::Vec3; use solidcraft_geom::Vec3;
use super::{CommandSpec, not_in_sketch}; use super::{CommandSpec, not_in_sketch};
@@ -21,7 +23,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.sweep", "Sweep", sweep) CommandSpec::new("solid.sweep", "Sweep", sweep)
.at("SOLID", "CREATE") .at("SOLID", "CREATE")
.icon("sweep") .icon("sweep")
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; 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); 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.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.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?"), 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?"),
@@ -1621,6 +1623,7 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
if path.is_empty() { if path.is_empty() {
return Err(bad(cmd, "`path` must list curve ids")); return Err(bad(cmd, "`path` must list curve ids"));
} }
let (orientation, twist, scale) = sweep_options(s, p, cmd)?;
add_feature( add_feature(
s, s,
p, p,
@@ -1629,12 +1632,58 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
profiles: profiles(p, cmd)?, profiles: profiles(p, cmd)?,
path_sketch, path_sketch,
path, path,
orientation,
twist,
scale,
operation: operation(p, cmd)?, operation: operation(p, cmd)?,
targets: string_list(p, "targets"), targets: string_list(p, "targets"),
}, },
) )
} }
/// A sweep's `orientation`, `twist` and `scale` (solid.sweep and surface.sweep), checked: an
/// orientation the sweep knows, a twist within four turns, an end scale within 0.01…100.
pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepOrientation, Option<String>, Option<String>)> {
let orientation = match p.get("orientation") {
None | Some(Value::Null) => SweepOrientation::Perpendicular,
Some(Value::String(o)) if o.eq_ignore_ascii_case("perpendicular") => SweepOrientation::Perpendicular,
Some(Value::String(o)) if o.eq_ignore_ascii_case("parallel") => SweepOrientation::Parallel,
Some(v) => {
let d = vec3(v)
.or_else(|| v.get("direction").and_then(vec3))
.ok_or_else(|| bad(cmd, "`orientation` must be perpendicular, parallel or a direction [x, y, z]"))?;
if !(d.len() > 1e-9) {
return Err(bad(cmd, "the `orientation` direction must not be zero"));
}
SweepOrientation::Direction(d)
}
};
let twist = match p.get("twist") {
None | Some(Value::Null) => None,
Some(_) => {
let t = expr(p, "twist").ok_or_else(|| bad(cmd, "`twist` must be an angle or an expression"))?;
let v = s.doc.eval(&t, Kind::Angle).map_err(|e| bad(cmd, format!("twist: {e}")))?;
if !(v.is_finite() && v.abs() <= solidcraft_kernel::SweepControl::MAX_TWIST) {
return Err(bad(cmd, "`twist` must be within four turns (1440 deg)"));
}
Some(t)
}
};
let scale = match p.get("scale") {
None | Some(Value::Null) => None,
Some(_) => {
let k = expr(p, "scale").ok_or_else(|| bad(cmd, "`scale` must be a number or an expression"))?;
let v = s.doc.eval(&k, Kind::Unitless).map_err(|e| bad(cmd, format!("scale: {e}")))?;
let range = solidcraft_kernel::SweepControl::MIN_SCALE..=solidcraft_kernel::SweepControl::MAX_SCALE;
if !(v.is_finite() && range.contains(&v)) {
return Err(bad(cmd, "`scale` (the end scale) must be between 0.01 and 100"));
}
Some(k)
}
};
Ok((orientation, twist, scale))
}
fn loft(s: &mut Session, p: &Value) -> Result<Value> { fn loft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.loft"; let cmd = "solid.loft";
let list = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must be a list"))?; let list = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must be a list"))?;
@@ -1753,6 +1802,10 @@ mod patterns_tests;
#[path = "revolve_convention_tests.rs"] #[path = "revolve_convention_tests.rs"]
mod revolve_convention_tests; mod revolve_convention_tests;
#[cfg(test)]
#[path = "sweep_options_tests.rs"]
mod sweep_options_tests;
#[cfg(test)] #[cfg(test)]
#[path = "offset_topology_tests.rs"] #[path = "offset_topology_tests.rs"]
mod offset_topology_tests; mod offset_topology_tests;
+3 -2
View File
@@ -41,7 +41,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.sweep", "Surface Sweep", surface_sweep) CommandSpec::new("surface.sweep", "Surface Sweep", surface_sweep)
.at("SURFACE", "CREATE") .at("SURFACE", "CREATE")
.icon("surface_sweep") .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); 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? (as solid.sweep); name?, body_name? (a surface body, no caps)"),
CommandSpec::new("surface.loft", "Surface Loft", surface_loft) CommandSpec::new("surface.loft", "Surface Loft", surface_loft)
.at("SURFACE", "CREATE") .at("SURFACE", "CREATE")
.icon("surface_loft") .icon("surface_loft")
@@ -192,7 +192,8 @@ fn surface_sweep(s: &mut Session, p: &Value) -> Result<Value> {
if path.is_empty() || path.len() > 1000 { if path.is_empty() || path.len() > 1000 {
return Err(bad(cmd, "`path` must list 1…1000 curve ids")); return Err(bad(cmd, "`path` must list 1…1000 curve ids"));
} }
add_feature(s, p, FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path }) let (orientation, twist, scale) = super::features::sweep_options(s, p, cmd)?;
add_feature(s, p, FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale })
} }
fn surface_loft(s: &mut Session, p: &Value) -> Result<Value> { fn surface_loft(s: &mut Session, p: &Value) -> Result<Value> {
@@ -0,0 +1,163 @@
//! Sweep options through the commands: orientation, twist and end scale on solid.sweep and
//! surface.sweep, checked against analytic values; 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, curve `c`), a square `Q` (side 10, curves `a`…`d`) on XY, and a
/// path `P`: a line 40 up the Z axis (`up`).
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": "XY", "name": "Q"}));
run(&mut s, "sketch.line", json!({"points": [[-5, -5], [5, -5], [5, 5], [-5, 5]], "closed": true, "ids": ["a", "b", "c", "d"]}));
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.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, sketch: &str, body: &str, extra: Value) -> Result<Value, String> {
let mut p = json!({"sketch": sketch, "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())
// Without the feature's own prefix (`F7:`), to compare features.
.map(|x| x.split_once(':').map_or(x, |(_, rest)| rest).to_string())
.collect();
n.sort();
n
}
/// A circle swept up a line to end scale 0.5 is a cone frustum; the surface sweep of its curve
/// is the frustum's lateral face. Saved and reopened, the design gives the same solid.
#[test]
fn scaled_sweep_is_a_frustum() {
let mut s = setup();
let (big, small, h) = (10.0, 5.0, 40.0);
let volume = PI * h * (big * big + big * small + small * small) / 3.0;
let lateral = PI * (big + small) * (h * h + (big - small) * (big - small)).sqrt();
sweep(&mut s, "C", "Frustum", json!({"scale": 0.5})).unwrap();
let m = measure(&s, "Frustum");
assert!((m.volume - volume).abs() < 2e-3 * volume, "volume {} vs {volume}", m.volume);
assert!((m.area - PI * (big * big + small * small) - lateral).abs() < 2e-3 * lateral, "area {}", m.area);
run(&mut s, "surface.sweep", json!({"sketch": "C", "curves": ["c"], "path_sketch": "P", "path": ["up"], "scale": "1/2", "body_name": "Cone"}));
let a = measure(&s, "Cone").area;
assert!((a - lateral).abs() < 2e-3 * lateral, "sheet {a} vs {lateral}");
// The options are saved with the feature and evaluate the same when reopened.
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
let reopened = Session::new(doc);
let again = measure(&reopened, "Frustum");
assert!((again.volume - m.volume).abs() < 1e-9 * volume);
}
/// A twisted square keeps its faces' names (each wall after its profile curve), and the twist is
/// a parameter: editing it changes the solid, a plain sweep has the same names.
#[test]
fn twisted_sweep_faces_follow_the_profile_curves() {
let mut s = setup();
sweep(&mut s, "Q", "Plain", json!({})).unwrap();
sweep(&mut s, "Q", "Twisted", json!({"twist": "90 deg", "name": "Twist"})).unwrap();
let (plain, twisted) = (face_names(&mut s, "Plain"), face_names(&mut s, "Twisted"));
assert_eq!(plain.len(), 6, "{plain:?}");
assert_eq!(plain, twisted);
for curve in ["a", "b", "c", "d"] {
assert!(twisted.iter().any(|n| n.ends_with(&format!(":{curve}"))), "{curve} in {twisted:?}");
}
// The end cap turns with the twist: at 90 deg a square's corners are back where they were,
// at 45 deg its top reaches out to the corner radius along the axes.
let reach = |s: &Session| {
let mesh = s.world_state().body("Twisted").unwrap().body.tessellate(0.01).unwrap();
mesh.positions.iter().filter(|p| (p.z - 40.0).abs() < 1e-6).map(|p| p.x).fold(f64::MIN, f64::max)
};
assert!((reach(&s) - 5.0).abs() < 1e-3, "{}", reach(&s));
run(&mut s, "timeline.edit", json!({"feature": "Twist", "set": {"twist": "45 deg"}}));
assert!((reach(&s) - 50f64.sqrt()).abs() < 1e-2, "{}", reach(&s));
let v = measure(&s, "Twisted").volume;
assert!((v - 4000.0).abs() < 0.01 * 4000.0, "{v}");
}
/// Parallel along a straight path leaning out of the profile's plane gives a sheared prism (the
/// volume of the upright one); so does a direction, along a straight path.
#[test]
fn orientations() {
let mut s = setup();
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Lean"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, -40]], "ids": ["lean"]}));
run(&mut s, "sketch.finish", json!({}));
let lean = |s: &mut Session, body: &str, o: Value| {
s.execute(
"solid.sweep",
&json!({"sketch": "Q", "path_sketch": "Lean", "path": ["lean"], "operation": "new", "body_name": body, "orientation": o}),
)
};
lean(&mut s, "Sheared", json!("parallel")).unwrap();
let m = measure(&s, "Sheared");
assert!((m.volume - 4000.0).abs() < 2e-3 * 4000.0, "{}", m.volume);
// Its ends stay level: flat at z = 0 and z = 40.
assert!(m.bbox.min.z.abs() < 1e-6 && (m.bbox.max.z - 40.0).abs() < 1e-6, "{:?}", m.bbox);
// Along a straight path every frame is the first: a direction changes nothing, the profile
// keeps its relation to the path as in a plain sweep.
lean(&mut s, "Directed", json!([0, 1, 0])).unwrap();
let d = measure(&s, "Directed");
assert!((d.volume - 4000.0).abs() < 2e-3 * 4000.0, "{}", d.volume);
assert!(d.bbox.max.y <= 5.0 + 1e-6 && d.bbox.min.y >= -5.0 - 1e-6);
}
/// Hostile options are refused with an error and leave the design as it was.
#[test]
fn hostile_sweep_options_are_refused() {
let mut s = setup();
let before = s.doc.features.len();
for bad in [
json!({"twist": "NaN"}),
json!({"twist": 1e300}),
json!({"twist": "1e9 deg"}),
json!({"twist": "1441 deg"}),
json!({"twist": "nope"}),
json!({"twist": [1, 2]}),
json!({"scale": 0}),
json!({"scale": -1}),
json!({"scale": "0"}),
json!({"scale": "1e9"}),
json!({"scale": "1/0"}),
json!({"orientation": "sideways"}),
json!({"orientation": [0, 0, 0]}),
json!({"orientation": [1e308, 0, 0]}),
json!({"orientation": {}}),
json!({"orientation": 7}),
// Along the path itself: no turn about the path is defined.
json!({"orientation": [0, 0, 1]}),
] {
assert!(sweep(&mut s, "Q", "Bad", bad.clone()).is_err(), "{bad}");
let mut p = json!({"sketch": "Q", "curves": ["a", "b"], "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}");
}
}
+3
View File
@@ -769,6 +769,9 @@ fn hostile_params_never_panic() {
"filter", "filter",
"kind", "kind",
"prefix", "prefix",
"orientation",
"twist",
"scale",
]; ];
// Commands are independent; worker threads take them one at a time (their costs vary a lot). // Commands are independent; worker threads take them one at a time (their costs vary a lot).
let ids: Vec<&str> = command_specs() let ids: Vec<&str> = command_specs()
+33 -9
View File
@@ -1786,6 +1786,38 @@ pub(crate) fn rotate_about(p: Vec3, center: Vec3, k: Vec3, angle: f64) -> Vec3 {
center + v * c + k.cross(v) * s + k * (k.dot(v) * (1.0 - c)) center + v * c + k.cross(v) * s + k * (k.dot(v) * (1.0 - c))
} }
/// 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>> {
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())),
};
let mut pts = vec![first];
for seg in path {
pts.extend(seg.points_after_start(1e-2));
}
Ok(pts)
}
/// [`sweep`] with sweep options. The default options are exactly [`sweep`]; any other
/// orientation, a twist or an end scale follow the path as a polyline through stations (see
/// [`crate::sweep_path_with`]).
pub fn sweep_with(plane: &Plane, region: &Region2, path: &[PathSeg], ctl: &crate::SweepControl) -> Result<Body> {
if ctl.is_default() {
return sweep(plane, region, path);
}
ctl.check()?;
region_ok(region)?;
if path.is_empty() || path.len() > 1000 {
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
}
if path.iter().any(|s| s.tangents().is_none()) {
return Err(KernelError::Invalid("a path segment has no direction".into()));
}
crate::sweep_path::sweep_path_with(plane, region, &path_polyline(path)?, ctl)
}
/// Sweep a planar region (outer loop) along a chain of path segments, keeping the profile /// Sweep a planar region (outer loop) along a chain of path segments, keeping the profile
/// perpendicular to the path (lines translate it, arcs rotate it). A path with sharp corners /// perpendicular to the path (lines translate it, arcs rotate it). A path with sharp corners
/// is swept as a polyline, mitred at the corners. /// is swept as a polyline, mitred at the corners.
@@ -1800,15 +1832,7 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
path.iter().map(PathSeg::tangents).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?; path.iter().map(PathSeg::tangents).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
let corner = ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos()); let corner = ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos());
if corner { if corner {
let first = match path.first() { return crate::sweep_path::sweep_path(plane, region, &path_polyline(path)?);
Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a,
None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())),
};
let mut pts = vec![first];
for seg in path {
pts.extend(seg.points_after_start(1e-2));
}
return crate::sweep_path::sweep_path(plane, region, &pts);
} }
// The outer loop and each hole, swept along the path; the ends are capped with holes. // The outer loop and each hole, swept along the path; the ends are capped with holes.
let profile = region_wires(plane, &oriented(region)?)?; let profile = region_wires(plane, &oriented(region)?)?;
+4 -3
View File
@@ -118,7 +118,8 @@ pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fill
pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint}; pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics}; pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
pub use build::{ pub use build::{
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, sphere, sweep, torus, PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, sphere, sweep,
sweep_with, torus,
}; };
pub use delete_face::delete_faces; pub use delete_face::delete_faces;
pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss}; pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss};
@@ -148,9 +149,9 @@ pub use surface_ruled::{Ruled, RuledCorners, RuledMode, ruled_surface};
pub use surface_untrim::{UntrimHoles, untrim}; pub use surface_untrim::{UntrimHoles, untrim};
pub use surfaces::{ pub use surfaces::{
copy_faces, extend, extrude_sheet, loft_sheet, patch_edges, patch_region, remove_faces, reverse_faces, revolve_sheet, stitch, sweep_sheet, copy_faces, extend, extrude_sheet, loft_sheet, patch_edges, patch_region, remove_faces, reverse_faces, revolve_sheet, stitch, sweep_sheet,
sweep_sheet_points, thicken, trim, unstitch, sweep_sheet_points, sweep_sheet_points_with, sweep_sheet_with, thicken, trim, unstitch,
}; };
pub use sweep_path::sweep_path; pub use sweep_path::{SweepControl, SweepOrientation, sweep_path, sweep_path_with};
pub use topo::{CylinderFace, cylinder_face_at}; pub use topo::{CylinderFace, cylinder_face_at};
pub use topo::{TopoCounts, merged_topology, seam_flags}; pub use topo::{TopoCounts, merged_topology, seam_flags};
pub use tspline::tspline_sheet; pub use tspline::tspline_sheet;
+26 -11
View File
@@ -211,16 +211,8 @@ pub fn sweep_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[crate::PathSeg])
} }
let ends: Vec<(Vec3, Vec3)> = let ends: Vec<(Vec3, Vec3)> =
path.iter().map(PathSeg::tangents).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?; path.iter().map(PathSeg::tangents).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
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())),
};
if ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos()) { if ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos()) {
let mut pts = vec![first]; return sweep_sheet_points(plane, chains, &crate::build::path_polyline(path)?);
for seg in path {
pts.extend(seg.points_after_start(1e-2));
}
return sweep_sheet_points(plane, chains, &pts);
} }
let mut sides = Vec::new(); let mut sides = Vec::new();
let mut offset = 0; let mut offset = 0;
@@ -254,16 +246,39 @@ pub fn sweep_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[crate::PathSeg])
/// B-spline wall per segment where the path has no corners, else ruled walls between the path's /// B-spline wall per segment where the path has no corners, else ruled walls between the path's
/// points, mitred at the corners (see [`sweep_sheet`]). /// points, mitred at the corners (see [`sweep_sheet`]).
pub fn sweep_sheet_points(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -> Result<Body> { pub fn sweep_sheet_points(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -> Result<Body> {
sweep_sheet_points_with(plane, chains, path, &crate::SweepControl::default())
}
/// [`sweep_sheet`] with sweep options. The default options are exactly [`sweep_sheet`]; any
/// other orientation, a twist or an end scale follow the path as a polyline through stations
/// (see [`sweep_sheet_points_with`]).
pub fn sweep_sheet_with(plane: &Plane, chains: &[Vec<Seg2>], path: &[crate::PathSeg], ctl: &crate::SweepControl) -> Result<Body> {
if ctl.is_default() {
return sweep_sheet(plane, chains, path);
}
ctl.check()?;
if path.is_empty() || path.len() > 1000 {
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
}
if path.iter().any(|s| s.tangents().is_none()) {
return Err(KernelError::Invalid("a path segment has no direction".into()));
}
sweep_sheet_points_with(plane, chains, &crate::build::path_polyline(path)?, ctl)
}
/// [`sweep_sheet_points`] with sweep options: orientation, twist and end scale.
pub fn sweep_sheet_points_with(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3], ctl: &crate::SweepControl) -> Result<Body> {
ctl.check()?;
if !crate::build::frame_ok(plane) { if !crate::build::frame_ok(plane) {
return Err(KernelError::Invalid("sweep frame is not finite and orthonormal".into())); return Err(KernelError::Invalid("sweep frame is not finite and orthonormal".into()));
} }
if chains.is_empty() || chains.len() > MAX_CHAINS { if chains.is_empty() || chains.len() > MAX_CHAINS {
return Err(KernelError::Invalid("pick the curves or profiles to sweep".into())); return Err(KernelError::Invalid("pick the curves or profiles to sweep".into()));
} }
let ruled = sheet_body(crate::sweep_path::ruled_sheet(plane, chains, path)?)?; let ruled = sheet_body(crate::sweep_path::ruled_sheet(plane, chains, path, ctl)?)?;
// Smooth walls where the path has no corners, kept when their area agrees with the ruled // Smooth walls where the path has no corners, kept when their area agrees with the ruled
// walls' (a fit that went wrong falls back to those). // walls' (a fit that went wrong falls back to those).
if let Ok(Some(sides)) = crate::sweep_path::smooth_sheet(plane, chains, path) if let Ok(Some(sides)) = crate::sweep_path::smooth_sheet(plane, chains, path, ctl)
&& let Ok(smooth) = sheet_body(sides) && let Ok(smooth) = sheet_body(sides)
&& let (Ok(a), Ok(b)) = (crate::measure(&smooth), crate::measure(&ruled)) && let (Ok(a), Ok(b)) = (crate::measure(&smooth), crate::measure(&ruled))
&& (a.area - b.area).abs() <= 0.02 * b.area.abs() && (a.area - b.area).abs() <= 0.02 * b.area.abs()
+387 -16
View File
@@ -1,7 +1,8 @@
//! Sweeps along 3D point paths (3D sketch curves: projected, intersection and included curves). //! 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 //! 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 //! wall per profile segment; paths with sharp corners (or profiles with holes) get ruled walls
//! between stations, mitred at the corners. //! between stations, mitred at the corners. Sweep options (orientation, twist, end scale; see
//! [`SweepControl`]) reshape the stations; their default leaves them exactly as described.
use mt::builder; use mt::builder;
use solidcraft_geom::{Plane, Region2, Seg2, Vec2, Vec3}; use solidcraft_geom::{Plane, Region2, Seg2, Vec2, Vec3};
@@ -23,6 +24,83 @@ const MAX_RULED_STATIONS: usize = 2000;
const CORNER: f64 = 0.35; const CORNER: f64 = 0.35;
/// Samples per profile segment of the smooth walls. /// Samples per profile segment of the smooth walls.
const SEG_SAMPLES: usize = 12; const SEG_SAMPLES: usize = 12;
/// Stations of a smooth sweep that twists or scales, at the least.
const MIN_CONTROLLED_STATIONS: usize = 16;
/// Stations of a smooth sweep that twists or scales, at the most.
const MAX_CONTROLLED_STATIONS: usize = 160;
/// Stations of a ruled sweep that twists or scales, at the least.
const MIN_CONTROLLED_RULED: usize = 64;
/// The most twist one station of a smooth sweep may carry (radians).
const SMOOTH_TWIST_STEP: f64 = std::f64::consts::PI / 18.0;
/// The most twist one station of a ruled sweep may carry (radians).
const RULED_TWIST_STEP: f64 = std::f64::consts::PI / 36.0;
/// How the profile turns as it follows a sweep path.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum SweepOrientation {
/// Square to the path, turned along it by a rotation-minimising frame.
#[default]
Perpendicular,
/// As drawn: the profile only moves along the path, never turning.
Parallel,
/// Square to the path, its turn about the path fixed by a direction: the direction's
/// projection onto the profile's plane stays where it was at the start.
Direction(Vec3),
}
/// 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)]
pub struct SweepControl {
pub orientation: SweepOrientation,
pub twist: f64,
pub scale: f64,
}
impl Default for SweepControl {
fn default() -> Self {
SweepControl { orientation: SweepOrientation::Perpendicular, twist: 0.0, scale: 1.0 }
}
}
impl SweepControl {
/// The most twist accepted: four turns (radians).
pub const MAX_TWIST: f64 = 4.0 * std::f64::consts::TAU;
/// The smallest end scale accepted.
pub const MIN_SCALE: f64 = 0.01;
/// The largest end scale accepted.
pub const MAX_SCALE: f64 = 100.0;
/// A plain sweep: the options change nothing.
pub fn is_default(&self) -> bool {
*self == SweepControl::default()
}
/// Whether the profile changes along the path (it twists or scales).
fn varies(&self) -> bool {
self.twist != 0.0 || self.scale != 1.0
}
/// 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.
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()));
}
if !(self.scale.is_finite() && (Self::MIN_SCALE..=Self::MAX_SCALE).contains(&self.scale)) {
return Err(KernelError::Invalid("the sweep end scale must be between 0.01 and 100".into()));
}
if let SweepOrientation::Direction(d) = self.orientation
&& !(d.is_finite() && d.len() > 1e-9 && d.len() < 1e9)
{
return Err(KernelError::Invalid("the sweep orientation direction must be a finite, non-zero vector".into()));
}
Ok(())
}
}
/// A linear map of world vectors (columns) plus the station it is centred at. /// A linear map of world vectors (columns) plus the station it is centred at.
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -127,6 +205,124 @@ fn stations(pts: &[Vec3], tangents: &[Vec3], mitre: &[Option<(Vec3, f64)>]) -> R
Ok(out) Ok(out)
} }
/// Frames square to the path whose turn about it keeps `d`'s projection: none where the path
/// runs along `d`.
fn frames_toward(tangents: &[Vec3], d: Vec3) -> Result<Vec<[Vec3; 3]>> {
let d = d.normalized().ok_or_else(|| KernelError::Invalid("the sweep orientation direction has no length".into()))?;
tangents
.iter()
.map(|t| {
let across = d - *t * t.dot(d);
// Within about 0.06 degrees of the direction the profile's turn is undefined.
if across.len() < 1e-3 {
return Err(KernelError::Invalid("the sweep path runs along the orientation direction".into()));
}
let r = across.normalized().ok_or_else(|| KernelError::Invalid("the sweep path runs along the orientation direction".into()))?;
Ok([*t, r, t.cross(r)])
})
.collect()
}
/// Stations shaped by sweep options: each maps a world vector at the first station to its image
/// at its own (twisted and scaled about the profile's normal through the first station, then
/// 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>> {
let bad = || KernelError::Invalid("sweep path".into());
let t0 = *tangents.first().ok_or_else(bad)?;
let f: Vec<[Vec3; 3]> = match ctl.orientation {
SweepOrientation::Perpendicular => frames(pts, tangents).ok_or_else(|| KernelError::Invalid("the sweep path doubles back".into()))?,
SweepOrientation::Parallel => vec![[Vec3::X, Vec3::Y, Vec3::Z]; pts.len()],
SweepOrientation::Direction(d) => frames_toward(tangents, d)?,
};
let f0 = *f.first().ok_or_else(bad)?;
// Twist and scale act about the profile's normal, pointing the way the path leaves it.
let n = plane.normal().normalized().ok_or_else(|| KernelError::Invalid("sweep frame".into()))?;
let axis = if n.dot(t0) < 0.0 { n * -1.0 } else { n };
// Kept as drawn, the profile flattens to nothing where the path runs along its plane: the
// path has to cross it at 5 degrees or more all the way.
if ctl.orientation == SweepOrientation::Parallel && tangents.iter().any(|t| t.dot(axis) < 5f64.to_radians().sin()) {
return Err(KernelError::Invalid(
"kept parallel, the profile would lie along the sweep path: the path must keep crossing the profile's plane (by 5 deg or more)".into(),
));
}
let mut run = Vec::with_capacity(pts.len());
let mut total = 0.0;
for (i, p) in pts.iter().enumerate() {
if let Some(prev) = i.checked_sub(1).and_then(|j| pts.get(j)) {
total += prev.dist(*p);
}
run.push(total);
}
if !(total > 1e-12) {
return Err(KernelError::Invalid("the sweep path has no length".into()));
}
let mut out = Vec::with_capacity(f.len());
for (i, fi) in f.iter().enumerate() {
let u = run.get(i).copied().unwrap_or(total) / total;
let (sin, cos) = (ctl.twist * u).sin_cos();
let k = 1.0 + (ctl.scale - 1.0) * u;
let shape = |v: Vec3| {
let along = axis * v.dot(axis);
let r = (v - along) * k;
along + r * cos + axis.cross(r) * sin
};
let col = |e: Vec3| {
let v = shape(e);
fi[0] * f0[0].dot(v) + fi[1] * f0[1].dot(v) + fi[2] * f0[2].dot(v)
};
let mut m = [col(Vec3::X), col(Vec3::Y), col(Vec3::Z)];
if ctl.orientation != SweepOrientation::Parallel
&& let Some(Some((b, s))) = mitre.get(i)
{
for c in &mut m {
*c += *b * ((s - 1.0) * c.dot(*b));
}
}
out.push(Station { at: *pts.get(i).ok_or_else(bad)?, 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 {
let rate = |i: usize| match (st.get(i), tangents.get(i)) {
(Some(s), Some(t)) => area * s.apply(plane.x).cross(s.apply(plane.y)).dot(*t).abs(),
_ => 0.0,
};
st.windows(2).enumerate().map(|(i, w)| 0.5 * (rate(i) + rate(i + 1)) * w[0].at.dist(w[1].at)).sum()
}
/// The stations of a smooth sweep: as many as the path has points (4…[`MAX_SMOOTH_STATIONS`]),
/// more when the profile twists or scales.
fn smooth_count(points: usize, ctl: &SweepControl) -> usize {
let n = points.saturating_sub(1).clamp(4, MAX_SMOOTH_STATIONS);
if !ctl.varies() {
return n;
}
let twist = (ctl.twist.abs() / SMOOTH_TWIST_STEP).ceil() as usize;
n.max(MIN_CONTROLLED_STATIONS).max(twist).min(MAX_CONTROLLED_STATIONS)
}
/// The path with points added along its straight runs (its corners kept) when the profile
/// twists or scales, so ruled walls follow the change.
fn densify(pts: &[Vec3], ctl: &SweepControl) -> Vec<Vec3> {
if !ctl.varies() {
return pts.to_vec();
}
let total: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
let want = MIN_CONTROLLED_RULED.max((ctl.twist.abs() / RULED_TWIST_STEP).ceil() as usize).min(MAX_RULED_STATIONS / 2);
let mut out = Vec::with_capacity(want + pts.len());
out.extend(pts.first().copied());
for w in pts.windows(2) {
let pieces = if total > 0.0 { ((w[0].dist(w[1]) / total) * want as f64).ceil() as usize } else { 1 };
let pieces = pieces.clamp(1, want.max(1));
out.extend((1..=pieces).map(|j| w[0].lerp(w[1], j as f64 / pieces as f64)));
}
out
}
/// The profile plane carried to a station. /// The profile plane carried to a station.
fn station_plane(plane: &Plane, s0: Vec3, st: &Station) -> Plane { fn station_plane(plane: &Plane, s0: Vec3, st: &Station) -> Plane {
Plane { origin: st.at + st.apply(plane.origin - s0), x: st.apply(plane.x), y: st.apply(plane.y) } Plane { origin: st.at + st.apply(plane.origin - s0), x: st.apply(plane.x), y: st.apply(plane.y) }
@@ -135,6 +331,12 @@ fn station_plane(plane: &Plane, s0: Vec3, st: &Station) -> Plane {
/// Sweep a planar region along a 3D polyline path. The profile keeps its relation to the path /// Sweep a planar region along a 3D polyline path. The profile keeps its relation to the path
/// (it need not touch it); the path should start near the profile. /// (it need not touch it); the path should start near the profile.
pub fn sweep_path(plane: &Plane, region: &Region2, path: &[Vec3]) -> Result<Body> { pub fn sweep_path(plane: &Plane, region: &Region2, path: &[Vec3]) -> Result<Body> {
sweep_path_with(plane, region, path, &SweepControl::default())
}
/// [`sweep_path`] with sweep options: orientation, twist and end scale.
pub fn sweep_path_with(plane: &Plane, region: &Region2, path: &[Vec3], ctl: &SweepControl) -> Result<Body> {
ctl.check()?;
if region.area().abs() < 1e-9 { if region.area().abs() < 1e-9 {
return Err(KernelError::Invalid("profile has no area".into())); return Err(KernelError::Invalid("profile has no area".into()));
} }
@@ -147,16 +349,21 @@ pub fn sweep_path(plane: &Plane, region: &Region2, path: &[Vec3]) -> Result<Body
if !corner && region.holes.is_empty() { if !corner && region.holes.is_empty() {
// Smooth: evenly spaced stations, tangents from neighbours. // Smooth: evenly spaced stations, tangents from neighbours.
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum(); let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
let n = (pts.len() - 1).clamp(4, MAX_SMOOTH_STATIONS); let n = smooth_count(pts.len(), ctl);
let sp = resample(&pts, n); let sp = resample(&pts, n);
let tangents = central_tangents(&sp)?; let tangents = central_tangents(&sp)?;
let st = stations(&sp, &tangents, &[])?; let (st, expected) = if ctl.is_default() {
let expected = region.area().abs() * len * start_cos(plane, &tangents); (stations(&sp, &tangents, &[])?, region.area().abs() * len * start_cos(plane, &tangents))
} else {
let st = controlled_stations(plane, &sp, &tangents, &[], ctl)?;
let v = swept_volume(plane, region.area().abs(), &st, &tangents);
(st, v)
};
if let Ok(b) = smooth_walls(plane, &profile.outer, &st, expected) { if let Ok(b) = smooth_walls(plane, &profile.outer, &st, expected) {
return Ok(b); return Ok(b);
} }
} }
ruled(plane, &profile, &pts) ruled(plane, &profile, &pts, ctl)
} }
fn central_tangents(p: &[Vec3]) -> Result<Vec<Vec3>> { fn central_tangents(p: &[Vec3]) -> Result<Vec<Vec3>> {
@@ -177,8 +384,9 @@ fn start_cos(plane: &Plane, tangents: &[Vec3]) -> f64 {
/// Stations at the path's points (at most [`MAX_RULED_STATIONS`]), mitred at corners, with the /// Stations at the path's points (at most [`MAX_RULED_STATIONS`]), mitred at corners, with the
/// points they stand at. /// points they stand at.
fn ruled_stations(pts: &[Vec3]) -> Result<(Vec<Vec3>, Vec<Station>)> { fn ruled_stations(plane: &Plane, pts: &[Vec3], ctl: &SweepControl) -> Result<(Vec<Vec3>, Vec<Station>)> {
let pts: Vec<Vec3> = if pts.len() > MAX_RULED_STATIONS { resample(pts, MAX_RULED_STATIONS) } else { pts.to_vec() }; let pts = densify(pts, ctl);
let pts: Vec<Vec3> = if pts.len() > MAX_RULED_STATIONS { resample(&pts, MAX_RULED_STATIONS) } else { pts };
let n = pts.len(); let n = pts.len();
let seg_dir = |i: usize| -> Option<Vec3> { (*pts.get(i + 1)? - *pts.get(i)?).normalized() }; let seg_dir = |i: usize| -> Option<Vec3> { (*pts.get(i + 1)? - *pts.get(i)?).normalized() };
let mut tangents = Vec::with_capacity(n); let mut tangents = Vec::with_capacity(n);
@@ -201,15 +409,16 @@ fn ruled_stations(pts: &[Vec3]) -> Result<(Vec<Vec3>, Vec<Station>)> {
tangents.push(t); tangents.push(t);
mitre.push(m); mitre.push(m);
} }
let st = stations(&pts, &tangents, &mitre)?; let st = if ctl.is_default() { stations(&pts, &tangents, &mitre)? } else { controlled_stations(plane, &pts, &tangents, &mitre, ctl)? };
Ok((pts, st)) Ok((pts, st))
} }
/// Walls of chains of sketch segments on `plane` (open, or closed) carried along a 3D polyline /// Walls of chains of sketch segments on `plane` (open, or closed) carried along a 3D polyline
/// path that starts near them: ruled between stations at the path's points, mitred at corners, /// path that starts near them: ruled between stations at the path's points, mitred at corners,
/// with no caps. Neighbouring walls share their edges. /// with no caps. Neighbouring walls share their edges.
pub(crate) fn ruled_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -> Result<Vec<Side>> { pub(crate) fn ruled_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3], ctl: &SweepControl) -> Result<Vec<Side>> {
let (pts, st) = ruled_stations(&clean(path)?)?; ctl.check()?;
let (pts, st) = ruled_stations(plane, &clean(path)?, ctl)?;
let edges: usize = chains.iter().map(Vec::len).sum(); let edges: usize = chains.iter().map(Vec::len).sum();
if st.len().saturating_mul(edges) > 400_000 { if st.len().saturating_mul(edges) > 400_000 {
return Err(KernelError::Invalid("the sweep is too detailed (curves × path points)".into())); return Err(KernelError::Invalid("the sweep is too detailed (curves × path points)".into()));
@@ -249,7 +458,8 @@ pub(crate) fn ruled_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) ->
/// turn sharper than [`CORNER`]): one B-spline wall per segment through evenly spaced, /// turn sharper than [`CORNER`]): one B-spline wall per segment through evenly spaced,
/// rotation-minimising stations, neighbouring walls sharing their seam edges; no caps. `None` /// rotation-minimising stations, neighbouring walls sharing their seam edges; no caps. `None`
/// for a path with corners (see [`ruled_sheet`]). /// for a path with corners (see [`ruled_sheet`]).
pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -> Result<Option<Vec<Side>>> { pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3], ctl: &SweepControl) -> Result<Option<Vec<Side>>> {
ctl.check()?;
let pts = clean(path)?; let pts = clean(path)?;
if pts.windows(3).any(|w| turn(w[1] - w[0], w[2] - w[1]) > CORNER) { if pts.windows(3).any(|w| turn(w[1] - w[0], w[2] - w[1]) > CORNER) {
return Ok(None); return Ok(None);
@@ -258,10 +468,10 @@ pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -
if chains.iter().map(Vec::len).sum::<usize>() > 4096 { if chains.iter().map(Vec::len).sum::<usize>() > 4096 {
return Err(fail("too many curves for a smooth sweep")); return Err(fail("too many curves for a smooth sweep"));
} }
let n = (pts.len() - 1).clamp(4, MAX_SMOOTH_STATIONS); let n = smooth_count(pts.len(), ctl);
let sp = resample(&pts, n); let sp = resample(&pts, n);
let tangents = central_tangents(&sp)?; let tangents = central_tangents(&sp)?;
let st = stations(&sp, &tangents, &[])?; let st = if ctl.is_default() { stations(&sp, &tangents, &[])? } else { controlled_stations(plane, &sp, &tangents, &[], ctl)? };
let s0 = st.first().map(|s| s.at).unwrap_or_default(); let s0 = st.first().map(|s| s.at).unwrap_or_default();
let at = |i: usize, q: Vec2| st.get(i).map(|s| s.at + s.apply(plane.to_world(q) - s0)).unwrap_or_default(); let at = |i: usize, q: Vec2| st.get(i).map(|s| s.at + s.apply(plane.to_world(q) - s0)).unwrap_or_default();
let n = st.len() - 1; let n = st.len() - 1;
@@ -340,8 +550,8 @@ pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -
/// Ruled walls between stations at the path's points (mitred at corners), every loop of the /// Ruled walls between stations at the path's points (mitred at corners), every loop of the
/// (oriented) profile. /// (oriented) profile.
fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3]) -> Result<Body> { fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3], ctl: &SweepControl) -> Result<Body> {
let (pts, st) = ruled_stations(pts)?; let (pts, st) = ruled_stations(plane, pts, ctl)?;
let s0 = pts.first().copied().unwrap_or_default(); let s0 = pts.first().copied().unwrap_or_default();
let loops: Vec<&NumberedLoop> = profile.loops().collect(); let loops: Vec<&NumberedLoop> = profile.loops().collect();
let mut wires: Vec<Vec<mt::Wire>> = Vec::with_capacity(st.len()); let mut wires: Vec<Vec<mt::Wire>> = Vec::with_capacity(st.len());
@@ -438,9 +648,11 @@ fn smooth_walls(plane: &Plane, profile: &NumberedLoop, st: &[Station], expected:
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI};
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3}; use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
use super::sweep_path; use super::{SweepControl, SweepOrientation, sweep_path, sweep_path_with};
fn circle(r: f64) -> Region2 { fn circle(r: f64) -> Region2 {
Region2 { outer: Loop2::circle(Vec2::ZERO, r), holes: Vec::new() } Region2 { outer: Loop2::circle(Vec2::ZERO, r), holes: Vec::new() }
@@ -476,4 +688,163 @@ mod tests {
assert!(sweep_path(&plane, &region, &[Vec3::ZERO]).is_err()); assert!(sweep_path(&plane, &region, &[Vec3::ZERO]).is_err());
assert!(sweep_path(&plane, &region, &[Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0)]).is_err()); assert!(sweep_path(&plane, &region, &[Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0)]).is_err());
} }
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() }
}
fn up(h: f64) -> [crate::PathSeg; 1] {
[crate::PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, h) }]
}
/// A circle swept along a line to an end scale is a cone frustum: its volume and lateral
/// area are analytic, for the solid and for the surface sweep.
#[test]
fn scaled_sweep_is_a_frustum() {
let (big, h, k) = (10.0, 40.0, 0.5);
let small = big * k;
let ctl = SweepControl { scale: k, ..SweepControl::default() };
let b = crate::sweep_with(&Plane::XY, &circle(big), &up(h), &ctl).unwrap();
let m = b.tessellate(0.005).unwrap().measure();
let volume = PI * h * (big * big + big * small + small * small) / 3.0;
let lateral = PI * (big + small) * (h * h + (big - small).powi(2)).sqrt();
let caps = PI * (big * big + small * small);
assert!((m.volume - volume).abs() < 2e-3 * volume, "volume {} vs {volume}", m.volume);
assert!((m.area - caps - lateral).abs() < 2e-3 * lateral, "lateral {} vs {lateral}", m.area - caps);
// The end cap is the scaled circle.
let top = b.tessellate(0.005).unwrap().positions.iter().filter(|p| (p.z - h).abs() < 1e-6).map(|p| p.x.hypot(p.y)).fold(0.0, f64::max);
assert!((top - small).abs() < 1e-3, "end radius {top}");
// The surface sweep of the circle's curve: the frustum's lateral face alone.
let sheet = crate::sweep_sheet_with(&Plane::XY, std::slice::from_ref(&circle(big).outer.segs), &up(h), &ctl).unwrap();
let a = crate::measure(&sheet).unwrap().area;
assert!((a - lateral).abs() < 2e-3 * lateral, "sheet {a} vs {lateral}");
}
/// The corners of a twisted square: each seam edge's angle at `z`.
fn corner_angles(b: &crate::Body, z: f64) -> Vec<f64> {
let mesh = b.tessellate(0.002).unwrap();
let mut out = Vec::new();
for e in &mesh.edges {
for w in e.windows(2) {
let (a, c) = (w[0], w[1]);
if (a.z - z) * (c.z - z) <= 0.0 && (a.z - c.z).abs() > 1e-9 {
let p = a.lerp(c, (z - a.z) / (c.z - a.z));
out.push(p.y.atan2(p.x));
}
}
}
out
}
/// A square swept along a line with a twist: the section halfway along is turned by half the
/// twist, the end by all of it; the volume is the prism's.
#[test]
fn twisted_sweep_turns_in_proportion() {
let (h, twist) = (40.0, 90f64.to_radians());
let ctl = SweepControl { twist, ..SweepControl::default() };
let b = crate::sweep_with(&Plane::XY, &square(5.0), &up(h), &ctl).unwrap();
// Twisted walls are saddles: a fine mesh to measure them.
let v = b.tessellate(0.0005).unwrap().measure().volume;
assert!((v - 100.0 * h).abs() < 2e-3 * 100.0 * h, "volume {v}");
// Corners start at 45 deg (and 135, -45, -135): each seam turns by the twist so far.
let off = |a: f64, want: f64| {
let d = (a - want).rem_euclid(FRAC_PI_2);
d.min(FRAC_PI_2 - d)
};
for (z, turned) in [(h / 2.0, twist / 2.0), (h, twist), (h / 4.0, twist / 4.0)] {
let angles = corner_angles(&b, z);
assert!(angles.len() >= 4, "seams at {z}: {angles:?}");
for a in &angles {
assert!(off(*a, FRAC_PI_4 + turned) < 0.2f64.to_radians(), "at {z}: {} vs {}", a.to_degrees(), (FRAC_PI_4 + turned).to_degrees());
}
}
// Right hand about the direction of travel: a positive twist going up turns
// counter-clockwise seen from above (the corner at 45 deg reaches 90 deg + 45 deg).
let end = corner_angles(&b, h);
assert!(end.iter().any(|a| (a - (FRAC_PI_4 + twist)).abs() < 0.2f64.to_radians()), "{end:?}");
// One wall per side and two caps, as an untwisted sweep.
assert_eq!(b.faces(0.01).unwrap().len(), 6);
}
/// Parallel keeps the profile as drawn along a curved path: the volume is its area times
/// the path's rise across the profile's plane.
#[test]
fn parallel_sweep_keeps_the_profile_square_to_its_normal() {
// An eighth of a circle: the path ends leaning 45 deg from the profile's normal.
let r = 30.0;
let path: Vec<Vec3> = (0..=60)
.map(|i| {
let t = i as f64 / 60.0 * FRAC_PI_4;
Vec3::new(r * (1.0 - t.cos()), 0.0, r * t.sin())
})
.collect();
let rise = r * FRAC_PI_4.sin();
let ctl = SweepControl { orientation: SweepOrientation::Parallel, ..SweepControl::default() };
let b = sweep_path_with(&Plane::XY, &square(3.0), &path, &ctl).unwrap();
let mesh = b.tessellate(0.005).unwrap();
let v = mesh.measure().volume;
let want = 36.0 * rise;
assert!((v - want).abs() < 5e-3 * want, "volume {v} vs {want}");
// The end cap is still horizontal: the body tops out flat at the path's end.
let top: Vec<&Vec3> = mesh.positions.iter().filter(|p| (p.z - rise).abs() < 1e-6).collect();
assert!(top.len() >= 4, "flat top");
assert!(mesh.positions.iter().all(|p| p.z <= rise + 1e-6));
// A path that turns to run along the profile's plane (a quarter circle) is refused.
let quarter: Vec<Vec3> = (0..=60)
.map(|i| {
let t = i as f64 / 60.0 * FRAC_PI_2;
Vec3::new(r * (1.0 - t.cos()), 0.0, r * t.sin())
})
.collect();
assert!(sweep_path_with(&Plane::XY, &square(3.0), &quarter, &ctl).is_err());
}
/// A direction keeps the square's sides lined up with it along a curved path; in a plane
/// square to the direction, that is the rotation-minimising frame too.
#[test]
fn direction_orientation_follows_the_vector() {
let r = 30.0;
let path: Vec<Vec3> = (0..=60)
.map(|i| {
let t = i as f64 / 60.0 * FRAC_PI_2;
Vec3::new(r * (1.0 - t.cos()), 0.0, r * t.sin())
})
.collect();
let ctl = SweepControl { orientation: SweepOrientation::Direction(Vec3::Y), ..SweepControl::default() };
let b = sweep_path_with(&Plane::XY, &square(3.0), &path, &ctl).unwrap();
let mesh = b.tessellate(0.005).unwrap();
let len = r * FRAC_PI_2;
assert!((mesh.measure().volume - 36.0 * len).abs() < 5e-3 * 36.0 * len);
// The sides facing ±Y stay at y = ±3.
assert!(mesh.positions.iter().all(|p| p.y.abs() <= 3.0 + 1e-6));
// The path running along the direction is refused.
let along = SweepControl { orientation: SweepOrientation::Direction(Vec3::Z), ..SweepControl::default() };
assert!(crate::sweep_with(&Plane::XY, &square(3.0), &up(10.0), &along).is_err());
}
/// Out-of-range options are refused, never a panic; the default options are a plain sweep.
#[test]
fn sweep_options_refuse_hostile_values() {
let bad = [
SweepControl { twist: f64::NAN, ..SweepControl::default() },
SweepControl { twist: 1e9, ..SweepControl::default() },
SweepControl { twist: SweepControl::MAX_TWIST * 1.01, ..SweepControl::default() },
SweepControl { scale: 0.0, ..SweepControl::default() },
SweepControl { scale: -1.0, ..SweepControl::default() },
SweepControl { scale: f64::INFINITY, ..SweepControl::default() },
SweepControl { scale: 1e6, ..SweepControl::default() },
SweepControl { orientation: SweepOrientation::Direction(Vec3::ZERO), ..SweepControl::default() },
SweepControl { orientation: SweepOrientation::Direction(Vec3::new(f64::NAN, 0.0, 1.0)), ..SweepControl::default() },
];
for ctl in bad {
assert!(crate::sweep_with(&Plane::XY, &circle(2.0), &up(10.0), &ctl).is_err(), "{ctl:?}");
assert!(crate::sweep_sheet_with(&Plane::XY, std::slice::from_ref(&circle(2.0).outer.segs), &up(10.0), &ctl).is_err(), "{ctl:?}");
assert!(sweep_path_with(&Plane::XY, &circle(2.0), &[Vec3::ZERO, Vec3::new(0.0, 0.0, 10.0)], &ctl).is_err(), "{ctl:?}");
}
// The largest twist still builds (a holed profile: ruled walls through dense stations).
let holed = Region2 { outer: square(3.0).outer, holes: vec![Loop2::circle(Vec2::ZERO, 1.0).reversed()] };
let max = SweepControl { twist: SweepControl::MAX_TWIST, scale: 0.5, ..SweepControl::default() };
assert!(crate::sweep_with(&Plane::XY, &holed, &up(10.0), &max).is_ok());
assert!(SweepControl::default().is_default());
}
} }
+15 -10
View File
@@ -108,6 +108,7 @@ pub enum Kind {
/// Sweep a profile along a path of sketch curves. /// Sweep a profile along a path of sketch curves.
Sweep { Sweep {
operation: usize, operation: usize,
opts: crate::dialogs_sweep::SweepOpts,
}, },
/// A construction plane offset from a plane. /// A construction plane offset from a plane.
OffsetPlane { OffsetPlane {
@@ -544,8 +545,8 @@ impl Dialog {
), ),
"solid.loft" => Dialog::new(Kind::Loft { operation: usize::from(has_bodies) }, vec![SelInput::new("Profiles", PROFILES, true)]), "solid.loft" => Dialog::new(Kind::Loft { operation: usize::from(has_bodies) }, vec![SelInput::new("Profiles", PROFILES, true)]),
"solid.sweep" => Dialog::new( "solid.sweep" => Dialog::new(
Kind::Sweep { operation: usize::from(has_bodies) }, Kind::Sweep { operation: usize::from(has_bodies), opts: crate::dialogs_sweep::SweepOpts::default() },
vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true)], vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true), crate::dialogs_sweep::direction_input()],
), ),
"solid.move" => Dialog::new(crate::dialogs_move::kind(0), crate::dialogs_move::inputs(0)), "solid.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.hole" => Dialog::new(hole_defaults(), vec![SelInput::new("Face", HOLE_FACES, true)]),
@@ -2537,7 +2538,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
combo(ui, "lf_op", &OP_LABELS, operation); combo(ui, "lf_op", &OP_LABELS, operation);
ui.end_row(); ui.end_row();
} }
Kind::Sweep { operation } => { Kind::Sweep { operation, opts } => {
enter |= crate::dialogs_sweep::rows(ui, opts, "sw");
row_label(ui, "Operation"); row_label(ui, "Operation");
combo(ui, "sw_op", &OP_LABELS, operation); combo(ui, "sw_op", &OP_LABELS, operation);
ui.end_row(); ui.end_row();
@@ -3548,16 +3550,15 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
let sections: Vec<Value> = sections.into_iter().map(|(sk, v)| json!({"sketch": sk, "profiles": v})).collect(); let sections: Vec<Value> = sections.into_iter().map(|(sk, v)| json!({"sketch": sk, "profiles": v})).collect();
("solid.loft", json!({"sections": sections, "operation": OPS.get(*operation).copied().unwrap_or("new")})) ("solid.loft", json!({"sections": sections, "operation": OPS.get(*operation).copied().unwrap_or("new")}))
} }
Kind::Sweep { operation } => { Kind::Sweep { operation, opts } => {
need(0, "a profile")?; need(0, "a profile")?;
need(1, "the path")?; need(1, "the path")?;
let (sketch, idx) = profiles(); let (sketch, idx) = profiles();
let path: Vec<String> = sels(d, 1).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect(); let path: 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 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")});
"solid.sweep", crate::dialogs_sweep::params(app, opts, sels(d, 2).first(), &mut p)?;
json!({"sketch": sketch, "profiles": idx, "path_sketch": path_sketch, "path": path, "operation": OPS.get(*operation).copied().unwrap_or("new")}), ("solid.sweep", p)
)
} }
Kind::OffsetPlane { offset } => { Kind::OffsetPlane { offset } => {
need(0, "a plane")?; need(0, "a plane")?;
@@ -4614,9 +4615,13 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
} }
d d
} }
FeatureKind::Sweep { sketch, profiles, path, operation, targets, .. } => { FeatureKind::Sweep { sketch, profiles, path, operation, targets, orientation, twist, scale, .. } => {
let mut d = start("solid.sweep")?; let mut d = start("solid.sweep")?;
d.kind = Kind::Sweep { operation: op_index(operation) }; let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, &feature_frame(s, f.component));
d.kind = Kind::Sweep { operation: op_index(operation), opts };
if let Some(inp) = d.inputs.get_mut(2) {
inp.items = direction.into_iter().collect();
}
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default(); let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
if let Some(inp) = d.inputs.get_mut(0) { if let Some(inp) = d.inputs.get_mut(0) {
inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect(); inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
+23 -10
View File
@@ -25,7 +25,7 @@ pub enum Surf {
symmetric: bool, symmetric: bool,
axis: Option<Value>, axis: Option<Value>,
}, },
Sweep, Sweep(crate::dialogs_sweep::SweepOpts),
Loft, Loft,
Patch, Patch,
Unstitch, Unstitch,
@@ -68,7 +68,7 @@ impl Surf {
match self { match self {
Surf::Extrude { .. } => "SURFACE EXTRUDE", Surf::Extrude { .. } => "SURFACE EXTRUDE",
Surf::Revolve { .. } => "SURFACE REVOLVE", Surf::Revolve { .. } => "SURFACE REVOLVE",
Surf::Sweep => "SURFACE SWEEP", Surf::Sweep(_) => "SURFACE SWEEP",
Surf::Loft => "SURFACE LOFT", Surf::Loft => "SURFACE LOFT",
Surf::Patch => "PATCH", Surf::Patch => "PATCH",
Surf::Unstitch => "UNSTITCH", Surf::Unstitch => "UNSTITCH",
@@ -110,7 +110,11 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
match k { match k {
Surf::Extrude { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true)], 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::Revolve { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Axis", AXES, false)],
Surf::Sweep => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Path", CURVES, true)], Surf::Sweep(_) => vec![
SelInput::new("Profiles or curves", PROFILES | CURVES, true),
SelInput::new("Path", CURVES, true),
crate::dialogs_sweep::direction_input(),
],
Surf::Loft => vec![SelInput::new("Sections (one sketch each)", PROFILES | CURVES, true)], Surf::Loft => vec![SelInput::new("Sections (one sketch each)", PROFILES | CURVES, true)],
Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)], Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)],
Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)], Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)],
@@ -137,7 +141,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
let k = match id { let k = match id {
"surface.extrude" => Surf::Extrude { distance: "10 mm".into(), direction: 0 }, "surface.extrude" => Surf::Extrude { distance: "10 mm".into(), direction: 0 },
"surface.revolve" => Surf::Revolve { angle: "360 deg".into(), symmetric: false, axis: None }, "surface.revolve" => Surf::Revolve { angle: "360 deg".into(), symmetric: false, axis: None },
"surface.sweep" => Surf::Sweep, "surface.sweep" => Surf::Sweep(crate::dialogs_sweep::SweepOpts::default()),
"surface.loft" => Surf::Loft, "surface.loft" => Surf::Loft,
"surface.patch" => Surf::Patch, "surface.patch" => Surf::Patch,
"surface.unstitch" => Surf::Unstitch, "surface.unstitch" => Surf::Unstitch,
@@ -243,7 +247,8 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
ui.checkbox(in_place, ""); ui.checkbox(in_place, "");
ui.end_row(); ui.end_row();
} }
Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Sweep | Surf::Loft => {} Surf::Sweep(opts) => enter |= crate::dialogs_sweep::rows(ui, opts, "ss"),
Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Loft => {}
} }
enter enter
} }
@@ -345,7 +350,7 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
} }
("surface.revolve", p) ("surface.revolve", p)
} }
Surf::Sweep => { Surf::Sweep(opts) => {
need(0, "profiles or curves to sweep")?; need(0, "profiles or curves to sweep")?;
need(1, "the path")?; need(1, "the path")?;
let mut p = sheet_picks(app, items(inputs, 0))?; let mut p = sheet_picks(app, items(inputs, 0))?;
@@ -354,6 +359,7 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
p["path_sketch"] = 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")?); 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); p["path"] = json!(path);
crate::dialogs_sweep::params(app, opts, items(inputs, 2).first(), &mut p)?;
("surface.sweep", p) ("surface.sweep", p)
} }
Surf::Loft => { Surf::Loft => {
@@ -578,10 +584,17 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
} }
return Some((Kind::Surface(k), ins, extra)); return Some((Kind::Surface(k), ins, extra));
} }
FeatureKind::SurfaceSweep { sketch, profiles, curves, path, .. } => ( FeatureKind::SurfaceSweep { sketch, profiles, curves, path, orientation, twist, scale, .. } => {
Surf::Sweep, let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, frame);
vec![sheet_sels(app, *sketch, profiles.as_ref(), curves), path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect()], (
), Surf::Sweep(opts),
vec![
sheet_sels(app, *sketch, profiles.as_ref(), curves),
path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect(),
direction.into_iter().collect(),
],
)
}
FeatureKind::SurfaceLoft { sections } => { FeatureKind::SurfaceLoft { sections } => {
(Surf::Loft, vec![sections.iter().flat_map(|sec| sheet_sels(app, sec.sketch, sec.profiles.as_ref(), &sec.curves)).collect()]) (Surf::Loft, vec![sections.iter().flat_map(|sec| sheet_sels(app, sec.sketch, sec.profiles.as_ref(), &sec.curves)).collect()])
} }
+116
View File
@@ -0,0 +1,116 @@
//! The options Sweep and Surface Sweep share (#771): how the profile turns along the path, a
//! twist over the path's length and the profile's scale at the end.
use serde_json::{Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::doc::{Mat, SweepOrientation};
use solidcraft_engine::geom::Vec3;
use crate::SolveApp;
use crate::dialogs::{combo, field, row_label};
use crate::selection::{AXES, EDGES, SelInput};
/// The orientation choices, in combo order: perpendicular, parallel, a direction.
const ORIENTATIONS: [&str; 3] = ["Turn with Path", "Keep as Drawn", "Follow Direction"];
/// The orientation choice that takes a picked direction.
const DIRECTION: usize = 2;
/// A twist field that means no twist.
const NO_TWIST: &str = "0 deg";
/// An end scale field that means no scaling.
const NO_SCALE: &str = "1";
/// A sweep dialog's options.
#[derive(Clone, Debug, PartialEq)]
pub struct SweepOpts {
/// An index into [`ORIENTATIONS`].
pub orientation: usize,
pub twist: String,
pub scale: String,
/// A stored direction (world), used while no direction is picked.
pub direction: Option<Vec3>,
}
impl Default for SweepOpts {
fn default() -> Self {
SweepOpts { orientation: 0, twist: NO_TWIST.into(), scale: NO_SCALE.into(), direction: None }
}
}
impl SweepOpts {
/// A feature's stored options as dialog values; `frame` maps the feature's component frame to
/// the world (a stored direction is in the component's frame). A direction along an origin
/// axis comes back as a pick of that axis.
pub fn of(orientation: &SweepOrientation, twist: &Option<String>, scale: &Option<String>, frame: &Mat) -> (SweepOpts, Option<Sel>) {
let mut o = SweepOpts {
twist: twist.clone().unwrap_or_else(|| NO_TWIST.into()),
scale: scale.clone().unwrap_or_else(|| NO_SCALE.into()),
..SweepOpts::default()
};
let mut pick = None;
match orientation {
SweepOrientation::Perpendicular => {}
SweepOrientation::Parallel => o.orientation = 1,
SweepOrientation::Direction(d) => {
o.orientation = DIRECTION;
let world = solidcraft_engine::doc::apply_vector(frame, *d);
match world.normalized().and_then(world_axis) {
Some(name) => pick = Some(Sel::Axis { name: name.into() }),
None => o.direction = Some(world),
}
}
}
(o, pick)
}
}
/// The origin axis a direction runs along (its way), if any.
fn world_axis(d: Vec3) -> Option<&'static str> {
[("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)].into_iter().find(|(_, a)| d.cross(*a).len() < 1e-9 && d.dot(*a) > 0.0).map(|(n, _)| n)
}
/// The input that picks a Follow Direction sweep's direction: an axis, a sketch line or a
/// straight edge.
pub fn direction_input() -> SelInput {
SelInput::new("Direction", AXES | EDGES, false)
}
/// The option rows; true when Enter was pressed in a field.
pub fn rows(ui: &mut egui::Ui, o: &mut SweepOpts, id: &str) -> bool {
let mut enter = false;
row_label(ui, "Orientation");
combo(ui, &format!("{id}_orient"), &ORIENTATIONS, &mut o.orientation);
ui.end_row();
row_label(ui, "Twist");
enter |= field(ui, &mut o.twist);
ui.end_row();
row_label(ui, "End Scale");
enter |= field(ui, &mut o.scale);
ui.end_row();
enter
}
/// The options as command parameters, added to `p`: the orientation, and the twist and end
/// scale when they change anything. `picked` is the direction input's selection.
pub fn params(app: &SolveApp, o: &SweepOpts, picked: Option<&Sel>, p: &mut Value) -> Result<(), String> {
match o.orientation {
0 => {}
1 => p["orientation"] = json!("parallel"),
_ => {
let d = match (picked, o.direction) {
(Some(x), _) => crate::dialogs::axis_of(app, x).ok_or("the direction must be an axis, a sketch line or a straight edge")?.1,
(None, Some(d)) => d,
(None, None) => return Err("select a direction (an axis, a sketch line or a straight edge) for the profile to follow".into()),
};
p["orientation"] = json!([d.x, d.y, d.z]);
}
}
let twist = o.twist.trim();
if !twist.is_empty() && twist != NO_TWIST {
p["twist"] = json!(twist);
}
let scale = o.scale.trim();
if !scale.is_empty() && scale != NO_SCALE {
p["scale"] = json!(scale);
}
Ok(())
}
+3
View File
@@ -32,6 +32,7 @@ pub mod dialogs_plastic;
pub mod dialogs_save_mesh; pub mod dialogs_save_mesh;
pub mod dialogs_sheet; pub mod dialogs_sheet;
pub mod dialogs_surface; pub mod dialogs_surface;
pub mod dialogs_sweep;
pub mod dim_view; pub mod dim_view;
#[cfg(test)] #[cfg(test)]
mod dimension_create_ui_tests; mod dimension_create_ui_tests;
@@ -141,6 +142,8 @@ pub mod smart_extrude;
#[cfg(test)] #[cfg(test)]
mod smart_extrude_ui_tests; mod smart_extrude_ui_tests;
pub mod stand_up; pub mod stand_up;
#[cfg(test)]
mod sweep_dialog_tests;
pub mod theme; pub mod theme;
#[cfg(test)] #[cfg(test)]
mod thin_extrude_tests; mod thin_extrude_tests;
+101
View File
@@ -0,0 +1,101 @@
//! The Sweep and Surface Sweep dialogs' options (#771): orientation, twist and end scale reach
//! the commands, and editing a sweep keeps them.
use crate::dialogs::Kind;
use crate::dialogs_surface::Surf;
use crate::scenario::Harness;
use serde_json::json;
use solidcraft_engine::Sel;
use solidcraft_engine::doc::{FeatureKind, SweepOrientation};
use solidcraft_engine::geom::Vec3;
/// A square `Q` on XY and a path `P` 40 up the Z axis; the square's sketch id.
fn setup(h: &mut Harness) -> u64 {
h.app.run("sketch.create", json!({"plane":"XY","name":"Q"})).unwrap();
h.app.run("sketch.rectangle.two_point", json!({"p0":[-5,-5],"p1":[5,5]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.run("sketch.create", json!({"plane":"XZ","name":"P"})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,0],[0,-40]],"ids":["up"]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.session.doc.find_feature("Q").unwrap().id
}
#[test]
fn a_new_sweep_takes_its_options_from_the_dialog() {
let mut h = Harness::new();
let sketch = setup(&mut h);
h.step(&json!({"call":"ui.start","params":{"command":"solid.sweep"}})).unwrap();
h.frames(3);
assert!(h.widget("Twist").is_some() && h.widget("End Scale").is_some() && h.widget("Orientation").is_some());
let d = h.app.dialog.as_mut().unwrap();
d.inputs[0].items = vec![Sel::Profile { sketch, index: 0 }];
d.inputs[1].items = vec![Sel::SketchCurve { id: "up".into() }];
d.inputs[2].items = vec![Sel::Axis { name: "X".into() }];
let Kind::Sweep { opts, .. } = &mut d.kind else { panic!("{:?}", d.kind) };
opts.orientation = 2;
opts.twist = "90 deg".into();
opts.scale = "0.5".into();
h.frames(2);
h.step(&json!({"widget":"OK"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
let f = h.app.session.doc.features.last().unwrap();
let FeatureKind::Sweep { orientation, twist, scale, .. } = &f.kind else { panic!("{:?}", f.kind) };
assert_eq!(*orientation, SweepOrientation::Direction(Vec3::X));
assert_eq!(twist.as_deref(), Some("90 deg"));
assert_eq!(scale.as_deref(), Some("0.5"));
}
#[test]
fn a_plain_sweep_from_the_dialog_stores_no_options() {
let mut h = Harness::new();
let sketch = setup(&mut h);
h.step(&json!({"call":"ui.start","params":{"command":"solid.sweep"}})).unwrap();
h.frames(3);
let d = h.app.dialog.as_mut().unwrap();
d.inputs[0].items = vec![Sel::Profile { sketch, index: 0 }];
d.inputs[1].items = vec![Sel::SketchCurve { id: "up".into() }];
h.step(&json!({"widget":"OK"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
let f = h.app.session.doc.features.last().unwrap();
let FeatureKind::Sweep { orientation, twist, scale, .. } = &f.kind else { panic!("{:?}", f.kind) };
assert!(orientation.is_perpendicular() && twist.is_none() && scale.is_none());
}
#[test]
fn editing_sweeps_keeps_their_options() {
let mut h = Harness::new();
setup(&mut h);
h.app
.run(
"solid.sweep",
json!({"sketch":"Q","path_sketch":"P","path":["up"],"orientation":[1,1,0],"twist":"30 deg","scale":"2","operation":"new","name":"Solid"}),
)
.unwrap();
h.app
.run(
"surface.sweep",
json!({"sketch":"Q","path_sketch":"P","path":["up"],"orientation":"parallel","twist":"45 deg","scale":"0.5","name":"Sheet"}),
)
.unwrap();
h.frames(4);
for name in ["Solid", "Sheet"] {
let before = h.app.session.doc.find_feature(name).unwrap().clone();
h.step(&json!({"call":"ui.editFeature","params":{"feature":name}})).unwrap();
h.frames(8);
match &h.app.dialog.as_ref().unwrap().kind {
Kind::Sweep { opts, .. } | Kind::Surface(Surf::Sweep(opts)) => assert_eq!(opts.twist, if name == "Solid" { "30 deg" } else { "45 deg" }),
k => panic!("{k:?}"),
}
h.step(&json!({"widget":"OK"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
// The options come back as they were (the profile pick is stored by its curves).
let options = |k: &FeatureKind| match k {
FeatureKind::Sweep { orientation, twist, scale, .. } | FeatureKind::SurfaceSweep { orientation, twist, scale, .. } => {
(*orientation, twist.clone(), scale.clone())
}
k => panic!("{k:?}"),
};
let after = h.app.session.doc.find_feature(name).unwrap();
assert_eq!(options(&after.kind), options(&before.kind), "{name}");
assert_eq!(after.param_names, before.param_names, "{name}");
}
}