From 6232fa33c2cd75abd555244b128f10eb178a7f68 Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:26:35 +0200 Subject: [PATCH] Joints: cam and follower coupling (joint.cam) (#1734) Thanks @SychPL. --- ROADMAP.md | 2 +- assets/icons/ui/source/icons.rs | 14 + assets/locales/de.json | 8 + assets/locales/en.json | 8 + assets/locales/fr.json | 8 + assets/locales/ja.json | 8 + assets/locales/ko.json | 8 + assets/locales/ru.json | 8 + assets/locales/uk.json | 8 + assets/locales/zh.json | 8 + .../doc/src/format/pending/cam-couplings.rs | 8 + crates/doc/src/joints.rs | 77 +- crates/doc/src/joints_cam.rs | 1024 +++++++++++++++++ .../src/cmd/component_clone_assembly.rs | 45 +- .../src/cmd/component_clone_assembly_tests.rs | 8 +- crates/engine/src/cmd/joints.rs | 155 ++- crates/engine/src/cmd/joints_cam_tests.rs | 435 +++++++ crates/engine/src/cmd/joints_tests.rs | 3 + crates/ui-egui/src/dialogs_assembly.rs | 15 +- crates/ui-egui/src/dialogs_cam.rs | 197 ++++ crates/ui-egui/src/dialogs_cam_tests.rs | 112 ++ crates/ui-egui/src/dialogs_coupling.rs | 22 +- crates/ui-egui/src/preview.rs | 10 + crates/ui-egui/src/workspace.rs | 1 + docs/book/src/guide/assemblies.md | 27 + skills/solidcraft-mechanisms/SKILL.md | 1 + skills/solidcraft-mechanisms/reference.md | 3 + 27 files changed, 2176 insertions(+), 47 deletions(-) create mode 100644 crates/doc/src/format/pending/cam-couplings.rs create mode 100644 crates/doc/src/joints_cam.rs create mode 100644 crates/engine/src/cmd/joints_cam_tests.rs create mode 100644 crates/ui-egui/src/dialogs_cam.rs create mode 100644 crates/ui-egui/src/dialogs_cam_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 456a400c..1033266f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -54,7 +54,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. | 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 | — | -| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | +| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), 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), 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 | — | | M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 | diff --git a/assets/icons/ui/source/icons.rs b/assets/icons/ui/source/icons.rs index 2f342a8c..56583146 100644 --- a/assets/icons/ui/source/icons.rs +++ b/assets/icons/ui/source/icons.rs @@ -1338,6 +1338,20 @@ pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: C pen.poly(&[(6.0, 10.0), (18.0, 10.0), (18.0, 15.0), (6.0, 15.0)], pen.fill, a); pen.arrow((21.0, 17.0), (21.0, 6.0), a); } + "cam" => { + // A cam on its shaft, its lobe up under the follower it lifts. + let cam: Vec<(f32, f32)> = (0..36) + .map(|k| { + let t = std::f32::consts::TAU * k as f32 / 36.0; + let r = 5.0 + 3.0 * (0.5 + 0.5 * t.sin()).powi(3); + (11.0 + r * t.cos(), 16.0 - r * t.sin()) + }) + .collect(); + pen.poly(&cam, pen.fill, ink); + pen.dot(11.0, 16.0, ink); + pen.poly(&[(9.0, 2.0), (13.0, 2.0), (13.0, 8.0), (9.0, 8.0)], pen.fill, a); + pen.arrow((18.5, 9.5), (18.5, 2.5), a); + } "interference" => { pen.closed(&[(3.0, 5.0), (14.0, 5.0), (14.0, 16.0), (3.0, 16.0)], ink); pen.poly(&[(9.0, 10.0), (14.0, 10.0), (14.0, 16.0), (9.0, 16.0)], ic.interference, ic.interference); diff --git a/assets/locales/de.json b/assets/locales/de.json index 7c2a2440..a7ef9a7b 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -94,6 +94,13 @@ "browser.precision": "Genauigkeit: {precision}", "browser.sketch_name": "Skizze {number}", "browser.units": "Einheiten: {units}", + "cam.flat": "Flachstößel", + "cam.follower": "Stößel", + "cam.knife": "Schneide", + "cam.needs_joints": "braucht ein Gelenk, das sich dreht, und eines, das gleitet oder schwenkt", + "cam.roller": "Rolle", + "cam.roller_pick": "Rolle (optional)", + "cam.tip_pick": "Spitze (optional)", "cmd.appearance_assign": "Erscheinungsbild", "cmd.appearance_library": "Erscheinungsbild-Bibliothek", "cmd.appearance_list": "Erscheinungsbilder auflisten", @@ -197,6 +204,7 @@ "cmd.inspect_minimum_radius": "Mindestradiusanalyse", "cmd.inspect_zebra": "Zebra-Analyse", "cmd.joint_as_built": "Fertigungsgelenk", + "cmd.joint_cam": "Nocken", "cmd.joint_create": "Gelenk", "cmd.joint_delete": "Gelenk löschen", "cmd.joint_drive": "Gelenke antreiben", diff --git a/assets/locales/en.json b/assets/locales/en.json index 7e316a01..b0a05d49 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -295,6 +295,13 @@ "browser.precision": "Precision: {precision}", "browser.sketch_name": "Sketch{number}", "browser.units": "Units: {units}", + "cam.flat": "Flat face", + "cam.follower": "Follower", + "cam.knife": "Knife edge", + "cam.needs_joints": "needs a joint that turns and one that slides or swings", + "cam.roller": "Roller", + "cam.roller_pick": "Roller (optional)", + "cam.tip_pick": "Tip (optional)", "cmd.appearance_assign": "Appearance", "cmd.appearance_library": "Appearance Library", "cmd.appearance_list": "List Appearances", @@ -398,6 +405,7 @@ "cmd.inspect_minimum_radius": "Minimum Radius Analysis", "cmd.inspect_zebra": "Zebra Analysis", "cmd.joint_as_built": "As-Built Joint", + "cmd.joint_cam": "Cam", "cmd.joint_create": "Joint", "cmd.joint_delete": "Delete Joint", "cmd.joint_drive": "Drive Joints", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index bd86bf26..2c853bd5 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -275,6 +275,13 @@ "browser.precision": "Précision : {precision}", "browser.sketch_name": "Esquisse{number}", "browser.units": "Unités : {units}", + "cam.flat": "Plateau", + "cam.follower": "Suiveur", + "cam.knife": "Arête", + "cam.needs_joints": "nécessite une liaison qui tourne et une qui glisse ou pivote", + "cam.roller": "Galet", + "cam.roller_pick": "Galet (facultatif)", + "cam.tip_pick": "Pointe (facultatif)", "cmd.appearance_assign": "Apparence", "cmd.appearance_library": "Bibliothèque d'apparences", "cmd.appearance_list": "Lister les apparences", @@ -378,6 +385,7 @@ "cmd.inspect_minimum_radius": "Analyse du rayon minimal", "cmd.inspect_zebra": "Analyse zébrée", "cmd.joint_as_built": "Liaison telle que construite", + "cmd.joint_cam": "Came", "cmd.joint_create": "Liaison", "cmd.joint_delete": "Supprimer la liaison", "cmd.joint_drive": "Piloter les liaisons", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 0ad9c1e8..9e8c692d 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -295,6 +295,13 @@ "browser.precision": "精度: {precision}", "browser.sketch_name": "スケッチ{number}", "browser.units": "単位: {units}", + "cam.flat": "平面", + "cam.follower": "従動節", + "cam.knife": "ナイフエッジ", + "cam.needs_joints": "回転するジョイントと、スライドまたは揺動するジョイントが必要", + "cam.roller": "ローラー", + "cam.roller_pick": "ローラー (オプション)", + "cam.tip_pick": "先端 (オプション)", "cmd.appearance_assign": "外観", "cmd.appearance_library": "外観ライブラリ", "cmd.appearance_list": "外観を一覧表示", @@ -398,6 +405,7 @@ "cmd.inspect_minimum_radius": "最小半径解析", "cmd.inspect_zebra": "ゼブラ解析", "cmd.joint_as_built": "現位置ジョイント", + "cmd.joint_cam": "カム", "cmd.joint_create": "ジョイント", "cmd.joint_delete": "ジョイントを削除", "cmd.joint_drive": "ジョイントを駆動", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 35aceaf0..df52217a 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -94,6 +94,13 @@ "browser.precision": "정밀도: {precision}", "browser.sketch_name": "스케치{number}", "browser.units": "단위: {units}", + "cam.flat": "평면", + "cam.follower": "종동절", + "cam.knife": "나이프 에지", + "cam.needs_joints": "회전하는 관절과 미끄러지거나 흔들리는 관절이 필요함", + "cam.roller": "롤러", + "cam.roller_pick": "롤러 (선택 사항)", + "cam.tip_pick": "끝점 (선택 사항)", "cmd.appearance_assign": "외관", "cmd.appearance_library": "외관 라이브러리", "cmd.appearance_list": "외관 목록", @@ -197,6 +204,7 @@ "cmd.inspect_minimum_radius": "최소 반경 분석", "cmd.inspect_zebra": "얼룩말줄무늬 분석", "cmd.joint_as_built": "조립 상태 관절", + "cmd.joint_cam": "캠", "cmd.joint_create": "관절", "cmd.joint_delete": "관절 삭제", "cmd.joint_drive": "관절 구동", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 5df35119..b50bfd44 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -251,6 +251,13 @@ "browser.precision": "Точность: {precision}", "browser.sketch_name": "Эскиз{number}", "browser.units": "Единицы: {units}", + "cam.flat": "Плоский", + "cam.follower": "Толкатель", + "cam.knife": "Острие", + "cam.needs_joints": "нужны вращающееся и скользящее или качающееся сопряжения", + "cam.roller": "Ролик", + "cam.roller_pick": "Ролик (необязательно)", + "cam.tip_pick": "Острие (необязательно)", "cmd.appearance_assign": "Внешний вид", "cmd.appearance_library": "Библиотека внешних видов", "cmd.appearance_list": "Список внешних видов", @@ -354,6 +361,7 @@ "cmd.inspect_minimum_radius": "Анализ минимального радиуса", "cmd.inspect_zebra": "Зебра-анализ", "cmd.joint_as_built": "Сопряжение на месте", + "cmd.joint_cam": "Кулачок", "cmd.joint_create": "Сопряжение", "cmd.joint_delete": "Удалить сопряжение", "cmd.joint_drive": "Привод сопряжений", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index d906673d..3b931791 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -295,6 +295,13 @@ "browser.precision": "Точність: {precision}", "browser.sketch_name": "Ескіз{number}", "browser.units": "Одиниці: {units}", + "cam.flat": "Плоский", + "cam.follower": "Штовхач", + "cam.knife": "Вістря", + "cam.needs_joints": "потрібні обертове й ковзне або хитне з'єднання", + "cam.roller": "Ролик", + "cam.roller_pick": "Ролик (необов'язково)", + "cam.tip_pick": "Вістря (необов'язково)", "cmd.appearance_assign": "Вигляд", "cmd.appearance_library": "Бібліотека виглядів", "cmd.appearance_list": "Список виглядів", @@ -398,6 +405,7 @@ "cmd.inspect_minimum_radius": "Аналіз мінімального радіуса", "cmd.inspect_zebra": "Зебра-аналіз", "cmd.joint_as_built": "З'єднання за поточним положенням", + "cmd.joint_cam": "Кулачок", "cmd.joint_create": "З'єднання", "cmd.joint_delete": "Видалити з'єднання", "cmd.joint_drive": "Керувати з'єднаннями", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index e04cf783..34e1af5a 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -295,6 +295,13 @@ "browser.precision": "精度: {precision}", "browser.sketch_name": "草图{number}", "browser.units": "单位: {units}", + "cam.flat": "平底", + "cam.follower": "从动件", + "cam.knife": "尖顶", + "cam.needs_joints": "需要一个旋转接头和一个滑动或摆动接头", + "cam.roller": "滚子", + "cam.roller_pick": "滚子(可选)", + "cam.tip_pick": "尖端(可选)", "cmd.appearance_assign": "外观", "cmd.appearance_library": "外观库", "cmd.appearance_list": "列出外观", @@ -398,6 +405,7 @@ "cmd.inspect_minimum_radius": "最小半径分析", "cmd.inspect_zebra": "斑马纹分析", "cmd.joint_as_built": "现状接头", + "cmd.joint_cam": "凸轮", "cmd.joint_create": "接头", "cmd.joint_delete": "删除接头", "cmd.joint_drive": "驱动接头", diff --git a/crates/doc/src/format/pending/cam-couplings.rs b/crates/doc/src/format/pending/cam-couplings.rs new file mode 100644 index 00000000..1f5c7917 --- /dev/null +++ b/crates/doc/src/format/pending/cam-couplings.rs @@ -0,0 +1,8 @@ +//! A motion link can carry a `cam` coupling (`joint.cam`): the cam's `profile` (a sketch's closed +//! profile by its outer loop's curve ids, or picked edges of the cam's bodies with their names), +//! the `follower` (`knife`, `roller` with a `radius`, or `flat`), an optional `tip` (a joint origin +//! where the follower touches) and `reverse` (the side it rides from). The link then also saves +//! `lift`, the follower's values over one turn of the cam, worked out from the profile again +//! whenever the design changes. Older designs need no transformation: a link without them is a +//! plain motion link, as it always was; 0.13.x asks for an update instead of reading a cam's +//! follower as a plain link of ratio 1. diff --git a/crates/doc/src/joints.rs b/crates/doc/src/joints.rs index f526d4c7..c8fae80a 100644 --- a/crates/doc/src/joints.rs +++ b/crates/doc/src/joints.rs @@ -1,8 +1,8 @@ //! Joints between occurrences: joint origins (snap frames on component geometry, re-found on //! every solve so they follow edits), joints (rigid, revolute, slider, cylindrical, pin-slot, //! planar, ball) with their motion values and limits, motion links (gears, racks and screws are -//! links whose ratio comes from radii or a pitch), and the solver that places occurrences from -//! the joint graph. +//! links whose ratio comes from radii or a pitch; a cam's follower follows a table worked out from +//! its profile, `joints_cam.rs`), and the solver that places occurrences from the joint graph. //! //! A joint makes B's joint frame coincide with A's frame moved by the joint's motion: //! `T_B · F_b = T_A · F_a · M(q) · Flip`, so `T_B = T_A · F_a · M(q) · Flip · F_b⁻¹`, where `T` @@ -140,7 +140,8 @@ pub struct Joint { pub suppressed: bool, } -/// Value of joint `b` (index `ib`) = ratio · value of joint `a` (index `ia`) + offset. +/// Value of joint `b` (index `ib`) = ratio · value of joint `a` (index `ia`) + offset, or for a +/// cam, its `lift` table at a's value + offset. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct MotionLink { pub a: u64, @@ -152,11 +153,29 @@ pub struct MotionLink { pub ratio: f64, #[serde(default)] pub offset: f64, - /// Gears, a rack and pinion or a screw: what the ratio comes from. The ratio is worked out - /// from it again whenever the design changes (`coupling_ratio`), so it follows the radii and - /// the pitch; the last one worked out is kept in `ratio`. + /// Gears, a rack and pinion, a screw or a cam: what the ratio (or a cam's table) comes from. + /// It is worked out from it again whenever the design changes (`coupling_values`), so it + /// follows the radii, the pitch and the cam's profile; the last one worked out is kept in + /// `ratio` and `lift`. #[serde(default, skip_serializing_if = "Option::is_none")] pub coupling: Option, + /// A cam's follower values over one turn of a (`cam::CAM_SAMPLES` of them, in b's units, the + /// offset left out); empty for every other link. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub lift: Vec, +} + +impl MotionLink { + /// b's value for a's value `x` (before b's limits). A cam's table that is not whole (a + /// hand-edited file) gives no value, so b keeps where it is; a table on any other link is + /// not read. + pub fn follow(&self, x: f64) -> f64 { + match &self.coupling { + Some(Coupling::Cam(_)) if self.lift.len() == cam::CAM_SAMPLES => cam::lift_at(&self.lift, x) + self.offset, + Some(Coupling::Cam(_)) => f64::NAN, + _ => self.ratio * x + self.offset, + } + } } /// A length a coupling's ratio comes from: millimetres, an expression over the design's @@ -199,14 +218,22 @@ pub enum Coupling { #[serde(default, skip_serializing_if = "std::ops::Not::not")] reverse: bool, }, + /// A cam on a revolute joint (a) and its follower (b, a slider or a revolute): b rides the + /// cam's profile (`joints_cam.rs`). + Cam(cam::Cam), } impl Coupling { - /// The lengths the ratio comes from: a's gear and b's, or the pinion's radius or the pitch. + /// The lengths the ratio comes from: a's gear and b's, the pinion's radius, the pitch, or a + /// cam's roller radius. pub fn sizes(&self) -> Vec<&Size> { match self { Coupling::Gear { ra, rb, .. } => vec![ra, rb], Coupling::RackPinion { radius: s, .. } | Coupling::Screw { pitch: s, .. } => vec![s], + Coupling::Cam(c) => match &c.follower { + cam::Follower::Roller { radius } => vec![radius], + _ => Vec::new(), + }, } } @@ -214,6 +241,10 @@ impl Coupling { match self { Coupling::Gear { ra, rb, .. } => vec![ra, rb], Coupling::RackPinion { radius: s, .. } | Coupling::Screw { pitch: s, .. } => vec![s], + Coupling::Cam(c) => match &mut c.follower { + cam::Follower::Roller { radius } => vec![radius], + _ => Vec::new(), + }, } } } @@ -231,14 +262,18 @@ fn size_of(doc: &Document, st: &ModelState, s: &Size) -> Result { if v.is_finite() && v > 1e-9 && v <= MAX_SIZE { Ok(v) } else { - Err(DocError::Invalid(format!("a gear, rack or screw needs a positive length, not {v}"))) + Err(DocError::Invalid(format!("a gear, rack, screw or roller needs a positive length, not {v}"))) } } -/// The ratio a link's coupling gives it (b's value per a's, in radians and mm), worked out from -/// the geometry and parameters it is defined by; `None` for a plain link. -pub fn coupling_ratio(doc: &Document, st: &ModelState, l: &MotionLink) -> Option> { - l.coupling.as_ref().map(|c| coupled(doc, st, l, c)) +/// The ratio a link's coupling gives it (b's value per a's, in radians and mm) and a cam's table +/// (`MotionLink::lift`; empty for the others), worked out from the geometry and parameters it is +/// defined by; `None` for a plain link. +pub fn coupling_values(doc: &Document, st: &ModelState, l: &MotionLink) -> Option)>> { + l.coupling.as_ref().map(|c| match c { + Coupling::Cam(c) => cam::table(doc, st, l, c).map(|t| (1.0, t)), + c => coupled(doc, st, l, c).map(|r| (r, Vec::new())), + }) } fn coupled(doc: &Document, st: &ModelState, l: &MotionLink, c: &Coupling) -> Result { @@ -260,6 +295,8 @@ fn coupled(doc: &Document, st: &ModelState, l: &MotionLink, c: &Coupling) -> Res } Coupling::RackPinion { radius, reverse } => Ok(travel(size_of(doc, st, radius)?)? * sign(*reverse)), Coupling::Screw { pitch, reverse } => Ok(travel(size_of(doc, st, pitch)? / std::f64::consts::TAU)? * sign(*reverse)), + // A cam's follower follows its table (`coupling_values`), not a ratio. + Coupling::Cam(_) => Ok(1.0), } } @@ -491,7 +528,11 @@ fn apply_links(asm: &Assembly, vals: &mut Values) { let before = linked(vals); for l in &asm.links { let Some(src) = vals.get(&l.a).and_then(|q| q.get(l.ia)).copied() else { continue }; - let mut next = l.ratio * src + l.offset; + let mut next = l.follow(src); + // A hand-edited table (or source) that gives no number leaves b where it is. + if !next.is_finite() { + continue; + } if let Some((lo, hi)) = limits.get(&(l.b, l.ib)) { next = next.max(*lo).min(*hi); } @@ -1388,6 +1429,9 @@ impl Document { } } +#[path = "joints_cam.rs"] +pub mod cam; + #[path = "joints_reach.rs"] mod reach; pub use reach::{REACH_TOL, Reach, Reached, reach}; @@ -1425,7 +1469,8 @@ mod tests { suppressed: false, }) .collect(); - let links = (1..n).rev().map(|a| MotionLink { a, ia: 0, b: a + 1, ib: 0, ratio: 2.0, offset: 0.0, coupling: None }).collect(); + let links = + (1..n).rev().map(|a| MotionLink { a, ia: 0, b: a + 1, ib: 0, ratio: 2.0, offset: 0.0, coupling: None, lift: Vec::new() }).collect(); Assembly { joints, links, ..Assembly::default() } } @@ -1477,8 +1522,8 @@ mod tests { } let st = ModelState::default(); let ratio = |a: u64, b: u64, c: Coupling| { - let l = MotionLink { a, ia: 0, b, ib: 0, ratio: 0.0, offset: 0.0, coupling: Some(c) }; - coupling_ratio(&doc, &st, &l).map(|r| r.map_err(|e| e.to_string())) + let l = MotionLink { a, ia: 0, b, ib: 0, ratio: 0.0, offset: 0.0, coupling: Some(c), lift: Vec::new() }; + coupling_values(&doc, &st, &l).map(|r| r.map(|(ratio, _)| ratio).map_err(|e| e.to_string())) }; let len = Size::Length; assert_eq!(ratio(1, 2, Coupling::Gear { ra: len(10.0), rb: len(40.0), internal: false, reverse: false }), Some(Ok(-0.25))); diff --git a/crates/doc/src/joints_cam.rs b/crates/doc/src/joints_cam.rs new file mode 100644 index 00000000..6d8454ae --- /dev/null +++ b/crates/doc/src/joints_cam.rs @@ -0,0 +1,1024 @@ +//! Cams (`joint.cam`): a revolute joint (the cam) drives a follower joint, a slider (a translating +//! follower) or a revolute (an oscillating follower), so that the follower rides the cam's +//! profile. The follower's value over one turn of the cam is worked out from the profile (a closed +//! sketch profile or a closed loop of the cam body's edges) and the follower (a knife edge, a +//! roller or a flat face) and kept on the motion link as a table, which `MotionLink::follow` +//! interpolates; the table is worked out again whenever the design changes. +//! +//! Everything is worked out in the cam's fixed frame C: the frame of the cam joint's other side, +//! in which the cam's profile turns by the joint's value about z. The follower's line (or pivot) is +//! its joint's A-side frame, which stays put whichever side of that joint moves. The follower comes +//! toward the cam from where it starts (a translating follower from far along its line, an +//! oscillating one from its arm pointing away from the cam's axis) and stops where it first +//! touches the profile: the follower stays in contact, as a spring would hold it. + +use std::f64::consts::TAU; + +use serde::{Deserialize, Serialize}; +use solidcraft_geom::{Vec2, Vec3}; + +use super::{JointKind, JointOrigin, MotionLink, Size, component_bodies, component_of, flip_of, occurrence_world, size_of}; +use crate::{DocError, Document, IDENTITY, Mat, ModelState, Result, apply_point, apply_vector, mat_inverse, mat_mul}; + +/// Samples of a cam's table over one turn of the cam (one per degree). +pub const CAM_SAMPLES: usize = 360; +/// Points a cam's profile is sampled into at most. +pub const MAX_PROFILE_POINTS: usize = 20_000; +/// Edges a cam's profile is picked from at most. +pub const MAX_PROFILE_EDGES: usize = 64; + +/// Where a cam's profile comes from. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum CamProfile { + /// A closed profile of a sketch (feature id) in the cam's component, by the ids of its outer + /// loop's curves (the profile sharing most of them; with none given, the profile with the + /// largest outer loop): that outer loop. + Sketch { + sketch: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + curves: Vec, + }, + /// Edges of the cam's bodies forming one closed loop: points on them in the component's + /// coordinates and the edges' persistent names (found first). + Edges { + occurrence: u64, + picks: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + names: Vec, + }, +} + +/// What touches the cam. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Follower { + /// A point (a knife edge seen along the cam's axis). + #[default] + Knife, + /// A roller of radius `radius` about the follower's point. + Roller { radius: Size }, + /// A flat face square to a translating follower's line. + Flat, +} + +/// A cam coupling's definition. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Cam { + pub profile: CamProfile, + #[serde(default)] + pub follower: Follower, + /// An oscillating follower's knife tip or roller centre, on the follower. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tip: Option, + /// A translating follower rides the cam from the −z side of its line; an oscillating one + /// comes to the cam turning the other way. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub reverse: bool, +} + +/// A value of a periodic table over one turn of `x` (radians): a monotone cubic through the +/// samples (Hermite, with central slopes limited as Fritsch and Carlson do), so it has no kinks +/// and never goes past its two samples (a follower on a dwell stays on it up to a rise). NaN for +/// an empty table or a non-finite `x`. +pub fn lift_at(table: &[f64], x: f64) -> f64 { + let n = table.len(); + let u = (x.rem_euclid(TAU) / TAU * n as f64).rem_euclid(n as f64); + if n == 0 || !u.is_finite() { + return f64::NAN; + } + let i = u.floor(); + let f = u - i; + let i = i as usize; + let at = |k: usize| table.get(k % n).copied().unwrap_or(f64::NAN); + let (p0, p1, p2, p3) = (at(i + n - 1), at(i), at(i + 1), at(i + 2)); + // The slope at a sample from the steps either side: none where they disagree (an extremum or + // a flat), else their mean, at most three times the smaller. + let slope = |a: f64, b: f64| if a * b <= 0.0 { 0.0 } else { 0.5 * (a + b).signum() * (a + b).abs().min(6.0 * a.abs().min(b.abs())) }; + let (m1, m2) = (slope(p1 - p0, p2 - p1), slope(p2 - p1, p3 - p2)); + let (f2, f3) = (f * f, f * f * f); + (2.0 * f3 - 3.0 * f2 + 1.0) * p1 + (f3 - 2.0 * f2 + f) * m1 + (3.0 * f2 - 2.0 * f3) * p2 + (f3 - f2) * m2 +} + +fn rot(v: Vec2, a: f64) -> Vec2 { + let (s, c) = a.sin_cos(); + Vec2::new(v.x * c - v.y * s, v.x * s + v.y * c) +} + +/// Where a translating follower coming along `-d` from far along its line through `g` first +/// touches the closed polygon `poly`: the largest `t` of its point `g + t·d` (its roller's centre +/// for `r > 0`; for `flat`, its face square to `d`). `None` when it misses the profile. +pub fn first_contact_along(poly: &[Vec2], g: Vec2, d: Vec2, r: f64, flat: bool) -> Option { + let n = d.perp(); + let mut best: Option = None; + let mut take = |t: f64| { + if t.is_finite() && best.is_none_or(|b| t > b) { + best = Some(t); + } + }; + if flat { + for p in poly { + take((*p - g).dot(d)); + } + return best; + } + let count = poly.len(); + for i in 0..count { + let (Some(&p), Some(&q)) = (poly.get(i), poly.get((i + 1) % count)) else { continue }; + let (a, b) = ((p - g).dot(n), (q - g).dot(n)); + // Edges wholly outside the band the follower sweeps. + if a.min(b) > r || a.max(b) < -r { + continue; + } + if r <= 0.0 { + if a == b { + // Along the line: both ends. + take((p - g).dot(d)); + take((q - g).dot(d)); + } else { + let k = a / (a - b); + take((p - g).dot(d) + (q - p).dot(d) * k); + } + continue; + } + // The roller's centre a radius from the edge: on its two parallels (the foot within the + // edge) or about its start (each vertex starts one edge). + let len = p.dist(q); + if let Some(e) = (q - p).normalized() { + let m = e.perp(); + let dm = d.dot(m); + if dm.abs() > 1e-15 { + for side in [r, -r] { + let t = (side - (g - p).dot(m)) / dm; + let foot = (g + d * t - p).dot(e); + if (0.0..=len).contains(&foot) { + take(t); + } + } + } + } + if a.abs() <= r { + take((p - g).dot(d) + (r * r - a * a).max(0.0).sqrt()); + } + } + best +} + +/// The angles in [0, 2π) where an oscillating follower's tip touches the closed polygon `poly`: +/// its tip (a roller's centre for `r > 0`) at `arm` from the pivot `pivot`, turned from the angle +/// `start` the way `turn` says (+1 counter-clockwise), sorted. +fn touches_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, start: f64, turn: f64) -> Vec { + let tip = |a: f64| pivot + Vec2::from_angle(a) * arm; + let count = poly.len(); + let edge = |i: usize| Some((*poly.get(i)?, *poly.get((i + 1) % count)?)); + let mut out = Vec::new(); + let mut take = |a: f64, keep: &dyn Fn(Vec2) -> bool| { + let turned = (turn * (a - start)).rem_euclid(TAU); + if turned.is_finite() && turned < TAU && keep(tip(a)) { + out.push(turned); + } + }; + // Angles of the tip's direction u where u·w = h: both solutions. + let meet = |w: Vec2, h: f64| -> Vec { + let len = w.len(); + if len <= 0.0 || !(h / len).is_finite() || (h / len).abs() > 1.0 { + return Vec::new(); + } + let (base, spread) = (w.angle(), (h / len).clamp(-1.0, 1.0).acos()); + vec![base + spread, base - spread] + }; + for (p, q) in (0..count).filter_map(edge) { + let near = dist_to_segment(pivot, p, q); + let far = (p - pivot).len().max((q - pivot).len()); + if near > arm + r || far < arm - r { + continue; + } + let len = p.dist(q); + let Some(e) = (q - p).normalized() else { continue }; + let m = e.perp(); + // On a parallel of the edge a radius away (the edge itself for a knife), the foot within it. + for side in if r > 0.0 { vec![r, -r] } else { vec![0.0] } { + for a in meet(m * arm, side - (pivot - p).dot(m)) { + take(a, &|t: Vec2| (0.0..=len).contains(&(t - p).dot(e))); + } + } + if r > 0.0 { + // A radius from the edge's start: |pivot + arm·u − p| = r. + let w = p - pivot; + for a in meet(w, (arm * arm + w.len2() - r * r) / (2.0 * arm)) { + take(a, &|_| true); + } + } + } + out.sort_by(f64::total_cmp); + out +} + +/// Whether an oscillating follower's tip, turned `turned` from `start` the way `turn` says, +/// overlaps the profile by more than `slack`. +fn overlaps_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, (start, turn): (f64, f64), turned: f64, slack: f64) -> bool { + let at = pivot + Vec2::from_angle(start + turn * turned) * arm; + let n = poly.len(); + let gap = + (0..n).filter_map(|i| Some((*poly.get(i)?, *poly.get((i + 1) % n)?))).map(|(p, q)| dist_to_segment(at, p, q)).fold(f64::INFINITY, f64::min); + // Inside counts only clear of the boundary: a knife on an edge touches it. + gap < r - slack || (gap > slack && inside(poly, at)) +} + +/// Where an oscillating follower first touches the closed polygon `poly` turning from `start` +/// (the angle it turns through, in (0, 2π)): see `touches_turning`. `None` when it misses the +/// profile or starts on it. +pub fn first_contact_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, start: f64, turn: f64) -> Option { + if overlaps_turning(poly, pivot, arm, r, (start, turn), 0.0, -f64::EPSILON) { + return None; + } + touches_turning(poly, pivot, arm, r, start, turn).into_iter().find(|t| *t > 0.0) +} + +/// Where an oscillating follower that touched the profile at `from` (turned from `start`) is +/// after the cam turned on a little: pushed back to where the profile now lets it be when its tip +/// overlaps the profile (by more than `slack`), else on to where it next touches. It stays on the +/// part of the profile it rides, however the profile crosses its tip's circle elsewhere. +pub fn next_contact_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, (start, turn): (f64, f64), from: f64, slack: f64) -> Option { + let touches = touches_turning(poly, pivot, arm, r, start, turn); + // A touch where the tip is clear of the rest of the profile (a roller inside the profile is a + // radius from the parallels of the edges near the one it overlaps too). + let clear = |t: &f64| !overlaps_turning(poly, pivot, arm, r, (start, turn), *t, slack); + if overlaps_turning(poly, pivot, arm, r, (start, turn), from, slack) { + touches.into_iter().rev().filter(|t| *t < from).find(clear) + } else { + touches.into_iter().filter(|t| *t >= from - 1e-12).find(clear) + } +} + +fn dist_to_segment(x: Vec2, p: Vec2, q: Vec2) -> f64 { + let d = q - p; + let t = if d.len2() > 0.0 { ((x - p).dot(d) / d.len2()).clamp(0.0, 1.0) } else { 0.0 }; + x.dist(p + d * t) +} + +/// Even-odd containment in a closed polygon. +fn inside(poly: &[Vec2], x: Vec2) -> bool { + let n = poly.len(); + let mut inside = false; + for i in 0..n { + let (Some(a), Some(b)) = (poly.get(i), poly.get((i + 1) % n)) else { continue }; + if (a.y > x.y) != (b.y > x.y) && x.x < a.x + (x.y - a.y) / (b.y - a.y) * (b.x - a.x) { + inside = !inside; + } + } + inside +} + +/// Everything a cam's table is worked out from, in the cam's fixed frame C. +struct Setup { + /// The profile at cam value 0. + poly: Vec, + /// +1 when the cam turns by +value about C's z (it is the joint's b side), −1 otherwise. + turn: f64, + /// A translating follower's line (a point and its unit direction, the slider's +z), or an + /// oscillating follower's pivot. + at: Vec2, + dir: Vec2, + /// An oscillating follower's arm (pivot to tip) where it is now. + arm: Option, + /// The side a translating follower rides from (+1: from +dir), or the way an oscillating one + /// turns toward the cam (+1: counter-clockwise). + side: f64, + /// b's value per mm of the follower's travel along `dir`, or per radian of its turn about z. + gain: f64, + radius: f64, + flat: bool, + size: f64, + /// C in the world, and the occurrence the follower's joint moves. + frame: Mat, + moving: u64, +} + +fn joint(doc: &Document, id: u64) -> Result<&super::Joint> { + doc.assembly.joints.iter().find(|j| j.id == id).ok_or_else(|| DocError::Unknown(format!("joint {id}"))) +} + +fn world(doc: &Document, occ: u64) -> Mat { + if occ == 0 { IDENTITY } else { occurrence_world(doc, occ) } +} + +/// The profile's points in its component's coordinates, the occurrence placing them, and the +/// profile's size. `fa`, `fb`: the cam joint's frames (picked edges continue along the edges in +/// the plane square to the cam's axis). +fn profile_points(doc: &Document, st: &ModelState, p: &CamProfile, cam: &super::Joint, (fa, fb): (&Mat, &Mat)) -> Result<(Vec, u64, f64)> { + let bad = |m: &str| DocError::Invalid(format!("cam: {m}")); + match p { + CamProfile::Sketch { sketch, curves } => { + let sk = st.sketches.iter().find(|s| s.feature == *sketch).ok_or_else(|| bad(&format!("no sketch {sketch}")))?; + let comp = doc.feature(*sketch).map(|f| f.component).unwrap_or(0); + let sides: Vec = + [cam.b.occurrence, cam.a.occurrence].into_iter().filter(|o| component_of(doc, *o).is_ok_and(|c| c == comp)).collect(); + let occ = match sides.as_slice() { + [o] => *o, + [] => return Err(bad("the profile's sketch must be in the component the cam joint turns")), + _ => return Err(bad("both sides of the cam joint are that sketch's component: pick the cam's edges instead")), + }; + let lp = &sketch_profile(sk, curves).ok_or_else(|| bad("the sketch has no closed profile (or the cam's profile is gone)"))?.region.outer; + let too_many = || bad(&format!("the profile is too detailed (more than {MAX_PROFILE_POINTS} points)")); + let size = bounds_2d(&sampled(lp, 0.1).ok_or_else(too_many)?); + let pts = sampled(lp, tolerance(size)).ok_or_else(too_many)?; + let pl = &sk.plane; + Ok((pts.iter().map(|q| pl.origin + pl.x * q.x + pl.y * q.y).collect(), occ, size)) + } + CamProfile::Edges { occurrence, picks, names } => { + if *occurrence != cam.a.occurrence && *occurrence != cam.b.occurrence { + return Err(bad("the profile's edges must be on the part the cam joint turns")); + } + if picks.is_empty() || picks.len() > MAX_PROFILE_EDGES || !picks.iter().all(|p| p.is_finite()) { + return Err(bad(&format!("pick 1 to {MAX_PROFILE_EDGES} edges"))); + } + let comp = component_of(doc, *occurrence)?; + let bodies = component_bodies(doc, st, comp); + let size = bodies.iter().map(|b| b.body.size()).fold(0.0, f64::max); + if bodies.is_empty() || !size.is_finite() || size <= 0.0 { + return Err(bad("the cam has no body")); + } + // Each body sampled to its own size (a large part beside the cam leaves the cam fine). + // Each body's edges with the faces beside them. + let meshes: Vec = + bodies.iter().map(|b| b.body.tessellate(tolerance(b.body.size()))).collect::>()?; + let edges: Vec<(usize, &Vec, &[u32])> = meshes + .iter() + .enumerate() + .flat_map(|(k, m)| m.edges.iter().enumerate().map(move |(i, e)| (k, e, m.edge_faces.get(i).map(Vec::as_slice).unwrap_or(&[])))) + .collect(); + let mut lines: Vec<&Vec> = Vec::new(); + let mut sides: Vec<(usize, &[u32])> = Vec::new(); + for (k, pick) in picks.iter().enumerate() { + let mut at = *pick; + // A named edge is looked for on the body that has it, and only there. + let mut on: Option = None; + if let Some(name) = names.get(k).filter(|n| !n.is_empty()) { + let (body, p) = bodies + .iter() + .enumerate() + .find_map(|(i, b)| crate::naming::find_edge(b, name, *pick).ok().map(|(p, _)| (i, p))) + .ok_or_else(|| bad(&format!("the edge `{name}` no longer exists (edit the cam to pick again)")))?; + (on, at) = (Some(body), p); + } + let nearest = edges + .iter() + .filter(|e| on.is_none_or(|b| e.0 == b)) + .map(|e| { + let d = e.1.windows(2).filter_map(|w| match w { + [p, q] => Some(at.dist_to_segment(*p, *q)), + _ => None, + }); + (d.fold(f64::MAX, f64::min), e) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .filter(|(d, _)| *d <= 1e-3 * size + 1e-3) + .ok_or_else(|| bad("no edge there"))?; + if !lines.iter().any(|l| std::ptr::eq(*l, nearest.1.1)) { + lines.push(nearest.1.1); + sides.push((nearest.1.0, nearest.1.2)); + } + } + // The other edges in the picked edges' plane square to the cam's axis, beside a face of + // theirs: a loop continues along them (a circle the kernel keeps as two arcs is picked + // with one click), and not onto another part touching it. + let f = if *occurrence == cam.b.occurrence { fb } else { fa }; + let axis = Vec3::new(f[2][0], f[2][1], f[2][2]); + let level = lines.first().and_then(|l| l.first()).map(|p| p.dot(axis)).unwrap_or(0.0); + // Joins and the plane to the picked edges' own size, not their bodies' (a small cam in a + // component with a large part must not close a gap of its own size). + let span = extent(lines.iter().flat_map(|l| l.iter())); + let (flat, join) = (1e-6 * span + 1e-9, 1e-6 * span + 1e-9); + let beside = |(k, faces): (usize, &[u32])| sides.iter().any(|(sk, sf)| *sk == k && sf.iter().any(|f| faces.contains(f))); + let pool: Vec<&Vec> = edges + .iter() + .filter(|(k, e, faces)| { + !lines.iter().any(|l| std::ptr::eq(*l, *e)) && beside((*k, faces)) && e.iter().all(|p| (p.dot(axis) - level).abs() <= flat) + }) + .map(|(_, e, _)| *e) + .collect(); + let loop_ = chain(&lines, &pool, join) + .ok_or_else(|| bad(&format!("the picked edges must form one closed loop (of at most {MAX_PROFILE_POINTS} points)")))?; + // The profile's own size (not its bodies'), for the checks on its plane and area. + let size = extent(loop_.iter()); + Ok((loop_, *occurrence, size)) + } + } +} + +/// Polylines joined end to end into one closed loop, without the repeated joints and the closing +/// point: every one of `lines` once, either way round, continued along `pool` where no line goes +/// on. `None` when they don't close, or close leaving a line out. +fn chain(lines: &[&Vec], pool: &[&Vec], tol: f64) -> Option> { + let (first, rest) = lines.split_first()?; + let mut out: Vec = first.to_vec(); + let (mut used, mut taken) = (vec![false; rest.len()], vec![false; pool.len()]); + // The pool's ends by their cell of a `tol` grid, so the next one is found among a few. + let cell = |p: Vec3| -> Option<[i64; 3]> { + let c = [p.x / tol, p.y / tol, p.z / tol]; + c.iter().all(|v| v.is_finite() && v.abs() < 1e15).then(|| c.map(|v| v.round() as i64)) + }; + let mut ends: std::collections::BTreeMap<[i64; 3], Vec<(usize, bool)>> = std::collections::BTreeMap::new(); + for (k, l) in pool.iter().enumerate() { + for (p, flip) in [(l.first(), false), (l.last(), true)] { + if let Some(c) = p.and_then(|p| cell(*p)) { + ends.entry(c).or_default().push((k, flip)); + } + } + } + let near_end = |end: Vec3, taken: &[bool]| -> Option<(usize, bool)> { + let c = cell(end)?; + for d in 0..27i64 { + let key = [c[0] + d % 3 - 1, c[1] + d / 3 % 3 - 1, c[2] + d / 9 - 1]; + for &(k, flip) in ends.get(&key).into_iter().flatten() { + let l = pool.get(k)?; + let p = if flip { l.last()? } else { l.first()? }; + if taken.get(k) == Some(&false) && p.dist(end) <= tol { + return Some((k, flip)); + } + } + } + None + }; + // The next picked polyline on from `end` (a few at most), and whether it runs backwards. + let next = |end: Vec3, used: &[bool]| { + rest.iter().enumerate().filter(|(k, _)| used.get(*k) == Some(&false)).find_map(|(k, l)| { + if l.first()?.dist(end) <= tol { + Some((k, false)) + } else if l.last()?.dist(end) <= tol { + Some((k, true)) + } else { + None + } + }) + }; + for _ in 0..rest.len() + pool.len() { + let end = *out.last()?; + if out.len() > 3 && out.first()?.dist(end) <= tol { + break; + } + if out.len() > MAX_PROFILE_POINTS { + return None; + } + let (l, flip) = match next(end, &used) { + Some((k, flip)) => { + *used.get_mut(k)? = true; + (*rest.get(k)?, flip) + } + None => { + let (k, flip) = near_end(end, &taken)?; + *taken.get_mut(k)? = true; + (*pool.get(k)?, flip) + } + }; + if flip { + out.extend(l.iter().rev().skip(1)); + } else { + out.extend(l.iter().skip(1)); + } + } + if out.len() < 4 || out.len() > MAX_PROFILE_POINTS + 1 || out.first()?.dist(*out.last()?) > tol || !used.iter().all(|u| *u) { + return None; + } + out.pop(); + Some(out) +} + +/// The profile of a solved sketch a cam's `curves` name: the one sharing most of them (the larger +/// on a tie), or with none named the one with the largest outer loop. `None` when no profile +/// shares at least half of them (most of the cam's outline gone: a neighbouring region sharing a +/// curve with it is not the cam). +pub fn sketch_profile<'a>(sk: &'a crate::SolvedSketch, curves: &[String]) -> Option<&'a solidcraft_sketch::Profile> { + if curves.len() > MAX_PROFILE_POINTS { + return None; + } + let named: std::collections::BTreeSet<&String> = curves.iter().collect(); + let shared = |p: &solidcraft_sketch::Profile| p.outer_curves.iter().filter(|c| named.contains(c)).count(); + let area = |p: &solidcraft_sketch::Profile| p.region.outer.signed_area().abs(); + sk.profiles + .iter() + .filter(|p| curves.is_empty() || 2 * shared(p) >= curves.len()) + .max_by(|a, b| shared(a).cmp(&shared(b)).then(area(a).total_cmp(&area(b)))) +} + +/// A loop's polyline within `tol`, segment by segment: `None` past `MAX_PROFILE_POINTS` points. +fn sampled(lp: &solidcraft_geom::Loop2, tol: f64) -> Option> { + let mut out = Vec::new(); + for seg in &lp.segs { + let p = seg.polyline(tol); + out.extend(p.iter().take(p.len().saturating_sub(1))); + if out.len() > MAX_PROFILE_POINTS { + return None; + } + } + Some(out) +} + +/// The diagonal of the box around some points (0 for none). +fn extent<'a>(pts: impl Iterator) -> f64 { + let (lo, hi) = pts.fold((Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)), |(lo, hi), p| { + (Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z)), Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z))) + }); + if lo.x <= hi.x { (hi - lo).len() } else { 0.0 } +} + +fn bounds_2d(pts: &[Vec2]) -> f64 { + let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN)); + for p in pts { + lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y)); + hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y)); + } + if lo.x <= hi.x { (hi - lo).len() } else { 0.0 } +} + +/// The chord error a profile is sampled to: 0.1 µm per 100 mm of its size, at least 0.1 µm. +fn tolerance(size: f64) -> f64 { + 1e-4 * (size / 100.0).max(1.0) +} + +/// The joints, frames and profile a cam link's table is worked out from. +fn setup(doc: &Document, st: &ModelState, l: &MotionLink, c: &Cam) -> Result { + let bad = |m: &str| DocError::Invalid(format!("cam: {m}")); + let (cam, fol) = (joint(doc, l.a)?, joint(doc, l.b)?); + if cam.kind != JointKind::Revolute { + return Err(bad("the cam turns on a revolute joint")); + } + if !matches!(fol.kind, JointKind::Slider | JointKind::Revolute) { + return Err(bad("the follower slides (slider) or swings (revolute)")); + } + if cam.a.occurrence == cam.b.occurrence { + return Err(bad("the cam joint joins two parts")); + } + let (fa, fb) = (super::resolve_origin(doc, st, &cam.a)?, super::resolve_origin(doc, st, &cam.b)?); + let (pts, occ, size) = profile_points(doc, st, &c.profile, cam, (&fa, &fb))?; + if pts.len() < 3 || pts.len() > MAX_PROFILE_POINTS || !size.is_finite() || size <= 0.0 { + return Err(bad(&format!("the profile must have 3 to {MAX_PROFILE_POINTS} points"))); + } + let inv = |m: &Mat| mat_inverse(m).ok_or_else(|| bad("degenerate frame")); + // The cam's fixed frame C (world) and the profile at value 0 in it, as `through` places the + // joint's sides. + let on_b = occ == cam.b.occurrence; + let (frame, local, fixed) = if on_b { + (mat_mul(&world(doc, cam.a.occurrence), &fa), mat_mul(&flip_of(cam), &inv(&fb)?), cam.a.occurrence) + } else { + (mat_mul(&mat_mul(&world(doc, cam.b.occurrence), &fb), &inv(&flip_of(cam))?), inv(&fa)?, cam.b.occurrence) + }; + let v: Vec = pts.iter().map(|p| apply_point(&local, *p)).collect(); + let mean = v.iter().map(|p| p.z).sum::() / v.len() as f64; + if v.iter().any(|p| (p.z - mean).abs() > 1e-6 * size + 1e-9) { + return Err(bad("the profile must lie in a plane square to the cam's axis")); + } + let poly: Vec = v.iter().map(|p| Vec2::new(p.x, p.y)).collect(); + if !poly.iter().all(|p| p.is_finite()) || area(&poly).abs() <= 1e-12 * size * size { + return Err(bad("the profile encloses no area")); + } + // The follower's line or pivot in C: its joint's frame on the side that stays (the side away + // from the cam's fixed part moves; b when neither side is that part), as `through` places it. + // The moving side travels (or turns) by +value along its z when it is b, −value when it is a. + let c_inv = inv(&frame)?; + let moves_b = follower_is_b(doc, cam, fol, (fixed, occ))?; + let (ga, gb) = (super::resolve_origin(doc, st, &fol.a)?, super::resolve_origin(doc, st, &fol.b)?); + let guide = if moves_b { + mat_mul(&world(doc, fol.a.occurrence), &ga) + } else { + mat_mul(&mat_mul(&world(doc, fol.b.occurrence), &gb), &inv(&flip_of(fol))?) + }; + let to_c = mat_mul(&c_inv, &guide); + let (g, d) = (apply_point(&to_c, Vec3::ZERO), apply_vector(&to_c, Vec3::Z)); + let (moving, sign_f) = if moves_b { (fol.b.occurrence, 1.0) } else { (fol.a.occurrence, -1.0) }; + let (radius, flat) = match &c.follower { + Follower::Knife => (0.0, false), + Follower::Roller { radius } => (size_of(doc, st, radius)?, false), + Follower::Flat => (0.0, true), + }; + let side = if c.reverse { -1.0 } else { 1.0 }; + let turn = if on_b { 1.0 } else { -1.0 }; + let at = Vec2::new(g.x, g.y); + // Where the follower touches now (in C): its tip, or the roller's picked round face. + let face = match &c.follower { + Follower::Roller { radius: Size::Circle(o) } => Some(o), + _ => None, + }; + // A roller picked by its round face (as its radius or its tip) turns about the face's axis: + // square to the profile's plane. + let roller = matches!(c.follower, Follower::Roller { .. }); + for o in [c.tip.as_ref(), face].into_iter().flatten().filter(|o| roller && matches!(o.snap, super::Snap::CircleCenter { .. })) { + let at = mat_mul(&c_inv, &doc.origin_world(st, o)?); + if Vec3::new(at[2][0], at[2][1], 0.0).len() > 1e-6 { + return Err(bad("the roller's axis must be parallel to the cam's")); + } + } + let tip = match c.tip.as_ref().or(face) { + Some(o) => { + let t = apply_point(&mat_mul(&c_inv, &doc.origin_world(st, o)?), Vec3::ZERO); + Some(Vec2::new(t.x, t.y)) + } + None => None, + }; + if fol.kind == JointKind::Slider { + if d.z.abs() > 1e-6 { + return Err(bad("the follower must slide square to the cam's axis")); + } + let dir = Vec2::new(d.x, d.y).normalized().ok_or_else(|| bad("degenerate frame"))?; + // Its line: along the slider's axis, through its tip (where it is across the line, which + // doesn't change as it slides), else through the axis. + let at = tip.map(|t| at + dir.perp() * (t - at).dot(dir.perp())).unwrap_or(at); + return Ok(Setup { poly, turn, at, dir, arm: None, side, gain: sign_f, radius, flat, size, frame, moving }); + } + if flat { + return Err(bad("a flat-faced follower slides (use a slider)")); + } + if Vec2::new(d.x, d.y).len() > 1e-6 { + return Err(bad("the follower must swing about an axis parallel to the cam's")); + } + let t = tip.ok_or_else(|| bad("an oscillating follower needs its tip (the knife's point or the roller's centre)"))?; + if at.len() <= 1e-9 * size { + return Err(bad("the follower's pivot is on the cam's axis")); + } + let arm = t - at; + if arm.len() <= 1e-9 * size { + return Err(bad("the follower's tip is on its pivot")); + } + // b turns the follower by its value about the joint's z, which is C's z or against it. + Ok(Setup { poly, turn, at, dir: Vec2::X, arm: Some(arm), side, gain: sign_f * d.z.signum(), radius, flat, size, frame, moving }) +} + +/// How many moving joints part occurrence `from` from each occurrence it is joined to, over the +/// unsuppressed joints but `skip`: a rigid joint moves nothing, and the root and the grounded +/// occurrences are all fixed to the world (`u64::MAX` here), so they cost nothing either. +fn hops(doc: &Document, from: u64, skip: [u64; 2]) -> std::collections::BTreeMap { + const WORLD: u64 = u64::MAX; + let mut next: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + let mut join = |a: u64, b: u64, cost: usize| { + next.entry(a).or_default().push((b, cost)); + next.entry(b).or_default().push((a, cost)); + }; + for j in doc.assembly.joints.iter().filter(|j| !j.suppressed && !skip.contains(&j.id)).take(super::MAX_JOINTS) { + join(j.a.occurrence, j.b.occurrence, usize::from(j.kind != JointKind::Rigid)); + } + join(0, WORLD, 0); + for o in doc.occurrences.iter().filter(|o| o.grounded) { + join(o.id, WORLD, 0); + } + // Breadth first with free steps taken first (each occurrence settled once, at its least cost). + let mut cost = std::collections::BTreeMap::from([(from, 0usize)]); + let mut done = std::collections::BTreeSet::new(); + let mut queue = std::collections::VecDeque::from([from]); + while let Some(o) = queue.pop_front() { + if !done.insert(o) { + continue; + } + let here = cost.get(&o).copied().unwrap_or(0); + for (other, step) in next.get(&o).into_iter().flatten() { + let d = here + step; + if cost.get(other).is_none_or(|c| d < *c) { + cost.insert(*other, d); + if *step == 0 { + queue.push_front(*other); + } else { + queue.push_back(*other); + } + } + } + } + cost +} + +/// Whether a follower joint's b side is the follower (its a side the guide): the guide is the side +/// fewer moving joints from the part the cam turns against (`frame`), through the other joints. A guide joined only to +/// the profile's part (`cam`) would turn with the profile: refused. +fn follower_is_b(doc: &Document, cam_joint: &super::Joint, fol: &super::Joint, (frame, cam): (u64, u64)) -> Result { + let skip = [cam_joint.id, fol.id]; + let from = hops(doc, frame, skip); + Ok(match (from.get(&fol.a.occurrence), from.get(&fol.b.occurrence)) { + (Some(a), Some(b)) => a <= b, + (Some(_), None) => true, + (None, Some(_)) => false, + (None, None) => { + let from = hops(doc, cam, skip); + if from.contains_key(&fol.a.occurrence) || from.contains_key(&fol.b.occurrence) { + return Err(DocError::Invalid( + "cam: the follower's guide turns with the cam's profile (put it on the part the cam turns against)".into(), + )); + } + true + } + }) +} + +/// A polygon's signed area, from its first point (so a small profile far from the axis keeps its +/// digits). +fn area(poly: &[Vec2]) -> f64 { + let (n, o) = (poly.len(), poly.first().copied().unwrap_or(Vec2::ZERO)); + (0..n).filter_map(|i| Some((*poly.get(i)? - o).cross(*poly.get((i + 1) % n)? - o))).sum::() / 2.0 +} + +/// The follower's places at the samples 0 ..= `CAM_SAMPLES` of a turn, `place(u, prev)` giving +/// it with the cam `u` samples on (from `prev`, where it was, when it is `follows`ed from sample +/// to sample). A followed follower must come back where it started after the turn. A step much +/// larger than the steps either side of it is followed again in finer steps: a smooth rise +/// shrinks with them, a jump (the follower leaving the profile and landing on another part of it) +/// stays one step, and is refused. `floor`: steps no larger than it are not looked at. +fn round(place: &dyn Fn(f64, Option) -> Result, follows: bool, floor: f64) -> Result> { + let mut raw: Vec = Vec::with_capacity(CAM_SAMPLES + 1); + for k in 0..=CAM_SAMPLES { + let prev = if follows { raw.last().copied() } else { None }; + raw.push(place(k as f64, prev)?); + } + let at = |k: usize| raw.get(k).copied().unwrap_or(f64::NAN); + if follows && (at(0) - at(CAM_SAMPLES)).abs() > 1e-6 { + return Err(DocError::Invalid("cam: after a turn of the cam the follower does not come back where it started (it would jump)".into())); + } + let step = |k: usize| (at(k + 1) - at(k)).abs(); + for k in 0..CAM_SAMPLES { + let (d, around) = (step(k), step((k + CAM_SAMPLES - 1) % CAM_SAMPLES).max(step((k + 1) % CAM_SAMPLES))); + if d <= floor || d <= 3.0 * around { + continue; + } + let (mut here, mut most) = (at(k), 0.0f64); + for j in 1..=32 { + let next = place(k as f64 + f64::from(j) / 32.0, follows.then_some(here))?; + most = most.max((next - here).abs()); + here = next; + } + if most > 0.5 * d { + return Err(DocError::Invalid(format!("cam: the follower would jump near {k} degrees of the cam's turn (it leaves the profile there)"))); + } + } + Ok(raw) +} + +/// A cam link's table: b's values at a's values 2π·k / `CAM_SAMPLES`, offset left out. A +/// translating follower stands at its first contact coming along its line; an oscillating one +/// starts at its first contact turning from pointing away from the cam's axis and is followed from +/// sample to sample (`round`). +pub fn table(doc: &Document, st: &ModelState, l: &MotionLink, c: &Cam) -> Result> { + let s = setup(doc, st, l, c)?; + let start = s.at.angle(); + let follows = s.arm.is_some(); + // Where the follower is with the cam `u` samples on: mm along its line, or the angle its arm + // turned through from pointing away from the cam's axis (the same direction in C at every + // sample), on from `prev` for an arm. + let place = |u: f64, prev: Option| -> Result { + // The follower turned back instead of the profile turned on. + let q = -s.turn * TAU * u / CAM_SAMPLES as f64; + match s.arm { + None => first_contact_along(&s.poly, rot(s.at, q), rot(s.dir * s.side, q), s.radius, s.flat) + .map(|t| t * s.side) + .ok_or_else(|| DocError::Invalid("cam: the follower's line misses the cam at some angles".into())), + Some(arm) => { + let (pivot, from) = (rot(s.at, q), (start + q, s.side)); + match prev { + None => first_contact_turning(&s.poly, pivot, arm.len(), s.radius, from.0, from.1), + Some(p) => next_contact_turning(&s.poly, pivot, arm.len(), s.radius, from, p, 1e-9 * s.size), + } + .ok_or_else(|| DocError::Invalid("cam: the follower's arm does not reach the cam at every angle (or starts on it)".into())) + } + } + }; + let raw = round(&place, follows, if follows { 1e-6 } else { 1e-6 * s.size })?; + let out: Vec = raw.iter().take(CAM_SAMPLES).map(|v| s.gain * if follows { start + s.side * v } else { *v }).collect(); + if !out.iter().all(|v| v.is_finite() && v.abs() <= 1e9 * s.size.max(1.0)) { + return Err(DocError::Invalid("cam: the follower's travel is out of range".into())); + } + Ok(out) +} + +/// The side a new cam's follower rides it from, as `Cam::reverse`: a translating follower from the +/// side of the cam's axis its bodies are on now, an oscillating one turning toward the cam the way +/// its arm is turned now from pointing away from the cam's axis. +pub fn default_reverse(doc: &Document, st: &ModelState, l: &MotionLink, c: &Cam) -> Result { + let s = setup(doc, st, l, &Cam { reverse: false, ..c.clone() })?; + if let Some(arm) = s.arm { + let off = (arm.angle() - s.at.angle() + std::f64::consts::PI).rem_euclid(TAU) - std::f64::consts::PI; + return Ok(off < 0.0); + } + let comp = component_of(doc, s.moving)?; + let bb = component_bodies(doc, st, comp).iter().fold(solidcraft_geom::Aabb3::EMPTY, |bb, b| bb.union(&b.mesh().bounds())); + if bb.min.x > bb.max.x { + return Ok(false); + } + let inv = mat_inverse(&s.frame).ok_or_else(|| DocError::Invalid("cam: degenerate frame".into()))?; + let centre = apply_point(&mat_mul(&inv, &world(doc, s.moving)), bb.center()); + // The cam's axis is C's z: the follower is on the −dir side of it. + Ok(Vec2::new(centre.x, centre.y).dot(s.dir) < 0.0) +} + +/// How far a translating follower's line passes from the cam's axis (mm; 0 for an oscillating one). +pub fn follower_offset(doc: &Document, st: &ModelState, l: &MotionLink, c: &Cam) -> Result { + let s = setup(doc, st, l, c)?; + Ok(if s.arm.is_some() { 0.0 } else { s.at.cross(s.dir).abs() }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A circle of radius `r` about `c` as a polygon of `n` points (its sag is r·(1 − cos π/n)). + fn circle(c: Vec2, r: f64, n: usize) -> Vec { + (0..n).map(|k| c + Vec2::from_angle(TAU * k as f64 / n as f64) * r).collect() + } + + /// An eccentric circle (radius 30, its centre 10 from the axis) turned by θ against a + /// follower sliding along +x: a knife edge on a line `h` from the axis stands at + /// 10·cos θ + √(30² − (h − 10·sin θ)²), a roller of radius 5 as the knife on the circle of + /// radius 35, and a flat face at 10·cos θ + 30. + #[test] + fn a_translating_follower_rides_an_eccentric_circle() { + let (e, r) = (10.0, 30.0); + let poly = circle(Vec2::new(e, 0.0), r, 4096); + for k in 0..72 { + let th = TAU * k as f64 / 72.0 - 0.3; + // The follower turned back by θ, as `table` does. + let (d, at) = (rot(Vec2::X, -th), |h: f64| rot(Vec2::new(0.0, h), -th)); + for h in [0.0, 6.0, -12.0] { + let knife = first_contact_along(&poly, at(h), d, 0.0, false).unwrap_or(f64::NAN); + let want = e * th.cos() + (r * r - (h - e * th.sin()).powi(2)).sqrt(); + assert!((knife - want).abs() < 1e-4, "knife θ {th} h {h}: {knife} vs {want}"); + let roller = first_contact_along(&poly, at(h), d, 5.0, false).unwrap_or(f64::NAN); + let want = e * th.cos() + ((r + 5.0).powi(2) - (h - e * th.sin()).powi(2)).sqrt(); + assert!((roller - want).abs() < 1e-4, "roller θ {th} h {h}: {roller} vs {want}"); + } + let flat = first_contact_along(&poly, Vec2::ZERO, d, 0.0, true).unwrap_or(f64::NAN); + assert!((flat - (e * th.cos() + r)).abs() < 1e-4, "flat θ {th}: {flat}"); + } + // A line past the cam misses it. + assert_eq!(first_contact_along(&poly, Vec2::new(0.0, 50.0), Vec2::X, 0.0, false), None); + assert_eq!(first_contact_along(&poly, Vec2::new(0.0, 50.0), Vec2::X, 5.0, false), None); + } + + /// An arm of 50 about (−50, 40) with a roller of 5 on the same cam: the roller's centre is where + /// the arm's circle meets the circle of radius 35 about the cam's centre, the first of the two + /// met turning from the arm pointing away from the axis. + #[test] + fn an_oscillating_follower_meets_the_offset_circle() { + let (e, r, roller) = (10.0, 30.0, 5.0); + let (pivot, arm) = (Vec2::new(-50.0, 40.0), 50.0); + let start = pivot.angle(); + for k in 0..36 { + let th = TAU * k as f64 / 36.0; + let c = Vec2::from_angle(th) * e; + let poly = circle(c, r, 4096); + // The two circles meet at m ± n. + let (dv, l) = (c - pivot, (c - pivot).len()); + let a = (arm * arm - (r + roller).powi(2) + l * l) / (2.0 * l); + let m = pivot + dv * (a / l); + let n = dv.perp() * ((arm * arm - a * a).sqrt() / l); + for turn in [1.0, -1.0] { + let swept = |p: Vec2| (turn * ((p - pivot).angle() - start)).rem_euclid(TAU); + let want = swept(m + n).min(swept(m - n)); + let got = first_contact_turning(&poly, pivot, arm, roller, start, turn).unwrap_or(f64::NAN); + assert!((got - want).abs() < 1e-5, "θ {th} turn {turn}: {got} vs {want}"); + } + } + // Followed on from where it touches, it stays on the part of the profile it rides: a C + // whose two arms both cross the tip's circle, the tip resting on the second arm. + let c: Vec = [(-10.0, 20.0), (30.0, 20.0), (30.0, 14.0), (-4.0, 14.0), (-4.0, -14.0), (30.0, -14.0), (30.0, -20.0), (-10.0, -20.0)] + .into_iter() + .map(|(x, y)| Vec2::new(x, y)) + .collect(); + let (pv, l) = (Vec2::new(40.0, 0.0), 22.0); + let touches = touches_turning(&c, pv, l, 0.0, 0.0, 1.0); + let on = |t: f64| (pv + Vec2::from_angle(t) * l).y; + let first = first_contact_turning(&c, pv, l, 0.0, 0.0, 1.0).unwrap_or(f64::NAN); + let second = touches.iter().copied().find(|t| on(*t) < 0.0 && *t > first).unwrap_or(f64::NAN); + assert!(on(first) > 0.0 && second > first, "{touches:?}"); + let stay = next_contact_turning(&c, pv, l, 0.0, (0.0, 1.0), second, 1e-9).unwrap_or(f64::NAN); + assert!((stay - second).abs() < 1e-12, "{stay} vs {second}"); + // A knife on an edge touches it, it doesn't overlap it; just inside, it does. + let square: Vec = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].into_iter().map(|(x, y)| Vec2::new(x, y)).collect(); + let (pv, l) = (Vec2::new(0.0, -4.0), 3.0); + let up = std::f64::consts::FRAC_PI_2; + assert!(!overlaps_turning(&square, pv, l, 0.0, (up, 1.0), 0.0, 1e-9)); + assert!(overlaps_turning(&square, pv, l + 0.01, 0.0, (up, 1.0), 0.0, 1e-9)); + assert!((area(&square.iter().map(|p| *p * 0.004 + Vec2::new(524_288.0, 524_288.0)).collect::>()) - 6.4e-5).abs() < 1e-12); + // An arm that can't reach, and one that starts on the cam. + let poly = circle(Vec2::new(e, 0.0), r, 512); + assert_eq!(first_contact_turning(&poly, pivot, 5.0, 0.0, start, 1.0), None); + assert_eq!(first_contact_turning(&poly, Vec2::new(10.0, 0.0), 10.0, 0.0, 0.0, 1.0), None); + } + + /// An arm whose first contact vanishes mid-turn and lands on another lobe jumps, though it + /// comes back after the turn: refused. On the eccentric circle it follows smoothly. + #[test] + fn a_follower_that_jumps_is_refused() { + let arm_round = |poly: &[Vec2], pivot: Vec2, arm: f64| { + let start = pivot.angle(); + let place = |u: f64, prev: Option| -> Result { + let q = -TAU * u / CAM_SAMPLES as f64; + let p = rot(pivot, q); + match prev { + None => first_contact_turning(poly, p, arm, 0.0, start + q, 1.0), + Some(v) => next_contact_turning(poly, p, arm, 0.0, (start + q, 1.0), v, 1e-9), + } + .ok_or_else(|| DocError::Invalid("misses".into())) + }; + round(&place, true, 1e-6) + }; + let lobes: Vec = (0..240) + .map(|k| { + let t = TAU * f64::from(k) / 240.0; + Vec2::from_angle(t) * (28.0 + 2.0 / 3.0 + 13.0 * (3.0 * t).cos()) + }) + .collect(); + let e = arm_round(&lobes, Vec2::new(35.0, 0.0), 20.0).map_err(|e| e.to_string()).err().unwrap_or_default(); + assert!(e.contains("jump"), "{e}"); + assert!(arm_round(&circle(Vec2::new(10.0, 0.0), 30.0, 2048), Vec2::new(-50.0, 40.0), 50.0).is_ok()); + } + + /// The table is read between its samples by a periodic cubic: a cosine sampled once a degree + /// reads back within a millionth anywhere, a turn either way round included; nothing reads + /// from an empty table or at a non-finite angle. + #[test] + fn a_table_reads_smoothly_between_its_samples() { + let t: Vec = (0..CAM_SAMPLES).map(|k| (TAU * k as f64 / CAM_SAMPLES as f64).cos()).collect(); + for x in [0.0, 0.0123, 1.0, 3.1, -2.5, 6.2, 40.0, -1e5] { + assert!((lift_at(&t, x) - x.cos()).abs() < 1e-6, "{x}: {}", lift_at(&t, x)); + } + assert!(lift_at(&[], 1.0).is_nan() && lift_at(&t, f64::NAN).is_nan() && lift_at(&t, f64::INFINITY).is_nan()); + // A dwell up to a rise reads flat up to the rise: never below the dwell, nor past the rise. + let mut step = vec![30.0; CAM_SAMPLES]; + for (k, v) in step.iter_mut().enumerate().skip(100).take(60) { + *v = 30.0 + 0.9 * (k - 99) as f64; + } + for k in 0..36_000 { + let v = lift_at(&step, TAU * k as f64 / 36_000.0); + assert!((30.0 - 1e-12..=30.0 + 0.9 * 60.0 + 1e-12).contains(&v), "{k}: {v}"); + } + assert!(lift_at(&[2.0], 1e300).is_finite()); + } + + /// The guide is the side of the follower's joint fewer moving joints from the cam's frame: a + /// guide on four rigid joints from it beats a piston three moving joints round a slider-crank; + /// a guide only on the cam's own side is refused. + #[test] + fn the_guide_is_the_side_fewer_moving_joints_from_the_frame() { + let origin = JointOrigin { occurrence: 0, snap: super::super::Snap::Point { point: Vec3::ZERO, z: None } }; + let joint = |id: u64, kind: JointKind, a: u64, b: u64| super::super::Joint { + id, + name: format!("J{id}"), + kind, + a: JointOrigin { occurrence: a, ..origin.clone() }, + b: JointOrigin { occurrence: b, ..origin.clone() }, + values: Vec::new(), + limits: Vec::new(), + flip: false, + offset: 0.0, + angle: 0.0, + suppressed: false, + }; + let occ = |id: u64, grounded: bool| crate::Occurrence { id, component: id, parent: 0, name: format!("O{id}"), transform: IDENTITY, grounded }; + // 1 frame (grounded), 2 cam, 3-6 a rigid chain to the guide 7, 8 the piston, 9 crank, 10 rod. + let mut doc = Document { occurrences: (1..=10).map(|id| occ(id, id == 1)).collect(), ..Document::default() }; + let (r, v, s) = (JointKind::Rigid, JointKind::Revolute, JointKind::Slider); + doc.assembly.joints = vec![ + joint(1, v, 1, 2), + joint(2, r, 1, 3), + joint(3, r, 3, 4), + joint(4, r, 4, 5), + joint(5, r, 5, 7), + joint(6, s, 8, 7), + joint(7, v, 1, 9), + joint(8, v, 9, 10), + joint(9, v, 10, 8), + ]; + let (cam, fol) = (doc.assembly.joints[0].clone(), doc.assembly.joints[5].clone()); + // The piston (a) follows, the guide (b) stays. + assert!(matches!(follower_is_b(&doc, &cam, &fol, (1, 2)), Ok(false))); + // A guide joined only to the cam turns with it. + doc.assembly.joints[1] = joint(2, r, 2, 3); + doc.occurrences.iter_mut().for_each(|o| o.grounded = o.id == 2); + doc.assembly.joints.truncate(6); + assert!(follower_is_b(&doc, &cam, &fol, (1, 2)).is_err()); + } + + /// Edges join end to end either way round into one closed loop; a gap or a spare edge is no loop. + #[test] + fn edges_chain_into_one_closed_loop() { + let p = |x: f64, y: f64| Vec3::new(x, y, 0.0); + let (a, b, c) = (vec![p(0.0, 0.0), p(1.0, 0.0)], vec![p(0.0, 1.0), p(1.0, 0.0)], vec![p(0.0, 1.0), p(0.0, 0.0)]); + let loop3 = chain(&[&a, &b, &c], &[], 1e-9).unwrap_or_default(); + assert_eq!(loop3, vec![p(0.0, 0.0), p(1.0, 0.0), p(0.0, 1.0)]); + assert_eq!(chain(&[&a, &b], &[], 1e-9), None); + let d = vec![p(5.0, 5.0), p(6.0, 6.0)]; + assert_eq!(chain(&[&a, &b, &c, &d], &[], 1e-9), None); + let ring = vec![p(1.0, 0.0), p(0.0, 1.0), p(-1.0, 0.0), p(0.0, -1.0), p(1.0, 0.0)]; + assert_eq!(chain(&[&ring], &[], 1e-9).map(|l| l.len()), Some(4)); + // One pick goes on along the other edges of its plane; a spare pick still fails. + assert_eq!(chain(&[&a], &[&d, &c, &b], 1e-9).map(|l| l.len()), Some(3)); + assert_eq!(chain(&[&a, &d], &[&c, &b], 1e-9), None); + // A loop of many short edges goes round from one of them, found by their ends; past the + // point cap it is refused, without going on round it. + let ring = |n: usize| -> Vec> { + let at = |k: usize| Vec3::new((TAU * k as f64 / n as f64).cos(), (TAU * k as f64 / n as f64).sin(), 0.0) * 100.0; + (0..n).map(|k| vec![at(k), at(k + 1)]).collect() + }; + let small = ring(10_000); + let rest: Vec<&Vec> = small.iter().skip(1).rev().collect(); + assert_eq!(chain(&[&small[0]], &rest, 1e-6).map(|l| l.len()), Some(10_000)); + let big = ring(100_000); + let rest: Vec<&Vec> = big.iter().skip(1).collect(); + assert_eq!(chain(&[&big[0]], &rest, 1e-7), None); + } +} diff --git a/crates/engine/src/cmd/component_clone_assembly.rs b/crates/engine/src/cmd/component_clone_assembly.rs index 88a2f2c9..7cf3a027 100644 --- a/crates/engine/src/cmd/component_clone_assembly.rs +++ b/crates/engine/src/cmd/component_clone_assembly.rs @@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use solidcraft_doc::Document; +use solidcraft_doc::joints::cam::CamProfile; use solidcraft_doc::joints::{Assembly, Coupling, JointOrigin, Size, Snap}; use crate::{EngineError, Result}; @@ -102,9 +103,31 @@ fn budget(assembly: &Assembly) -> Result<()> { return Err(invalid("exploded view contains non-finite moves")); } } - if !assembly.links.iter().all(|l| l.ratio.is_finite() && l.offset.is_finite()) { + if !assembly.links.iter().all(|l| l.ratio.is_finite() && l.offset.is_finite() && l.lift.iter().all(|v| v.is_finite())) { return Err(invalid("motion link contains non-finite values")); } + for cam in assembly.links.iter().filter_map(|l| match &l.coupling { + Some(Coupling::Cam(c)) => Some(c), + _ => None, + }) { + add_references(&mut references, cam.tip.iter().count())?; + if let Some(tip) = &cam.tip { + finite_origin(tip)?; + } + match &cam.profile { + CamProfile::Sketch { curves, .. } => { + add_references(&mut references, curves.len())?; + curves.iter().try_for_each(|c| text(c))?; + } + CamProfile::Edges { picks, names, .. } => { + add_references(&mut references, picks.len() + names.len())?; + if !picks.iter().all(|p| p.is_finite()) { + return Err(invalid("motion link contains non-finite values")); + } + names.iter().try_for_each(|n| text(n))?; + } + } + } for size in assembly.links.iter().filter_map(|l| l.coupling.as_ref()).flat_map(Coupling::sizes) { match size { Size::Length(v) if !v.is_finite() => return Err(invalid("motion link contains non-finite values")), @@ -300,12 +323,26 @@ pub(super) fn clone_assembly( continue; } let mut item = old.clone(); - // A gear's picked round faces are on occurrences of the copy too. + // A gear's picked round faces are on occurrences of the copy too, and so are a cam's + // profile and its follower's tip. let faces = item.coupling.as_mut().is_none_or(|c| { - c.sizes_mut().into_iter().all(|size| match size { + let sizes = c.sizes_mut().into_iter().all(|size| match size { Size::Circle(o) => origin(o, occurrences, features, bodies).map(|new| *o = new).is_some(), _ => true, - }) + }); + let Coupling::Cam(cam) = c else { return sizes }; + let tip = match &mut cam.tip { + Some(t) => origin(t, occurrences, features, bodies).map(|new| *t = new).is_some(), + None => true, + }; + let profile = match &mut cam.profile { + CamProfile::Sketch { sketch, .. } => features.get(sketch).map(|new| *sketch = *new).is_some(), + CamProfile::Edges { occurrence, names, .. } => { + occurrences.get(occurrence).map(|new| *occurrence = *new).is_some() + && names.iter_mut().all(|n| n.is_empty() || face_name(n, features, bodies).map(|new| *n = new).is_some()) + } + }; + sizes && tip && profile }); match (joints.get(&old.a), joints.get(&old.b)) { (Some(aa), Some(bb)) if faces => { diff --git a/crates/engine/src/cmd/component_clone_assembly_tests.rs b/crates/engine/src/cmd/component_clone_assembly_tests.rs index f9157eab..63f11036 100644 --- a/crates/engine/src/cmd/component_clone_assembly_tests.rs +++ b/crates/engine/src/cmd/component_clone_assembly_tests.rs @@ -44,8 +44,8 @@ fn documents() -> (Document, Document) { NamedOrigin { name: "Outside".into(), origin: at(9) }, ]; source.assembly.links = vec![ - MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.3, coupling: None }, - MotionLink { a: 1, ia: 0, b: 3, ib: 0, ratio: 1.0, offset: 0.0, coupling: None }, + MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.3, coupling: None, lift: Vec::new() }, + MotionLink { a: 1, ia: 0, b: 3, ib: 0, ratio: 1.0, offset: 0.0, coupling: None, lift: Vec::new() }, ]; source.assembly.contact_sets = vec![ ContactSet { id: 1, name: "Internal contact".into(), occurrences: vec![1, 2], suppressed: true }, @@ -106,8 +106,8 @@ fn copied_gears_take_their_picked_faces_along() { let circle = |face: &str| Size::Circle(JointOrigin { occurrence: 1, snap: Snap::CircleCenter { pick: Vec3::X, face: Some(face.into()) } }); let gear = |ra| Coupling::Gear { ra, rb: Size::Length(5.0), internal: false, reverse: false }; source.assembly.links = vec![ - MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.0, coupling: Some(gear(circle("F1:side"))) }, - MotionLink { a: 2, ia: 0, b: 1, ib: 0, ratio: -0.5, offset: 0.0, coupling: Some(gear(circle("F99:side"))) }, + MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.0, coupling: Some(gear(circle("F1:side"))), lift: Vec::new() }, + MotionLink { a: 2, ia: 0, b: 1, ib: 0, ratio: -0.5, offset: 0.0, coupling: Some(gear(circle("F99:side"))), lift: Vec::new() }, ]; let mut destination = source.clone(); destination.occurrences.extend([101, 102].map(occurrence)); diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index 1a346467..91b55ed3 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -7,6 +7,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde_json::{Value, json}; use solidcraft_doc::expr::Kind; +use solidcraft_doc::joints::cam::{self, Cam, CamProfile, Follower}; use solidcraft_doc::joints::{self, Coupling, Joint, JointKind, JointOrigin, MotionLink, NamedOrigin, Size, Snap}; use solidcraft_geom::{Aabb3, Vec3}; @@ -60,6 +61,9 @@ pub static COMMANDS: &[CommandSpec] = &[ travel per turn (mm or an expression); reverse?: bool (a left-hand thread); link?: index (replace that motion link) → link, \ ratio (mm per degree, or degrees per mm); b keeps where it is", ), + CommandSpec::new("joint.cam", "Cam", cam_cmd).at("SOLID", "ASSEMBLE").icon("cam").params( + "a: the cam's joint (revolute); b: the follower's joint, a slider (a translating follower) or a revolute (an oscillating one), b driven by a; profile: {sketch: id|name, index?: the profile (default: the largest)} | {edges: [[x,y,z]…] (points on edges of the cam's bodies forming one closed loop, world), occurrence?}: the cam's outline, in a plane square to its axis, read again whenever the design changes; follower?: knife|roller|flat (default knife; flat: a face square to a slider's line); radius?: the roller's radius (mm, an expression or {circle: [x,y,z], occurrence?}: the roller's round face, which is its tip too); tip?: a joint origin where the follower touches: its knife's point or its roller's centre, such as {circle: [x,y,z]} on the roller (needed for an oscillating follower; a translating one's line runs through it, by default along its slider's axis); flip?: bool (ride the cam from the other side than the one the follower is on now); link?: index (replace that motion link) → link, lift: {min, max, rise, unit} (b's travel over a turn of the cam), reverse (the side it rides from), follower_offset (mm: how far a translating follower's line passes from the cam's axis); b keeps where it is", + ), CommandSpec::new("joint.limits", "Joint Limits", limits).params("joint; index?: value index (default 0); min?, max? (omit both to clear)"), CommandSpec::new("joint.edit", "Edit Joint", edit).params("joint; type?, flip?, offset?, angle?, suppressed?, name?"), CommandSpec::new("joint.delete", "Delete Joint", delete).params("joint: id|name (or origin: name, link: index)"), @@ -80,29 +84,30 @@ pub fn resolve(s: &mut Session) -> joints::Solution { resolve_holding(s, &BTreeMap::new()) } -/// Work out the ratios of the coupled motion links again (a radius or a parameter may have -/// changed). A coupling that can't be worked out (its face gone) keeps its last ratio; -/// `joint.list` says why. +/// Work out the ratios (and cam tables) of the coupled motion links again (a radius, a parameter +/// or a cam's profile may have changed). A coupling that can't be worked out (its face gone) +/// keeps its last ratio and table; `joint.list` says why. fn recouple(s: &mut Session) { if s.doc.assembly.links.iter().all(|l| l.coupling.is_none()) { return; } let st = s.model.state(); - let changed: Vec<(usize, f64)> = s + let changed: Vec<(usize, (f64, Vec))> = s .doc .assembly .links .iter() .enumerate() - .filter_map(|(i, l)| joints::coupling_ratio(&s.doc, &st, l)?.ok().filter(|r| *r != l.ratio).map(|r| (i, r))) + .filter_map(|(i, l)| joints::coupling_values(&s.doc, &st, l)?.ok().filter(|(r, t)| *r != l.ratio || *t != l.lift).map(|v| (i, v))) .collect(); if changed.is_empty() { return; } let doc = s.doc_mut(); - for (i, r) in changed { + for (i, (r, t)) in changed { if let Some(l) = doc.assembly.links.get_mut(i) { l.ratio = r; + l.lift = t; } } } @@ -709,7 +714,7 @@ fn motion_link(s: &mut Session, p: &Value) -> Result { None => 0.0, }; let ratio = ratio * ratio_scale(s, a, ia, b, ib); - s.doc_mut().assembly.links.push(MotionLink { a, ia, b, ib, ratio, offset, coupling: None }); + s.doc_mut().assembly.links.push(MotionLink { a, ia, b, ib, ratio, offset, coupling: None, lift: Vec::new() }); let sol = resolve(s); Ok(sol_json(&sol, p)) } @@ -766,7 +771,7 @@ fn gear(s: &mut Session, p: &Value) -> Result { internal: bool_(p, "internal").unwrap_or(false), reverse: reverse(p), }; - couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c) }) + couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() }) } fn rack_pinion(s: &mut Session, p: &Value) -> Result { @@ -774,7 +779,7 @@ fn rack_pinion(s: &mut Session, p: &Value) -> Result { let (a, b) = joint_pair(s, p, cmd)?; let (ia, ib) = turn_and_travel(s, a, b, cmd)?; let c = Coupling::RackPinion { radius: size_param(s, p, "radius", cmd)?, reverse: reverse(p) }; - couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c) }) + couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() }) } fn screw(s: &mut Session, p: &Value) -> Result { @@ -794,7 +799,120 @@ fn screw(s: &mut Session, p: &Value) -> Result { } }; let c = Coupling::Screw { pitch: size_param(s, p, "pitch", cmd)?, reverse: reverse(p) }; - couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c) }) + couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() }) +} + +fn cam_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "joint.cam"; + let (a, b) = joint_pair(s, p, cmd)?; + let kind = |id: u64| s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.kind); + if kind(a) != Some(JointKind::Revolute) { + return Err(bad(cmd, "the cam (a) turns on a revolute joint")); + } + let oscillating = match kind(b) { + Some(JointKind::Slider) => false, + Some(JointKind::Revolute) => true, + _ => return Err(bad(cmd, "the follower (b) is a slider or a revolute joint")), + }; + let follower = match str_(p, "follower").unwrap_or("knife") { + "knife" => Follower::Knife, + "roller" => Follower::Roller { radius: size_param(s, p, "radius", cmd)? }, + "flat" => Follower::Flat, + _ => return Err(bad(cmd, "`follower` is knife, roller or flat")), + }; + if oscillating && p.get("tip").is_none() && !matches!(follower, Follower::Roller { radius: Size::Circle(_) }) { + return Err(bad(cmd, "an oscillating follower needs `tip`: its knife's point or its roller's centre ({circle: [x, y, z]} on the roller)")); + } + let tip = match p.get("tip") { + Some(v) => Some(origin_param(s, Some(v), cmd)?), + None => None, + }; + let mut c = Cam { profile: cam_profile(s, p, cmd)?, follower, tip, reverse: false }; + let probe = MotionLink { a, ia: 0, b, ib: 0, ratio: 1.0, offset: 0.0, coupling: None, lift: Vec::new() }; + let st = s.model.state(); + c.reverse = cam::default_reverse(&s.doc, &st, &probe, &c).map_err(|e| bad(cmd, e.to_string()))? != bool_(p, "flip").unwrap_or(false); + let offset = cam::follower_offset(&s.doc, &st, &probe, &c).map_err(|e| bad(cmd, e.to_string()))?; + let reverse = c.reverse; + let mut out = couple(s, p, cmd, MotionLink { coupling: Some(Coupling::Cam(c)), ..probe })?; + let index = out["link"].as_u64().and_then(|i| usize::try_from(i).ok()).unwrap_or(usize::MAX); + if let (Some(o), Some(l)) = (out.as_object_mut(), s.doc.assembly.links.get(index)) { + o.remove("ratio"); + o.insert("lift".into(), lift_json(l, oscillating)); + o.insert("reverse".into(), json!(reverse)); + if !oscillating { + o.insert("follower_offset".into(), json!(offset)); + } + } + Ok(out) +} + +/// A cam's follower travel over a turn of the cam: b's lowest and highest values and their +/// difference, in degrees (`angle`) or mm. +fn lift_json(l: &MotionLink, angle: bool) -> Value { + // A table that is not whole (a hand-edited file) moves nothing, and says so. + if l.lift.len() != cam::CAM_SAMPLES { + return Value::Null; + } + let unit = |v: f64| if angle { v.to_degrees() } else { v }; + let (lo, hi) = l.lift.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v))); + let (lo, hi) = (unit(lo + l.offset), unit(hi + l.offset)); + json!({"min": lo, "max": hi, "rise": hi - lo, "unit": if angle { "deg" } else { "mm" }}) +} + +/// A cam's profile: a sketch's closed profile (kept by its outer loop's curve ids, so the sketch's +/// other profiles may change), or picked edges of the cam's bodies (kept in the component's +/// coordinates with the edges' names). +fn cam_profile(s: &Session, p: &Value, cmd: &str) -> Result { + let v = p.get("profile").ok_or_else(|| bad(cmd, "`profile` is required: {sketch: id|name} or {edges: [[x, y, z], …]}"))?; + if let Some(sk) = v.get("sketch") { + let sketch = super::features::sketch_id(s, Some(sk), cmd, "profile.sketch")?; + let st = s.model.state(); + let solved = + st.sketches.iter().find(|x| x.feature == sketch).ok_or_else(|| bad(cmd, "that sketch is not evaluated (it is after the marker?)"))?; + let picked = match v.get("index") { + None => cam::sketch_profile(solved, &[]), + Some(i) => i.as_u64().and_then(|i| usize::try_from(i).ok()).and_then(|i| solved.profiles.get(i)), + }; + let profile = picked.ok_or_else(|| bad(cmd, "the sketch has no such closed profile (an open curve makes none)"))?; + return Ok(CamProfile::Sketch { sketch, curves: profile.outer_curves.clone() }); + } + let Some(Value::Array(pts)) = v.get("edges") else { + return Err(bad(cmd, "`profile` is {sketch: id|name} or {edges: [[x, y, z], …]}")); + }; + if pts.is_empty() || pts.len() > cam::MAX_PROFILE_EDGES { + return Err(bad(cmd, format!("pick 1 to {} edges", cam::MAX_PROFILE_EDGES))); + } + let world: Vec = pts.iter().map(vec3).collect::>().ok_or_else(|| bad(cmd, "`edges` are finite [x, y, z] points"))?; + let occurrence = match v.get("occurrence") { + Some(o) => occurrence_id(s, Some(o), cmd)?, + None => world.first().and_then(|w| occurrence_at(s, *w)).ok_or_else(|| bad(cmd, "no body there"))?, + }; + let t = if occurrence == 0 { solidcraft_doc::IDENTITY } else { joints::occurrence_world(&s.doc, occurrence) }; + let inv = solidcraft_doc::mat_inverse(&t).ok_or_else(|| bad(cmd, "occurrence transform"))?; + let picks: Vec = world.iter().map(|w| solidcraft_doc::apply_point(&inv, *w)).collect(); + let comp = s.doc.occurrences.iter().find(|o| o.id == occurrence).map(|o| o.component).unwrap_or(0); + let st = s.model.state(); + let bodies: Vec<&solidcraft_doc::ModelBody> = st.bodies.iter().filter(|b| s.doc.model_body_component(b) == comp).collect(); + // Each pick's edge name on the body whose edges pass nearest it. + let names = picks + .iter() + .map(|q| { + let near = |b: &&solidcraft_doc::ModelBody| { + b.mesh() + .edges + .iter() + .flat_map(|e| e.windows(2)) + .filter_map(|w| Some(q.dist_to_segment(*w.first()?, *w.get(1)?))) + .fold(f64::MAX, f64::min) + }; + bodies + .iter() + .min_by(|x, y| near(x).total_cmp(&near(y))) + .and_then(|b| solidcraft_doc::naming::edge_names_at(b, &[*q]).into_iter().next()) + .unwrap_or_default() + }) + .collect(); + Ok(CamProfile::Edges { occurrence, picks, names }) } /// Add a coupling's motion link, or put it in place of link `link`: its ratio worked out from its @@ -826,14 +944,18 @@ fn couple(s: &mut Session, p: &Value, cmd: &str, mut l: MotionLink) -> Result break, } } - l.ratio = joints::coupling_ratio(&s.doc, &s.model.state(), &l).unwrap_or(Ok(l.ratio)).map_err(|e| bad(cmd, e.to_string()))?; + (l.ratio, l.lift) = + joints::coupling_values(&s.doc, &s.model.state(), &l).unwrap_or(Ok((l.ratio, Vec::new()))).map_err(|e| bad(cmd, e.to_string()))?; let vals = joints::joint_values(&s.doc.assembly); let value = |id: u64, i: usize| vals.get(&id).and_then(|q| q.get(i)).copied().unwrap_or(0.0); // A link put in place of another keeps its offset, as a radius edited in the design does. l.offset = match replace.and_then(|i| links.get(i)) { Some(old) => old.offset, - None => value(l.b, l.ib) - l.ratio * value(l.a, l.ia), + None => value(l.b, l.ib) - MotionLink { offset: 0.0, ..l.clone() }.follow(value(l.a, l.ia)), }; + if !l.offset.is_finite() { + return Err(bad(cmd, "the coupling gives the driven joint no value where it is")); + } let shown = l.ratio / ratio_scale(s, l.a, l.ia, l.b, l.ib); let doc = s.doc_mut(); let index = match replace { @@ -994,10 +1116,17 @@ fn list(s: &mut Session, p: &Value) -> Result { json!({"a": l.a, "ia": l.ia, "b": l.b, "ib": l.ib, "ratio": ratio, "offset": offset, "unit": if angle { "deg" } else { "mm" }}); if let Some(c) = &l.coupling { v["coupling"] = json!(c); - if let Some(Err(e)) = joints::coupling_ratio(&s.doc, &st, l) { + if let Some(Err(e)) = joints::coupling_values(&s.doc, &st, l) { v["error"] = json!(e.to_string()); } } + // A cam's follower follows its table, not a ratio: its travel instead. + if matches!(l.coupling, Some(Coupling::Cam(_))) + && let Some(o) = v.as_object_mut() + { + o.remove("ratio"); + o.insert("lift".into(), lift_json(l, angle)); + } v }) .collect(); diff --git a/crates/engine/src/cmd/joints_cam_tests.rs b/crates/engine/src/cmd/joints_cam_tests.rs new file mode 100644 index 00000000..3ed46a74 --- /dev/null +++ b/crates/engine/src/cmd/joints_cam_tests.rs @@ -0,0 +1,435 @@ +//! Cams: a revolute joint drives a follower that rides the cam's profile. Expected values are +//! analytic: an eccentric circle of radius R whose centre is e from its axis puts a knife-edge +//! follower on a line h from the axis at e·cos θ + √(R² − (h − e·sin θ)²), a roller of radius r +//! where a knife on the circle of radius R + r would be, a flat face at e·cos θ + R; an +//! oscillating follower's roller sits where its arm's circle meets the circle of radius R + r. + +use super::*; +use solidcraft_doc::joints::Coupling; +use solidcraft_doc::joints::cam::CamProfile; + +/// The cam's eccentricity and radius (mm). +const E: f64 = 10.0; +const R: f64 = 30.0; + +fn joint_id(s: &Session, name: &str) -> u64 { + s.doc.assembly.joints.iter().find(|j| j.name == name).map(|j| j.id).unwrap_or(0) +} + +/// A joint's value as `joint.list` gives it (degrees or mm). +fn shown(s: &mut Session, joint: &str) -> f64 { + let l = run(s, "joint.list", json!({"quiet": true})); + let j = l["joints"].as_array().into_iter().flatten().find(|j| j["name"] == joint).cloned().unwrap_or_default(); + j["values"][0]["value"].as_f64().unwrap_or(f64::NAN) +} + +/// Where a knife edge on a line `h` from the axis stands on the circle of radius `r` about the +/// cam's centre turned by `deg`. +fn knife(deg: f64, h: f64, r: f64) -> f64 { + let t = deg.to_radians(); + E * t.cos() + (r * r - (h - E * t.sin()).powi(2)).sqrt() +} + +/// A grounded base, and a cam: a disc of radius `rc` (a parameter, 30 mm) about (10, 0), 5 mm thick, +/// drawn in sketch `CamSketch` of its own component, on a revolute joint `Cam` about Z through the +/// origin (the cam is its b side). +fn cam_on_base() -> Session { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "rc", "expression": "30 mm"})); + run(&mut s, "parameters.add", json!({"name": "th", "expression": "5 mm"})); + part(&mut s, "Base", [-90.0, -90.0, -20.0]); + let g = occ(&s, "Base"); + run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true})); + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": "Cam"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "CamSketch"})); + run(&mut s, "sketch.circle.center", json!({"center": [E, 0], "radius": R, "id": "c1"})); + run(&mut s, "sketch.dimension", json!({"entities": ["c1"], "type": "radius", "value": "rc"})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": "th"})); + pin(&mut s, "revolute", "Cam", "Base", "Cam", Vec3::ZERO, Vec3::Z); + s +} + +/// A tappet (a 20 mm bar, 4 wide) sliding along +X on the line y = `h`, its end at x = `tip`, on a +/// slider `Tappet`. +fn tappet(s: &mut Session, h: f64, tip: f64) { + run(s, "component.activate", json!({"component": "root"})); + run(s, "component.create", json!({"name": "Tappet"})); + run(s, "solid.box", json!({"length": 20, "width": 4, "height": 5, "corner": [tip, h - 2.0, 0]})); + pin(s, "slider", "Tappet", "Base", "Tappet", Vec3::new(0.0, h, 2.5), Vec3::X); +} + +fn close(got: f64, want: f64, tol: f64) -> bool { + (got - want).abs() < tol +} + +/// A knife-edge tappet on the eccentric circle, its line through the axis and 6 mm past it, rises +/// and falls as e·cos θ + √(R² − (h − e·sin θ)²); the tappet's body goes with it. +#[test] +fn a_knife_edge_tappet_rides_an_eccentric_circle() { + for h in [0.0, 6.0] { + let mut s = cam_on_base(); + let tip = knife(0.0, h, R); + tappet(&mut s, h, tip); + let r = run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}})); + assert_eq!((r["link"].clone(), r["reverse"].clone()), (json!(0), json!(false)), "{r}"); + assert!(close(r["follower_offset"].as_f64().unwrap_or(f64::NAN), h, 1e-9), "{r}"); + // It stays where it is, and its travel over a turn is the cam's. + assert!(close(shown(&mut s, "Tappet"), 0.0, 1e-9), "{r}"); + let over_a_turn: Vec = (0..3600).map(|k| knife(f64::from(k) / 10.0, h, R)).collect(); + let rise = over_a_turn.iter().fold(f64::MIN, |a, b| a.max(*b)) - over_a_turn.iter().fold(f64::MAX, |a, b| a.min(*b)); + assert!(close(r["lift"]["rise"].as_f64().unwrap_or(f64::NAN), rise, 2e-3), "{r} vs {rise}"); + for deg in [30.0f64, 90.0, 135.0, 200.0, 300.0, -45.0, 400.0] { + let d = run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg})); + assert_eq!(d["conflicts"].as_array().map(Vec::len), Some(0), "{d}"); + let want = knife(deg, h, R) - tip; + assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "h {h} θ {deg}: {} vs {want}", shown(&mut s, "Tappet")); + let end = place(&s, "Tappet", Vec3::new(tip, h, 2.5)); + assert!(close(end.x, tip + want, 1e-3) && close(end.y, h, 1e-9), "{end:?}"); + } + } +} + +/// The follower's line is through its tip, wherever its slider's origin is across the line; a +/// guide of its own (grounded, the slider's b side) makes the tappet the slider's a side, and it +/// rides the same way. +#[test] +fn a_tappet_on_a_guide_of_its_own_rides_its_tip_s_line() { + let h = 6.0; + let tip = knife(0.0, h, R); + let mut s = cam_on_base(); + part(&mut s, "Guide", [70.0, 20.0, 0.0]); + let g = occ(&s, "Guide"); + run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true})); + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": "Tappet"})); + run(&mut s, "solid.box", json!({"length": 20, "width": 4, "height": 5, "corner": [tip, h - 2.0, 0]})); + // The slider's origins 9 mm across from the tappet's line, a the tappet, b the guide. + pin(&mut s, "slider", "Tappet", "Tappet", "Guide", Vec3::new(0.0, h + 9.0, 2.5), Vec3::X); + let r = run( + &mut s, + "joint.cam", + json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "tip": {"occurrence": "Tappet", "point": [tip, h, 2.5]}}), + ); + assert!(close(r["follower_offset"].as_f64().unwrap_or(f64::NAN), h, 1e-9), "{r}"); + for deg in [70.0f64, 160.0, 290.0] { + run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg})); + let end = place(&s, "Tappet", Vec3::new(tip, h, 2.5)); + assert!(close(end.x, knife(deg, h, R), 1e-3), "θ {deg}: {end:?} vs {}", knife(deg, h, R)); + } +} + +/// A roller of radius 5 rides the circle of radius R + 5; a flat face e·cos θ + R, and when the +/// cam's radius parameter grows by 2 the flat face stands 2 further out at every angle. +#[test] +fn rollers_and_flat_faces_ride_their_offset_circles() { + let mut s = cam_on_base(); + let tip = knife(0.0, 0.0, R + 5.0); + tappet(&mut s, 0.0, tip); + run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": "5 mm"})); + for deg in [60.0f64, 150.0, 250.0] { + run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg})); + let want = knife(deg, 0.0, R + 5.0) - tip; + assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "roller θ {deg}: {} vs {want}", shown(&mut s, "Tappet")); + } + let mut s = cam_on_base(); + tappet(&mut s, 3.0, E + R); + run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "flat"})); + for deg in [60.0f64, 150.0, 250.0] { + run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg})); + let want = E * deg.to_radians().cos() - E; + assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "flat θ {deg}: {} vs {want}", shown(&mut s, "Tappet")); + } + // Editing the profile: the table is worked out again. + run(&mut s, "parameters.change", json!({"name": "rc", "expression": "32 mm"})); + let want = E * 250f64.to_radians().cos() - E + 2.0; + assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "after the edit: {} vs {want}", shown(&mut s, "Tappet")); +} + +/// The cam's top edge picked on its body gives the table its sketch gives, and follows the +/// design the same way. +#[test] +fn picked_edges_give_the_sketch_s_table() { + let table = |profile: Value| { + let mut s = cam_on_base(); + tappet(&mut s, 0.0, E + R); + run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": profile})); + s.doc.assembly.links.first().map(|l| (l.lift.clone(), l.offset)).unwrap_or_default() + }; + let (sketch, a) = table(json!({"sketch": "CamSketch"})); + let (edges, b) = table(json!({"edges": [[E + R, 0, 5]]})); + assert_eq!((sketch.len(), edges.len()), (360, 360)); + assert!(sketch.iter().zip(&edges).all(|(x, y)| close(*x, *y, 1e-3)) && close(a, b, 1e-3)); + // A second disc of the cam's component touching it in the same plane is not part of its loop. + let mut s = cam_on_base(); + run(&mut s, "component.activate", json!({"component": "Cam"})); + run(&mut s, "solid.cylinder", json!({"base": [E + R + 20.0, 0, 0], "axis": [0, 0, 1], "radius": 20, "height": 5})); + tappet(&mut s, 0.0, E + R); + run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[E - R, 0, 5]]}})); + let touching = s.doc.assembly.links.first().map(|l| l.lift.clone()).unwrap_or_default(); + assert!(touching.len() == 360 && touching.iter().zip(&sketch).all(|(x, y)| close(*x, *y, 1e-3)), "{touching:?}"); + let mut s = cam_on_base(); + tappet(&mut s, 0.0, E + R); + run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[E + R, 0, 5]]}})); + assert!(matches!( + s.doc.assembly.links.first().and_then(|l| l.coupling.clone()), + Some(Coupling::Cam(c)) if matches!(&c.profile, CamProfile::Edges { names, .. } if names.first().is_some_and(|n| !n.is_empty())) + )); + run(&mut s, "parameters.change", json!({"name": "rc", "expression": "32 mm"})); + // A thicker cam: the picked edge is found at its new height, and its loop with it. + run(&mut s, "parameters.change", json!({"name": "th", "expression": "7 mm"})); + let l = run(&mut s, "joint.list", json!({"quiet": true})); + assert!(l["links"][0].get("error").is_none(), "{l}"); + run(&mut s, "joint.drive", json!({"joint": "Cam", "value": 90})); + let want = knife(90.0, 0.0, 32.0) - (E + R); + assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "{} vs {want}", shown(&mut s, "Tappet")); +} + +/// An arm of 50 about (−50, 40) with a roller of 5: its roller's centre is where the arm's circle +/// meets the circle of radius R + 5 about the cam's centre, the meeting it turns into first from +/// pointing away from the cam's axis. +#[test] +fn an_oscillating_roller_follower_swings_to_the_offset_circle() { + let (pivot, arm, roller) = (Vec3::new(-50.0, 40.0, 2.5), 50.0, 5.0); + let away = pivot.y.atan2(pivot.x); + let wrap = |a: f64| (a + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; + let angle = |p: Vec3| (p.y - pivot.y).atan2(p.x - pivot.x); + // Where the roller's centre is at cam angle `deg`, turning the way `turn` says. + let centre = |deg: f64, turn: f64| { + let c = Vec3::new(E * deg.to_radians().cos(), E * deg.to_radians().sin(), 2.5); + let [p, q] = meet(pivot, arm, c, R + roller); + let swept = |x: Vec3| (turn * (angle(x) - away)).rem_euclid(std::f64::consts::TAU); + if swept(p) < swept(q) { p } else { q } + }; + let [p, q] = meet(pivot, arm, Vec3::new(E, 0.0, 2.5), R + roller); + let start = if wrap(angle(p) - away).abs() < wrap(angle(q) - away).abs() { p } else { q }; + let turn = wrap(angle(start) - away).signum(); + let mut s = cam_on_base(); + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": "Arm"})); + run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 5, "corner": [start.x - 2.0, start.y - 2.0, 0]})); + pin(&mut s, "revolute", "Arm", "Base", "Arm", pivot, Vec3::Z); + // An oscillating follower needs its tip. + assert!(s.execute("joint.cam", &json!({"a": "Cam", "b": "Arm", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": 5})).is_err()); + let r = run( + &mut s, + "joint.cam", + json!({"a": "Cam", "b": "Arm", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": 5, + "tip": {"occurrence": "Arm", "point": [start.x, start.y, 2.5]}}), + ); + assert_eq!((r["reverse"].clone(), r["lift"]["unit"].clone()), (json!(turn < 0.0), json!("deg")), "{r}"); + for deg in [40.0f64, 120.0, 210.0, 330.0] { + run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg})); + let want = wrap(angle(centre(deg, turn)) - angle(start)).to_degrees(); + assert!(close(shown(&mut s, "Arm"), want, 1e-3), "θ {deg}: {} vs {want}", shown(&mut s, "Arm")); + let at = place(&s, "Arm", start); + assert!(at.dist(centre(deg, turn)) < 1e-3, "θ {deg}: {at:?}"); + } + // The roller's round face picked on its arm is where it touches, and its radius: the same table. + let table = s.doc.assembly.links.first().map(|l| l.lift.clone()).unwrap_or_default(); + let mut t = cam_on_base(); + run(&mut t, "component.activate", json!({"component": "root"})); + run(&mut t, "component.create", json!({"name": "Arm"})); + run(&mut t, "solid.cylinder", json!({"base": [start.x, start.y, 0], "axis": [0, 0, 1], "radius": roller, "height": 5})); + pin(&mut t, "revolute", "Arm", "Base", "Arm", pivot, Vec3::Z); + let face = [start.x + roller, start.y, 2.5]; + run( + &mut t, + "joint.cam", + json!({"a": "Cam", "b": "Arm", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": {"occurrence": "Arm", "circle": face}}), + ); + let picked = t.doc.assembly.links.first().map(|l| l.lift.clone()).unwrap_or_default(); + assert!(picked.len() == 360 && picked.iter().zip(&table).all(|(x, y)| close(*x, *y, 1e-6))); +} + +/// A stationary cam: the profile on the grounded part, a rotor turning about it with the tappet's +/// guide on it. Turned by +θ against the cam, the tappet stands where it would on a cam turned by +/// −θ. A guide on the cam's own (grounded) side would turn with the profile: refused. +#[test] +fn a_stationary_cam_drives_a_follower_on_a_turning_rotor() { + let h = 6.0; + let tip = knife(0.0, h, R); + let mut s = cam_on_base(); + // The cam is the fixed part: ground it, and take the base off its joint. + let (cam, base) = (occ(&s, "Cam"), occ(&s, "Base")); + run(&mut s, "joint.delete", json!({"joint": "Cam"})); + run(&mut s, "occurrence.ground", json!({"occurrence": cam, "grounded": true})); + run(&mut s, "occurrence.ground", json!({"occurrence": base, "grounded": false})); + pin(&mut s, "revolute", "Turret", "Cam", "Base", Vec3::ZERO, Vec3::Z); + tappet(&mut s, h, tip); + run(&mut s, "joint.cam", json!({"a": "Turret", "b": "Tappet", "profile": {"sketch": "CamSketch"}})); + for deg in [50.0f64, 140.0, 260.0] { + let d = run(&mut s, "joint.drive", json!({"joint": "Turret", "value": deg})); + assert_eq!(d["conflicts"].as_array().map(Vec::len), Some(0), "{d}"); + let want = knife(-deg, h, R) - tip; + assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "θ {deg}: {} vs {want}", shown(&mut s, "Tappet")); + } + let mut s = cam_on_base(); + let (cam, base) = (occ(&s, "Cam"), occ(&s, "Base")); + run(&mut s, "joint.delete", json!({"joint": "Cam"})); + run(&mut s, "occurrence.ground", json!({"occurrence": cam, "grounded": true})); + run(&mut s, "occurrence.ground", json!({"occurrence": base, "grounded": false})); + part(&mut s, "Rotor", [-90.0, 60.0, -20.0]); + pin(&mut s, "revolute", "Turret", "Cam", "Rotor", Vec3::ZERO, Vec3::Z); + run(&mut s, "occurrence.ground", json!({"occurrence": base, "grounded": true})); + tappet(&mut s, h, tip); + let e = s.execute("joint.cam", &json!({"a": "Turret", "b": "Tappet", "profile": {"sketch": "CamSketch"}})).map_err(|e| e.to_string()).err(); + assert!(e.as_deref().is_some_and(|e| e.contains("turns with the cam")), "{e:?}"); +} + +/// A motion study turning the cam plays the tappet through its analytic travel. +#[test] +fn a_cam_plays_in_a_motion_study() { + let mut s = cam_on_base(); + let tip = knife(0.0, 0.0, R); + tappet(&mut s, 0.0, tip); + run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}})); + run(&mut s, "motion.study", json!({"name": "Turn", "steps": 10, "keys": [{"joint": "Cam", "points": [[0, 0], [10, 360]]}]})); + for step in [0, 2, 5, 7, 10] { + run(&mut s, "motion.play", json!({"study": "Turn", "step": step})); + let want = knife(36.0 * f64::from(step), 0.0, R) - tip; + assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "step {step}: {} vs {want}", shown(&mut s, "Tappet")); + } +} + +/// The tappet is the piston of a slider-crank: a 40 mm rod from its end to a 20 mm crank about +/// (95, 0). Turning the cam moves the tappet, and the loop turns the crank to where the rod's +/// circle meets the crank's, on the branch it started on. A stop in a contact set with the cam and +/// the tappet halts the cam where the tappet reaches it; the cam and the flat tappet only touch. +#[test] +fn a_cam_drives_a_closed_loop_and_stops_at_a_contact() { + let mut s = cam_on_base(); + let tip = knife(0.0, 0.0, R); + tappet(&mut s, 0.0, tip); + let (q, end) = (Vec3::new(95.0, 0.0, 2.5), Vec3::new(tip + 20.0, 0.0, 2.5)); + let crank_pin = |x: f64| meet(q, 20.0, Vec3::new(x, 0.0, 2.5), 40.0).into_iter().find(|p| p.y > 0.0).unwrap_or(Vec3::ZERO); + let pin0 = crank_pin(end.x); + for (name, at) in [("Rod", end), ("Crank", pin0)] { + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": name})); + run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [at.x - 1.0, at.y - 1.0, 10]})); + } + pin(&mut s, "revolute", "Crank", "Base", "Crank", q, Vec3::Z); + pin(&mut s, "revolute", "Wrist", "Tappet", "Rod", end, Vec3::Z); + pin(&mut s, "revolute", "Big end", "Rod", "Crank", pin0, Vec3::Z); + run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}})); + for deg in [45.0f64, 120.0, 180.0] { + let d = run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg})); + assert_eq!(d["conflicts"].as_array().map(Vec::len), Some(0), "{d}"); + let x = end.x + knife(deg, 0.0, R) - tip; + let at = place(&s, "Crank", pin0); + assert!(at.dist(crank_pin(x)) < 1e-3, "θ {deg}: {at:?} vs {:?}", crank_pin(x)); + } + // A flat tappet, a stop 5 mm past where its end is at the top, and contact on. + let mut s = cam_on_base(); + tappet(&mut s, 0.0, E + R); + run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "flat"})); + run(&mut s, "joint.drive", json!({"joint": "Cam", "value": 180})); + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": "Stop"})); + run(&mut s, "solid.box", json!({"length": 3, "width": 4, "height": 5, "corner": [E + R + 15.0, -2, 0]})); + let st = occ(&s, "Stop"); + run(&mut s, "occurrence.ground", json!({"occurrence": st, "grounded": true})); + run(&mut s, "contact.create", json!({"occurrences": ["Cam", "Tappet", "Stop"]})); + // Touching the cam stops nothing: half a turn moves the tappet 20 mm back up, as far as the stop. + let d = run(&mut s, "joint.drive", json!({"joint": "Cam", "value": 90})); + assert!(d["stopped_by_contact"].is_null(), "{d}"); + let d = run(&mut s, "joint.drive", json!({"joint": "Cam", "value": 0})); + assert!(d["stopped_by_contact"].is_object(), "{d}"); + let reached = shown(&mut s, "Tappet"); + assert!(reached > -5.5 && reached < -4.5, "{reached}"); +} + +/// What a cam can't be made from is refused, and a table edited by hand never puts a NaN into a +/// joint's value. +#[test] +fn hostile_cams_are_refused() { + let mut s = cam_on_base(); + tappet(&mut s, 0.0, E + R); + let refused = |s: &mut Session, p: Value| s.execute("joint.cam", &p).map_err(|e| e.to_string()).err().unwrap_or_else(|| panic!("accepted {p}")); + // An open curve makes no closed profile. + run(&mut s, "component.activate", json!({"component": "Cam"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Open"})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 5], [10, 20]]})); + run(&mut s, "sketch.finish", json!({})); + let e = refused(&mut s, json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "Open"}})); + assert!(e.contains("closed profile"), "{e}"); + // Two closed edges are not one loop; a line past the cam misses it. + let e = refused(&mut s, json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[E + R, 0, 5], [E + R, 0, 0]]}})); + assert!(e.contains("closed loop"), "{e}"); + let mut far = cam_on_base(); + tappet(&mut far, 50.0, 0.0); + let e = refused(&mut far, json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}})); + assert!(e.contains("misses"), "{e}"); + for p in [ + json!({"a": "Tappet", "b": "Cam", "profile": {"sketch": "CamSketch"}}), + json!({"a": "Cam", "b": "Cam", "profile": {"sketch": "CamSketch"}}), + json!({"a": "Cam", "b": "Tappet"}), + json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch", "index": 7}}), + json!({"a": "Cam", "b": "Tappet", "profile": {"edges": []}}), + json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[1, "x", 0]]}}), + json!({"a": "Cam", "b": "Tappet", "profile": {"edges": vec![[E + R, 0.0, 5.0]; 65]}}), + json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller"}), + json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": -1}), + json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": "nope"}), + json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": 1e300}), + json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "spring"}), + json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "link": 3}), + ] { + refused(&mut s, p); + } + assert!(s.doc.assembly.links.is_empty()); + // A table edited by hand: NaN leaves the follower where it is, any length reads. + run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}})); + let before = solidcraft_doc::joints::joint_values(&s.doc.assembly); + for lift in [vec![f64::NAN; 360], vec![1.0, 2.0, 3.0], vec![f64::INFINITY]] { + let mut d = (*s.doc).clone(); + if let Some(l) = d.assembly.links.first_mut() { + l.lift = lift; + } + let vals = solidcraft_doc::joints::joint_values(&d.assembly); + assert!(vals.values().flatten().all(|v| v.is_finite()), "{vals:?}"); + // The follower keeps where it is. + assert_eq!(vals.get(&joint_id(&s, "Tappet")), d.assembly.joints.iter().find(|j| j.name == "Tappet").map(|j| &j.values), "{before:?}"); + } + // A table on a plain link is not read. + let mut d = (*s.doc).clone(); + if let Some(l) = d.assembly.links.first_mut() { + (l.coupling, l.ratio, l.offset) = (None, 0.0, 3.0); + } + assert_eq!(solidcraft_doc::joints::joint_values(&d.assembly).get(&joint_id(&s, "Tappet")).and_then(|q| q.first()).copied(), Some(3.0)); + // A roller's picked face must turn about an axis parallel to the cam's, a tip given or not. + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": "Pin"})); + run(&mut s, "solid.cylinder", json!({"base": [60, 30, 0], "axis": [1, 0, 0], "radius": 3, "height": 10})); + run(&mut s, "joint.delete", json!({"link": 0})); + let tilted = json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", + "radius": {"occurrence": "Pin", "circle": [65, 33, 0]}, "tip": {"occurrence": "Tappet", "point": [E + R, 0, 2.5]}}); + let e = refused(&mut s, tilted); + assert!(e.contains("roller's axis"), "{e}"); +} + +/// A cam saves and opens with its definition and table; a link saved before cams reads as a +/// plain link. +#[test] +fn cams_save_and_open_with_their_table() { + let mut s = cam_on_base(); + tappet(&mut s, 0.0, E + R); + run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": 2})); + run(&mut s, "joint.drive", json!({"joint": "Cam", "value": 30})); + let text = s.doc.to_json(); + let back = solidcraft_doc::format::read(&text).unwrap_or_else(|e| panic!("{e}")); + assert_eq!(back.assembly.links, s.doc.assembly.links); + assert_eq!(back.assembly.links.first().map(|l| l.lift.len()), Some(360)); + let l = run(&mut s, "joint.list", json!({})); + let link = &l["links"][0]; + assert!(link.get("ratio").is_none() && link["lift"]["unit"] == "mm" && link["coupling"]["type"] == "cam", "{link}"); + assert!(link["coupling"].get("lift").is_none(), "{link}"); + let mut v: Value = serde_json::from_str(&text).unwrap_or_default(); + for l in v["assembly"]["links"].as_array_mut().into_iter().flatten() { + l.as_object_mut().map(|o| (o.remove("coupling"), o.remove("lift"))); + } + let old = solidcraft_doc::format::read(&v.to_string()).unwrap_or_else(|e| panic!("{e}")); + assert!(old.assembly.links.iter().all(|l| l.coupling.is_none() && l.lift.is_empty())); +} diff --git a/crates/engine/src/cmd/joints_tests.rs b/crates/engine/src/cmd/joints_tests.rs index 2abeadb5..d02a803c 100644 --- a/crates/engine/src/cmd/joints_tests.rs +++ b/crates/engine/src/cmd/joints_tests.rs @@ -1445,3 +1445,6 @@ fn hostile_trace_params_are_refused() { #[path = "joints_coupling_tests.rs"] mod coupling; + +#[path = "joints_cam_tests.rs"] +mod cam; diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 7a51992f..05fe187d 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -1,5 +1,5 @@ //! Assembly dialogs: New Component, Joint, As-built Joint, Joint Origin, Rigid Group, Drive -//! Joints, Motion Link, Gear, Rack and Pinion, Screw (`dialogs_coupling`) and Interference. Joint origins are picked by hovering geometry: a face +//! Joints, Motion Link, Gear, Rack and Pinion, Screw, Cam (`dialogs_coupling`) and Interference. Joint origins are picked by hovering geometry: a face //! snaps to its centre (or a round face to the centre of its nearer end), an edge to its middle //! (a round edge to its centre), a vertex to itself; the snap shows as a glyph with its z axis //! before the click. Joints and drives preview on the placed (world) model: the moving @@ -31,7 +31,7 @@ const TYPE_LABELS: [&str; 7] = ["Rigid", "Revolute", "Slider", "Cylindrical", "P /// What a joint origin input accepts (snaps on faces, edges and vertices). const SNAPS: u16 = FACES | EDGES | VERTICES; /// Commands whose effect is where occurrences sit (previewed on the placed model). -const MOVES: [&str; 11] = [ +const MOVES: [&str; 12] = [ "joint.create", "joint.as_built", "joint.drive", @@ -41,6 +41,7 @@ const MOVES: [&str; 11] = [ "joint.gear", "joint.rack_pinion", "joint.screw", + "joint.cam", "parts.insert", "parts.fastener", ]; @@ -72,6 +73,7 @@ pub enum Asm { offset: String, }, Coupling(Box), + Cam(Box), Interference { result: Option, overlaps: Vec, @@ -151,13 +153,14 @@ impl Asm { "joint.rack_pinion" => "RACK AND PINION", _ => "SCREW", }, + Asm::Cam(_) => "CAM", Asm::Interference { .. } => "INTERFERENCE", Asm::Trace(_) => "MOTION TRACE", } } pub fn previews(&self) -> bool { - matches!(self, Asm::Joint(_) | Asm::Drive { .. } | Asm::RigidGroup | Asm::Coupling(_)) + matches!(self, Asm::Joint(_) | Asm::Drive { .. } | Asm::RigidGroup | Asm::Coupling(_) | Asm::Cam(_)) } } @@ -185,6 +188,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { (Asm::MotionLink { a: 0, b, ia: 0, ib: 0, ratio: "1".into(), offset: "0".into() }, vec![]) } "joint.gear" | "joint.rack_pinion" | "joint.screw" => crate::dialogs_coupling::start(app, id)?, + "joint.cam" => crate::dialogs_coupling::cam::start(app), "inspect.interference" => { (Asm::Interference { result: None, overlaps: Vec::new(), of: Vec::new() }, vec![SelInput::new("Bodies (all if none)", BODIES, true)]) } @@ -686,6 +690,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se ui.end_row(); } Asm::Coupling(f) => enter |= crate::dialogs_coupling::rows(app, ui, f, inputs), + Asm::Cam(f) => enter |= crate::dialogs_coupling::cam::rows(app, ui, f, inputs), Asm::Interference { result, overlaps, of } => { let picked: Vec = inputs.first().map(|i| i.items.clone()).unwrap_or_default(); row_label(ui, ""); @@ -914,6 +919,7 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result return crate::dialogs_coupling::commands(app, f, inputs), + Asm::Cam(f) => return crate::dialogs_coupling::cam::commands(app, f, inputs), // The check runs from its Compute button; OK only closes. Asm::Interference { .. } => Vec::new(), Asm::Trace(f) => cmd("motion.trace.keep", trace::params(s, f, inputs)?), @@ -925,6 +931,9 @@ pub fn animate(app: &SolveApp, k: &Asm, cmds: &mut Vec<(String, Value)>) { if let Asm::Coupling(f) = k { crate::dialogs_coupling::animate(&app.session, f, cmds); } + if let Asm::Cam(f) = k { + crate::dialogs_coupling::cam::animate(&app.session, f, cmds); + } let Asm::Joint(f) = k else { return }; if !f.animate { return; diff --git a/crates/ui-egui/src/dialogs_cam.rs b/crates/ui-egui/src/dialogs_cam.rs new file mode 100644 index 00000000..983e8779 --- /dev/null +++ b/crates/ui-egui/src/dialogs_cam.rs @@ -0,0 +1,197 @@ +//! The Cam dialog (`joint.cam`): a cam turning on a revolute joint drives a follower, a slider or +//! a revolute, along the cam's profile (a closed sketch profile, or edges of the cam's body). The +//! follower is a knife edge, a roller (its radius typed, or its round face picked, which is also +//! where it touches) or a flat face. Picking the profile on a part chooses the joint that part +//! turns on. Play turns the cam a full turn so the follower shows its motion before OK. + +use egui::RichText; +use serde_json::{Value, json}; +use solidcraft_engine::Sel; +use solidcraft_engine::Session; +use solidcraft_engine::doc::joints::JointKind; + +use super::{ROUND, joint_row, occurrence, pick, picked_radius, trim, turn}; +use crate::SolveApp; +use crate::dialogs::{check, combo, field, row_label}; +use crate::dialogs_assembly::Asm; +use crate::selection::{EDGES, FACES, PROFILES, SelInput, VERTICES}; +use crate::theme::Tokens; + +/// The follower types, as `joint.cam` takes them, and their labels. +const FOLLOWERS: [&str; 3] = ["knife", "roller", "flat"]; +const FOLLOWER_LABELS: [&str; 3] = ["Knife edge", "Roller", "Flat face"]; + +/// The inputs: the cam's profile, the roller's round face, the follower's tip. +const PROFILE: usize = 0; +const ROLLER: usize = 1; +const TIP: usize = 2; + +#[derive(Clone, Debug)] +pub struct CamForm { + /// The cam's joint and the follower's (ids). + pub a: u64, + pub b: u64, + /// Index into `FOLLOWERS`. + pub follower: usize, + /// The roller's radius, used where no round face is picked. + pub radius: String, + /// Ride the cam from the other side. + pub flip: bool, + /// The body of the profile pick the cam's joint was last chosen from, so a pick chooses once. + pub picked: Option, + pub animate: bool, + /// Animation phase, 0…1. + pub phase: f64, +} + +/// Joints of a kind: (id, label). +fn joints_of(s: &Session, kinds: &[JointKind]) -> Vec<(u64, String)> { + s.doc.assembly.joints.iter().filter(|j| kinds.contains(&j.kind)).map(|j| (j.id, format!("{} ({:?})", j.name, j.kind))).collect() +} + +pub fn start(app: &SolveApp) -> (Asm, Vec) { + let s = &app.session; + let cams = joints_of(s, &[JointKind::Revolute]); + let followers = joints_of(s, &[JointKind::Slider, JointKind::Revolute]); + let a = cams.first().map(|j| j.0).unwrap_or(0); + // A slider first, else another revolute than the cam's. + let b = followers.iter().find(|j| j.0 != a).map(|j| j.0).unwrap_or(0); + let f = CamForm { a, b, follower: 0, radius: "5 mm".into(), flip: false, picked: None, animate: false, phase: 0.0 }; + let inputs = vec![ + SelInput::new("Profile", PROFILES | EDGES, true), + SelInput::new("Roller (optional)", ROUND, false), + SelInput::new("Tip (optional)", FACES | EDGES | VERTICES, false), + ]; + (Asm::Cam(Box::new(f)), inputs) +} + +/// The occurrence the profile picked in `inputs` is on: an edge's body's, or a sketch's component's +/// first. +fn profile_occurrence(s: &Session, inputs: &[SelInput]) -> Option<(String, u64)> { + match inputs.get(PROFILE)?.items.first()? { + Sel::Edge { body, .. } => Some((body.clone(), occurrence(s, body)?)), + Sel::Profile { sketch, .. } => { + let comp = s.doc.feature(*sketch).map(|f| f.component)?; + let occ = if comp == 0 { 0 } else { s.doc.occurrence_of(comp)?.id }; + Some((format!("sketch {sketch}"), occ)) + } + _ => None, + } +} + +/// The dialog's rows; true when Enter was pressed in a value. +pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, f: &mut CamForm, inputs: &[SelInput]) -> bool { + let t = Tokens::get(); + let s = &app.session; + let (cams, followers) = (joints_of(s, &[JointKind::Revolute]), joints_of(s, &[JointKind::Slider, JointKind::Revolute])); + if cams.is_empty() || followers.len() < 2 && followers.iter().all(|j| cams.iter().any(|c| c.0 == j.0)) { + row_label(ui, ""); + ui.label(RichText::new(crate::i18n::tr_label("needs a joint that turns and one that slides or swings")).color(t.text_dim)); + ui.end_row(); + return false; + } + // A profile picked on a part chooses the joint that part turns on, once. + let at = profile_occurrence(s, inputs); + if at.as_ref().map(|x| &x.0) != f.picked.as_ref() { + if let Some(j) = at.as_ref().and_then(|(_, o)| { + let turning = s.doc.assembly.joints.iter().filter(|j| j.kind == JointKind::Revolute); + turning.clone().find(|j| j.b.occurrence == *o).or(turning.clone().find(|j| j.a.occurrence == *o)) + }) { + f.a = j.id; + if f.b == f.a { + f.b = followers.iter().find(|x| x.0 != f.a).map(|x| x.0).unwrap_or(0); + } + } + f.picked = at.map(|x| x.0); + } + let mut enter = false; + joint_row(ui, "Cam", "cam_a", &cams, &mut f.a); + joint_row(ui, "Follower", "cam_b", &followers, &mut f.b); + row_label(ui, "Type"); + let labels: Vec<&str> = FOLLOWER_LABELS.iter().map(|l| crate::i18n::tr_label(l)).collect(); + combo(ui, "cam_follower", &labels, &mut f.follower); + ui.end_row(); + if FOLLOWERS.get(f.follower) == Some(&"roller") { + row_label(ui, "Radius"); + match (pick(inputs, ROLLER), picked_radius(s, inputs, ROLLER)) { + (_, Some(r)) => { + ui.label(format!("{} mm ({})", trim(r), crate::i18n::tr_label("picked"))); + } + (Some(_), None) => { + ui.label(RichText::new(crate::i18n::tr_label("not a round face")).color(t.warning)); + } + (None, None) => enter |= field(ui, &mut f.radius), + } + ui.end_row(); + } + row_label(ui, "Other side"); + check(ui, &mut f.flip); + ui.end_row(); + row_label(ui, "Animate"); + let label = if f.animate { "■ Stop" } else { "▶ Play" }; + let r = ui.button(label).on_hover_text(crate::i18n::tr_label("Play the joint's motion in the view")); + crate::scenario::publish_handle("dialog:Animate", r.rect.center()); + if r.clicked() { + f.animate = !f.animate; + } + ui.end_row(); + if f.animate { + let now = ui.input(|i| i.time); + // 72 steps over 6 s: each one a preview. + f.phase = ((now / 6.0).fract() * 72.0).floor() / 72.0; + ui.ctx().request_repaint_after(std::time::Duration::from_millis(30)); + } + enter +} + +/// The command OK runs. +pub fn commands(app: &SolveApp, f: &CamForm, inputs: &[SelInput]) -> Result, String> { + let s = &app.session; + let joint = |id: u64| { + s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.id).ok_or_else(|| "choose the cam's and the follower's joints".to_string()) + }; + let (a, b) = (joint(f.a)?, joint(f.b)?); + let items = inputs.get(PROFILE).map(|i| i.items.as_slice()).unwrap_or_default(); + let profile = match items.first() { + Some(Sel::Profile { sketch, index }) => json!({"sketch": sketch, "index": index}), + Some(Sel::Edge { body, .. }) => { + let pts: Vec = + items.iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(json!([point.x, point.y, point.z])) } else { None }).collect(); + let mut p = json!({ "edges": pts }); + if let Some(o) = occurrence(s, body) { + p["occurrence"] = json!(o); + } + p + } + _ => return Err("select the cam's profile (a sketch profile or the cam's edges)".into()), + }; + let follower = FOLLOWERS.get(f.follower).copied().unwrap_or("knife"); + let mut p = json!({"a": a, "b": b, "profile": profile, "follower": follower, "flip": f.flip}); + if follower == "roller" { + p["radius"] = match pick(inputs, ROLLER) { + Some((body, at)) => { + let mut v = json!({"circle": [at.x, at.y, at.z]}); + if let Some(o) = occurrence(s, body) { + v["occurrence"] = json!(o); + } + v + } + None => json!(f.radius.trim()), + }; + } + if let Some(snap) = inputs.get(TIP).and_then(|i| i.items.first()).and_then(|x| crate::dialogs_assembly::snap_of(s, x)) { + p["tip"] = snap.param; + } + Ok(vec![("joint.cam".to_string(), p)]) +} + +/// While animating, the preview turns the cam a full turn. +pub fn animate(s: &Session, f: &CamForm, cmds: &mut Vec<(String, Value)>) { + if f.animate { + turn(s, f.a, f.phase, cmds); + } +} + +#[cfg(test)] +#[path = "dialogs_cam_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/dialogs_cam_tests.rs b/crates/ui-egui/src/dialogs_cam_tests.rs new file mode 100644 index 00000000..dd8808e7 --- /dev/null +++ b/crates/ui-egui/src/dialogs_cam_tests.rs @@ -0,0 +1,112 @@ +use serde_json::json; +use solidcraft_engine::Session; +use solidcraft_engine::geom::Vec3; + +use super::*; +use crate::Services; +use crate::dialogs::{Kind, apply_commands, preview_commands}; + +/// A grounded base, a cam (a disc of radius 30 about (10, 0)) turning on a revolute joint about +Z +/// through the origin, a tappet on a slider along +X, its end on the cam, and a spare part on a +/// revolute joint of its own. +fn valve() -> SolveApp { + let mut s = Session::default(); + let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")); + run("component.create", json!({"name": "Base"})); + run("solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [-80, -80, -20]})); + run("component.activate", json!({"component": "root"})); + run("component.create", json!({"name": "Cam"})); + run("solid.cylinder", json!({"base": [10, 0, 0], "axis": [0, 0, 1], "radius": 30, "height": 5})); + run("component.activate", json!({"component": "root"})); + run("component.create", json!({"name": "Tappet"})); + run("solid.box", json!({"length": 20, "width": 4, "height": 5, "corner": [40, -2, 0]})); + run("component.activate", json!({"component": "root"})); + run("component.create", json!({"name": "Spare"})); + run("solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [-80, 60, 0]})); + run("component.activate", json!({"component": "root"})); + run("occurrence.ground", json!({"occurrence": "Base:1", "grounded": true})); + for (kind, name, z) in [("revolute", "Cam", [0, 0, 1]), ("slider", "Tappet", [1, 0, 0]), ("revolute", "Spare", [0, 0, 1])] { + let at = |o: &str| json!({"occurrence": o, "point": [0, 0, 2.5], "z": z}); + run("joint.create", json!({"type": kind, "a": at("Base"), "b": at(name), "flip": true, "name": name})); + } + SolveApp::new(s, Services::default()) +} + +/// A pick of the mesh edge nearest a point. +fn edge(app: &SolveApp, p: Vec3) -> Sel { + let st = app.session.world_state(); + let mut best: Option<(f64, Sel)> = None; + for b in &st.bodies { + for (index, e) in b.mesh().edges.iter().enumerate() { + let d = e.windows(2).filter_map(|w| Some(p.dist_to_segment(*w.first()?, *w.get(1)?))).fold(f64::MAX, f64::min); + if best.as_ref().is_none_or(|(bd, _)| d < *bd) { + best = Some((d, Sel::Edge { body: b.name.clone(), index, point: p })); + } + } + } + best.map(|x| x.1).unwrap() +} + +fn joint(app: &SolveApp, name: &str) -> u64 { + app.session.doc.assembly.joints.iter().find(|j| j.name == name).map(|j| j.id).unwrap_or(0) +} + +/// Picking the cam's edge chooses the joint it turns on; OK makes the cam, and Play turns the cam +/// in the preview. +#[test] +fn a_picked_cam_edge_makes_the_cam() { + let mut app = valve(); + app.start("joint.cam"); + let mut d = app.dialog.take().unwrap(); + // A second revolute joint for the cam to start on: the pick puts the cam right. + if let Kind::Assembly(Asm::Cam(f)) = &mut d.kind { + (f.a, f.b) = (joint(&app, "Spare"), joint(&app, "Tappet")); + } + d.active = 0; + // The far side of the cam's top edge (its near side touches the tappet). + let pick = edge(&app, Vec3::new(-20.0, 0.0, 5.0)); + d.pick(&app.session, pick); + app.dialog = Some(d); + let ctx = egui::Context::default(); + for _ in 0..3 { + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| crate::dialogs::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + } + let mut d = app.dialog.clone().expect("the dialog stays open"); + if let Kind::Assembly(Asm::Cam(f)) = &mut d.kind { + // The joint the picked part turns on, and a follower that is another joint. + assert_eq!((f.a, f.b), (joint(&app, "Cam"), joint(&app, "Tappet"))); + (f.follower, f.radius) = (1, "4 mm".into()); + } + let c = apply_commands(&app, &d).unwrap(); + let (id, p) = &c[0]; + assert_eq!((id.as_str(), p["follower"].clone(), p["radius"].clone()), ("joint.cam", json!("roller"), json!("4 mm")), "{p}"); + assert!(p["profile"]["edges"][0].is_array() && p["profile"]["occurrence"].is_u64(), "{p}"); + if let Kind::Assembly(Asm::Cam(f)) = &mut d.kind { + (f.follower, f.animate, f.phase) = (0, true, 0.25); + } + let cmds = preview_commands(&app, &d).unwrap(); + assert_eq!(cmds.get(1).map(|c| (c.0.as_str(), c.1["values"].clone())), Some(("joint.drive", json!(["90 deg"]))), "{cmds:?}"); + let c = apply_commands(&app, &d).unwrap(); + for (id, p) in c { + app.run(&id, p).unwrap(); + } + let l = app.session.doc.assembly.links.first().cloned().unwrap(); + assert_eq!(l.lift.len(), 360); + // A quarter turn lowers the knife tappet from 40 to √(30² − 10²). + app.run("joint.drive", json!({"joint": "Cam", "value": 90})).unwrap(); + let v = solidcraft_engine::doc::joints::joint_values(&app.session.doc.assembly).get(&joint(&app, "Tappet")).cloned().unwrap_or_default(); + assert!((v.first().copied().unwrap_or(f64::NAN) - (800f64.sqrt() - 40.0)).abs() < 1e-3, "{v:?}"); +} + +/// The dialog's rows pass their English text, which the catalogs translate by its key. +#[test] +fn the_cam_dialog_labels_translate() { + let de = crate::i18n::Locale::for_language("de"); + for (english, german) in [("Cam", "Nocken"), ("Follower", "Stößel"), ("Roller", "Rolle"), ("Tip (optional)", "Spitze (optional)")] { + assert_eq!(de.label(english), german); + } + assert_eq!(crate::i18n::Locale::for_language("zh").label("Knife edge"), "尖顶"); +} diff --git a/crates/ui-egui/src/dialogs_coupling.rs b/crates/ui-egui/src/dialogs_coupling.rs index 2cda97f6..bcb03d6b 100644 --- a/crates/ui-egui/src/dialogs_coupling.rs +++ b/crates/ui-egui/src/dialogs_coupling.rs @@ -1,5 +1,5 @@ //! The Gear, Rack and Pinion and Screw dialogs: two joints coupled by radii or a pitch -//! (`joint.gear`, `joint.rack_pinion`, `joint.screw`). A radius is typed, or comes from the round +//! (`joint.gear`, `joint.rack_pinion`, `joint.screw`); the Cam dialog is `dialogs_cam.rs`. A radius is typed, or comes from the round //! face picked on the part, and then follows that face when the design changes. Picking a gear's //! face also picks the joint it turns on. Play turns the driving joint so the coupling shows its //! motion before OK. @@ -259,21 +259,31 @@ pub fn commands(app: &SolveApp, f: &CouplingForm, inputs: &[SelInput]) -> Result Ok(vec![(f.command.to_string(), p)]) } -/// While animating, the preview turns the driving joint a full turn: its first value that turns -/// (a planar joint's is its third), the values before it as they are. +/// While animating, the preview turns the driving joint a full turn. pub fn animate(s: &Session, f: &CouplingForm, cmds: &mut Vec<(String, Value)>) { - let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == f.a).filter(|_| f.animate) else { return }; + if f.animate { + turn(s, f.a, f.phase, cmds); + } +} + +/// Drive `joint` to `phase` of a full turn: its first value that turns (a planar joint's is its +/// third), the values before it as they are. +fn turn(s: &Session, joint: u64, phase: f64, cmds: &mut Vec<(String, Value)>) { + let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == joint) else { return }; let Some(turn) = (0..j.kind.dofs().len()).find(|i| j.kind.is_angle(*i)) else { return }; let values: Vec = (0..=turn) .map(|i| match j.values.get(i).copied().unwrap_or(0.0) { - _ if i == turn => format!("{} deg", f.phase * 360.0), + _ if i == turn => format!("{} deg", phase * 360.0), v if j.kind.is_angle(i) => format!("{} deg", v.to_degrees()), v => format!("{v} mm"), }) .collect(); - cmds.push(("joint.drive".into(), json!({"joint": f.a, "values": values}))); + cmds.push(("joint.drive".into(), json!({"joint": joint, "values": values}))); } +#[path = "dialogs_cam.rs"] +pub mod cam; + #[cfg(test)] #[path = "dialogs_coupling_tests.rs"] mod tests; diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs index f234ab8a..04e00bb3 100644 --- a/crates/ui-egui/src/preview.rs +++ b/crates/ui-egui/src/preview.rs @@ -207,6 +207,7 @@ fn motion_key(cmds: &[(String, Value)], revision: u64, colors: Colors) -> Option | "joint.gear" | "joint.rack_pinion" | "joint.screw" + | "joint.cam" ) }) { @@ -856,6 +857,15 @@ mod tests { }; assert!(motion_key(&gear("0 deg"), 7, colors()).is_some()); assert_eq!(motion_key(&gear("0 deg"), 7, colors()), motion_key(&gear("90 deg"), 7, colors())); + // So does a cam dialog turning its cam. + let cam = |angle: &str| { + vec![ + ("joint.cam".to_string(), json!({"a": 1, "b": 2, "profile": {"sketch": 3}})), + ("joint.drive".to_string(), json!({"joint": 1, "values": [angle]})), + ] + }; + assert!(motion_key(&cam("0 deg"), 7, colors()).is_some()); + assert_eq!(motion_key(&cam("0 deg"), 7, colors()), motion_key(&cam("90 deg"), 7, colors())); } /// Combine previews its result (cut, join or intersect between existing bodies) without an diff --git a/crates/ui-egui/src/workspace.rs b/crates/ui-egui/src/workspace.rs index 674d1437..ed99b7eb 100644 --- a/crates/ui-egui/src/workspace.rs +++ b/crates/ui-egui/src/workspace.rs @@ -19,6 +19,7 @@ const ASSEMBLE: &[&str] = &[ "joint.gear", "joint.rack_pinion", "joint.screw", + "joint.cam", "motion.study", "motion.trace", "explode.create", diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index 743a30ce..975528b4 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -55,6 +55,33 @@ driven one. A joint follows one link at most, and a coupling that would drive it refused. `joint.list` lists each coupling's definition under `links` with an `error` when it can't be worked out (the picked face is gone); the link then keeps its last ratio. +**Cam** (`joint.cam {a, b, profile}`, on the **ASSEMBLE** panel) makes a follower ride a cam. The +cam turns on a revolute joint (a); the follower is a slider (b, a translating follower such as a +valve tappet) or a revolute joint (b, an oscillating follower such as a rocker arm). The profile +is the cam's outline in a plane square to its axis: a closed profile of a sketch in the cam's +component, or the cam's edges picked on its body (one click on an edge takes the closed loop it is +on in that plane). The follower is: + +- a **Knife edge**: a point on the cam; +- a **Roller** of a radius, typed or taken from the roller's round face (picking that face also + says where the roller is); +- a **Flat face** square to a slider's line, such as a bucket tappet. + +A translating follower's line runs along its slider's axis, through its **Tip** when one is picked +(its knife's point or its roller's centre), so a follower offset from the cam's axis rides the +right flank. An oscillating follower needs its tip, or its roller's face, to know its arm. The +follower rides the cam from the side it is on (**Other side** takes the other one). The follower's +travel over a turn of the cam is worked out from the profile and kept with the link, one value a +degree with a smooth curve between them that never overshoots its samples. It is worked out again +whenever the design changes: change the cam's sketch, a parameter or the roller, and the follower +follows. Like the gears, the new link leaves the follower where it is (model it touching the cam), +and the cam is a motion link: it drives in **Drive Joints**, plays in motion studies, works in +closed loops (a tappet can be the piston of a slider-crank) and stops at contact sets. The profile +may also be on the grounded part, with the follower's guide on a turret that the cam joint turns. +`joint.list` gives a cam's link `lift` (the follower's lowest and highest values and its rise) +instead of a ratio. A profile that is not closed, a line or an arm that misses the cam somewhere, +a follower that would jump, and axes that are not square or parallel are refused. + A joint moves one component against another, so its two picks must be on different components. A STEP file with several parts often comes in as bodies of one component, and picks on two of those bodies make no joint. The Joint and As-built Joint dialogs say so under the picks and offer diff --git a/skills/solidcraft-mechanisms/SKILL.md b/skills/solidcraft-mechanisms/SKILL.md index 26d72590..a9c2fcff 100644 --- a/skills/solidcraft-mechanisms/SKILL.md +++ b/skills/solidcraft-mechanisms/SKILL.md @@ -42,6 +42,7 @@ Written against SolidCraft `main` after 0.11.1, which counts the degrees of free ## Linked and closed motion - **Motion links:** `joint.motion_link {"a":..., "b":..., "ratio":r, "offset":o}` sets b = r·a + o, clamped to b's own limits. `ratio` is b's units per a's: a pinion that moves its rack 0.5 mm per degree takes `ratio: 0.5`. `offset` is in b's units: degrees for an angle, mm for a distance, or an expression. A mirrored part takes `ratio: -1`, and limits mirrored too. Chains (a drives b, b drives c) work in any order of creation. Drive only the head of a chain. - **Gears, racks and screws:** links whose ratio comes from radii or a pitch, kept up to date when they change. `joint.gear {"a":..., "b":..., "ra":r1, "rb":r2}` turns b r1/r2 as far as a, the other way (`internal: true`: the same way). `joint.rack_pinion {"a":pinion, "b":rack, "radius":r}` moves the rack r·θ. `joint.screw {"joint":cylindrical, "pitch":p}` (or `a`, `b` for a revolute and a slider) advances p per turn. A size is mm, an expression (`"m*z1/2"`), or a round face `{"occurrence":..., "circle":[x,y,z]}` whose radius is read again after every edit. `reverse: true` turns b the other way (a left-hand thread). The driven joint keeps where it is. Drive a, never b. +- **Cams:** `joint.cam {"a":camJoint, "b":follower, "profile":{"sketch":"CamSketch"}}` (or `{"edges":[[x,y,z]]}` on the cam's body) makes a slider or revolute follower ride the cam's outline. `follower`: `knife` (default), `roller` with `radius` (mm, an expression or the roller's round face), `flat`. A revolute follower needs `tip` (its knife's point or roller's centre). The table is worked out again after every edit; the follower keeps where it is, so model it touching. Drive the cam, never the follower. - **Closed loops:** an actuator between a frame and a hinged panel takes three joints besides the hinge: - a revolute root pin (frame and barrel) - a slider (barrel and rod), with z along the actuator from root to tip on both origins, both at the root pin, and the stroke as its limits diff --git a/skills/solidcraft-mechanisms/reference.md b/skills/solidcraft-mechanisms/reference.md index 8ae5dfd4..1d59a920 100644 --- a/skills/solidcraft-mechanisms/reference.md +++ b/skills/solidcraft-mechanisms/reference.md @@ -64,6 +64,7 @@ A slider's value grows as b moves along +z of its origin. Give its two origins t - `joint.rigid_group` joins every occurrence to the first one with a rigid as-built joint. - `joint.motion_link`: b = ratio·a + offset, clamped to b's limits. `ratio` is in b's units per a's: mm per degree when an angle drives a distance (a pinion of pitch radius r moves its rack r·π/180 mm per degree), degrees per mm the other way, and a plain factor between two angles or two distances. `offset` is in b's units: degrees for an angle, mm for a distance, or an expression such as `"10 deg"`. `joint.list` gives each link's `ratio` the same way and its `offset` with its `unit`. `ia`/`ib` pick a value of a multi-value joint (default 0). `joint.delete {"link": n}` removes link n (its index in `joint.list` `links`). - `joint.gear {a, b, ra, rb, internal?, reverse?}`, `joint.rack_pinion {a, b, radius, reverse?}` and `joint.screw {joint | a, b; pitch, reverse?}` make a motion link whose ratio comes from its sizes: gears -ra/rb (+ra/rb internal), a rack r mm per radian of its pinion (either joint may be `a`), a screw pitch per turn. Each size is mm, an expression, or `{occurrence, circle: [x,y,z]}`: the radius of the round face there, read again whenever the design changes. The reply gives `link` (its index) and `ratio` as `joint.list` does; `link: n` in the params replaces link n and keeps its offset. A new coupling's offset keeps b where it is. `joint.list` `links` carry the `coupling` and an `error` when it can't be worked out (a picked face gone: the last ratio stays). A value two links would set, or a coupling that would drive its own driver, is refused. +- `joint.cam {a, b, profile, follower?, radius?, tip?, flip?, link?}`: a is the cam's revolute joint, b a slider or revolute follower. `profile` is `{sketch, index?}` (a closed profile of a sketch in the cam's component) or `{edges: [[x,y,z]...], occurrence?}` (edges of the cam's body; one edge continues around its closed loop in the plane). The reply gives `link`, `lift: {min, max, rise, unit}` (b's travel over a turn), `reverse` (the side it rides from; `flip: true` takes the other) and, for a slider, `follower_offset`. `joint.list` `links` show `lift` instead of `ratio` for a cam. - `joint.drive`: `values` drives the values it lists, in order (`Spindle` above is cylindrical: 30 degrees, then 5 mm), and `value` the first; values left out keep theirs, or follow in a closed loop. Values are clamped to the limits. The reply gives `values` as `joint.list` does (`{name, value, unit, limits}`), and `moved`; when the joint can move nothing, a `reason`. - `joint.reach` (inverse kinematics): moves the joints on the occurrence's chain so its point `at` (world, where it is now; default: the centre of its bodies) gets to `point`, from the pose it is in to the nearest one, within limits, through motion links, and with closed loops kept closed. `z` with `axis` (world, now; default its own z) also turns that axis. `joints: [...]` moves only those (loop followers still follow). The reply: `reached`, `error_mm` (and `error_deg`), `point` where it ended, `values` of the joints that moved, `moved`, and when short, `stopped_by_limit: [{joint, value, limit}]` or `stopped_by_loop: {joint}`. A part that nothing moves answers `moved: false` with a `reason`. Contact sets are not checked; check with `contact.check` afterwards. - `motion.study {"study":..., ...}` edits a study; `keys` given replace all keys. @@ -127,6 +128,8 @@ The same pattern moved the solar wings of the SELENE rover (`Rover-v7.solidcraft | `joint.drive` answers `moved: false`, or `motion.play` lists the joint under `unmoved` | Read `reason`. Most often the chain is joined to nothing grounded: `unconnected` names the part, and `joint.list` lists the chain under `floating`, its occurrences with `dof: 6`. Ground it, join it, or `joint.rigid_group` it with the structure. | | A drive stops short, `stopped_by_loop` | A loop reached a limit or can't close further; `joint` names the joint that stopped it. Check the limits against your stroke numbers. | | A gear, rack or screw turns its partner the wrong way | The joints' axes point opposite ways (or the rack lies on the other side of the pinion). Run the command again with `reverse: true` and `link: n`. | +| `joint.cam` says the follower's line misses the cam | The slider's line (through `tip` when given) passes beside the cam at some angle. Pick the follower's tip, or move its slider's axis over the cam. | +| A cam's follower rides the wrong flank or sinks into the cam | It took the other side of the cam's axis (`reverse` in the reply): run `joint.cam` again with `flip: true` and `link: n`. | | `already follows a motion link` from `joint.gear` | A joint follows one link at most. Make the joint that already follows the driving one (`a`), as in a gear train G1 → G2 → G3. | | A linked joint stops following | Its value is clamped to its own limits. Hinge 2 in the example has `[[-60, 0]]`, so a drive of Hinge below 0 leaves it at 0. Give a mirrored joint the mirrored limits. | | `joint.reach` answers `reached: false` | The point is out of reach: `point` and `error_mm` give the nearest pose. `stopped_by_limit` names the limits it rests on, `stopped_by_loop` the loop that can't follow further; with neither, the chain can't get there at all (too far, or out of its plane). |