From 5fa8e7d60a343f6e9b754a148bd38a2c9602e374 Mon Sep 17 00:00:00 2001 From: Irakli Kourtis Date: Thu, 15 Oct 2026 06:24:31 -0400 Subject: [PATCH] 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. --- ROADMAP.md | 5 +- assets/locales/de.json | 6 + assets/locales/en.json | 6 + assets/locales/fr.json | 6 + assets/locales/ja.json | 6 + assets/locales/ko.json | 6 + assets/locales/ru.json | 6 + assets/locales/uk.json | 6 + assets/locales/zh.json | 6 + crates/doc/src/document.rs | 42 ++ crates/doc/src/eval.rs | 33 +- crates/doc/src/eval_surface.rs | 7 +- crates/doc/src/feature_walk_tests/fixtures.rs | 12 +- .../src/feature_walk_tests/golden_exprs.txt | 10 + .../feature_walk_tests/golden_geometry.txt | 4 + .../src/feature_walk_tests/golden_refs.txt | 7 + crates/doc/src/format.rs | 28 ++ crates/engine/src/cmd/features.rs | 57 ++- crates/engine/src/cmd/surface.rs | 5 +- crates/engine/src/cmd/sweep_options_tests.rs | 163 +++++++ crates/engine/src/tests.rs | 3 + crates/kernel/src/build.rs | 42 +- crates/kernel/src/lib.rs | 7 +- crates/kernel/src/surfaces.rs | 37 +- crates/kernel/src/sweep_path.rs | 403 +++++++++++++++++- crates/ui-egui/src/dialogs.rs | 25 +- crates/ui-egui/src/dialogs_surface.rs | 33 +- crates/ui-egui/src/dialogs_sweep.rs | 116 +++++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/sweep_dialog_tests.rs | 101 +++++ 30 files changed, 1120 insertions(+), 71 deletions(-) create mode 100644 crates/engine/src/cmd/sweep_options_tests.rs create mode 100644 crates/ui-egui/src/dialogs_sweep.rs create mode 100644 crates/ui-egui/src/sweep_dialog_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 7ac1c53c..e85ad1a9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | | M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 | | M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 | -| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), 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 | | 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 | — | @@ -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 touch (no clean crossing) enter that profile as polylines. 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 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. diff --git a/assets/locales/de.json b/assets/locales/de.json index 5e1c167b..b369b424 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -333,6 +333,12 @@ "ui.save_as": "Speichern unter", "ui.save_as_ellipsis": "Speichern unter...", "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.select": "Auswählen", "ui.selection": "Auswahl", diff --git a/assets/locales/en.json b/assets/locales/en.json index 2dd5226e..43a426f8 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -636,6 +636,12 @@ "ui.save_as": "Save As", "ui.save_as_ellipsis": "Save As...", "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.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.", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index 0471c86d..bf42e9bf 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -593,6 +593,12 @@ "ui.save_as": "Enregistrer sous", "ui.save_as_ellipsis": "Enregistrer sous...", "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.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.", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 3306922a..2f8dac30 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -636,6 +636,12 @@ "ui.save_as": "名前を付けて保存", "ui.save_as_ellipsis": "名前を付けて保存…", "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.select": "選択", "ui.select_other_hint": "その他を選択: モデル上で左ボタンを長押し(または右クリック › その他を選択…)するとカーソル下の要素が一覧表示されます。同じ場所を再度クリックすると次の要素が選択されます。", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index ca2ca85d..debb6431 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -333,6 +333,12 @@ "ui.save_as": "다른 이름으로 저장", "ui.save_as_ellipsis": "다른 이름으로 저장...", "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.select": "선택", "ui.selection": "선택", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 3587ac1c..509e91d3 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -607,6 +607,12 @@ "ui.save_as": "Сохранить как", "ui.save_as_ellipsis": "Сохранить как...", "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.select": "Выбрать", "ui.select_other_hint": "Выбрать другое: удерживайте левую кнопку на модели (или правый щелчок › Выбрать другое…), чтобы увидеть всё под курсором; повторный щелчок в том же месте выбирает следующий объект.", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index 2eef5844..d296994c 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -578,6 +578,12 @@ "ui.save_as": "Зберегти як", "ui.save_as_ellipsis": "Зберегти як...", "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.select": "Вибрати", "ui.select_other_hint": "Вибір іншого: утримуйте ліву кнопку нерухомо на моделі (або клацніть правою › Вибрати інше…), щоб побачити список усього під курсором; клацніть ще раз у тому ж місці, щоб вибрати наступний об'єкт там.", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 8dd6b774..656a0459 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -634,6 +634,12 @@ "ui.save_as": "另存为", "ui.save_as_ellipsis": "另存为...", "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.select": "选择", "ui.select_other_hint": "“选择其他”:在模型上按住左键(或右键单击 › 选择其他…)列出光标下的所有对象;在同一位置再次单击可选择该处的下一个对象。", diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index b199acb9..2cf094af 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -262,6 +262,26 @@ pub struct ThinWall { pub thickness2: Option, } +/// How a swept profile turns as it follows its path. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)] +#[serde(rename_all = "snake_case")] +pub enum SweepOrientation { + /// Square to the path, turning with it (a rotation-minimising frame). + #[default] + Perpendicular, + /// As drawn: the profile moves along the path without turning. + Parallel, + /// Square to the path, its turn about the path fixed by this direction (in the feature's + /// frame): the direction's projection onto the profile stays where it was at the start. + Direction(#[walk(dir)] Vec3), +} + +impl SweepOrientation { + pub fn is_perpendicular(&self) -> bool { + *self == SweepOrientation::Perpendicular + } +} + /// Where a thin wall lies against its curves. A closed loop's inside is the area it encloses; /// an open chain's is the side it bends toward (a straight one's, the left of its first /// curve as drawn). @@ -701,6 +721,17 @@ pub enum FeatureKind { #[walk(sketch)] path_sketch: u64, path: Vec, + /// 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, + /// 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, #[serde(default)] operation: Operation, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -1327,6 +1358,17 @@ pub enum FeatureKind { #[walk(sketch)] path_sketch: u64, path: Vec, + /// 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, + /// 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, }, /// A surface body lofted through sections in order, each one open or closed chain of /// sketch curves: walls only, no caps. diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index e66a7166..e7a33a91 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -3043,7 +3043,8 @@ fn feature_tools_with_constructor( let roles = crate::lineage::SectionRoles { sections: sketches.into_iter().zip(secs.iter().map(|(_, lp)| lp)).collect() }; Ok(vec![roles.tool(body)]) } - FeatureKind::Sweep { sketch, profiles, path_sketch, path, .. } => { + FeatureKind::Sweep { sketch, profiles, path_sketch, path, orientation, twist, scale, .. } => { + let ctl = sweep_control(vals, orientation, twist, scale)?; let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?; let ps = st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?; @@ -3061,10 +3062,10 @@ fn feature_tools_with_constructor( let tool = |r: &Region2, body: Body| crate::lineage::ProfileRoles::swept(Some(ss), Some(r)).tool(body, None); // A path through 3D sketch curves follows them as points. if let Some(pts) = path_points(ps, path, start)? { - return regions.iter().map(|r| Ok(tool(r, kernel::sweep_path(&ss.plane, r, &pts)?))).collect(); + return regions.iter().map(|r| Ok(tool(r, kernel::sweep_path_with(&ss.plane, r, &pts, &ctl)?))).collect(); } let segs = path_segments(ps, path, start)?; - regions.iter().map(|r| Ok(tool(r, kernel::sweep(&ss.plane, r, &segs)?))).collect() + regions.iter().map(|r| Ok(tool(r, kernel::sweep_with(&ss.plane, r, &segs, &ctl)?))).collect() } FeatureKind::Pipe { path_sketch, path, diameter, wall, .. } => { let ps = @@ -3112,6 +3113,32 @@ fn feature_tools_with_constructor( } /// A chain of sketch curves as 3D path segments, starting at the end nearest `start`. +/// A sweep's options from its stored orientation and its twist and end scale expressions +/// (none: no twist, scale 1). Out-of-range values are refused by the kernel. +pub(crate) fn sweep_control( + vals: &BTreeMap, + orientation: &crate::SweepOrientation, + twist: &Option, + scale: &Option, +) -> Result { + let orientation = match *orientation { + crate::SweepOrientation::Perpendicular => kernel::SweepOrientation::Perpendicular, + crate::SweepOrientation::Parallel => kernel::SweepOrientation::Parallel, + crate::SweepOrientation::Direction(d) => kernel::SweepOrientation::Direction(d), + }; + let twist = match twist { + Some(t) => val(vals, t, Kind::Angle)?, + None => 0.0, + }; + let scale = match scale { + Some(k) => val(vals, k, Kind::Unitless)?, + None => 1.0, + }; + let ctl = kernel::SweepControl { orientation, twist, scale }; + ctl.check()?; + Ok(ctl) +} + /// A pipe's section: a circle of radius `r`, hollow when it has a wall. fn pipe_region(vals: &BTreeMap, r: f64, wall: &Option) -> Result { let holes = match wall { diff --git a/crates/doc/src/eval_surface.rs b/crates/doc/src/eval_surface.rs index 8ee61e94..e3fbcf30 100644 --- a/crates/doc/src/eval_surface.rs +++ b/crates/doc/src/eval_surface.rs @@ -176,7 +176,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, } Ok(()) } - FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path } => { + FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale } => { + let ctl = crate::eval::sweep_control(vals, orientation, twist, scale)?; let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?; let ps = st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?; @@ -195,8 +196,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, let pts: Vec = chains.iter().flatten().flat_map(|s| s.polyline(1e-2)).collect(); let start = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, p| a + *p) * (1.0 / pts.len().max(1) as f64)); let body = match path_points(ps, path, start)? { - Some(pts) => kernel::sweep_sheet_points(&ss.plane, &chains, &pts)?, - None => kernel::sweep_sheet(&ss.plane, &chains, &path_segments(ps, path, start)?)?, + Some(pts) => kernel::sweep_sheet_points_with(&ss.plane, &chains, &pts, &ctl)?, + None => kernel::sweep_sheet_with(&ss.plane, &chains, &path_segments(ps, path, start)?, &ctl)?, }; let traced = crate::lineage::ChainRoles { sketch: ss, chains: &chains }.tool(body); push_sheet(st, f, traced); diff --git a/crates/doc/src/feature_walk_tests/fixtures.rs b/crates/doc/src/feature_walk_tests/fixtures.rs index e6f79fc7..c643343b 100644 --- a/crates/doc/src/feature_walk_tests/fixtures.rs +++ b/crates/doc/src/feature_walk_tests/fixtures.rs @@ -75,6 +75,11 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> { ("thread", json!({"type": "thread", "face": o, "length": "thread.length"})), ("loft", json!({"type": "loft", "sections": []})), ("sweep", json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"]})), + ( + "sweep_options", + json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"], + "orientation": {"direction": [0, 1, 0]}, "twist": "sweep.twist", "scale": "sweep.scale"}), + ), ("shell", json!({"type": "shell", "faces": [o], "thickness": "shell.thickness"})), ("draft", json!({"type": "draft", "faces": [o], "angle": "draft.angle", "neutral": plane("draft_neutral"), "pull": [0, 0, 1]})), ("construction_plane", json!({"type": "construction_plane", "plane": plane("cplane")})), @@ -147,6 +152,11 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> { json!({"type": "surface_revolve", "sketch": 1, "axis": {"type": "world", "axis": "X"}, "angle": "srev.angle", "angle2": "srev.angle2"}), ), ("surface_sweep", json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"]})), + ( + "surface_sweep_options", + json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"], + "orientation": "parallel", "twist": "surface_sweep.twist", "scale": "surface_sweep.scale"}), + ), ("surface_loft", json!({"type": "surface_loft", "sections": []})), ("surface_offset", json!({"type": "surface_offset", "faces": [o], "distance": "soff.distance", "tolerance": "soff.tolerance"})), ("unstitch", json!({"type": "unstitch", "body": "B"})), @@ -181,7 +191,7 @@ pub(super) fn with_refs(name: &str, mut v: Value) -> Value { let o = [0, 0, 0]; let tool = json!({"body": "B", "point": o}); let extra = match name { - "extrude" | "extrude_thin" | "revolve" | "sweep" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}), + "extrude" | "extrude_thin" | "revolve" | "sweep" | "sweep_options" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}), "fillet_constant" | "fillet_chord" | "fillet_curvature" | "fillet_variable" | "fillet_spline" | "chamfer" | "shell" | "draft" | "offset_face" | "delete_face" | "replace_face" | "sheet_flange" | "sheet_hem" | "sheet_perforate" | "sheet_unfold" | "boss" | "lip" | "snap_fit" | "rest" | "surface_offset" => { diff --git a/crates/doc/src/feature_walk_tests/golden_exprs.txt b/crates/doc/src/feature_walk_tests/golden_exprs.txt index d4cb20e5..7d169e87 100644 --- a/crates/doc/src/feature_walk_tests/golden_exprs.txt +++ b/crates/doc/src/feature_walk_tests/golden_exprs.txt @@ -136,6 +136,11 @@ thread expressions: thread.length loft sweep +sweep_options + inputs: + Twist = sweep.twist (Angle) + Scale = sweep.scale (Unitless) + expressions: sweep.twist, sweep.scale shell inputs: Thickness = shell.thickness (Length) @@ -317,6 +322,11 @@ surface_revolve Angle 2 = srev.angle2 (Angle) expressions: srev.angle, srev.angle2 surface_sweep +surface_sweep_options + inputs: + Twist = surface_sweep.twist (Angle) + Scale = surface_sweep.scale (Unitless) + expressions: surface_sweep.twist, surface_sweep.scale surface_loft surface_offset inputs: diff --git a/crates/doc/src/feature_walk_tests/golden_geometry.txt b/crates/doc/src/feature_walk_tests/golden_geometry.txt index c133c5c3..c9db3a6d 100644 --- a/crates/doc/src/feature_walk_tests/golden_geometry.txt +++ b/crates/doc/src/feature_walk_tests/golden_geometry.txt @@ -63,6 +63,9 @@ loft profiles: 4 all, 5 all sweep profiles: 1 all +sweep_options + moves: orientation.direction[1], orientation.direction[2] + profiles: 1 all shell moves: faces[0][0], faces[0][1], faces[0][2] face points: [0, 0, 0] @@ -142,6 +145,7 @@ surface_extrude surface_revolve moves: axis.dir[0], axis.dir[1], axis.dir[2], axis.origin[0], axis.origin[1], axis.origin[2] surface_sweep +surface_sweep_options surface_loft surface_offset moves: faces[0][0], faces[0][1], faces[0][2] diff --git a/crates/doc/src/feature_walk_tests/golden_refs.txt b/crates/doc/src/feature_walk_tests/golden_refs.txt index de2068ef..70110cc7 100644 --- a/crates/doc/src/feature_walk_tests/golden_refs.txt +++ b/crates/doc/src/feature_walk_tests/golden_refs.txt @@ -67,6 +67,10 @@ sweep sketches: [1, 2] remap_refs: path_sketch, sketch remap_bodies: targets[0] +sweep_options + sketches: [1, 2] + remap_refs: path_sketch, sketch + remap_bodies: targets[0] shell remap_bodies: body draft @@ -159,6 +163,9 @@ surface_revolve surface_sweep sketches: [1, 2] remap_refs: path_sketch, sketch +surface_sweep_options + sketches: [1, 2] + remap_refs: path_sketch, sketch surface_loft sketches: [4, 5] remap_refs: sections[0].sketch, sections[1].sketch diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 3a8f9693..518854e5 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -191,6 +191,10 @@ //! A bend point can also lie on a bend modelled with its radius (a cylinder between two flat //! faces tangent to it), which then is one bend at the two faces' virtual sharp with the //! rule's radius; 0.11.0 found no edge there or refused the curved face. +//! Also under 21, a Sweep and a Surface Sweep can carry `orientation` (`parallel`, or +//! `{direction: [x, y, z]}`; absent: perpendicular), `twist` (an angle expression) and `scale` +//! (the end scale, an expression). A sweep without them saves as before; 0.11.x refuses +//! format 21, so no released build sweeps a twisted or scaled profile straight. use serde_json::Value; @@ -1763,4 +1767,28 @@ mod tests { let json = edited(&d, |v| v["components"][1]["id"] = u64::MAX.into()); assert!(read(&json).unwrap_err().to_string().contains("damaged design")); } + + /// A sweep saved before its options existed loads as a plain sweep and saves as it was; the + /// options round-trip. + #[test] + fn sweep_options_are_optional_and_round_trip() { + use crate::{FeatureKind, SweepOrientation}; + let old = + serde_json::json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"], "operation": "new_body"}); + let k: FeatureKind = serde_json::from_value(old.clone()).unwrap(); + let FeatureKind::Sweep { orientation, twist, scale, .. } = &k else { panic!("{k:?}") }; + assert!(orientation.is_perpendicular() && twist.is_none() && scale.is_none()); + assert_eq!(serde_json::to_value(&k).unwrap(), old); + let old = serde_json::json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"]}); + let k: FeatureKind = serde_json::from_value(old.clone()).unwrap(); + assert_eq!(serde_json::to_value(&k).unwrap(), old); + let new = serde_json::json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"], + "orientation": {"direction": [0.0, 1.0, 0.0]}, "twist": "90 deg", "scale": "0.5"}); + let k: FeatureKind = serde_json::from_value(new.clone()).unwrap(); + let FeatureKind::SurfaceSweep { orientation, .. } = &k else { panic!("{k:?}") }; + assert_eq!(*orientation, SweepOrientation::Direction(solidcraft_geom::Vec3::Y)); + assert_eq!(serde_json::to_value(&k).unwrap(), new); + let parallel: SweepOrientation = serde_json::from_value(serde_json::json!("parallel")).unwrap(); + assert_eq!(parallel, SweepOrientation::Parallel); + } } diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 012e199a..5ec1c356 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -1,7 +1,9 @@ //! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move. 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 super::{CommandSpec, not_in_sketch}; @@ -21,7 +23,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("solid.sweep", "Sweep", sweep) .at("SOLID", "CREATE") .icon("sweep") - .params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; 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.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?"), @@ -1621,6 +1623,7 @@ fn sweep(s: &mut Session, p: &Value) -> Result { if path.is_empty() { return Err(bad(cmd, "`path` must list curve ids")); } + let (orientation, twist, scale) = sweep_options(s, p, cmd)?; add_feature( s, p, @@ -1629,12 +1632,58 @@ fn sweep(s: &mut Session, p: &Value) -> Result { profiles: profiles(p, cmd)?, path_sketch, path, + orientation, + twist, + scale, operation: operation(p, cmd)?, 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, Option)> { + 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 { let cmd = "solid.loft"; 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"] mod revolve_convention_tests; +#[cfg(test)] +#[path = "sweep_options_tests.rs"] +mod sweep_options_tests; + #[cfg(test)] #[path = "offset_topology_tests.rs"] mod offset_topology_tests; diff --git a/crates/engine/src/cmd/surface.rs b/crates/engine/src/cmd/surface.rs index 2171b2b6..22b0d6f8 100644 --- a/crates/engine/src/cmd/surface.rs +++ b/crates/engine/src/cmd/surface.rs @@ -41,7 +41,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("surface.sweep", "Surface Sweep", surface_sweep) .at("SURFACE", "CREATE") .icon("surface_sweep") - .params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); path_sketch: sketch; path: [curve ids in order] (a path with corners, or 3D curves, gives ruled walls mitred at the corners); 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) .at("SURFACE", "CREATE") .icon("surface_loft") @@ -192,7 +192,8 @@ fn surface_sweep(s: &mut Session, p: &Value) -> Result { if path.is_empty() || path.len() > 1000 { 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 { diff --git a/crates/engine/src/cmd/sweep_options_tests.rs b/crates/engine/src/cmd/sweep_options_tests.rs new file mode 100644 index 00000000..2d6b804b --- /dev/null +++ b/crates/engine/src/cmd/sweep_options_tests.rs @@ -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 { + 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 { + let f = run(s, "model.faces", json!({ "body": body })); + let mut n: Vec = 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}"); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4b023c9d..e1b4b481 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -769,6 +769,9 @@ fn hostile_params_never_panic() { "filter", "kind", "prefix", + "orientation", + "twist", + "scale", ]; // Commands are independent; worker threads take them one at a time (their costs vary a lot). let ids: Vec<&str> = command_specs() diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 649e192c..ffcfd70e 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -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)) } +/// 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> { + 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 { + 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 /// perpendicular to the path (lines translate it, arcs rotate it). A path with sharp corners /// is swept as a polyline, mitred at the corners. @@ -1800,15 +1832,7 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result path.iter().map(PathSeg::tangents).collect::>().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()); if corner { - 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)); - } - return crate::sweep_path::sweep_path(plane, region, &pts); + return crate::sweep_path::sweep_path(plane, region, &path_polyline(path)?); } // The outer loop and each hole, swept along the path; the ends are capped with holes. let profile = region_wires(plane, &oriented(region)?)?; diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 89bc7bbe..0eaf370c 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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 boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics}; 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 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 surfaces::{ 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::{TopoCounts, merged_topology, seam_flags}; pub use tspline::tspline_sheet; diff --git a/crates/kernel/src/surfaces.rs b/crates/kernel/src/surfaces.rs index e43e2126..82416f0c 100644 --- a/crates/kernel/src/surfaces.rs +++ b/crates/kernel/src/surfaces.rs @@ -211,16 +211,8 @@ pub fn sweep_sheet(plane: &Plane, chains: &[Vec], path: &[crate::PathSeg]) } let ends: Vec<(Vec3, Vec3)> = path.iter().map(PathSeg::tangents).collect::>().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()) { - let mut pts = vec![first]; - for seg in path { - pts.extend(seg.points_after_start(1e-2)); - } - return sweep_sheet_points(plane, chains, &pts); + return sweep_sheet_points(plane, chains, &crate::build::path_polyline(path)?); } let mut sides = Vec::new(); let mut offset = 0; @@ -254,16 +246,39 @@ pub fn sweep_sheet(plane: &Plane, chains: &[Vec], path: &[crate::PathSeg]) /// 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`]). pub fn sweep_sheet_points(plane: &Plane, chains: &[Vec], path: &[Vec3]) -> Result { + 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], path: &[crate::PathSeg], ctl: &crate::SweepControl) -> Result { + 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], path: &[Vec3], ctl: &crate::SweepControl) -> Result { + ctl.check()?; if !crate::build::frame_ok(plane) { return Err(KernelError::Invalid("sweep frame is not finite and orthonormal".into())); } if chains.is_empty() || chains.len() > MAX_CHAINS { 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 // 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(a), Ok(b)) = (crate::measure(&smooth), crate::measure(&ruled)) && (a.area - b.area).abs() <= 0.02 * b.area.abs() diff --git a/crates/kernel/src/sweep_path.rs b/crates/kernel/src/sweep_path.rs index d6480a29..2ba84275 100644 --- a/crates/kernel/src/sweep_path.rs +++ b/crates/kernel/src/sweep_path.rs @@ -1,7 +1,8 @@ //! Sweeps along 3D point paths (3D sketch curves: projected, intersection and included curves). //! The profile follows the path with a rotation-minimising frame. Smooth paths get one B-spline //! wall per profile segment; paths with sharp corners (or profiles with holes) get ruled walls -//! between stations, mitred at the corners. +//! 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 solidcraft_geom::{Plane, Region2, Seg2, Vec2, Vec3}; @@ -23,6 +24,83 @@ const MAX_RULED_STATIONS: usize = 2000; const CORNER: f64 = 0.35; /// Samples per profile segment of the smooth walls. 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. #[derive(Clone, Copy)] @@ -127,6 +205,124 @@ fn stations(pts: &[Vec3], tangents: &[Vec3], mitre: &[Option<(Vec3, f64)>]) -> R 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> { + 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> { + 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 { + 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. 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) } @@ -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 /// (it need not touch it); the path should start near the profile. pub fn sweep_path(plane: &Plane, region: &Region2, path: &[Vec3]) -> Result { + 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 { + ctl.check()?; if region.area().abs() < 1e-9 { return Err(KernelError::Invalid("profile has no area".into())); } @@ -147,16 +349,21 @@ pub fn sweep_path(plane: &Plane, region: &Region2, path: &[Vec3]) -> Result Result> { @@ -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 /// points they stand at. -fn ruled_stations(pts: &[Vec3]) -> Result<(Vec, Vec)> { - let pts: Vec = if pts.len() > MAX_RULED_STATIONS { resample(pts, MAX_RULED_STATIONS) } else { pts.to_vec() }; +fn ruled_stations(plane: &Plane, pts: &[Vec3], ctl: &SweepControl) -> Result<(Vec, Vec)> { + let pts = densify(pts, ctl); + let pts: Vec = if pts.len() > MAX_RULED_STATIONS { resample(&pts, MAX_RULED_STATIONS) } else { pts }; let n = pts.len(); let seg_dir = |i: usize| -> Option { (*pts.get(i + 1)? - *pts.get(i)?).normalized() }; let mut tangents = Vec::with_capacity(n); @@ -201,15 +409,16 @@ fn ruled_stations(pts: &[Vec3]) -> Result<(Vec, Vec)> { tangents.push(t); 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)) } /// 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, /// with no caps. Neighbouring walls share their edges. -pub(crate) fn ruled_sheet(plane: &Plane, chains: &[Vec], path: &[Vec3]) -> Result> { - let (pts, st) = ruled_stations(&clean(path)?)?; +pub(crate) fn ruled_sheet(plane: &Plane, chains: &[Vec], path: &[Vec3], ctl: &SweepControl) -> Result> { + ctl.check()?; + let (pts, st) = ruled_stations(plane, &clean(path)?, ctl)?; let edges: usize = chains.iter().map(Vec::len).sum(); if st.len().saturating_mul(edges) > 400_000 { 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], path: &[Vec3]) -> /// 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` /// for a path with corners (see [`ruled_sheet`]). -pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec], path: &[Vec3]) -> Result>> { +pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec], path: &[Vec3], ctl: &SweepControl) -> Result>> { + ctl.check()?; let pts = clean(path)?; if pts.windows(3).any(|w| turn(w[1] - w[0], w[2] - w[1]) > CORNER) { return Ok(None); @@ -258,10 +468,10 @@ pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec], path: &[Vec3]) - if chains.iter().map(Vec::len).sum::() > 4096 { 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 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 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; @@ -340,8 +550,8 @@ pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec], path: &[Vec3]) - /// Ruled walls between stations at the path's points (mitred at corners), every loop of the /// (oriented) profile. -fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3]) -> Result { - let (pts, st) = ruled_stations(pts)?; +fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3], ctl: &SweepControl) -> Result { + let (pts, st) = ruled_stations(plane, pts, ctl)?; let s0 = pts.first().copied().unwrap_or_default(); let loops: Vec<&NumberedLoop> = profile.loops().collect(); let mut wires: Vec> = Vec::with_capacity(st.len()); @@ -438,9 +648,11 @@ fn smooth_walls(plane: &Plane, profile: &NumberedLoop, st: &[Station], expected: #[cfg(test)] mod tests { + use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI}; + 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 { Region2 { outer: Loop2::circle(Vec2::ZERO, r), holes: Vec::new() } @@ -476,4 +688,163 @@ mod tests { assert!(sweep_path(&plane, ®ion, &[Vec3::ZERO]).is_err()); assert!(sweep_path(&plane, ®ion, &[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 { + 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 = (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 = (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 = (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()); + } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7b52aa41..1db39266 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -108,6 +108,7 @@ pub enum Kind { /// Sweep a profile along a path of sketch curves. Sweep { operation: usize, + opts: crate::dialogs_sweep::SweepOpts, }, /// A construction plane offset from a plane. OffsetPlane { @@ -544,8 +545,8 @@ impl Dialog { ), "solid.loft" => Dialog::new(Kind::Loft { operation: usize::from(has_bodies) }, vec![SelInput::new("Profiles", PROFILES, true)]), "solid.sweep" => Dialog::new( - Kind::Sweep { operation: usize::from(has_bodies) }, - vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true)], + Kind::Sweep { operation: usize::from(has_bodies), opts: crate::dialogs_sweep::SweepOpts::default() }, + vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true), crate::dialogs_sweep::direction_input()], ), "solid.move" => Dialog::new(crate::dialogs_move::kind(0), crate::dialogs_move::inputs(0)), "solid.hole" => Dialog::new(hole_defaults(), vec![SelInput::new("Face", HOLE_FACES, true)]), @@ -2537,7 +2538,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { combo(ui, "lf_op", &OP_LABELS, operation); ui.end_row(); } - Kind::Sweep { operation } => { + Kind::Sweep { operation, opts } => { + enter |= crate::dialogs_sweep::rows(ui, opts, "sw"); row_label(ui, "Operation"); combo(ui, "sw_op", &OP_LABELS, operation); ui.end_row(); @@ -3548,16 +3550,15 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S let sections: Vec = sections.into_iter().map(|(sk, v)| json!({"sketch": sk, "profiles": v})).collect(); ("solid.loft", json!({"sections": sections, "operation": OPS.get(*operation).copied().unwrap_or("new")})) } - Kind::Sweep { operation } => { + Kind::Sweep { operation, opts } => { need(0, "a profile")?; need(1, "the path")?; let (sketch, idx) = profiles(); let path: Vec = sels(d, 1).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect(); let path_sketch = curves_sketch(app, &path, sketch.as_u64()).ok_or("the path curves must be in one sketch")?; - ( - "solid.sweep", - json!({"sketch": sketch, "profiles": idx, "path_sketch": path_sketch, "path": path, "operation": OPS.get(*operation).copied().unwrap_or("new")}), - ) + let mut p = json!({"sketch": sketch, "profiles": idx, "path_sketch": path_sketch, "path": path, "operation": OPS.get(*operation).copied().unwrap_or("new")}); + crate::dialogs_sweep::params(app, opts, sels(d, 2).first(), &mut p)?; + ("solid.sweep", p) } Kind::OffsetPlane { offset } => { need(0, "a plane")?; @@ -4614,9 +4615,13 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + FeatureKind::Sweep { sketch, profiles, path, operation, targets, orientation, twist, scale, .. } => { let mut d = start("solid.sweep")?; - d.kind = Kind::Sweep { operation: op_index(operation) }; + let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, &feature_frame(s, f.component)); + d.kind = Kind::Sweep { operation: op_index(operation), opts }; + if let Some(inp) = d.inputs.get_mut(2) { + inp.items = direction.into_iter().collect(); + } let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default(); if let Some(inp) = d.inputs.get_mut(0) { inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect(); diff --git a/crates/ui-egui/src/dialogs_surface.rs b/crates/ui-egui/src/dialogs_surface.rs index df9ae473..d235be7d 100644 --- a/crates/ui-egui/src/dialogs_surface.rs +++ b/crates/ui-egui/src/dialogs_surface.rs @@ -25,7 +25,7 @@ pub enum Surf { symmetric: bool, axis: Option, }, - Sweep, + Sweep(crate::dialogs_sweep::SweepOpts), Loft, Patch, Unstitch, @@ -68,7 +68,7 @@ impl Surf { match self { Surf::Extrude { .. } => "SURFACE EXTRUDE", Surf::Revolve { .. } => "SURFACE REVOLVE", - Surf::Sweep => "SURFACE SWEEP", + Surf::Sweep(_) => "SURFACE SWEEP", Surf::Loft => "SURFACE LOFT", Surf::Patch => "PATCH", Surf::Unstitch => "UNSTITCH", @@ -110,7 +110,11 @@ fn inputs(k: &Surf) -> Vec { match k { Surf::Extrude { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true)], Surf::Revolve { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Axis", AXES, false)], - Surf::Sweep => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Path", CURVES, true)], + Surf::Sweep(_) => vec![ + SelInput::new("Profiles or curves", PROFILES | CURVES, true), + SelInput::new("Path", CURVES, true), + crate::dialogs_sweep::direction_input(), + ], Surf::Loft => vec![SelInput::new("Sections (one sketch each)", PROFILES | CURVES, true)], Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)], Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)], @@ -137,7 +141,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { let k = match id { "surface.extrude" => Surf::Extrude { distance: "10 mm".into(), direction: 0 }, "surface.revolve" => Surf::Revolve { angle: "360 deg".into(), symmetric: false, axis: None }, - "surface.sweep" => Surf::Sweep, + "surface.sweep" => Surf::Sweep(crate::dialogs_sweep::SweepOpts::default()), "surface.loft" => Surf::Loft, "surface.patch" => Surf::Patch, "surface.unstitch" => Surf::Unstitch, @@ -243,7 +247,8 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool { ui.checkbox(in_place, ""); ui.end_row(); } - Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Sweep | Surf::Loft => {} + Surf::Sweep(opts) => enter |= crate::dialogs_sweep::rows(ui, opts, "ss"), + Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Loft => {} } enter } @@ -345,7 +350,7 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map { + Surf::Sweep(opts) => { need(0, "profiles or curves to sweep")?; need(1, "the path")?; let mut p = sheet_picks(app, items(inputs, 0))?; @@ -354,6 +359,7 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map { @@ -578,10 +584,17 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K } return Some((Kind::Surface(k), ins, extra)); } - FeatureKind::SurfaceSweep { sketch, profiles, curves, path, .. } => ( - Surf::Sweep, - vec![sheet_sels(app, *sketch, profiles.as_ref(), curves), path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect()], - ), + FeatureKind::SurfaceSweep { sketch, profiles, curves, path, orientation, twist, scale, .. } => { + let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, frame); + ( + Surf::Sweep(opts), + vec![ + sheet_sels(app, *sketch, profiles.as_ref(), curves), + path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect(), + direction.into_iter().collect(), + ], + ) + } FeatureKind::SurfaceLoft { sections } => { (Surf::Loft, vec![sections.iter().flat_map(|sec| sheet_sels(app, sec.sketch, sec.profiles.as_ref(), &sec.curves)).collect()]) } diff --git a/crates/ui-egui/src/dialogs_sweep.rs b/crates/ui-egui/src/dialogs_sweep.rs new file mode 100644 index 00000000..ad5c7ecc --- /dev/null +++ b/crates/ui-egui/src/dialogs_sweep.rs @@ -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, +} + +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, scale: &Option, frame: &Mat) -> (SweepOpts, Option) { + 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(()) +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1be810e6..d3a73d9c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,7 @@ pub mod dialogs_plastic; pub mod dialogs_save_mesh; pub mod dialogs_sheet; pub mod dialogs_surface; +pub mod dialogs_sweep; pub mod dim_view; #[cfg(test)] mod dimension_create_ui_tests; @@ -141,6 +142,8 @@ pub mod smart_extrude; #[cfg(test)] mod smart_extrude_ui_tests; pub mod stand_up; +#[cfg(test)] +mod sweep_dialog_tests; pub mod theme; #[cfg(test)] mod thin_extrude_tests; diff --git a/crates/ui-egui/src/sweep_dialog_tests.rs b/crates/ui-egui/src/sweep_dialog_tests.rs new file mode 100644 index 00000000..6f50cf34 --- /dev/null +++ b/crates/ui-egui/src/sweep_dialog_tests.rs @@ -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}"); + } +}