Sweep: orientation, twist and end scale (#1269)
Part of #771 and #772. solid.sweep and surface.sweep take orientation (perpendicular, parallel or a direction), twist (up to 4 turns) and end scale (0.01 to 100), applied about the profile's normal through the path's start; default options run the old sweep, so existing sweeps are unchanged. New fields are optional, left out when unset and noted under format 21; twist and scale are model parameters. Both sweep dialogs get Orientation, Twist and End Scale rows; labels in every catalog. Checked on main + this branch: cargo check --workspace, kernel/engine/UI sweep tests, the doc suite and the robustness goldens.
This commit is contained in:
1 parent
0872eb959a
commit
5fa8e7d60a
30 files changed
+1120
-71
No files matched your search
+4
-1
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "その他を選択: モデル上で左ボタンを長押し(または右クリック › その他を選択…)するとカーソル下の要素が一覧表示されます。同じ場所を再度クリックすると次の要素が選択されます。",
|
||||
|
||||
@@ -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": "선택",
|
||||
|
||||
@@ -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": "Выбрать другое: удерживайте левую кнопку на модели (или правый щелчок › Выбрать другое…), чтобы увидеть всё под курсором; повторный щелчок в том же месте выбирает следующий объект.",
|
||||
|
||||
@@ -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": "Вибір іншого: утримуйте ліву кнопку нерухомо на моделі (або клацніть правою › Вибрати інше…), щоб побачити список усього під курсором; клацніть ще раз у тому ж місці, щоб вибрати наступний об'єкт там.",
|
||||
|
||||
@@ -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": "“选择其他”:在模型上按住左键(或右键单击 › 选择其他…)列出光标下的所有对象;在同一位置再次单击可选择该处的下一个对象。",
|
||||
|
||||
@@ -262,6 +262,26 @@ pub struct ThinWall {
|
||||
pub thickness2: Option<String>,
|
||||
}
|
||||
|
||||
/// How a swept profile turns as it follows its path.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SweepOrientation {
|
||||
/// Square to the path, turning with it (a rotation-minimising frame).
|
||||
#[default]
|
||||
Perpendicular,
|
||||
/// As drawn: the profile moves along the path without turning.
|
||||
Parallel,
|
||||
/// Square to the path, its turn about the path fixed by this direction (in the feature's
|
||||
/// frame): the direction's projection onto the profile stays where it was at the start.
|
||||
Direction(#[walk(dir)] Vec3),
|
||||
}
|
||||
|
||||
impl SweepOrientation {
|
||||
pub fn is_perpendicular(&self) -> bool {
|
||||
*self == SweepOrientation::Perpendicular
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a thin wall lies against its curves. A closed loop's inside is the area it encloses;
|
||||
/// 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<String>,
|
||||
/// How the profile turns along the path (default: perpendicular to it).
|
||||
#[serde(default, skip_serializing_if = "SweepOrientation::is_perpendicular")]
|
||||
orientation: SweepOrientation,
|
||||
/// Angle the profile turns about the path over its length.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(angle = "Twist")]
|
||||
twist: Option<String>,
|
||||
/// The profile's scale at the end of the path (linear along its length).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(unitless = "Scale")]
|
||||
scale: Option<String>,
|
||||
#[serde(default)]
|
||||
operation: Operation,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -1327,6 +1358,17 @@ pub enum FeatureKind {
|
||||
#[walk(sketch)]
|
||||
path_sketch: u64,
|
||||
path: Vec<String>,
|
||||
/// How the profile turns along the path (default: perpendicular to it).
|
||||
#[serde(default, skip_serializing_if = "SweepOrientation::is_perpendicular")]
|
||||
orientation: SweepOrientation,
|
||||
/// Angle the profile turns about the path over its length.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(angle = "Twist")]
|
||||
twist: Option<String>,
|
||||
/// The profile's scale at the end of the path (linear along its length).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(unitless = "Scale")]
|
||||
scale: Option<String>,
|
||||
},
|
||||
/// A surface body lofted through sections in order, each one open or closed chain of
|
||||
/// sketch curves: walls only, no caps.
|
||||
|
||||
+30
-3
@@ -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<String, Value>,
|
||||
orientation: &crate::SweepOrientation,
|
||||
twist: &Option<String>,
|
||||
scale: &Option<String>,
|
||||
) -> Result<kernel::SweepControl> {
|
||||
let orientation = match *orientation {
|
||||
crate::SweepOrientation::Perpendicular => kernel::SweepOrientation::Perpendicular,
|
||||
crate::SweepOrientation::Parallel => kernel::SweepOrientation::Parallel,
|
||||
crate::SweepOrientation::Direction(d) => kernel::SweepOrientation::Direction(d),
|
||||
};
|
||||
let twist = match twist {
|
||||
Some(t) => val(vals, t, Kind::Angle)?,
|
||||
None => 0.0,
|
||||
};
|
||||
let scale = match scale {
|
||||
Some(k) => val(vals, k, Kind::Unitless)?,
|
||||
None => 1.0,
|
||||
};
|
||||
let ctl = kernel::SweepControl { orientation, twist, scale };
|
||||
ctl.check()?;
|
||||
Ok(ctl)
|
||||
}
|
||||
|
||||
/// A pipe's section: a circle of radius `r`, hollow when it has a wall.
|
||||
fn pipe_region(vals: &BTreeMap<String, Value>, r: f64, wall: &Option<String>) -> Result<Region2> {
|
||||
let holes = match wall {
|
||||
|
||||
@@ -176,7 +176,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, 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<String, Value>, f: &Feature,
|
||||
let pts: Vec<Vec2> = chains.iter().flatten().flat_map(|s| s.polyline(1e-2)).collect();
|
||||
let start = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, p| a + *p) * (1.0 / pts.len().max(1) as f64));
|
||||
let body = match path_points(ps, path, start)? {
|
||||
Some(pts) => kernel::sweep_sheet_points(&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);
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<Value> {
|
||||
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<Value> {
|
||||
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<String>, Option<String>)> {
|
||||
let orientation = match p.get("orientation") {
|
||||
None | Some(Value::Null) => SweepOrientation::Perpendicular,
|
||||
Some(Value::String(o)) if o.eq_ignore_ascii_case("perpendicular") => SweepOrientation::Perpendicular,
|
||||
Some(Value::String(o)) if o.eq_ignore_ascii_case("parallel") => SweepOrientation::Parallel,
|
||||
Some(v) => {
|
||||
let d = vec3(v)
|
||||
.or_else(|| v.get("direction").and_then(vec3))
|
||||
.ok_or_else(|| bad(cmd, "`orientation` must be perpendicular, parallel or a direction [x, y, z]"))?;
|
||||
if !(d.len() > 1e-9) {
|
||||
return Err(bad(cmd, "the `orientation` direction must not be zero"));
|
||||
}
|
||||
SweepOrientation::Direction(d)
|
||||
}
|
||||
};
|
||||
let twist = match p.get("twist") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => {
|
||||
let t = expr(p, "twist").ok_or_else(|| bad(cmd, "`twist` must be an angle or an expression"))?;
|
||||
let v = s.doc.eval(&t, Kind::Angle).map_err(|e| bad(cmd, format!("twist: {e}")))?;
|
||||
if !(v.is_finite() && v.abs() <= solidcraft_kernel::SweepControl::MAX_TWIST) {
|
||||
return Err(bad(cmd, "`twist` must be within four turns (1440 deg)"));
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
};
|
||||
let scale = match p.get("scale") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => {
|
||||
let k = expr(p, "scale").ok_or_else(|| bad(cmd, "`scale` must be a number or an expression"))?;
|
||||
let v = s.doc.eval(&k, Kind::Unitless).map_err(|e| bad(cmd, format!("scale: {e}")))?;
|
||||
let range = solidcraft_kernel::SweepControl::MIN_SCALE..=solidcraft_kernel::SweepControl::MAX_SCALE;
|
||||
if !(v.is_finite() && range.contains(&v)) {
|
||||
return Err(bad(cmd, "`scale` (the end scale) must be between 0.01 and 100"));
|
||||
}
|
||||
Some(k)
|
||||
}
|
||||
};
|
||||
Ok((orientation, twist, scale))
|
||||
}
|
||||
|
||||
fn loft(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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;
|
||||
|
||||
@@ -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<Value> {
|
||||
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<Value> {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Sweep options through the commands: orientation, twist and end scale on solid.sweep and
|
||||
//! surface.sweep, checked against analytic values; naming, saving and hostile values.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
/// A circle `C` (radius 10, curve `c`), a square `Q` (side 10, curves `a`…`d`) on XY, and a
|
||||
/// path `P`: a line 40 up the Z axis (`up`).
|
||||
fn setup() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "C"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10, "id": "c"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Q"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[-5, -5], [5, -5], [5, 5], [-5, 5]], "closed": true, "ids": ["a", "b", "c", "d"]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "P"}));
|
||||
// The XZ sketch's y runs down world Z.
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, -40]], "ids": ["up"]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
s
|
||||
}
|
||||
|
||||
fn measure(s: &Session, body: &str) -> solidcraft_kernel::BodyMeasure {
|
||||
solidcraft_kernel::measure(&s.world_state().body(body).unwrap().body).unwrap()
|
||||
}
|
||||
|
||||
fn sweep(s: &mut Session, sketch: &str, body: &str, extra: Value) -> Result<Value, String> {
|
||||
let mut p = json!({"sketch": sketch, "path_sketch": "P", "path": ["up"], "operation": "new", "body_name": body});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
s.execute("solid.sweep", &p).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn face_names(s: &mut Session, body: &str) -> Vec<String> {
|
||||
let f = run(s, "model.faces", json!({ "body": body }));
|
||||
let mut n: Vec<String> = f["faces"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|x| x["name"].as_str())
|
||||
// Without the feature's own prefix (`F7:`), to compare features.
|
||||
.map(|x| x.split_once(':').map_or(x, |(_, rest)| rest).to_string())
|
||||
.collect();
|
||||
n.sort();
|
||||
n
|
||||
}
|
||||
|
||||
/// A circle swept up a line to end scale 0.5 is a cone frustum; the surface sweep of its curve
|
||||
/// is the frustum's lateral face. Saved and reopened, the design gives the same solid.
|
||||
#[test]
|
||||
fn scaled_sweep_is_a_frustum() {
|
||||
let mut s = setup();
|
||||
let (big, small, h) = (10.0, 5.0, 40.0);
|
||||
let volume = PI * h * (big * big + big * small + small * small) / 3.0;
|
||||
let lateral = PI * (big + small) * (h * h + (big - small) * (big - small)).sqrt();
|
||||
sweep(&mut s, "C", "Frustum", json!({"scale": 0.5})).unwrap();
|
||||
let m = measure(&s, "Frustum");
|
||||
assert!((m.volume - volume).abs() < 2e-3 * volume, "volume {} vs {volume}", m.volume);
|
||||
assert!((m.area - PI * (big * big + small * small) - lateral).abs() < 2e-3 * lateral, "area {}", m.area);
|
||||
run(&mut s, "surface.sweep", json!({"sketch": "C", "curves": ["c"], "path_sketch": "P", "path": ["up"], "scale": "1/2", "body_name": "Cone"}));
|
||||
let a = measure(&s, "Cone").area;
|
||||
assert!((a - lateral).abs() < 2e-3 * lateral, "sheet {a} vs {lateral}");
|
||||
// The options are saved with the feature and evaluate the same when reopened.
|
||||
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
let reopened = Session::new(doc);
|
||||
let again = measure(&reopened, "Frustum");
|
||||
assert!((again.volume - m.volume).abs() < 1e-9 * volume);
|
||||
}
|
||||
|
||||
/// A twisted square keeps its faces' names (each wall after its profile curve), and the twist is
|
||||
/// a parameter: editing it changes the solid, a plain sweep has the same names.
|
||||
#[test]
|
||||
fn twisted_sweep_faces_follow_the_profile_curves() {
|
||||
let mut s = setup();
|
||||
sweep(&mut s, "Q", "Plain", json!({})).unwrap();
|
||||
sweep(&mut s, "Q", "Twisted", json!({"twist": "90 deg", "name": "Twist"})).unwrap();
|
||||
let (plain, twisted) = (face_names(&mut s, "Plain"), face_names(&mut s, "Twisted"));
|
||||
assert_eq!(plain.len(), 6, "{plain:?}");
|
||||
assert_eq!(plain, twisted);
|
||||
for curve in ["a", "b", "c", "d"] {
|
||||
assert!(twisted.iter().any(|n| n.ends_with(&format!(":{curve}"))), "{curve} in {twisted:?}");
|
||||
}
|
||||
// The end cap turns with the twist: at 90 deg a square's corners are back where they were,
|
||||
// at 45 deg its top reaches out to the corner radius along the axes.
|
||||
let reach = |s: &Session| {
|
||||
let mesh = s.world_state().body("Twisted").unwrap().body.tessellate(0.01).unwrap();
|
||||
mesh.positions.iter().filter(|p| (p.z - 40.0).abs() < 1e-6).map(|p| p.x).fold(f64::MIN, f64::max)
|
||||
};
|
||||
assert!((reach(&s) - 5.0).abs() < 1e-3, "{}", reach(&s));
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Twist", "set": {"twist": "45 deg"}}));
|
||||
assert!((reach(&s) - 50f64.sqrt()).abs() < 1e-2, "{}", reach(&s));
|
||||
let v = measure(&s, "Twisted").volume;
|
||||
assert!((v - 4000.0).abs() < 0.01 * 4000.0, "{v}");
|
||||
}
|
||||
|
||||
/// Parallel along a straight path leaning out of the profile's plane gives a sheared prism (the
|
||||
/// volume of the upright one); so does a direction, along a straight path.
|
||||
#[test]
|
||||
fn orientations() {
|
||||
let mut s = setup();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Lean"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, -40]], "ids": ["lean"]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let lean = |s: &mut Session, body: &str, o: Value| {
|
||||
s.execute(
|
||||
"solid.sweep",
|
||||
&json!({"sketch": "Q", "path_sketch": "Lean", "path": ["lean"], "operation": "new", "body_name": body, "orientation": o}),
|
||||
)
|
||||
};
|
||||
lean(&mut s, "Sheared", json!("parallel")).unwrap();
|
||||
let m = measure(&s, "Sheared");
|
||||
assert!((m.volume - 4000.0).abs() < 2e-3 * 4000.0, "{}", m.volume);
|
||||
// Its ends stay level: flat at z = 0 and z = 40.
|
||||
assert!(m.bbox.min.z.abs() < 1e-6 && (m.bbox.max.z - 40.0).abs() < 1e-6, "{:?}", m.bbox);
|
||||
// Along a straight path every frame is the first: a direction changes nothing, the profile
|
||||
// keeps its relation to the path as in a plain sweep.
|
||||
lean(&mut s, "Directed", json!([0, 1, 0])).unwrap();
|
||||
let d = measure(&s, "Directed");
|
||||
assert!((d.volume - 4000.0).abs() < 2e-3 * 4000.0, "{}", d.volume);
|
||||
assert!(d.bbox.max.y <= 5.0 + 1e-6 && d.bbox.min.y >= -5.0 - 1e-6);
|
||||
}
|
||||
|
||||
/// Hostile options are refused with an error and leave the design as it was.
|
||||
#[test]
|
||||
fn hostile_sweep_options_are_refused() {
|
||||
let mut s = setup();
|
||||
let before = s.doc.features.len();
|
||||
for bad in [
|
||||
json!({"twist": "NaN"}),
|
||||
json!({"twist": 1e300}),
|
||||
json!({"twist": "1e9 deg"}),
|
||||
json!({"twist": "1441 deg"}),
|
||||
json!({"twist": "nope"}),
|
||||
json!({"twist": [1, 2]}),
|
||||
json!({"scale": 0}),
|
||||
json!({"scale": -1}),
|
||||
json!({"scale": "0"}),
|
||||
json!({"scale": "1e9"}),
|
||||
json!({"scale": "1/0"}),
|
||||
json!({"orientation": "sideways"}),
|
||||
json!({"orientation": [0, 0, 0]}),
|
||||
json!({"orientation": [1e308, 0, 0]}),
|
||||
json!({"orientation": {}}),
|
||||
json!({"orientation": 7}),
|
||||
// Along the path itself: no turn about the path is defined.
|
||||
json!({"orientation": [0, 0, 1]}),
|
||||
] {
|
||||
assert!(sweep(&mut s, "Q", "Bad", bad.clone()).is_err(), "{bad}");
|
||||
let mut p = json!({"sketch": "Q", "curves": ["a", "b"], "path_sketch": "P", "path": ["up"]});
|
||||
p.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone());
|
||||
assert!(s.execute("surface.sweep", &p).is_err(), "surface {bad}");
|
||||
assert_eq!(s.doc.features.len(), before, "{bad}");
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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<Vec<Vec3>> {
|
||||
let first = match path.first() {
|
||||
Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a,
|
||||
None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())),
|
||||
};
|
||||
let mut pts = vec![first];
|
||||
for seg in path {
|
||||
pts.extend(seg.points_after_start(1e-2));
|
||||
}
|
||||
Ok(pts)
|
||||
}
|
||||
|
||||
/// [`sweep`] with sweep options. The default options are exactly [`sweep`]; any other
|
||||
/// orientation, a twist or an end scale follow the path as a polyline through stations (see
|
||||
/// [`crate::sweep_path_with`]).
|
||||
pub fn sweep_with(plane: &Plane, region: &Region2, path: &[PathSeg], ctl: &crate::SweepControl) -> Result<Body> {
|
||||
if ctl.is_default() {
|
||||
return sweep(plane, region, path);
|
||||
}
|
||||
ctl.check()?;
|
||||
region_ok(region)?;
|
||||
if path.is_empty() || path.len() > 1000 {
|
||||
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
|
||||
}
|
||||
if path.iter().any(|s| s.tangents().is_none()) {
|
||||
return Err(KernelError::Invalid("a path segment has no direction".into()));
|
||||
}
|
||||
crate::sweep_path::sweep_path_with(plane, region, &path_polyline(path)?, ctl)
|
||||
}
|
||||
|
||||
/// Sweep a planar region (outer loop) along a chain of path segments, keeping the profile
|
||||
/// 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<Body>
|
||||
path.iter().map(PathSeg::tangents).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
|
||||
let corner = ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos());
|
||||
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)?)?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -211,16 +211,8 @@ pub fn sweep_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[crate::PathSeg])
|
||||
}
|
||||
let ends: Vec<(Vec3, Vec3)> =
|
||||
path.iter().map(PathSeg::tangents).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
|
||||
let first = match path.first() {
|
||||
Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a,
|
||||
None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())),
|
||||
};
|
||||
if ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos()) {
|
||||
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<Seg2>], 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<Seg2>], path: &[Vec3]) -> Result<Body> {
|
||||
sweep_sheet_points_with(plane, chains, path, &crate::SweepControl::default())
|
||||
}
|
||||
|
||||
/// [`sweep_sheet`] with sweep options. The default options are exactly [`sweep_sheet`]; any
|
||||
/// other orientation, a twist or an end scale follow the path as a polyline through stations
|
||||
/// (see [`sweep_sheet_points_with`]).
|
||||
pub fn sweep_sheet_with(plane: &Plane, chains: &[Vec<Seg2>], path: &[crate::PathSeg], ctl: &crate::SweepControl) -> Result<Body> {
|
||||
if ctl.is_default() {
|
||||
return sweep_sheet(plane, chains, path);
|
||||
}
|
||||
ctl.check()?;
|
||||
if path.is_empty() || path.len() > 1000 {
|
||||
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
|
||||
}
|
||||
if path.iter().any(|s| s.tangents().is_none()) {
|
||||
return Err(KernelError::Invalid("a path segment has no direction".into()));
|
||||
}
|
||||
sweep_sheet_points_with(plane, chains, &crate::build::path_polyline(path)?, ctl)
|
||||
}
|
||||
|
||||
/// [`sweep_sheet_points`] with sweep options: orientation, twist and end scale.
|
||||
pub fn sweep_sheet_points_with(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3], ctl: &crate::SweepControl) -> Result<Body> {
|
||||
ctl.check()?;
|
||||
if !crate::build::frame_ok(plane) {
|
||||
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()
|
||||
|
||||
+387
-16
@@ -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<Vec<[Vec3; 3]>> {
|
||||
let d = d.normalized().ok_or_else(|| KernelError::Invalid("the sweep orientation direction has no length".into()))?;
|
||||
tangents
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let across = d - *t * t.dot(d);
|
||||
// Within about 0.06 degrees of the direction the profile's turn is undefined.
|
||||
if across.len() < 1e-3 {
|
||||
return Err(KernelError::Invalid("the sweep path runs along the orientation direction".into()));
|
||||
}
|
||||
let r = across.normalized().ok_or_else(|| KernelError::Invalid("the sweep path runs along the orientation direction".into()))?;
|
||||
Ok([*t, r, t.cross(r)])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Stations shaped by sweep options: each maps a world vector at the first station to its image
|
||||
/// at its own (twisted and scaled about the profile's normal through the first station, then
|
||||
/// turned by the orientation's frames), optionally stretched across a corner (mitre). Twist and
|
||||
/// scale grow with the length run along `pts`.
|
||||
fn controlled_stations(plane: &Plane, pts: &[Vec3], tangents: &[Vec3], mitre: &[Option<(Vec3, f64)>], ctl: &SweepControl) -> Result<Vec<Station>> {
|
||||
let bad = || KernelError::Invalid("sweep path".into());
|
||||
let t0 = *tangents.first().ok_or_else(bad)?;
|
||||
let f: Vec<[Vec3; 3]> = match ctl.orientation {
|
||||
SweepOrientation::Perpendicular => frames(pts, tangents).ok_or_else(|| KernelError::Invalid("the sweep path doubles back".into()))?,
|
||||
SweepOrientation::Parallel => vec![[Vec3::X, Vec3::Y, Vec3::Z]; pts.len()],
|
||||
SweepOrientation::Direction(d) => frames_toward(tangents, d)?,
|
||||
};
|
||||
let f0 = *f.first().ok_or_else(bad)?;
|
||||
// Twist and scale act about the profile's normal, pointing the way the path leaves it.
|
||||
let n = plane.normal().normalized().ok_or_else(|| KernelError::Invalid("sweep frame".into()))?;
|
||||
let axis = if n.dot(t0) < 0.0 { n * -1.0 } else { n };
|
||||
// Kept as drawn, the profile flattens to nothing where the path runs along its plane: the
|
||||
// path has to cross it at 5 degrees or more all the way.
|
||||
if ctl.orientation == SweepOrientation::Parallel && tangents.iter().any(|t| t.dot(axis) < 5f64.to_radians().sin()) {
|
||||
return Err(KernelError::Invalid(
|
||||
"kept parallel, the profile would lie along the sweep path: the path must keep crossing the profile's plane (by 5 deg or more)".into(),
|
||||
));
|
||||
}
|
||||
let mut run = Vec::with_capacity(pts.len());
|
||||
let mut total = 0.0;
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
if let Some(prev) = i.checked_sub(1).and_then(|j| pts.get(j)) {
|
||||
total += prev.dist(*p);
|
||||
}
|
||||
run.push(total);
|
||||
}
|
||||
if !(total > 1e-12) {
|
||||
return Err(KernelError::Invalid("the sweep path has no length".into()));
|
||||
}
|
||||
let mut out = Vec::with_capacity(f.len());
|
||||
for (i, fi) in f.iter().enumerate() {
|
||||
let u = run.get(i).copied().unwrap_or(total) / total;
|
||||
let (sin, cos) = (ctl.twist * u).sin_cos();
|
||||
let k = 1.0 + (ctl.scale - 1.0) * u;
|
||||
let shape = |v: Vec3| {
|
||||
let along = axis * v.dot(axis);
|
||||
let r = (v - along) * k;
|
||||
along + r * cos + axis.cross(r) * sin
|
||||
};
|
||||
let col = |e: Vec3| {
|
||||
let v = shape(e);
|
||||
fi[0] * f0[0].dot(v) + fi[1] * f0[1].dot(v) + fi[2] * f0[2].dot(v)
|
||||
};
|
||||
let mut m = [col(Vec3::X), col(Vec3::Y), col(Vec3::Z)];
|
||||
if ctl.orientation != SweepOrientation::Parallel
|
||||
&& let Some(Some((b, s))) = mitre.get(i)
|
||||
{
|
||||
for c in &mut m {
|
||||
*c += *b * ((s - 1.0) * c.dot(*b));
|
||||
}
|
||||
}
|
||||
out.push(Station { at: *pts.get(i).ok_or_else(bad)?, m });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The volume a profile of `area` on `plane` sweeps through the stations: each station's
|
||||
/// section area times how squarely the path crosses it, summed along the path (trapezoids).
|
||||
fn swept_volume(plane: &Plane, area: f64, st: &[Station], tangents: &[Vec3]) -> f64 {
|
||||
let rate = |i: usize| match (st.get(i), tangents.get(i)) {
|
||||
(Some(s), Some(t)) => area * s.apply(plane.x).cross(s.apply(plane.y)).dot(*t).abs(),
|
||||
_ => 0.0,
|
||||
};
|
||||
st.windows(2).enumerate().map(|(i, w)| 0.5 * (rate(i) + rate(i + 1)) * w[0].at.dist(w[1].at)).sum()
|
||||
}
|
||||
|
||||
/// The stations of a smooth sweep: as many as the path has points (4…[`MAX_SMOOTH_STATIONS`]),
|
||||
/// more when the profile twists or scales.
|
||||
fn smooth_count(points: usize, ctl: &SweepControl) -> usize {
|
||||
let n = points.saturating_sub(1).clamp(4, MAX_SMOOTH_STATIONS);
|
||||
if !ctl.varies() {
|
||||
return n;
|
||||
}
|
||||
let twist = (ctl.twist.abs() / SMOOTH_TWIST_STEP).ceil() as usize;
|
||||
n.max(MIN_CONTROLLED_STATIONS).max(twist).min(MAX_CONTROLLED_STATIONS)
|
||||
}
|
||||
|
||||
/// The path with points added along its straight runs (its corners kept) when the profile
|
||||
/// twists or scales, so ruled walls follow the change.
|
||||
fn densify(pts: &[Vec3], ctl: &SweepControl) -> Vec<Vec3> {
|
||||
if !ctl.varies() {
|
||||
return pts.to_vec();
|
||||
}
|
||||
let total: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||
let want = MIN_CONTROLLED_RULED.max((ctl.twist.abs() / RULED_TWIST_STEP).ceil() as usize).min(MAX_RULED_STATIONS / 2);
|
||||
let mut out = Vec::with_capacity(want + pts.len());
|
||||
out.extend(pts.first().copied());
|
||||
for w in pts.windows(2) {
|
||||
let pieces = if total > 0.0 { ((w[0].dist(w[1]) / total) * want as f64).ceil() as usize } else { 1 };
|
||||
let pieces = pieces.clamp(1, want.max(1));
|
||||
out.extend((1..=pieces).map(|j| w[0].lerp(w[1], j as f64 / pieces as f64)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The profile plane carried to a station.
|
||||
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<Body> {
|
||||
sweep_path_with(plane, region, path, &SweepControl::default())
|
||||
}
|
||||
|
||||
/// [`sweep_path`] with sweep options: orientation, twist and end scale.
|
||||
pub fn sweep_path_with(plane: &Plane, region: &Region2, path: &[Vec3], ctl: &SweepControl) -> Result<Body> {
|
||||
ctl.check()?;
|
||||
if region.area().abs() < 1e-9 {
|
||||
return Err(KernelError::Invalid("profile has no area".into()));
|
||||
}
|
||||
@@ -147,16 +349,21 @@ pub fn sweep_path(plane: &Plane, region: &Region2, path: &[Vec3]) -> Result<Body
|
||||
if !corner && region.holes.is_empty() {
|
||||
// Smooth: evenly spaced stations, tangents from neighbours.
|
||||
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||
let n = (pts.len() - 1).clamp(4, MAX_SMOOTH_STATIONS);
|
||||
let n = smooth_count(pts.len(), ctl);
|
||||
let sp = resample(&pts, n);
|
||||
let tangents = central_tangents(&sp)?;
|
||||
let st = stations(&sp, &tangents, &[])?;
|
||||
let expected = region.area().abs() * len * start_cos(plane, &tangents);
|
||||
let (st, expected) = if ctl.is_default() {
|
||||
(stations(&sp, &tangents, &[])?, region.area().abs() * len * start_cos(plane, &tangents))
|
||||
} else {
|
||||
let st = controlled_stations(plane, &sp, &tangents, &[], ctl)?;
|
||||
let v = swept_volume(plane, region.area().abs(), &st, &tangents);
|
||||
(st, v)
|
||||
};
|
||||
if let Ok(b) = smooth_walls(plane, &profile.outer, &st, expected) {
|
||||
return Ok(b);
|
||||
}
|
||||
}
|
||||
ruled(plane, &profile, &pts)
|
||||
ruled(plane, &profile, &pts, ctl)
|
||||
}
|
||||
|
||||
fn central_tangents(p: &[Vec3]) -> Result<Vec<Vec3>> {
|
||||
@@ -177,8 +384,9 @@ fn start_cos(plane: &Plane, tangents: &[Vec3]) -> f64 {
|
||||
|
||||
/// Stations at the path's points (at most [`MAX_RULED_STATIONS`]), mitred at corners, with the
|
||||
/// points they stand at.
|
||||
fn ruled_stations(pts: &[Vec3]) -> Result<(Vec<Vec3>, Vec<Station>)> {
|
||||
let pts: Vec<Vec3> = 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<Vec3>, Vec<Station>)> {
|
||||
let pts = densify(pts, ctl);
|
||||
let pts: Vec<Vec3> = if pts.len() > MAX_RULED_STATIONS { resample(&pts, MAX_RULED_STATIONS) } else { pts };
|
||||
let n = pts.len();
|
||||
let seg_dir = |i: usize| -> Option<Vec3> { (*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<Vec3>, Vec<Station>)> {
|
||||
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<Seg2>], path: &[Vec3]) -> Result<Vec<Side>> {
|
||||
let (pts, st) = ruled_stations(&clean(path)?)?;
|
||||
pub(crate) fn ruled_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3], ctl: &SweepControl) -> Result<Vec<Side>> {
|
||||
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<Seg2>], 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<Seg2>], path: &[Vec3]) -> Result<Option<Vec<Side>>> {
|
||||
pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3], ctl: &SweepControl) -> Result<Option<Vec<Side>>> {
|
||||
ctl.check()?;
|
||||
let pts = clean(path)?;
|
||||
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<Seg2>], path: &[Vec3]) -
|
||||
if chains.iter().map(Vec::len).sum::<usize>() > 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<Seg2>], 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<Body> {
|
||||
let (pts, st) = ruled_stations(pts)?;
|
||||
fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3], ctl: &SweepControl) -> Result<Body> {
|
||||
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<mt::Wire>> = 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<f64> {
|
||||
let mesh = b.tessellate(0.002).unwrap();
|
||||
let mut out = Vec::new();
|
||||
for e in &mesh.edges {
|
||||
for w in e.windows(2) {
|
||||
let (a, c) = (w[0], w[1]);
|
||||
if (a.z - z) * (c.z - z) <= 0.0 && (a.z - c.z).abs() > 1e-9 {
|
||||
let p = a.lerp(c, (z - a.z) / (c.z - a.z));
|
||||
out.push(p.y.atan2(p.x));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A square swept along a line with a twist: the section halfway along is turned by half the
|
||||
/// twist, the end by all of it; the volume is the prism's.
|
||||
#[test]
|
||||
fn twisted_sweep_turns_in_proportion() {
|
||||
let (h, twist) = (40.0, 90f64.to_radians());
|
||||
let ctl = SweepControl { twist, ..SweepControl::default() };
|
||||
let b = crate::sweep_with(&Plane::XY, &square(5.0), &up(h), &ctl).unwrap();
|
||||
// Twisted walls are saddles: a fine mesh to measure them.
|
||||
let v = b.tessellate(0.0005).unwrap().measure().volume;
|
||||
assert!((v - 100.0 * h).abs() < 2e-3 * 100.0 * h, "volume {v}");
|
||||
// Corners start at 45 deg (and 135, -45, -135): each seam turns by the twist so far.
|
||||
let off = |a: f64, want: f64| {
|
||||
let d = (a - want).rem_euclid(FRAC_PI_2);
|
||||
d.min(FRAC_PI_2 - d)
|
||||
};
|
||||
for (z, turned) in [(h / 2.0, twist / 2.0), (h, twist), (h / 4.0, twist / 4.0)] {
|
||||
let angles = corner_angles(&b, z);
|
||||
assert!(angles.len() >= 4, "seams at {z}: {angles:?}");
|
||||
for a in &angles {
|
||||
assert!(off(*a, FRAC_PI_4 + turned) < 0.2f64.to_radians(), "at {z}: {} vs {}", a.to_degrees(), (FRAC_PI_4 + turned).to_degrees());
|
||||
}
|
||||
}
|
||||
// Right hand about the direction of travel: a positive twist going up turns
|
||||
// counter-clockwise seen from above (the corner at 45 deg reaches 90 deg + 45 deg).
|
||||
let end = corner_angles(&b, h);
|
||||
assert!(end.iter().any(|a| (a - (FRAC_PI_4 + twist)).abs() < 0.2f64.to_radians()), "{end:?}");
|
||||
// One wall per side and two caps, as an untwisted sweep.
|
||||
assert_eq!(b.faces(0.01).unwrap().len(), 6);
|
||||
}
|
||||
|
||||
/// Parallel keeps the profile as drawn along a curved path: the volume is its area times
|
||||
/// the path's rise across the profile's plane.
|
||||
#[test]
|
||||
fn parallel_sweep_keeps_the_profile_square_to_its_normal() {
|
||||
// An eighth of a circle: the path ends leaning 45 deg from the profile's normal.
|
||||
let r = 30.0;
|
||||
let path: Vec<Vec3> = (0..=60)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 60.0 * FRAC_PI_4;
|
||||
Vec3::new(r * (1.0 - t.cos()), 0.0, r * t.sin())
|
||||
})
|
||||
.collect();
|
||||
let rise = r * FRAC_PI_4.sin();
|
||||
let ctl = SweepControl { orientation: SweepOrientation::Parallel, ..SweepControl::default() };
|
||||
let b = sweep_path_with(&Plane::XY, &square(3.0), &path, &ctl).unwrap();
|
||||
let mesh = b.tessellate(0.005).unwrap();
|
||||
let v = mesh.measure().volume;
|
||||
let want = 36.0 * rise;
|
||||
assert!((v - want).abs() < 5e-3 * want, "volume {v} vs {want}");
|
||||
// The end cap is still horizontal: the body tops out flat at the path's end.
|
||||
let top: Vec<&Vec3> = mesh.positions.iter().filter(|p| (p.z - rise).abs() < 1e-6).collect();
|
||||
assert!(top.len() >= 4, "flat top");
|
||||
assert!(mesh.positions.iter().all(|p| p.z <= rise + 1e-6));
|
||||
// A path that turns to run along the profile's plane (a quarter circle) is refused.
|
||||
let quarter: Vec<Vec3> = (0..=60)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 60.0 * FRAC_PI_2;
|
||||
Vec3::new(r * (1.0 - t.cos()), 0.0, r * t.sin())
|
||||
})
|
||||
.collect();
|
||||
assert!(sweep_path_with(&Plane::XY, &square(3.0), &quarter, &ctl).is_err());
|
||||
}
|
||||
|
||||
/// A direction keeps the square's sides lined up with it along a curved path; in a plane
|
||||
/// square to the direction, that is the rotation-minimising frame too.
|
||||
#[test]
|
||||
fn direction_orientation_follows_the_vector() {
|
||||
let r = 30.0;
|
||||
let path: Vec<Vec3> = (0..=60)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 60.0 * FRAC_PI_2;
|
||||
Vec3::new(r * (1.0 - t.cos()), 0.0, r * t.sin())
|
||||
})
|
||||
.collect();
|
||||
let ctl = SweepControl { orientation: SweepOrientation::Direction(Vec3::Y), ..SweepControl::default() };
|
||||
let b = sweep_path_with(&Plane::XY, &square(3.0), &path, &ctl).unwrap();
|
||||
let mesh = b.tessellate(0.005).unwrap();
|
||||
let len = r * FRAC_PI_2;
|
||||
assert!((mesh.measure().volume - 36.0 * len).abs() < 5e-3 * 36.0 * len);
|
||||
// The sides facing ±Y stay at y = ±3.
|
||||
assert!(mesh.positions.iter().all(|p| p.y.abs() <= 3.0 + 1e-6));
|
||||
// The path running along the direction is refused.
|
||||
let along = SweepControl { orientation: SweepOrientation::Direction(Vec3::Z), ..SweepControl::default() };
|
||||
assert!(crate::sweep_with(&Plane::XY, &square(3.0), &up(10.0), &along).is_err());
|
||||
}
|
||||
|
||||
/// Out-of-range options are refused, never a panic; the default options are a plain sweep.
|
||||
#[test]
|
||||
fn sweep_options_refuse_hostile_values() {
|
||||
let bad = [
|
||||
SweepControl { twist: f64::NAN, ..SweepControl::default() },
|
||||
SweepControl { twist: 1e9, ..SweepControl::default() },
|
||||
SweepControl { twist: SweepControl::MAX_TWIST * 1.01, ..SweepControl::default() },
|
||||
SweepControl { scale: 0.0, ..SweepControl::default() },
|
||||
SweepControl { scale: -1.0, ..SweepControl::default() },
|
||||
SweepControl { scale: f64::INFINITY, ..SweepControl::default() },
|
||||
SweepControl { scale: 1e6, ..SweepControl::default() },
|
||||
SweepControl { orientation: SweepOrientation::Direction(Vec3::ZERO), ..SweepControl::default() },
|
||||
SweepControl { orientation: SweepOrientation::Direction(Vec3::new(f64::NAN, 0.0, 1.0)), ..SweepControl::default() },
|
||||
];
|
||||
for ctl in bad {
|
||||
assert!(crate::sweep_with(&Plane::XY, &circle(2.0), &up(10.0), &ctl).is_err(), "{ctl:?}");
|
||||
assert!(crate::sweep_sheet_with(&Plane::XY, std::slice::from_ref(&circle(2.0).outer.segs), &up(10.0), &ctl).is_err(), "{ctl:?}");
|
||||
assert!(sweep_path_with(&Plane::XY, &circle(2.0), &[Vec3::ZERO, Vec3::new(0.0, 0.0, 10.0)], &ctl).is_err(), "{ctl:?}");
|
||||
}
|
||||
// The largest twist still builds (a holed profile: ruled walls through dense stations).
|
||||
let holed = Region2 { outer: square(3.0).outer, holes: vec![Loop2::circle(Vec2::ZERO, 1.0).reversed()] };
|
||||
let max = SweepControl { twist: SweepControl::MAX_TWIST, scale: 0.5, ..SweepControl::default() };
|
||||
assert!(crate::sweep_with(&Plane::XY, &holed, &up(10.0), &max).is_ok());
|
||||
assert!(SweepControl::default().is_default());
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,7 @@ pub enum Kind {
|
||||
/// Sweep a profile along a path of sketch curves.
|
||||
Sweep {
|
||||
operation: usize,
|
||||
opts: crate::dialogs_sweep::SweepOpts,
|
||||
},
|
||||
/// A construction plane offset from a plane.
|
||||
OffsetPlane {
|
||||
@@ -544,8 +545,8 @@ impl Dialog {
|
||||
),
|
||||
"solid.loft" => Dialog::new(Kind::Loft { operation: usize::from(has_bodies) }, vec![SelInput::new("Profiles", PROFILES, true)]),
|
||||
"solid.sweep" => Dialog::new(
|
||||
Kind::Sweep { operation: usize::from(has_bodies) },
|
||||
vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true)],
|
||||
Kind::Sweep { operation: usize::from(has_bodies), opts: crate::dialogs_sweep::SweepOpts::default() },
|
||||
vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true), crate::dialogs_sweep::direction_input()],
|
||||
),
|
||||
"solid.move" => Dialog::new(crate::dialogs_move::kind(0), crate::dialogs_move::inputs(0)),
|
||||
"solid.hole" => Dialog::new(hole_defaults(), vec![SelInput::new("Face", HOLE_FACES, true)]),
|
||||
@@ -2537,7 +2538,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
combo(ui, "lf_op", &OP_LABELS, operation);
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Sweep { operation } => {
|
||||
Kind::Sweep { operation, opts } => {
|
||||
enter |= crate::dialogs_sweep::rows(ui, opts, "sw");
|
||||
row_label(ui, "Operation");
|
||||
combo(ui, "sw_op", &OP_LABELS, operation);
|
||||
ui.end_row();
|
||||
@@ -3548,16 +3550,15 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
let sections: Vec<Value> = sections.into_iter().map(|(sk, v)| json!({"sketch": sk, "profiles": v})).collect();
|
||||
("solid.loft", json!({"sections": sections, "operation": OPS.get(*operation).copied().unwrap_or("new")}))
|
||||
}
|
||||
Kind::Sweep { operation } => {
|
||||
Kind::Sweep { operation, opts } => {
|
||||
need(0, "a profile")?;
|
||||
need(1, "the path")?;
|
||||
let (sketch, idx) = profiles();
|
||||
let path: Vec<String> = sels(d, 1).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect();
|
||||
let path_sketch = curves_sketch(app, &path, sketch.as_u64()).ok_or("the path curves must be in one sketch")?;
|
||||
(
|
||||
"solid.sweep",
|
||||
json!({"sketch": sketch, "profiles": idx, "path_sketch": path_sketch, "path": path, "operation": OPS.get(*operation).copied().unwrap_or("new")}),
|
||||
)
|
||||
let mut p = json!({"sketch": sketch, "profiles": idx, "path_sketch": path_sketch, "path": path, "operation": OPS.get(*operation).copied().unwrap_or("new")});
|
||||
crate::dialogs_sweep::params(app, opts, sels(d, 2).first(), &mut p)?;
|
||||
("solid.sweep", p)
|
||||
}
|
||||
Kind::OffsetPlane { offset } => {
|
||||
need(0, "a plane")?;
|
||||
@@ -4614,9 +4615,13 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Sweep { sketch, profiles, path, operation, targets, .. } => {
|
||||
FeatureKind::Sweep { sketch, profiles, path, operation, targets, orientation, twist, scale, .. } => {
|
||||
let mut d = start("solid.sweep")?;
|
||||
d.kind = Kind::Sweep { operation: op_index(operation) };
|
||||
let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, &feature_frame(s, f.component));
|
||||
d.kind = Kind::Sweep { operation: op_index(operation), opts };
|
||||
if let Some(inp) = d.inputs.get_mut(2) {
|
||||
inp.items = direction.into_iter().collect();
|
||||
}
|
||||
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
|
||||
if let Some(inp) = d.inputs.get_mut(0) {
|
||||
inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
|
||||
|
||||
@@ -25,7 +25,7 @@ pub enum Surf {
|
||||
symmetric: bool,
|
||||
axis: Option<Value>,
|
||||
},
|
||||
Sweep,
|
||||
Sweep(crate::dialogs_sweep::SweepOpts),
|
||||
Loft,
|
||||
Patch,
|
||||
Unstitch,
|
||||
@@ -68,7 +68,7 @@ impl Surf {
|
||||
match self {
|
||||
Surf::Extrude { .. } => "SURFACE EXTRUDE",
|
||||
Surf::Revolve { .. } => "SURFACE REVOLVE",
|
||||
Surf::Sweep => "SURFACE SWEEP",
|
||||
Surf::Sweep(_) => "SURFACE SWEEP",
|
||||
Surf::Loft => "SURFACE LOFT",
|
||||
Surf::Patch => "PATCH",
|
||||
Surf::Unstitch => "UNSTITCH",
|
||||
@@ -110,7 +110,11 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
|
||||
match k {
|
||||
Surf::Extrude { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true)],
|
||||
Surf::Revolve { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Axis", AXES, false)],
|
||||
Surf::Sweep => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Path", CURVES, true)],
|
||||
Surf::Sweep(_) => vec![
|
||||
SelInput::new("Profiles or curves", PROFILES | CURVES, true),
|
||||
SelInput::new("Path", CURVES, true),
|
||||
crate::dialogs_sweep::direction_input(),
|
||||
],
|
||||
Surf::Loft => vec![SelInput::new("Sections (one sketch each)", PROFILES | CURVES, true)],
|
||||
Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)],
|
||||
Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)],
|
||||
@@ -137,7 +141,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
let k = match id {
|
||||
"surface.extrude" => Surf::Extrude { distance: "10 mm".into(), direction: 0 },
|
||||
"surface.revolve" => Surf::Revolve { angle: "360 deg".into(), symmetric: false, axis: None },
|
||||
"surface.sweep" => Surf::Sweep,
|
||||
"surface.sweep" => Surf::Sweep(crate::dialogs_sweep::SweepOpts::default()),
|
||||
"surface.loft" => Surf::Loft,
|
||||
"surface.patch" => Surf::Patch,
|
||||
"surface.unstitch" => Surf::Unstitch,
|
||||
@@ -243,7 +247,8 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
|
||||
ui.checkbox(in_place, "");
|
||||
ui.end_row();
|
||||
}
|
||||
Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Sweep | Surf::Loft => {}
|
||||
Surf::Sweep(opts) => enter |= crate::dialogs_sweep::rows(ui, opts, "ss"),
|
||||
Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Loft => {}
|
||||
}
|
||||
enter
|
||||
}
|
||||
@@ -345,7 +350,7 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
|
||||
}
|
||||
("surface.revolve", p)
|
||||
}
|
||||
Surf::Sweep => {
|
||||
Surf::Sweep(opts) => {
|
||||
need(0, "profiles or curves to sweep")?;
|
||||
need(1, "the path")?;
|
||||
let mut p = sheet_picks(app, items(inputs, 0))?;
|
||||
@@ -354,6 +359,7 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
|
||||
p["path_sketch"] =
|
||||
json!(crate::dialogs::curves_sketch(app, &path, p["sketch"].as_u64()).ok_or("the path curves must be in one other sketch")?);
|
||||
p["path"] = json!(path);
|
||||
crate::dialogs_sweep::params(app, opts, items(inputs, 2).first(), &mut p)?;
|
||||
("surface.sweep", p)
|
||||
}
|
||||
Surf::Loft => {
|
||||
@@ -578,10 +584,17 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
|
||||
}
|
||||
return Some((Kind::Surface(k), ins, extra));
|
||||
}
|
||||
FeatureKind::SurfaceSweep { sketch, profiles, curves, path, .. } => (
|
||||
Surf::Sweep,
|
||||
vec![sheet_sels(app, *sketch, profiles.as_ref(), curves), path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect()],
|
||||
),
|
||||
FeatureKind::SurfaceSweep { sketch, profiles, curves, path, orientation, twist, scale, .. } => {
|
||||
let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, frame);
|
||||
(
|
||||
Surf::Sweep(opts),
|
||||
vec![
|
||||
sheet_sels(app, *sketch, profiles.as_ref(), curves),
|
||||
path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect(),
|
||||
direction.into_iter().collect(),
|
||||
],
|
||||
)
|
||||
}
|
||||
FeatureKind::SurfaceLoft { sections } => {
|
||||
(Surf::Loft, vec![sections.iter().flat_map(|sec| sheet_sels(app, sec.sketch, sec.profiles.as_ref(), &sec.curves)).collect()])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! The options Sweep and Surface Sweep share (#771): how the profile turns along the path, a
|
||||
//! twist over the path's length and the profile's scale at the end.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::doc::{Mat, SweepOrientation};
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::dialogs::{combo, field, row_label};
|
||||
use crate::selection::{AXES, EDGES, SelInput};
|
||||
|
||||
/// The orientation choices, in combo order: perpendicular, parallel, a direction.
|
||||
const ORIENTATIONS: [&str; 3] = ["Turn with Path", "Keep as Drawn", "Follow Direction"];
|
||||
/// The orientation choice that takes a picked direction.
|
||||
const DIRECTION: usize = 2;
|
||||
/// A twist field that means no twist.
|
||||
const NO_TWIST: &str = "0 deg";
|
||||
/// An end scale field that means no scaling.
|
||||
const NO_SCALE: &str = "1";
|
||||
|
||||
/// A sweep dialog's options.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SweepOpts {
|
||||
/// An index into [`ORIENTATIONS`].
|
||||
pub orientation: usize,
|
||||
pub twist: String,
|
||||
pub scale: String,
|
||||
/// A stored direction (world), used while no direction is picked.
|
||||
pub direction: Option<Vec3>,
|
||||
}
|
||||
|
||||
impl Default for SweepOpts {
|
||||
fn default() -> Self {
|
||||
SweepOpts { orientation: 0, twist: NO_TWIST.into(), scale: NO_SCALE.into(), direction: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl SweepOpts {
|
||||
/// A feature's stored options as dialog values; `frame` maps the feature's component frame to
|
||||
/// the world (a stored direction is in the component's frame). A direction along an origin
|
||||
/// axis comes back as a pick of that axis.
|
||||
pub fn of(orientation: &SweepOrientation, twist: &Option<String>, scale: &Option<String>, frame: &Mat) -> (SweepOpts, Option<Sel>) {
|
||||
let mut o = SweepOpts {
|
||||
twist: twist.clone().unwrap_or_else(|| NO_TWIST.into()),
|
||||
scale: scale.clone().unwrap_or_else(|| NO_SCALE.into()),
|
||||
..SweepOpts::default()
|
||||
};
|
||||
let mut pick = None;
|
||||
match orientation {
|
||||
SweepOrientation::Perpendicular => {}
|
||||
SweepOrientation::Parallel => o.orientation = 1,
|
||||
SweepOrientation::Direction(d) => {
|
||||
o.orientation = DIRECTION;
|
||||
let world = solidcraft_engine::doc::apply_vector(frame, *d);
|
||||
match world.normalized().and_then(world_axis) {
|
||||
Some(name) => pick = Some(Sel::Axis { name: name.into() }),
|
||||
None => o.direction = Some(world),
|
||||
}
|
||||
}
|
||||
}
|
||||
(o, pick)
|
||||
}
|
||||
}
|
||||
|
||||
/// The origin axis a direction runs along (its way), if any.
|
||||
fn world_axis(d: Vec3) -> Option<&'static str> {
|
||||
[("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)].into_iter().find(|(_, a)| d.cross(*a).len() < 1e-9 && d.dot(*a) > 0.0).map(|(n, _)| n)
|
||||
}
|
||||
|
||||
/// The input that picks a Follow Direction sweep's direction: an axis, a sketch line or a
|
||||
/// straight edge.
|
||||
pub fn direction_input() -> SelInput {
|
||||
SelInput::new("Direction", AXES | EDGES, false)
|
||||
}
|
||||
|
||||
/// The option rows; true when Enter was pressed in a field.
|
||||
pub fn rows(ui: &mut egui::Ui, o: &mut SweepOpts, id: &str) -> bool {
|
||||
let mut enter = false;
|
||||
row_label(ui, "Orientation");
|
||||
combo(ui, &format!("{id}_orient"), &ORIENTATIONS, &mut o.orientation);
|
||||
ui.end_row();
|
||||
row_label(ui, "Twist");
|
||||
enter |= field(ui, &mut o.twist);
|
||||
ui.end_row();
|
||||
row_label(ui, "End Scale");
|
||||
enter |= field(ui, &mut o.scale);
|
||||
ui.end_row();
|
||||
enter
|
||||
}
|
||||
|
||||
/// The options as command parameters, added to `p`: the orientation, and the twist and end
|
||||
/// scale when they change anything. `picked` is the direction input's selection.
|
||||
pub fn params(app: &SolveApp, o: &SweepOpts, picked: Option<&Sel>, p: &mut Value) -> Result<(), String> {
|
||||
match o.orientation {
|
||||
0 => {}
|
||||
1 => p["orientation"] = json!("parallel"),
|
||||
_ => {
|
||||
let d = match (picked, o.direction) {
|
||||
(Some(x), _) => crate::dialogs::axis_of(app, x).ok_or("the direction must be an axis, a sketch line or a straight edge")?.1,
|
||||
(None, Some(d)) => d,
|
||||
(None, None) => return Err("select a direction (an axis, a sketch line or a straight edge) for the profile to follow".into()),
|
||||
};
|
||||
p["orientation"] = json!([d.x, d.y, d.z]);
|
||||
}
|
||||
}
|
||||
let twist = o.twist.trim();
|
||||
if !twist.is_empty() && twist != NO_TWIST {
|
||||
p["twist"] = json!(twist);
|
||||
}
|
||||
let scale = o.scale.trim();
|
||||
if !scale.is_empty() && scale != NO_SCALE {
|
||||
p["scale"] = json!(scale);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user