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