From f7967b1ff64d72c1161979623db365074be397ca Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:47:38 +0200 Subject: [PATCH] Joints: gears, rack and pinion, and screws (#1524) Motion links can carry a coupling (gear pair, rack and pinion, screw) whose ratio follows radii, picked round faces or pitch. Design format 23. Co-authored-by: SychPL Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 +- assets/icons/ui/source/icons.rs | 43 +++ assets/locales/de.json | 21 +- assets/locales/en.json | 21 +- assets/locales/fr.json | 21 +- assets/locales/ja.json | 21 +- assets/locales/ko.json | 21 +- assets/locales/ru.json | 21 +- assets/locales/uk.json | 21 +- assets/locales/zh.json | 21 +- crates/doc/src/format.rs | 12 +- crates/doc/src/joints.rs | 201 +++++++++++-- .../src/cmd/component_clone_assembly.rs | 21 +- .../src/cmd/component_clone_assembly_tests.rs | 31 +- crates/engine/src/cmd/joints.rs | 210 ++++++++++++- .../engine/src/cmd/joints_coupling_tests.rs | 255 ++++++++++++++++ crates/engine/src/cmd/joints_tests.rs | 3 + crates/engine/src/cmd/motion.rs | 7 +- crates/ui-egui/src/dialogs_assembly.rs | 31 +- crates/ui-egui/src/dialogs_coupling.rs | 279 ++++++++++++++++++ crates/ui-egui/src/dialogs_coupling_tests.rs | 198 +++++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/preview.rs | 23 +- crates/ui-egui/src/workspace.rs | 3 + docs/book/src/guide/assemblies.md | 27 ++ docs/design-container.md | 2 +- docs/features.md | 7 +- skills/solidcraft-mechanisms/SKILL.md | 1 + skills/solidcraft-mechanisms/reference.md | 5 +- 29 files changed, 1476 insertions(+), 54 deletions(-) create mode 100644 crates/engine/src/cmd/joints_coupling_tests.rs create mode 100644 crates/ui-egui/src/dialogs_coupling.rs create mode 100644 crates/ui-egui/src/dialogs_coupling_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1cc2dc12..2cae5667 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), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — | -| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | +| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), gears, rack and pinion and screws (motion links whose ratio follows picked radii, parameters or a pitch, also inside closed loops), 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, Gear, Rack and Pinion, Screw 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 4f887905..3a354f68 100644 --- a/assets/icons/ui/source/icons.rs +++ b/assets/icons/ui/source/icons.rs @@ -21,6 +21,19 @@ fn ellipse(x: f32, y: f32, rx: f32, ry: f32, a0: f32, a1: f32, n: usize) -> Vec< (0..=n).map(|i| a0 + (a1 - a0) * i as f32 / n as f32).map(|a| (x + rx * a.cos(), y + ry * a.sin())).collect() } +/// A gear's toothed outline about (x, y): `n` teeth from the root radius `root` out to `tip`, +/// turned by `phase` radians. +fn teeth(x: f32, y: f32, root: f32, tip: f32, n: usize, phase: f32) -> Vec<(f32, f32)> { + let step = std::f32::consts::TAU / n as f32; + let at = |a: f32, r: f32| (x + r * a.cos(), y - r * a.sin()); + (0..n) + .flat_map(|k| { + let a = phase + step * k as f32; + [at(a - 0.3 * step, root), at(a - 0.15 * step, tip), at(a + 0.15 * step, tip), at(a + 0.3 * step, root)] + }) + .collect() +} + struct Pen<'a> { p: &'a Painter, r: Rect, @@ -1256,6 +1269,36 @@ pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: C pen.dot(7.0, 14.0, a); pen.dot(17.5, 9.0, a); } + // Couplings: the driven part in the accent. + "gear" => { + pen.circle(9.0, 14.0, 5.2, pen.fill, Color32::TRANSPARENT); + pen.closed(&teeth(9.0, 14.0, 5.2, 6.8, 9, 0.0), ink); + pen.dot(9.0, 14.0, ink); + pen.circle(17.6, 7.4, 3.2, pen.fill, Color32::TRANSPARENT); + pen.closed(&teeth(17.6, 7.4, 3.2, 4.6, 6, 0.3), a); + pen.dot(17.6, 7.4, a); + } + "rack_pinion" => { + pen.circle(12.0, 7.4, 4.2, pen.fill, Color32::TRANSPARENT); + pen.closed(&teeth(12.0, 7.4, 4.2, 5.8, 8, 0.2), ink); + pen.dot(12.0, 7.4, ink); + let mut rack = vec![(2.0, 18.0)]; + for k in 0..5 { + let x = 2.5 + 4.0 * k as f32; + rack.extend([(x, 14.3), (x + 1.0, 12.8), (x + 2.2, 12.8), (x + 3.2, 14.3)]); + } + rack.extend([(22.0, 14.3), (22.0, 18.0)]); + pen.closed(&rack, a); + pen.arrow((7.0, 21.5), (17.5, 21.5), a); + } + "screw" => { + pen.poly(&[(9.5, 2.0), (14.5, 2.0), (14.5, 22.0), (9.5, 22.0)], pen.fill, ink); + for y in [5.0, 8.0, 17.0, 20.0] { + pen.line(&[(9.5, y), (14.5, y - 2.0)], ink); + } + 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); + } "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 35e5f8af..0d0c6524 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -760,6 +760,9 @@ "cmd.joint_limits": "Gelenkgrenzen", "cmd.joint_list": "Gelenke auflisten", "cmd.joint_motion_link": "Bewegungsverknüpfung", + "cmd.joint_gear": "Zahnrad", + "cmd.joint_rack_pinion": "Zahnstange und Ritzel", + "cmd.joint_screw": "Schraube", "cmd.joint_origin": "Gelenkursprung", "cmd.joint_rigid_group": "Starrgruppe", "cmd.joint_solve": "Gelenke lösen", @@ -1122,5 +1125,21 @@ "Min": "Min", "Max": "Max", "Length": "Länge", - "Area": "Fläche" + "Area": "Fläche", + "coupling.gear_1": "Zahnrad 1", + "coupling.gear_2": "Zahnrad 2", + "coupling.pinion": "Ritzel", + "coupling.rack": "Zahnstange", + "coupling.nut": "Mutter", + "coupling.radius_1": "Radius 1", + "coupling.radius_2": "Radius 2", + "coupling.internal": "Innenzahnrad", + "coupling.pitch": "Steigung", + "coupling.picked": "gewählt", + "coupling.not_round": "keine runde Fläche", + "coupling.gear_1_pick": "Zahnrad 1 (optional)", + "coupling.gear_2_pick": "Zahnrad 2 (optional)", + "coupling.pinion_pick": "Ritzel (optional)", + "coupling.needs_turns": "braucht zwei Gelenke, die sich drehen", + "coupling.needs_turn_and_slide": "braucht ein Gelenk, das sich dreht, und eines, das gleitet" } diff --git a/assets/locales/en.json b/assets/locales/en.json index 12317a2c..18d234ca 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -125,6 +125,9 @@ "cmd.joint_limits": "Joint Limits", "cmd.joint_list": "List Joints", "cmd.joint_motion_link": "Motion Link", + "cmd.joint_gear": "Gear", + "cmd.joint_rack_pinion": "Rack and Pinion", + "cmd.joint_screw": "Screw", "cmd.joint_origin": "Joint Origin", "cmd.joint_rigid_group": "Rigid Group", "cmd.joint_solve": "Solve Joints", @@ -1377,5 +1380,21 @@ "Centre to centre": "Centre to centre", "Overall length": "Overall length", "Min": "Min", - "Max": "Max" + "Max": "Max", + "coupling.gear_1": "Gear 1", + "coupling.gear_2": "Gear 2", + "coupling.pinion": "Pinion", + "coupling.rack": "Rack", + "coupling.nut": "Nut", + "coupling.radius_1": "Radius 1", + "coupling.radius_2": "Radius 2", + "coupling.internal": "Internal gear", + "coupling.pitch": "Pitch", + "coupling.picked": "picked", + "coupling.not_round": "not a round face", + "coupling.gear_1_pick": "Gear 1 (optional)", + "coupling.gear_2_pick": "Gear 2 (optional)", + "coupling.pinion_pick": "Pinion (optional)", + "coupling.needs_turns": "needs two joints that turn", + "coupling.needs_turn_and_slide": "needs a joint that turns and one that slides" } diff --git a/assets/locales/fr.json b/assets/locales/fr.json index 6aabcff4..182b8a9c 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -125,6 +125,9 @@ "cmd.joint_limits": "Limites de liaison", "cmd.joint_list": "Lister les liaisons", "cmd.joint_motion_link": "Lien de mouvement", + "cmd.joint_gear": "Engrenage", + "cmd.joint_rack_pinion": "Pignon et crémaillère", + "cmd.joint_screw": "Vis", "cmd.joint_origin": "Origine de liaison", "cmd.joint_rigid_group": "Groupe rigide", "cmd.joint_solve": "Résoudre les liaisons", @@ -1230,5 +1233,21 @@ "RULED SURFACE": "SURFACE RÉGLÉE", "Mitre": "Onglet", "Reference face": "Face de référence", - "Round": "Arrondi" + "Round": "Arrondi", + "coupling.gear_1": "Engrenage 1", + "coupling.gear_2": "Engrenage 2", + "coupling.pinion": "Pignon", + "coupling.rack": "Crémaillère", + "coupling.nut": "Écrou", + "coupling.radius_1": "Rayon 1", + "coupling.radius_2": "Rayon 2", + "coupling.internal": "Engrenage intérieur", + "coupling.pitch": "Pas", + "coupling.picked": "sélectionné", + "coupling.not_round": "pas une face ronde", + "coupling.gear_1_pick": "Engrenage 1 (facultatif)", + "coupling.gear_2_pick": "Engrenage 2 (facultatif)", + "coupling.pinion_pick": "Pignon (facultatif)", + "coupling.needs_turns": "nécessite deux liaisons qui tournent", + "coupling.needs_turn_and_slide": "nécessite une liaison qui tourne et une qui glisse" } diff --git a/assets/locales/ja.json b/assets/locales/ja.json index b88765b2..29bb48ed 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -125,6 +125,9 @@ "cmd.joint_limits": "ジョイントの制限", "cmd.joint_list": "ジョイントを一覧表示", "cmd.joint_motion_link": "モーション リンク", + "cmd.joint_gear": "ギア", + "cmd.joint_rack_pinion": "ラック アンド ピニオン", + "cmd.joint_screw": "ねじ", "cmd.joint_origin": "ジョイント原点", "cmd.joint_rigid_group": "リジッド グループ", "cmd.joint_solve": "ジョイントを解決", @@ -1377,5 +1380,21 @@ "Centre to centre": "中心間", "Overall length": "全長", "Min": "最小", - "Max": "最大" + "Max": "最大", + "coupling.gear_1": "ギア 1", + "coupling.gear_2": "ギア 2", + "coupling.pinion": "ピニオン", + "coupling.rack": "ラック", + "coupling.nut": "ナット", + "coupling.radius_1": "半径 1", + "coupling.radius_2": "半径 2", + "coupling.internal": "内歯車", + "coupling.pitch": "ピッチ", + "coupling.picked": "選択済み", + "coupling.not_round": "円筒面ではありません", + "coupling.gear_1_pick": "ギア 1 (オプション)", + "coupling.gear_2_pick": "ギア 2 (オプション)", + "coupling.pinion_pick": "ピニオン (オプション)", + "coupling.needs_turns": "回転するジョイントが 2 つ必要", + "coupling.needs_turn_and_slide": "回転するジョイントとスライドするジョイントが必要" } diff --git a/assets/locales/ko.json b/assets/locales/ko.json index a2b1c812..ea74c84b 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -760,6 +760,9 @@ "cmd.joint_limits": "관절 제한", "cmd.joint_list": "관절 목록", "cmd.joint_motion_link": "모션 링크", + "cmd.joint_gear": "기어", + "cmd.joint_rack_pinion": "랙 앤 피니언", + "cmd.joint_screw": "나사", "cmd.joint_origin": "관절 원점", "cmd.joint_rigid_group": "강체 그룹", "cmd.joint_solve": "관절 해석", @@ -1122,5 +1125,21 @@ "Min": "최소", "Max": "최대", "Length": "길이", - "Area": "면적" + "Area": "면적", + "coupling.gear_1": "기어 1", + "coupling.gear_2": "기어 2", + "coupling.pinion": "피니언", + "coupling.rack": "랙", + "coupling.nut": "너트", + "coupling.radius_1": "반지름 1", + "coupling.radius_2": "반지름 2", + "coupling.internal": "내접 기어", + "coupling.pitch": "피치", + "coupling.picked": "선택됨", + "coupling.not_round": "원통 면이 아님", + "coupling.gear_1_pick": "기어 1 (선택 사항)", + "coupling.gear_2_pick": "기어 2 (선택 사항)", + "coupling.pinion_pick": "피니언 (선택 사항)", + "coupling.needs_turns": "회전하는 관절 두 개가 필요함", + "coupling.needs_turn_and_slide": "회전하는 관절과 미끄러지는 관절이 필요함" } diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 1aa22081..964efd54 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -114,6 +114,9 @@ "cmd.joint_limits": "Ограничения сопряжения", "cmd.joint_list": "Список сопряжений", "cmd.joint_motion_link": "Связь движений", + "cmd.joint_gear": "Зубчатая передача", + "cmd.joint_rack_pinion": "Реечная передача", + "cmd.joint_screw": "Винт", "cmd.joint_origin": "Точка сопряжения", "cmd.joint_rigid_group": "Жёсткая группа", "cmd.joint_solve": "Решить сопряжения", @@ -1142,5 +1145,21 @@ "render.opaque_image_summary": "Предпросмотр PNG · непрозрачный PBR · прозрачность через смешивание", "joint.one_component": "Эти детали - тела одного компонента: {bodies}. Сопряжение двигает один компонент относительно другого, поэтому каждой детали нужен свой компонент.", "joint.one_body": "Оба выбора на {body}. Выберите второй на другой детали.", - "joint.from_bodies_hint": "Каждое выбранное тело переходит в свой новый компонент и остаётся на месте." + "joint.from_bodies_hint": "Каждое выбранное тело переходит в свой новый компонент и остаётся на месте.", + "coupling.gear_1": "Шестерня 1", + "coupling.gear_2": "Шестерня 2", + "coupling.pinion": "Шестерня", + "coupling.rack": "Рейка", + "coupling.nut": "Гайка", + "coupling.radius_1": "Радиус 1", + "coupling.radius_2": "Радиус 2", + "coupling.internal": "Внутреннее зацепление", + "coupling.pitch": "Шаг", + "coupling.picked": "выбрано", + "coupling.not_round": "не круглая грань", + "coupling.gear_1_pick": "Шестерня 1 (необязательно)", + "coupling.gear_2_pick": "Шестерня 2 (необязательно)", + "coupling.pinion_pick": "Шестерня (необязательно)", + "coupling.needs_turns": "нужны два вращающихся сопряжения", + "coupling.needs_turn_and_slide": "нужны вращающееся и скользящее сопряжения" } diff --git a/assets/locales/uk.json b/assets/locales/uk.json index 2ffc11cd..449050bb 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -115,6 +115,9 @@ "cmd.joint_limits": "Обмеження з'єднання", "cmd.joint_list": "Список з'єднань", "cmd.joint_motion_link": "Зв'язок руху", + "cmd.joint_gear": "Зубчаста передача", + "cmd.joint_rack_pinion": "Рейкова передача", + "cmd.joint_screw": "Гвинт", "cmd.joint_origin": "Початок з'єднання", "cmd.joint_rigid_group": "Жорстка група", "cmd.joint_solve": "Розв'язати з'єднання", @@ -1061,5 +1064,21 @@ "drive.unconnected": "Нічого не рухається: {part} не з'єднано ні з чим закріпленим", "joint.one_component": "Ці деталі - тіла одного компонента: {bodies}. З'єднання рухає один компонент відносно іншого, тому кожній деталі потрібен власний компонент.", "joint.one_body": "Обидва вибори на {body}. Виберіть другий на іншій деталі.", - "joint.from_bodies_hint": "Кожне вибране тіло переходить у власний новий компонент і лишається на місці." + "joint.from_bodies_hint": "Кожне вибране тіло переходить у власний новий компонент і лишається на місці.", + "coupling.gear_1": "Шестерня 1", + "coupling.gear_2": "Шестерня 2", + "coupling.pinion": "Шестерня", + "coupling.rack": "Рейка", + "coupling.nut": "Гайка", + "coupling.radius_1": "Радіус 1", + "coupling.radius_2": "Радіус 2", + "coupling.internal": "Внутрішнє зачеплення", + "coupling.pitch": "Крок", + "coupling.picked": "вибрано", + "coupling.not_round": "не кругла грань", + "coupling.gear_1_pick": "Шестерня 1 (необов'язково)", + "coupling.gear_2_pick": "Шестерня 2 (необов'язково)", + "coupling.pinion_pick": "Шестерня (необов'язково)", + "coupling.needs_turns": "потрібні два обертові з'єднання", + "coupling.needs_turn_and_slide": "потрібні обертове й ковзне з'єднання" } diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 8a8432d5..bea389b1 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -125,6 +125,9 @@ "cmd.joint_limits": "接头限制", "cmd.joint_list": "列出接头", "cmd.joint_motion_link": "运动关联", + "cmd.joint_gear": "齿轮", + "cmd.joint_rack_pinion": "齿轮齿条", + "cmd.joint_screw": "螺旋", "cmd.joint_origin": "接头原点", "cmd.joint_rigid_group": "刚性组", "cmd.joint_solve": "解算接头", @@ -1377,5 +1380,21 @@ "Centre to centre": "中心距", "Overall length": "总长", "Min": "最小", - "Max": "最大" + "Max": "最大", + "coupling.gear_1": "齿轮 1", + "coupling.gear_2": "齿轮 2", + "coupling.pinion": "小齿轮", + "coupling.rack": "齿条", + "coupling.nut": "螺母", + "coupling.radius_1": "半径 1", + "coupling.radius_2": "半径 2", + "coupling.internal": "内齿轮", + "coupling.pitch": "螺距", + "coupling.picked": "已拾取", + "coupling.not_round": "不是圆柱面", + "coupling.gear_1_pick": "齿轮 1(可选)", + "coupling.gear_2_pick": "齿轮 2(可选)", + "coupling.pinion_pick": "小齿轮(可选)", + "coupling.needs_turns": "需要两个旋转接头", + "coupling.needs_turn_and_slide": "需要一个旋转接头和一个滑动接头" } diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 091bb4e3..16a4500a 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -222,13 +222,20 @@ //! parameters); the source's `at`, `height` and `angle` then hold the numbers they last came //! to. A text without `exprs` is set as it always was; 0.12.0 asks for an update instead of //! dropping the expressions and keeping the text at its last numbers. +//! - 23: a motion link can carry a `coupling`: a gear pair (`ra`, `rb`, `internal`), a rack and +//! pinion (`radius`) or a screw (`pitch`), each size a number in mm, an expression or a picked +//! round face, and `reverse`. The link's `ratio` is worked out from it whenever the design +//! changes and saved as well, so a link reads the same without it. Version 22 designs need no +//! transformation: a link without `coupling` is a plain motion link, as it always was; 0.13.0 +//! (which reads up to 22) asks for an update instead of keeping a gear's ratio when its radius +//! changes. use serde_json::Value; use crate::{DocError, Document, Result}; /// The format this build writes. -pub const FORMAT: u32 = 22; +pub const FORMAT: u32 = 23; pub fn format_string(n: u32) -> String { format!("solidcraft/{n}") @@ -245,7 +252,7 @@ pub fn version_of(s: &str) -> Option { /// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`. type JsonStep = fn(&mut Value) -> Result<()>; -const JSON_STEPS: [JsonStep; 21] = [ +const JSON_STEPS: [JsonStep; 22] = [ |_| Ok(()), v2_to_v3, v3_to_v4, @@ -267,6 +274,7 @@ const JSON_STEPS: [JsonStep; 21] = [ |_| Ok(()), |_| Ok(()), |_| Ok(()), + |_| Ok(()), ]; /// Stored face and edge names were worked out from geometry before format 14; they get the diff --git a/crates/doc/src/joints.rs b/crates/doc/src/joints.rs index 5cf20897..afeb93a2 100644 --- a/crates/doc/src/joints.rs +++ b/crates/doc/src/joints.rs @@ -1,7 +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, and the solver that places -//! occurrences from the joint graph. +//! 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. //! //! 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` @@ -151,6 +152,115 @@ 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`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coupling: Option, +} + +/// A length a coupling's ratio comes from: millimetres, an expression over the design's +/// parameters, or the radius of the round face picked on an occurrence (a circle-centre snap, +/// found again on every solve like a joint origin). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Size { + Length(f64), + Expr(String), + Circle(JointOrigin), +} + +/// The geometry a motion link's ratio comes from. `reverse` turns the follower the other way. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Coupling { + /// Two gears on revolute joints, `ra` on a's and `rb` on b's (pitch radii, or any two lengths + /// in their proportion such as tooth counts): b turns ra/rb as far as a, the other way round, + /// or the same way when one is an internal (ring) gear. + Gear { + ra: Size, + rb: Size, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + internal: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + reverse: bool, + }, + /// A pinion of pitch radius `radius` on a revolute joint and a rack on a slider: the rack + /// travels the radius times the pinion's turn in radians (either joint may drive). + RackPinion { + radius: Size, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + reverse: bool, + }, + /// A screw: the travel advances `pitch` per turn (a cylindrical joint's angle and distance, + /// or a revolute joint and a slider). Right-handed unless reversed. + Screw { + pitch: Size, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + reverse: bool, + }, +} + +impl Coupling { + /// The lengths the ratio comes from: a's gear and b's, or the pinion's radius or the pitch. + 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], + } + } + + pub fn sizes_mut(&mut self) -> Vec<&mut Size> { + match self { + Coupling::Gear { ra, rb, .. } => vec![ra, rb], + Coupling::RackPinion { radius: s, .. } | Coupling::Screw { pitch: s, .. } => vec![s], + } + } +} + +/// Largest length a coupling takes (mm). +const MAX_SIZE: f64 = 1e7; + +/// A coupling length in millimetres: positive and finite. +fn size_of(doc: &Document, st: &ModelState, s: &Size) -> Result { + let v = match s { + Size::Length(v) => *v, + Size::Expr(e) => doc.eval(e, crate::expr::Kind::Length)?, + Size::Circle(o) => circle_at(doc, st, o)?.1.radius, + }; + 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}"))) + } +} + +/// 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)) +} + +fn coupled(doc: &Document, st: &ModelState, l: &MotionLink, c: &Coupling) -> Result { + let angle = |id: u64, i: usize| doc.assembly.joints.iter().find(|j| j.id == id).is_some_and(|j| j.kind.is_angle(i)); + let (turn_a, turn_b) = (angle(l.a, l.ia), angle(l.b, l.ib)); + // Travel per radian, for a turn coupled to a travel either way round. + let travel = |k: f64| match (turn_a, turn_b) { + (true, false) => Ok(k), + (false, true) => Ok(1.0 / k), + _ => Err(DocError::Invalid("a rack or a screw couples a turn and a travel".into())), + }; + let sign = |reverse: bool| if reverse { -1.0 } else { 1.0 }; + match c { + Coupling::Gear { ra, rb, internal, reverse } => { + if !(turn_a && turn_b) { + return Err(DocError::Invalid("gears couple two turns".into())); + } + Ok(size_of(doc, st, ra)? / size_of(doc, st, rb)? * sign(!internal) * sign(*reverse)) + } + 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)), + } } /// Joints, joint origins and motion links of a design. @@ -272,27 +382,35 @@ pub fn resolve_origin(doc: &Document, st: &ModelState, o: &JointOrigin) -> Resul let (_, c, n) = best.ok_or_else(|| DocError::Invalid("joint origin: no planar face there".into()))?; frame(c, n, None).ok_or_else(bad) } - Snap::CircleCenter { pick, face } => { - let pick = &named_pick(st, *pick, face.as_deref())?; - let comp = component_of(doc, o.occurrence)?; - let cyl = component_bodies(doc, st, comp) - .into_iter() - .filter_map(|b| solidcraft_kernel::cylinder_face_at(&b.body, *pick)) - .min_by(|a, b| { - let off = |c: &solidcraft_kernel::CylinderFace| { - let v = *pick - c.axis_point; - ((v - c.axis * v.dot(c.axis)).len() - c.radius).abs() - }; - off(a).total_cmp(&off(b)) - }) - .ok_or_else(|| DocError::Invalid("joint origin: no cylindrical face there".into()))?; - let s = (*pick - cyl.axis_point).dot(cyl.axis); + Snap::CircleCenter { .. } => { + let (pick, cyl) = circle_at(doc, st, o)?; + let s = (pick - cyl.axis_point).dot(cyl.axis); let (at, dir) = if (s - cyl.start).abs() <= (cyl.end - s).abs() { (cyl.start, -1.0) } else { (cyl.end, 1.0) }; frame(cyl.axis_point + cyl.axis * at, cyl.axis * dir, None).ok_or_else(bad) } } } +/// The pick of a circle-centre snap (on its named face) and the cylindrical face there +/// (component coordinates). Any other snap is refused. +fn circle_at(doc: &Document, st: &ModelState, o: &JointOrigin) -> Result<(Vec3, solidcraft_kernel::CylinderFace)> { + let Snap::CircleCenter { pick, face } = &o.snap else { return Err(DocError::Invalid("pick a round face".into())) }; + let pick = named_pick(st, *pick, face.as_deref())?; + let comp = component_of(doc, o.occurrence)?; + let cyl = component_bodies(doc, st, comp) + .into_iter() + .filter_map(|b| solidcraft_kernel::cylinder_face_at(&b.body, pick)) + .min_by(|a, b| { + let off = |c: &solidcraft_kernel::CylinderFace| { + let v = pick - c.axis_point; + ((v - c.axis * v.dot(c.axis)).len() - c.radius).abs() + }; + off(a).total_cmp(&off(b)) + }) + .ok_or_else(|| DocError::Invalid("joint origin: no cylindrical face there".into()))?; + Ok((pick, cyl)) +} + /// World transform of an occurrence (through its parents). pub fn occurrence_world(doc: &Document, occ: u64) -> Mat { let mut m = IDENTITY; @@ -1283,7 +1401,7 @@ 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 }).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(); Assembly { joints, links, ..Assembly::default() } } @@ -1306,6 +1424,53 @@ mod tests { assert_eq!(again, vals); } + /// A coupling saves as its definition: sizes as numbers, expressions or picked faces, flags + /// only when set; a link without one (as every design before couplings has) reads as before. + #[test] + fn couplings_read_and_write_their_definition() { + let old: MotionLink = serde_json::from_str(r#"{"a": 1, "b": 2, "ratio": 2}"#).unwrap(); + assert_eq!((old.coupling, old.offset, old.ia), (None, 0.0, 0)); + let text = r#"{"a": 1, "b": 2, "ratio": -0.5, "coupling": {"type": "gear", "ra": 10, "rb": "r2 * 2", + "internal": true}}"#; + let l: MotionLink = serde_json::from_str(text).unwrap(); + let Some(Coupling::Gear { ra, rb, internal, reverse }) = &l.coupling else { panic!("{l:?}") }; + assert_eq!((ra, rb, *internal, *reverse), (&Size::Length(10.0), &Size::Expr("r2 * 2".into()), true, false)); + let back = serde_json::to_value(&l).unwrap(); + assert_eq!(back["coupling"], serde_json::json!({"type": "gear", "ra": 10.0, "rb": "r2 * 2", "internal": true})); + let picked: Size = serde_json::from_str(r#"{"occurrence": 3, "snap": {"type": "circle_center", "pick": [1, 0, 0]}}"#).unwrap(); + assert!(matches!(picked, Size::Circle(JointOrigin { occurrence: 3, snap: Snap::CircleCenter { .. } }))); + let screw: Coupling = serde_json::from_str(r#"{"type": "screw", "pitch": 1.5, "reverse": true}"#).unwrap(); + assert_eq!(screw, Coupling::Screw { pitch: Size::Length(1.5), reverse: true }); + } + + /// Ratios from the sizes: gears turn by their radii (the other way unless internal), a rack + /// and a screw travel per radian whichever of the two joints drives. + #[test] + fn coupling_ratios_follow_their_sizes() { + let mut doc = Document { assembly: chain(3, 0.0), ..Document::default() }; + if let Some(j) = doc.assembly.joints.get_mut(2) { + j.kind = JointKind::Slider; + } + 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 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))); + assert_eq!(ratio(1, 2, Coupling::Gear { ra: len(10.0), rb: len(40.0), internal: true, reverse: false }), Some(Ok(0.25))); + assert_eq!(ratio(1, 3, Coupling::RackPinion { radius: len(8.0), reverse: false }), Some(Ok(8.0))); + assert_eq!(ratio(3, 1, Coupling::RackPinion { radius: len(8.0), reverse: true }), Some(Ok(-0.125))); + let screw = ratio(1, 3, Coupling::Screw { pitch: len(std::f64::consts::TAU), reverse: false }); + assert_eq!(screw, Some(Ok(1.0))); + assert!(matches!(ratio(1, 2, Coupling::RackPinion { radius: len(8.0), reverse: false }), Some(Err(_)))); + assert!(matches!(ratio(1, 3, Coupling::Gear { ra: len(1.0), rb: len(1.0), internal: false, reverse: false }), Some(Err(_)))); + for bad in [0.0, -2.0, f64::NAN, f64::INFINITY, 1e300] { + assert!(matches!(ratio(1, 2, Coupling::Gear { ra: len(bad), rb: len(1.0), internal: false, reverse: false }), Some(Err(_))), "{bad}"); + } + assert!(matches!(ratio(1, 2, Coupling::Gear { ra: Size::Expr("nope".into()), rb: len(1.0), internal: false, reverse: false }), Some(Err(_)))); + } + #[test] fn motions_compose() { assert!(close(&motion(JointKind::Revolute, &[std::f64::consts::FRAC_PI_2]), &rot_z(std::f64::consts::FRAC_PI_2))); diff --git a/crates/engine/src/cmd/component_clone_assembly.rs b/crates/engine/src/cmd/component_clone_assembly.rs index a07845ab..88a2f2c9 100644 --- a/crates/engine/src/cmd/component_clone_assembly.rs +++ b/crates/engine/src/cmd/component_clone_assembly.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use solidcraft_doc::Document; -use solidcraft_doc::joints::{Assembly, JointOrigin, Snap}; +use solidcraft_doc::joints::{Assembly, Coupling, JointOrigin, Size, Snap}; use crate::{EngineError, Result}; @@ -105,6 +105,14 @@ fn budget(assembly: &Assembly) -> Result<()> { if !assembly.links.iter().all(|l| l.ratio.is_finite() && l.offset.is_finite()) { return Err(invalid("motion link contains non-finite values")); } + 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")), + Size::Expr(e) => text(e)?, + Size::Circle(o) => finite_origin(o)?, + Size::Length(_) => {} + } + } if references > MAX_REFERENCES { return Err(invalid("assembly references exceed the processing budget")); } @@ -291,9 +299,16 @@ pub(super) fn clone_assembly( if !touched_joints.contains(&old.a) && !touched_joints.contains(&old.b) { continue; } + let mut item = old.clone(); + // A gear's picked round faces are on occurrences of the copy too. + let faces = item.coupling.as_mut().is_none_or(|c| { + c.sizes_mut().into_iter().all(|size| match size { + Size::Circle(o) => origin(o, occurrences, features, bodies).map(|new| *o = new).is_some(), + _ => true, + }) + }); match (joints.get(&old.a), joints.get(&old.b)) { - (Some(aa), Some(bb)) => { - let mut item = old.clone(); + (Some(aa), Some(bb)) if faces => { item.a = *aa; item.b = *bb; a.links.push(item); diff --git a/crates/engine/src/cmd/component_clone_assembly_tests.rs b/crates/engine/src/cmd/component_clone_assembly_tests.rs index 6930db75..f9157eab 100644 --- a/crates/engine/src/cmd/component_clone_assembly_tests.rs +++ b/crates/engine/src/cmd/component_clone_assembly_tests.rs @@ -1,6 +1,6 @@ #![allow(clippy::unwrap_used, clippy::panic)] use super::*; -use solidcraft_doc::joints::{Joint, JointKind, MotionLink, NamedOrigin}; +use solidcraft_doc::joints::{Coupling, Joint, JointKind, MotionLink, NamedOrigin, Size}; use solidcraft_doc::motion::{ContactSet, ExplodedView, MotionStudy, StudyKey}; use solidcraft_doc::{IDENTITY, Occurrence}; use solidcraft_geom::Vec3; @@ -43,8 +43,10 @@ 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 }, MotionLink { a: 1, ia: 0, b: 3, ib: 0, ratio: 1.0, offset: 0.0 }]; + 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 }, + ]; source.assembly.contact_sets = vec![ ContactSet { id: 1, name: "Internal contact".into(), occurrences: vec![1, 2], suppressed: true }, ContactSet { id: 2, name: "Cross contact".into(), occurrences: vec![1, 9], suppressed: false }, @@ -96,6 +98,29 @@ fn internal_relationships_receive_independent_ids_and_remapped_typed_references( assert_eq!(source.assembly.joints[0].values[0], 0.25); } +/// A gear's picked round face is on the copy's occurrence and face; one picked on geometry the +/// copy doesn't have is reported, not pointed back at the original. +#[test] +fn copied_gears_take_their_picked_faces_along() { + let (mut source, _) = documents(); + 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"))) }, + ]; + let mut destination = source.clone(); + destination.occurrences.extend([101, 102].map(occurrence)); + let report = + clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 101), (2, 102)]), &BTreeMap::from([(1, 11), (10, 110)]), &BTreeMap::new()) + .unwrap(); + let copied = destination.assembly.links.get(2).and_then(|l| l.coupling.clone()); + let Some(Coupling::Gear { ra: Size::Circle(o), .. }) = copied else { panic!("{:?}", destination.assembly.links) }; + assert!(o.occurrence == 101 && matches!(&o.snap, Snap::CircleCenter { face: Some(f), .. } if f == "F11:side"), "{o:?}"); + assert_eq!(destination.assembly.links.len(), 3, "the gear on foreign geometry stays behind"); + assert!(report.iter().any(|m| m.contains("motion link")), "{report:?}"); +} + #[test] fn naming_remaps_exact_tokens_unicode_and_legacy_body_identity() { let features = BTreeMap::from([(1, 21), (10, 30)]); diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index a6c8e890..92b42b68 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -7,7 +7,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde_json::{Value, json}; use solidcraft_doc::expr::Kind; -use solidcraft_doc::joints::{self, Joint, JointKind, JointOrigin, MotionLink, NamedOrigin, Snap}; +use solidcraft_doc::joints::{self, Coupling, Joint, JointKind, JointOrigin, MotionLink, NamedOrigin, Size, Snap}; use solidcraft_geom::{Aabb3, Vec3}; use super::CommandSpec; @@ -40,6 +40,23 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SOLID", "ASSEMBLE") .icon("motion_link") .params("a, b: joints; ratio (b = ratio · a + offset): b's units per a's (mm per deg for an angle driving a distance); offset?: in b's units (deg for an angle, mm for a distance, or an expression); ia?, ib?: value indices (default 0)"), + CommandSpec::new("joint.gear", "Gear", gear).at("SOLID", "ASSEMBLE").icon("gear").params( + "a, b: joints that turn (revolute), b driven by a; ra, rb: the gears' pitch radii (or tooth counts): mm, an expression, or \ + {circle: [x,y,z], occurrence?}: the round face there, its radius (read again whenever the design changes, so the ratio follows \ + edits); internal?: bool (one is a ring gear: both turn the same way; by default b turns the other way); reverse?: bool; \ + link?: index (replace that motion link) → link, ratio (b's degrees per a's); b keeps where it is", + ), + CommandSpec::new("joint.rack_pinion", "Rack and Pinion", rack_pinion).at("SOLID", "ASSEMBLE").icon("rack_pinion").params( + "a, b: a joint that turns (revolute: the pinion) and one that slides (slider: the rack), in either order, b driven by a; \ + radius: the pinion's pitch radius (mm, an expression, or {circle: [x,y,z], occurrence?}): the rack travels radius × the turn in \ + radians; reverse?: bool; link?: index (replace that motion link) → link, ratio (mm per degree, or degrees per mm); b keeps \ + where it is", + ), + CommandSpec::new("joint.screw", "Screw", screw).at("SOLID", "ASSEMBLE").icon("screw").params( + "joint: a cylindrical joint (its turn drives its travel) | a, b: a joint that turns and one that slides, b driven by a; pitch: \ + 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.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)"), @@ -53,11 +70,40 @@ pub static COMMANDS: &[CommandSpec] = &[ ), ]; -/// Re-solve the joints and move the occurrences they place (after any edit). +/// Re-solve the joints and move the occurrences they place (after any edit). Gears, racks and +/// screws first take their ratios from the radii and pitches they are defined by. pub fn resolve(s: &mut Session) -> joints::Solution { + recouple(s); 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. +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 + .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))) + .collect(); + if changed.is_empty() { + return; + } + let doc = s.doc_mut(); + for (i, r) in changed { + if let Some(l) = doc.assembly.links.get_mut(i) { + l.ratio = r; + } + } +} + /// Re-solve the joints, keeping the `held` values (the ones being driven, mapped to the values /// they move from) where loops are closed. pub fn resolve_holding(s: &mut Session, held: &joints::Held) -> joints::Solution { @@ -101,6 +147,14 @@ pub(crate) fn named_values(j: &Joint, q: &[f64]) -> Vec { .collect() } +/// A joint's values as the solver places it (after motion links, so a screw's travel follows its +/// turn), named as `joint.list` gives them. +pub(crate) fn effective_values(s: &Session, id: u64) -> Option> { + let j = s.doc.assembly.joints.iter().find(|j| j.id == id)?; + let vals = joints::joint_values(&s.doc.assembly); + Some(named_values(j, vals.get(&id).map(Vec::as_slice).unwrap_or(&j.values))) +} + fn occurrence_name(s: &Session, id: u64) -> String { s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_else(|| id.to_string()) } @@ -495,7 +549,7 @@ fn drive(s: &mut Session, p: &Value) -> Result { let sol = joints::solve(&s.doc, &s.model.state()); let mut out = sol_json(&sol, p); out["joint"] = json!(id); - out["values"] = json!(s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| named_values(j, &j.values))); + out["values"] = json!(effective_values(s, id)); out["stopped_by_contact"] = r["stopped_by_contact"].clone(); out["stopped_by_loop"] = r["stopped_by_loop"].clone(); out["moved"] = json!(super::motion::moved(&before, &super::motion::positions(s)).next().is_some()); @@ -526,11 +580,149 @@ 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 }); + s.doc_mut().assembly.links.push(MotionLink { a, ia, b, ib, ratio, offset, coupling: None }); let sol = resolve(s); Ok(sol_json(&sol, p)) } +/// A coupling length: a number (mm), an expression (kept as typed, so it follows the +/// parameters), or `{circle: [x, y, z], occurrence?}`: the round face there, by its radius. +fn size_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result { + match p.get(key) { + Some(Value::Number(n)) => n.as_f64().map(Size::Length).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite length"))), + Some(Value::String(t)) if t.len() <= 1000 => Ok(Size::Expr(t.trim().to_string())), + Some(o @ Value::Object(m)) if m.contains_key("circle") => Ok(Size::Circle(origin_param(s, Some(o), cmd)?)), + _ => Err(bad(cmd, format!("`{key}` must be a length (mm or an expression) or a round face ({{circle: [x, y, z]}})"))), + } +} + +/// A joint's first value that turns (an angle), or that travels (a distance). +fn value_index(s: &Session, id: u64, turn: bool) -> Option { + let j = s.doc.assembly.joints.iter().find(|j| j.id == id)?; + (0..j.kind.dofs().len()).find(|i| j.kind.is_angle(*i) == turn) +} + +/// The values a rack or a screw couples: a's turn and b's travel, or a's travel and b's turn. +fn turn_and_travel(s: &Session, a: u64, b: u64, cmd: &str) -> Result<(usize, usize)> { + match (value_index(s, a, true), value_index(s, b, false), value_index(s, a, false), value_index(s, b, true)) { + (Some(ia), Some(ib), ..) | (_, _, Some(ia), Some(ib)) => Ok((ia, ib)), + _ => Err(bad(cmd, "couple a joint that turns (revolute) with one that slides (slider)")), + } +} + +/// Two different joints `a` and `b`. +fn joint_pair(s: &Session, p: &Value, cmd: &str) -> Result<(u64, u64)> { + let (a, b) = (joint_id(s, p.get("a"), cmd)?, joint_id(s, p.get("b"), cmd)?); + if a == b { + return Err(bad(cmd, "couple two different joints")); + } + Ok((a, b)) +} + +fn reverse(p: &Value) -> bool { + bool_(p, "reverse").unwrap_or(false) +} + +fn gear(s: &mut Session, p: &Value) -> Result { + let cmd = "joint.gear"; + let (a, b) = joint_pair(s, p, cmd)?; + let turn = |id: u64| value_index(s, id, true).ok_or_else(|| bad(cmd, "gears need two joints that turn (revolute)")); + let (ia, ib) = (turn(a)?, turn(b)?); + let c = Coupling::Gear { + ra: size_param(s, p, "ra", cmd)?, + rb: size_param(s, p, "rb", cmd)?, + 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) }) +} + +fn rack_pinion(s: &mut Session, p: &Value) -> Result { + let cmd = "joint.rack_pinion"; + 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) }) +} + +fn screw(s: &mut Session, p: &Value) -> Result { + let cmd = "joint.screw"; + let (a, ia, b, ib) = match p.get("joint") { + Some(v) => { + let id = joint_id(s, Some(v), cmd)?; + match (value_index(s, id, true), value_index(s, id, false)) { + (Some(ia), Some(ib)) => (id, ia, id, ib), + _ => return Err(bad(cmd, "a screw on one joint needs a joint that turns and slides (cylindrical); or give a and b")), + } + } + None => { + let (a, b) = joint_pair(s, p, cmd)?; + let (ia, ib) = turn_and_travel(s, a, b, cmd)?; + (a, ia, b, ib) + } + }; + 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) }) +} + +/// Add a coupling's motion link, or put it in place of link `link`: its ratio worked out from its +/// sizes, a new link's offset leaving b where it is (the teeth mesh as they are modelled). +fn couple(s: &mut Session, p: &Value, cmd: &str, mut l: MotionLink) -> Result { + let links = &s.doc.assembly.links; + let replace = match p.get("link") { + None => None, + Some(v) => { + Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < links.len()).ok_or_else(|| bad(cmd, "no such motion link"))?) + } + }; + if replace.is_none() && links.len() >= joints::MAX_JOINTS { + return Err(bad(cmd, "too many motion links")); + } + let others: Vec<&MotionLink> = links.iter().enumerate().filter(|(i, _)| Some(*i) != replace).map(|(_, k)| k).collect(); + let name = |id: u64| s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.name.clone()).unwrap_or_default(); + if others.iter().any(|k| (k.b, k.ib) == (l.b, l.ib)) { + return Err(bad(cmd, format!("{} already follows a motion link: couple it the other way round (as a)", name(l.b)))); + } + // The values that set a's, back through the links: b among them would make a cycle. + let mut at = (l.a, l.ia); + for _ in 0..=others.len() { + if at == (l.b, l.ib) { + return Err(bad(cmd, format!("{} already drives {} through motion links", name(l.b), name(l.a)))); + } + match others.iter().find(|k| (k.b, k.ib) == at) { + Some(k) => at = (k.a, k.ia), + None => 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()))?; + 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), + }; + let shown = l.ratio / ratio_scale(s, l.a, l.ia, l.b, l.ib); + let doc = s.doc_mut(); + let index = match replace { + Some(i) => { + if let Some(slot) = doc.assembly.links.get_mut(i) { + *slot = l; + } + i + } + None => { + doc.assembly.links.push(l); + doc.assembly.links.len() - 1 + } + }; + let sol = resolve(s); + let mut out = sol_json(&sol, p); + out["link"] = json!(index); + out["ratio"] = json!(shown); + Ok(out) +} + fn is_angle(s: &Session, joint: u64, i: usize) -> bool { s.doc.assembly.joints.iter().find(|j| j.id == joint).is_some_and(|j| j.kind.is_angle(i)) } @@ -666,7 +858,15 @@ fn list(s: &mut Session, p: &Value) -> Result { let angle = is_angle(s, l.b, l.ib); let offset = if angle { l.offset.to_degrees() } else { l.offset }; let ratio = l.ratio / ratio_scale(s, l.a, l.ia, l.b, l.ib); - json!({"a": l.a, "ia": l.ia, "b": l.b, "ib": l.ib, "ratio": ratio, "offset": offset, "unit": if angle { "deg" } else { "mm" }}) + let mut v = + 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) { + v["error"] = json!(e.to_string()); + } + } + v }) .collect(); let origins: Vec = s.doc.assembly.origins.iter().map(|o| json!({"name": o.name, "occurrence": o.origin.occurrence})).collect(); diff --git a/crates/engine/src/cmd/joints_coupling_tests.rs b/crates/engine/src/cmd/joints_coupling_tests.rs new file mode 100644 index 00000000..afdbb67e --- /dev/null +++ b/crates/engine/src/cmd/joints_coupling_tests.rs @@ -0,0 +1,255 @@ +//! Gears, racks and screws: motion links whose ratio comes from radii or a pitch. Expected values +//! are analytic: a gear train turns by the ratio of its radii, a rack travels r·θ, a screw +//! advances its pitch per turn. + +use super::*; +use solidcraft_doc::joints::{Coupling, Size}; + +/// 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) +} + +fn close_to(got: f64, want: f64) -> bool { + (got - want).abs() < 1e-6 +} + +/// A round disc (a gear's pitch circle) of radius `r` about a vertical axis through (x, 0), 5 mm +/// thick, in a component of its own, on a revolute joint to the grounded base. +fn disc(s: &mut Session, name: &str, x: f64, r: &str) { + run(s, "component.activate", json!({"component": "root"})); + run(s, "component.create", json!({"name": name})); + run(s, "solid.cylinder", json!({"base": [x, 0, 0], "axis": [0, 0, 1], "radius": r, "height": 5})); + pin(s, "revolute", name, "Base", name, Vec3::new(x, 0.0, 0.0), Vec3::Z); +} + +fn base(s: &mut Session) { + part(s, "Base", [-60.0, -60.0, -20.0]); + let g = occ(s, "Base"); + run(s, "occurrence.ground", json!({"occurrence": g, "grounded": true})); +} + +/// Gears of pitch radii r1 (a parameter), 20 and 15 in a row: G1 meshes G2 (radii picked on their +/// discs), G2 meshes G3 (radii typed). +fn gear_train() -> Session { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "r1", "expression": "10 mm"})); + base(&mut s); + disc(&mut s, "G1", 0.0, "r1"); + disc(&mut s, "G2", 30.0, "20 mm"); + disc(&mut s, "G3", 65.0, "15 mm"); + let r = run( + &mut s, + "joint.gear", + json!({"a": "G1", "b": "G2", "ra": {"occurrence": "G1", "circle": [10, 0, 2.5]}, "rb": {"occurrence": "G2", "circle": [50, 0, 2.5]}}), + ); + assert_eq!((r["link"].clone(), r["ratio"].as_f64().map(|x| close_to(x, -0.5))), (json!(0), Some(true)), "{r}"); + run(&mut s, "joint.gear", json!({"a": "G2", "b": "G3", "ra": 20, "rb": "15 mm"})); + s +} + +/// A point on a disc's rim, where the disc is placed. +fn rim(s: &Session, name: &str, x: f64, r: f64) -> Vec3 { + place(s, name, Vec3::new(x + r, 0.0, 2.5)) +} + +fn turned(x: f64, r: f64, deg: f64) -> Vec3 { + let a = deg.to_radians(); + Vec3::new(x + r * a.cos(), r * a.sin(), 2.5) +} + +#[test] +fn a_gear_train_of_three_turns_by_its_radii() { + let mut s = gear_train(); + let r = run(&mut s, "joint.drive", json!({"joint": "G1", "value": 90})); + assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}"); + // ω2 = −ω1·r1/r2, ω3 = −ω2·r2/r3 = ω1·r1/r3: external gears turn the other way. + assert!(close_to(shown(&mut s, "G2"), -45.0) && close_to(shown(&mut s, "G3"), 60.0), "{}", run(&mut s, "joint.list", json!({}))); + for (name, x, r, deg) in [("G1", 0.0, 10.0, 90.0), ("G2", 30.0, 20.0, -45.0), ("G3", 65.0, 15.0, 60.0)] { + assert!(near(rim(&s, name, x, r), turned(x, r, deg)), "{name}: {:?}", rim(&s, name, x, r)); + } + // The picked radius follows the design: G1 grows to 12, so G2 turns 90·12/20. + run(&mut s, "parameters.change", json!({"name": "r1", "expression": "12 mm"})); + assert!(close_to(shown(&mut s, "G2"), -54.0) && close_to(shown(&mut s, "G3"), 72.0), "{}", run(&mut s, "joint.list", json!({}))); + let l = run(&mut s, "joint.list", json!({})); + assert!(close_to(l["links"][0]["ratio"].as_f64().unwrap_or(f64::NAN), -0.6), "{l}"); + assert_eq!(l["links"][0]["coupling"]["type"], "gear", "{l}"); + // A motion study playing G1 turns the train the same way. + run(&mut s, "motion.study", json!({"name": "Spin", "steps": 10, "keys": [{"joint": "G1", "points": [[0, 0], [10, 30]]}]})); + run(&mut s, "motion.play", json!({"study": "Spin", "step": 10})); + assert!(close_to(shown(&mut s, "G3"), 24.0), "{}", shown(&mut s, "G3")); + // An internal (ring) gear turns its mate the same way; redefining a link keeps its offset. + let r = run(&mut s, "joint.gear", json!({"a": "G2", "b": "G3", "ra": 20, "rb": 15, "internal": true, "link": 1})); + assert_eq!(r["link"], 1, "{r}"); + run(&mut s, "joint.drive", json!({"joint": "G1", "value": 60})); + assert!(close_to(shown(&mut s, "G2"), -36.0) && close_to(shown(&mut s, "G3"), -48.0), "{}", run(&mut s, "joint.list", json!({}))); + // Undo takes the redefinition back. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.assembly.links.get(1).and_then(|l| l.coupling.clone()).map(|c| matches!(c, Coupling::Gear { internal: false, .. })), Some(true)); +} + +#[test] +fn gears_save_and_open_with_their_definition() { + let mut s = gear_train(); + run(&mut s, "joint.drive", json!({"joint": "G1", "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!(matches!(back.assembly.links.first().and_then(|l| l.coupling.as_ref()), Some(Coupling::Gear { ra: Size::Circle(_), .. }))); + // A link saved before couplings (no `coupling`) opens as a plain 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")); + } + 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()) && old.assembly.links.len() == 2); +} + +/// A pinion of radius 8 about +Z at the origin, a rack under it sliding along +X, and a stop the +/// rack runs into after 10 mm. +fn rack() -> Session { + let mut s = Session::default(); + base(&mut s); + disc(&mut s, "Pinion", 0.0, "8 mm"); + for (name, corner, length) in [("Rack", [-20.0, -14.0, 0.0], 40.0), ("Stop", [30.0, -14.0, 0.0], 4.0)] { + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": name})); + run(&mut s, "solid.box", json!({"length": length, "width": 4, "height": 4, "corner": corner})); + } + pin(&mut s, "slider", "Rail", "Base", "Rack", Vec3::new(0.0, -10.0, 0.0), Vec3::X); + pin(&mut s, "rigid", "Stop", "Base", "Stop", Vec3::new(30.0, -10.0, 0.0), Vec3::Z); + s +} + +#[test] +fn a_rack_travels_r_times_theta() { + let mut s = rack(); + let r = run(&mut s, "joint.rack_pinion", json!({"a": "Pinion", "b": "Rail", "radius": {"occurrence": "Pinion", "circle": [8, 0, 2]}})); + // Per degree in the answer, as joint.list gives a link between an angle and a distance. + assert!(close_to(r["ratio"].as_f64().unwrap_or(f64::NAN), 8f64.to_radians()), "{r}"); + run(&mut s, "joint.drive", json!({"joint": "Pinion", "value": 45})); + let travel = 8.0 * std::f64::consts::FRAC_PI_4; + assert!(close_to(shown(&mut s, "Rail"), travel), "{}", shown(&mut s, "Rail")); + // The pinion turns counter-clockwise about +Z; its bottom, where the rack is, moves along +X. + assert!(near(place(&s, "Rack", Vec3::new(-20.0, -14.0, 0.0)), Vec3::new(-20.0 + travel, -14.0, 0.0))); + // Contact sets hold the rack at the stop, and the pinion with it: 10 mm is 10/8 rad. + run(&mut s, "contact.create", json!({"occurrences": ["Rack", "Stop"]})); + let r = run(&mut s, "joint.drive", json!({"joint": "Pinion", "value": 180})); + assert!(r["stopped_by_contact"].is_object(), "{r}"); + let (angle, travel) = (shown(&mut s, "Pinion"), shown(&mut s, "Rail")); + assert!((angle.to_radians() - 1.25).abs() < 0.01, "{angle}"); + assert!(close_to(travel, 8.0 * angle.to_radians()), "{travel} at {angle}°"); + // Either may drive: the rack turning the pinion, reversed. + let mut s = rack(); + let r = run(&mut s, "joint.rack_pinion", json!({"a": "Rail", "b": "Pinion", "radius": "8 mm", "reverse": true})); + assert!(close_to(r["ratio"].as_f64().unwrap_or(f64::NAN), -1.0 / 8f64.to_radians()), "{r}"); + run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 5})); + assert!(close_to(shown(&mut s, "Pinion"), -(5.0f64 / 8.0).to_degrees()), "{}", shown(&mut s, "Pinion")); +} + +#[test] +fn a_screw_advances_its_pitch_per_turn() { + let mut s = Session::default(); + base(&mut s); + part(&mut s, "Nut", [-1.0, -1.0, 0.0]); + pin(&mut s, "cylindrical", "Thread", "Base", "Nut", Vec3::ZERO, Vec3::Z); + run(&mut s, "joint.screw", json!({"joint": "Thread", "pitch": 1.5})); + let r = run(&mut s, "joint.drive", json!({"joint": "Thread", "values": [720]})); + assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}"); + let travel = r["values"][1]["value"].as_f64().unwrap_or(f64::NAN); + assert!(close_to(travel, 3.0), "{r}"); + assert!(near(place(&s, "Nut", Vec3::new(-1.0, -1.0, 0.0)), Vec3::new(-1.0, -1.0, 3.0))); + // A left-hand thread goes the other way. + run(&mut s, "joint.screw", json!({"joint": "Thread", "pitch": 1.5, "reverse": true, "link": 0})); + let r = run(&mut s, "joint.drive", json!({"joint": "Thread", "values": [360]})); + assert!(close_to(r["values"][1]["value"].as_f64().unwrap_or(f64::NAN), -1.5), "{r}"); + // A revolute joint and a slider, the pitch a parameter: changing it changes the lead. + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "lead", "expression": "2 mm"})); + base(&mut s); + part(&mut s, "Shaft", [-1.0, -1.0, 0.0]); + part(&mut s, "Carriage", [10.0, -1.0, 0.0]); + pin(&mut s, "revolute", "Spin", "Base", "Shaft", Vec3::ZERO, Vec3::Z); + pin(&mut s, "slider", "Lift", "Base", "Carriage", Vec3::new(11.0, 0.0, 0.0), Vec3::Z); + run(&mut s, "joint.screw", json!({"a": "Spin", "b": "Lift", "pitch": "lead"})); + run(&mut s, "joint.drive", json!({"joint": "Spin", "value": 900})); + assert!(close_to(shown(&mut s, "Lift"), 5.0), "{}", shown(&mut s, "Lift")); + run(&mut s, "parameters.change", json!({"name": "lead", "expression": "4 mm"})); + assert!(close_to(shown(&mut s, "Lift"), 10.0), "{}", shown(&mut s, "Lift")); +} + +/// A motor gear turns the four-bar's crank: half as fast, the other way round, and the loop +/// follows. +#[test] +fn a_gear_drives_a_closed_loop() { + let (mut s, _, q0) = four_bar(); + part(&mut s, "Motor", [80.0, -1.0, -5.0]); + pin(&mut s, "revolute", "Motor", "Ground", "Motor", Vec3::new(80.0, 0.0, 0.0), Vec3::Z); + run(&mut s, "joint.gear", json!({"a": "Motor", "b": "Crank", "ra": 10, "rb": 20})); + let r = run(&mut s, "joint.drive", json!({"joint": "Motor", "value": -120})); + assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}"); + assert!(r["stopped_by_loop"].is_null(), "{r}"); + assert!(close_to(shown(&mut s, "Crank"), 60.0), "{}", shown(&mut s, "Crank")); + assert!(near(place(&s, "Rocker", q0), rocker_pin(0.0, 60.0, q0)), "{:?}", place(&s, "Rocker", q0)); +} + +/// A pinion moves the piston of a slider-crank: the piston travels r·θ along its bore and the +/// crank follows, the rod keeping its length. +#[test] +fn a_rack_drives_a_slider_crank_from_its_piston() { + let (mut s, p0, s0) = slider_crank(); + part(&mut s, "Pinion", [0.0, -30.0, -5.0]); + pin(&mut s, "revolute", "Pinion", "Ground", "Pinion", Vec3::new(0.0, -30.0, 0.0), Vec3::Z); + run(&mut s, "joint.rack_pinion", json!({"a": "Pinion", "b": "Bore", "radius": 5})); + let r = run(&mut s, "joint.drive", json!({"joint": "Pinion", "value": 60})); + assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}"); + assert!(r["stopped_by_loop"].is_null(), "{r}"); + let travel = 5.0 * 60f64.to_radians(); + assert!(close_to(shown(&mut s, "Bore"), travel), "{}", shown(&mut s, "Bore")); + let wrist = place(&s, "Piston", s0); + assert!(near(wrist, s0 + Vec3::X * travel), "{wrist:?}"); + let pin_at = place(&s, "Rod", p0); + assert!((pin_at.dist(wrist) - 40.0).abs() < 1e-6 && (pin_at.len() - 15.0).abs() < 1e-6, "{pin_at:?}"); +} + +#[test] +fn couplings_refuse_what_they_cannot_couple() { + let mut s = gear_train(); + let refused = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).err().map(|e| e.to_string()).unwrap_or_default(); + // G3 already follows G2; a second writer, or a cycle back to G1, is refused. + assert!(refused(&mut s, "joint.gear", json!({"a": "G1", "b": "G3", "ra": 1, "rb": 1})).contains("already follows")); + assert!(refused(&mut s, "joint.gear", json!({"a": "G3", "b": "G1", "ra": 1, "rb": 1})).contains("already drives")); + assert!(refused(&mut s, "joint.gear", json!({"a": "G1", "b": "G1", "ra": 1, "rb": 1})).contains("two different")); + // A gear needs turns, a rack a turn and a travel. + assert!(refused(&mut s, "joint.rack_pinion", json!({"a": "G1", "b": "G3", "radius": 5})).contains("slides")); + // Hostile sizes and indices never panic and change nothing. + let before = s.doc.assembly.clone(); + for bad in [ + json!(-3), + json!(0), + json!(1e300), + json!("nope"), + json!("1/0"), + json!({"circle": [1e308, 0, 0]}), + json!({"circle": "x"}), + json!([1]), + Value::Null, + ] { + for (id, p) in [ + ("joint.gear", json!({"a": "G3", "b": "G1", "ra": bad, "rb": 1})), + ("joint.gear", json!({"a": "G1", "b": "G2", "ra": 1, "rb": bad, "link": 0})), + ("joint.rack_pinion", json!({"a": "G1", "b": "G2", "radius": bad})), + ("joint.screw", json!({"joint": "G1", "pitch": bad})), + ] { + assert!(s.execute(id, &p).is_err(), "{id} {p}"); + } + } + for bad in [json!(-1), json!(2), json!(1.5), json!(1e300), json!("0"), json!([0]), Value::Null] { + let p = json!({"a": "G1", "b": "G2", "ra": 1, "rb": 1, "link": bad}); + assert!(s.execute("joint.gear", &p).is_err(), "{p}"); + } + assert_eq!(s.doc.assembly, before); +} diff --git a/crates/engine/src/cmd/joints_tests.rs b/crates/engine/src/cmd/joints_tests.rs index 7d7a3290..1c86e632 100644 --- a/crates/engine/src/cmd/joints_tests.rs +++ b/crates/engine/src/cmd/joints_tests.rs @@ -1178,3 +1178,6 @@ fn a_new_joint_starts_inside_its_limits_and_says_so() { let r = run(&mut s, "joint.as_built", json!({"type": "cylindrical", "a": "A", "b": "B", "name": "Spindle", "limits": [[-30, 30], [0, 5]]})); assert!(r.get("warnings").is_none(), "{r}"); } + +#[path = "joints_coupling_tests.rs"] +mod coupling; diff --git a/crates/engine/src/cmd/motion.rs b/crates/engine/src/cmd/motion.rs index de82075e..33332ca0 100644 --- a/crates/engine/src/cmd/motion.rs +++ b/crates/engine/src/cmd/motion.rs @@ -10,7 +10,7 @@ use solidcraft_geom::Vec3; use solidcraft_kernel::Body; use super::CommandSpec; -use super::joints::{cannot_move, joint_id, named_values, occurrence_id, resolve_holding, value}; +use super::joints::{cannot_move, effective_values, joint_id, occurrence_id, resolve_holding, value}; use crate::params::{bad, bool_, str_, vec3}; use crate::{EngineError, Result, Session}; @@ -350,10 +350,7 @@ fn drive_with_contact(s: &mut Session, want: &[(u64, Vec)], given: &BTreeSe stopped = None; stopped_by_loop = Some(json!({"joint": name, "fraction": lo})); } - let values: Vec = want - .iter() - .map(|(id, _)| json!({"joint": id, "values": s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| named_values(j, &j.values))})) - .collect(); + let values: Vec = want.iter().map(|(id, _)| json!({"joint": id, "values": effective_values(s, *id)})).collect(); let out = json!({"values": values, "stopped_by_contact": stopped, "stopped_by_loop": stopped_by_loop, "conflicts": sol.conflicts.len()}); Ok((out, sol)) } diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 5c6a7e15..3010838e 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 and Interference. Joint origins are picked by hovering geometry: a face +//! Joints, Motion Link, Gear, Rack and Pinion, Screw (`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 @@ -27,8 +27,19 @@ 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; 8] = - ["joint.create", "joint.as_built", "joint.drive", "joint.edit", "joint.limits", "joint.rigid_group", "parts.insert", "parts.fastener"]; +const MOVES: [&str; 11] = [ + "joint.create", + "joint.as_built", + "joint.drive", + "joint.edit", + "joint.limits", + "joint.rigid_group", + "joint.gear", + "joint.rack_pinion", + "joint.screw", + "parts.insert", + "parts.fastener", +]; #[derive(Clone, Debug)] pub enum Asm { @@ -56,6 +67,7 @@ pub enum Asm { ratio: String, offset: String, }, + Coupling(Box), Interference { result: Option, overlaps: Vec, @@ -128,12 +140,17 @@ impl Asm { Asm::RigidGroup => "RIGID GROUP", Asm::Drive { .. } => "DRIVE JOINTS", Asm::MotionLink { .. } => "MOTION LINK", + Asm::Coupling(f) => match f.command { + "joint.gear" => "GEAR", + "joint.rack_pinion" => "RACK AND PINION", + _ => "SCREW", + }, Asm::Interference { .. } => "INTERFERENCE", } } pub fn previews(&self) -> bool { - matches!(self, Asm::Joint(_) | Asm::Drive { .. } | Asm::RigidGroup) + matches!(self, Asm::Joint(_) | Asm::Drive { .. } | Asm::RigidGroup | Asm::Coupling(_)) } } @@ -166,6 +183,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { let b = usize::from(movable(s).len() > 1); (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)?, "inspect.interference" => { (Asm::Interference { result: None, overlaps: Vec::new(), of: Vec::new() }, vec![SelInput::new("Bodies (all if none)", BODIES, true)]) } @@ -657,6 +675,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se enter |= field(ui, offset); ui.end_row(); } + Asm::Coupling(f) => enter |= crate::dialogs_coupling::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, ""); @@ -883,6 +902,7 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result return crate::dialogs_coupling::commands(app, f, inputs), // The check runs from its Compute button; OK only closes. Asm::Interference { .. } => Vec::new(), }) @@ -890,6 +910,9 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result) { + if let Asm::Coupling(f) = k { + crate::dialogs_coupling::animate(&app.session, f, cmds); + } let Asm::Joint(f) = k else { return }; if !f.animate { return; diff --git a/crates/ui-egui/src/dialogs_coupling.rs b/crates/ui-egui/src/dialogs_coupling.rs new file mode 100644 index 00000000..2cda97f6 --- /dev/null +++ b/crates/ui-egui/src/dialogs_coupling.rs @@ -0,0 +1,279 @@ +//! 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 +//! 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. + +use egui::RichText; +use serde_json::{Value, json}; +use solidcraft_engine::Sel; +use solidcraft_engine::Session; +use solidcraft_engine::doc::expr::Kind as ValueKind; + +use crate::SolveApp; +use crate::dialogs::{check, combo, field, row_label}; +use crate::dialogs_assembly::Asm; +use crate::selection::{EDGES, FACES, SelInput}; +use crate::theme::Tokens; + +/// What a round-face input takes: a face, or an edge of one. +const ROUND: u16 = FACES | EDGES; + +#[derive(Clone, Debug)] +pub struct CouplingForm { + /// `joint.gear`, `joint.rack_pinion` or `joint.screw`. + pub command: &'static str, + /// The driving joint and the driven one (ids; the same cylindrical joint for a screw on one). + pub a: u64, + pub b: u64, + /// Typed sizes, used where no round face is picked: gear 1's and gear 2's radii, the pinion's + /// radius, or the screw's pitch. + pub sizes: [String; 2], + pub internal: bool, + pub reverse: bool, + /// The bodies of the picks the joints were last chosen from, so a pick chooses once. + pub picked: [Option; 2], + pub animate: bool, + /// Animation phase, 0…1. + pub phase: f64, +} + +/// Joints with a value that turns (`turn`) or slides: (id, label). +fn joints_that(s: &Session, turn: bool) -> Vec<(u64, String)> { + s.doc + .assembly + .joints + .iter() + .filter(|j| (0..j.kind.dofs().len()).any(|i| j.kind.is_angle(i) == turn)) + .map(|j| (j.id, format!("{} ({:?})", j.name, j.kind))) + .collect() +} + +/// The joint roles and whether the driven one turns (a gear) or slides. +fn roles(command: &str) -> ([&'static str; 2], bool) { + match command { + "joint.gear" => (["Gear 1", "Gear 2"], true), + "joint.rack_pinion" => (["Pinion", "Rack"], false), + _ => (["Screw", "Nut"], false), + } +} + +pub fn start(app: &SolveApp, id: &str) -> Option<(Asm, Vec)> { + let s = &app.session; + let (_, turn_b) = roles(id); + let (turns, b_list) = (joints_that(s, true), joints_that(s, turn_b)); + let first = |l: &[(u64, String)]| l.first().map(|j| j.0).unwrap_or(0); + let (command, a, b, sizes, inputs) = match id { + "joint.gear" => ( + "joint.gear", + first(&turns), + turns.get(1).map(|j| j.0).unwrap_or(0), + ["10 mm".to_string(), "20 mm".to_string()], + vec![SelInput::new("Gear 1 (optional)", ROUND, false), SelInput::new("Gear 2 (optional)", ROUND, false)], + ), + "joint.rack_pinion" => ( + "joint.rack_pinion", + first(&turns), + first(&b_list), + ["10 mm".to_string(), String::new()], + vec![SelInput::new("Pinion (optional)", ROUND, false)], + ), + "joint.screw" => { + // A joint that turns and slides is a screw on its own. + let both = turns.iter().find(|t| b_list.iter().any(|l| l.0 == t.0)).map(|t| t.0); + let a = both.unwrap_or(first(&turns)); + ("joint.screw", a, both.unwrap_or(first(&b_list)), ["1.5 mm".to_string(), String::new()], vec![]) + } + _ => return None, + }; + let f = CouplingForm { command, a, b, sizes, internal: false, reverse: false, picked: [None, None], animate: false, phase: 0.0 }; + Some((Asm::Coupling(Box::new(f)), inputs)) +} + +/// The occurrence placing a picked body (0: the root), by the body's placement: each instance of +/// a copied gear is an occurrence of its own. +fn occurrence(s: &Session, body: &str) -> Option { + s.doc.body_frames(&s.model.state()).into_iter().find(|f| f.0 == body).map(|(_, _, path, _)| path.last().copied().unwrap_or(0)) +} + +fn pick(inputs: &[SelInput], i: usize) -> Option<(&str, solidcraft_engine::geom::Vec3)> { + match inputs.get(i)?.items.first()? { + Sel::Face { body, point, .. } | Sel::Edge { body, point, .. } => Some((body.as_str(), *point)), + _ => None, + } +} + +/// The radius of the round face picked in input `i`. +fn picked_radius(s: &Session, inputs: &[SelInput], i: usize) -> Option { + let (body, point) = pick(inputs, i)?; + let st = s.world_state(); + solidcraft_engine::kernel::cylinder_face_at(&st.body(body)?.body, point).map(|c| c.radius) +} + +/// Size `i` in mm: the picked face's radius, or the typed one. +fn size(s: &Session, f: &CouplingForm, inputs: &[SelInput], i: usize) -> Option { + picked_radius(s, inputs, i).or_else(|| s.doc.eval(f.sizes.get(i)?, ValueKind::Length).ok()).filter(|v| v.is_finite() && *v > 0.0) +} + +/// A joint row: the joints `list` offers, `sel` the chosen id. +fn joint_row(ui: &mut egui::Ui, label: &str, salt: &str, list: &[(u64, String)], sel: &mut u64) { + let labels: Vec<&str> = list.iter().map(|j| j.1.as_str()).collect(); + let mut pos = list.iter().position(|j| j.0 == *sel).unwrap_or(0); + row_label(ui, label); + combo(ui, salt, &labels, &mut pos); + ui.end_row(); + *sel = list.get(pos).map(|j| j.0).unwrap_or(0); +} + +/// The dialog's rows; true when Enter was pressed in a value. +pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, f: &mut CouplingForm, inputs: &[SelInput]) -> bool { + let t = Tokens::get(); + let s = &app.session; + let ([role_a, role_b], turn_b) = roles(f.command); + let (la, lb) = (joints_that(s, true), joints_that(s, turn_b)); + if la.is_empty() || lb.is_empty() { + row_label(ui, ""); + let need = if turn_b { "needs two joints that turn" } else { "needs a joint that turns and one that slides" }; + ui.label(RichText::new(crate::i18n::tr_label(need)).color(t.text_dim)); + ui.end_row(); + return false; + } + // A face picked on a part chooses the joint that part turns on, once. + for (i, slot) in [&mut f.a, &mut f.b].into_iter().enumerate() { + let body = pick(inputs, i).map(|(b, _)| b.to_string()); + if body.is_some() && body != f.picked.get(i).cloned().flatten() { + let turning = s.doc.assembly.joints.iter().filter(|j| la.iter().any(|l| l.0 == j.id)); + if let Some(j) = body + .as_deref() + .and_then(|b| occurrence(s, b)) + .and_then(|o| turning.clone().find(|j| j.b.occurrence == o).or(turning.clone().find(|j| j.a.occurrence == o))) + { + *slot = j.id; + } + } + if let Some(p) = f.picked.get_mut(i) { + *p = body; + } + } + let mut enter = false; + let size_row = |ui: &mut egui::Ui, label: &str, i: usize, text: &mut String, enter: &mut bool| { + row_label(ui, label); + match (pick(inputs, i), picked_radius(s, inputs, i)) { + (_, Some(r)) => { + ui.label(format!("{} mm ({})", trim(r), crate::i18n::tr_label("picked"))); + } + // A pick on a flat face or a straight edge has no radius: say so rather than fail on OK. + (Some(_), None) => { + ui.label(RichText::new(crate::i18n::tr_label("not a round face")).color(t.warning)); + } + (None, None) => *enter |= field(ui, text), + } + ui.end_row(); + }; + joint_row(ui, role_a, "cp_a", &la, &mut f.a); + let [s0, s1] = &mut f.sizes; + match f.command { + "joint.gear" => { + size_row(ui, "Radius 1", 0, s0, &mut enter); + joint_row(ui, role_b, "cp_b", &lb, &mut f.b); + size_row(ui, "Radius 2", 1, s1, &mut enter); + row_label(ui, "Internal gear"); + check(ui, &mut f.internal); + ui.end_row(); + } + "joint.rack_pinion" => { + size_row(ui, "Radius", 0, s0, &mut enter); + joint_row(ui, role_b, "cp_b", &lb, &mut f.b); + } + _ => { + joint_row(ui, role_b, "cp_b", &lb, &mut f.b); + row_label(ui, "Pitch"); + enter |= field(ui, s0); + ui.end_row(); + } + } + row_label(ui, "Reverse"); + check(ui, &mut f.reverse); + ui.end_row(); + // What the coupling does, as joint.list gives a link's ratio. + let sign = |neg: bool| if neg { -1.0 } else { 1.0 }; + let shown = match f.command { + "joint.gear" => size(s, f, inputs, 0) + .zip(size(s, f, inputs, 1)) + .map(|(ra, rb)| format!("{} deg / deg", trim(ra / rb * sign(!f.internal) * sign(f.reverse)))), + "joint.rack_pinion" => size(s, f, inputs, 0).map(|r| format!("{} mm / deg", trim(r.to_radians() * sign(f.reverse)))), + _ => size(s, f, inputs, 0).map(|p| format!("{} mm / deg", trim(p / 360.0 * sign(f.reverse)))), + }; + row_label(ui, "Ratio"); + ui.label(RichText::new(shown.unwrap_or_else(|| "-".into())).color(t.text_dim)); + 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); + // 48 steps over 4 s: each one a preview. + f.phase = ((now / 4.0).fract() * 48.0).floor() / 48.0; + ui.ctx().request_repaint_after(std::time::Duration::from_millis(30)); + } + enter +} + +/// A number without trailing zeros. +fn trim(v: f64) -> String { + let s = format!("{v:.4}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} + +/// The command OK runs. +pub fn commands(app: &SolveApp, f: &CouplingForm, 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 joints to couple".to_string()); + let (a, b) = (joint(f.a)?, joint(f.b)?); + // A picked face, or the typed size. + let size = |i: usize| -> Value { + match pick(inputs, i) { + Some((body, p)) => { + let mut v = json!({"circle": [p.x, p.y, p.z]}); + if let Some(o) = occurrence(s, body) { + v["occurrence"] = json!(o); + } + v + } + None => json!(f.sizes.get(i).map(|t| t.trim()).unwrap_or("")), + } + }; + let mut p = match f.command { + "joint.gear" => json!({"a": a, "b": b, "ra": size(0), "rb": size(1), "internal": f.internal}), + "joint.rack_pinion" => json!({"a": a, "b": b, "radius": size(0)}), + _ if a == b => json!({"joint": a, "pitch": f.sizes[0].trim()}), + _ => json!({"a": a, "b": b, "pitch": f.sizes[0].trim()}), + }; + p["reverse"] = json!(f.reverse); + 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. +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 }; + 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), + 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}))); +} + +#[cfg(test)] +#[path = "dialogs_coupling_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/dialogs_coupling_tests.rs b/crates/ui-egui/src/dialogs_coupling_tests.rs new file mode 100644 index 00000000..206d3467 --- /dev/null +++ b/crates/ui-egui/src/dialogs_coupling_tests.rs @@ -0,0 +1,198 @@ +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 with discs G1 (radius 10 at x = 0) and G2 (radius 20 at x = 30) turning on +/// revolute joints about +Z, and a nut on a cylindrical joint. +fn discs() -> SolveApp { + let mut s = Session::default(); + let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")); + for (name, base, r) in [("Base", [0, -60, -20], 2), ("G1", [0, 0, 0], 10), ("G2", [30, 0, 0], 20), ("Nut", [0, 60, 0], 3)] { + run("component.activate", json!({"component": "root"})); + run("component.create", json!({ "name": name })); + run("solid.cylinder", json!({"base": base, "axis": [0, 0, 1], "radius": r, "height": 5})); + } + run("component.activate", json!({"component": "root"})); + run("occurrence.ground", json!({"occurrence": "Base:1", "grounded": true})); + for (kind, name, x, y) in [("revolute", "G1", 0, 0), ("revolute", "G2", 30, 0), ("cylindrical", "Nut", 0, 60)] { + let at = |o: &str| json!({"occurrence": o, "point": [x, y, 0], "z": [0, 0, 1]}); + 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 round wall a point of the exact geometry lies on (the facets of the wall's mesh +/// cut inside it, so the nearest face of any body). +fn wall(app: &SolveApp, p: Vec3) -> Sel { + let st = app.session.world_state(); + let mut best: Option<(f64, Sel)> = None; + for b in &st.bodies { + let m = b.mesh(); + for (t, f) in m.triangles.iter().zip(&m.tri_face) { + let Some([a, bb, c]) = m.tri(t) else { continue }; + let d = crate::preview::point_triangle_dist(p, a, bb, c); + if best.as_ref().is_none_or(|(bd, _)| d < *bd) { + best = Some((d, Sel::Face { body: b.name.clone(), index: *f as usize, 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) +} + +/// The text of every text shape in a frame's output. +fn texts(out: &egui::FullOutput) -> Vec { + out.shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) => Some(t.galley.text().to_string()), + _ => None, + }) + .collect() +} + +/// Picking a gear's round face chooses the joint it turns on and takes its radius; the ratio +/// row says what the pair does, and OK makes the coupling. +#[test] +fn picked_gear_faces_choose_their_joints_and_radii() { + let mut app = discs(); + app.start("joint.gear"); + let mut d = app.dialog.take().unwrap(); + // G2 first, G1 second: the joints follow the picks, whatever the dialog started on. + for (i, p) in [Vec3::new(50.0, 0.0, 2.5), Vec3::new(10.0, 0.0, 2.5)].into_iter().enumerate() { + d.active = i; + let pick = wall(&app, p); + d.pick(&app.session, pick); + } + app.dialog = Some(d); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0)); + let frame = |app: &mut SolveApp| { + let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx())); + // Nothing is painted: drop the texture changes (an unapplied delta panics on drop). + out.textures_delta.clear(); + out + }; + frame(&mut app); + let out = frame(&mut app); + let shown = texts(&out); + assert!(shown.iter().any(|t| t == "-2 deg / deg"), "{shown:?}"); + assert!(shown.iter().any(|t| t.starts_with("20 mm")), "{shown:?}"); + let d = app.dialog.as_ref().expect("the dialog stays open"); + let c = apply_commands(&app, d).unwrap(); + let (id, p) = &c[0]; + assert_eq!((id.as_str(), p["a"].clone(), p["b"].clone()), ("joint.gear", json!(joint(&app, "G2")), json!(joint(&app, "G1"))), "{p}"); + assert!(p["ra"]["circle"].is_array() && p["rb"]["occurrence"].is_u64(), "{p}"); + // Play turns the driving gear in the preview. + let mut d = d.clone(); + if let Kind::Assembly(Asm::Coupling(f)) = &mut d.kind { + f.animate = true; + f.phase = 0.25; + } + let cmds = preview_commands(&app, &d).unwrap(); + assert_eq!(cmds.get(1).map(|c| c.1["values"].clone()), Some(json!(["90 deg"])), "{cmds:?}"); + for (id, p) in c { + app.run(&id, p).unwrap(); + } + let l = app.session.doc.assembly.links.first().cloned().unwrap(); + assert!((l.ratio + 2.0).abs() < 1e-9, "{l:?}"); +} + +/// Two instances of one gear (a copied component) are two occurrences, each on its own joint: +/// picks on each choose each one's joint, and the engine finds each pick on its own instance. +#[test] +fn instances_of_one_gear_are_coupled_as_two() { + let mut s = Session::default(); + let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")); + for (name, r) in [("Base", 2), ("Twin", 15)] { + run("component.activate", json!({"component": "root"})); + run("component.create", json!({ "name": name })); + run("solid.cylinder", json!({"base": [0, 0, if name == "Base" { -20 } else { 0 }], "axis": [0, 0, 1], "radius": r, "height": 5})); + } + run("component.activate", json!({"component": "root"})); + run("occurrence.copy", json!({"component": "Twin", "translate": [30, 0, 0]})); + run("occurrence.ground", json!({"occurrence": "Base:1", "grounded": true})); + for (name, x) in [("Twin:1", 0), ("Twin:2", 30)] { + let at = |o: &str| json!({"occurrence": o, "point": [x, 0, 0], "z": [0, 0, 1]}); + run("joint.create", json!({"type": "revolute", "a": at("Base:1"), "b": at(name), "flip": true, "name": name})); + } + let mut app = SolveApp::new(s, Services::default()); + let occ = |app: &SolveApp, name: &str| app.session.doc.occurrences.iter().find(|o| o.name == name).map(|o| o.id).unwrap_or(0); + app.start("joint.gear"); + let mut d = app.dialog.take().unwrap(); + for (i, p) in [Vec3::new(-15.0, 0.0, 2.5), Vec3::new(45.0, 0.0, 2.5)].into_iter().enumerate() { + d.active = i; + let pick = wall(&app, p); + d.pick(&app.session, pick); + } + app.dialog = Some(d); + let ctx = egui::Context::default(); + for _ in 0..2 { + 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 c = apply_commands(&app, app.dialog.as_ref().unwrap()).unwrap(); + let p = &c[0].1; + assert_eq!((p["a"].clone(), p["b"].clone()), (json!(joint(&app, "Twin:1")), json!(joint(&app, "Twin:2"))), "{p}"); + assert_eq!((p["ra"]["occurrence"].clone(), p["rb"]["occurrence"].clone()), (json!(occ(&app, "Twin:1")), json!(occ(&app, "Twin:2"))), "{p}"); + app.run(&c[0].0, p.clone()).unwrap(); + assert!((app.session.doc.assembly.links[0].ratio + 1.0).abs() < 1e-9); +} + +/// A cylindrical joint is a screw on its own: the dialog sends it as `joint` with the pitch. +#[test] +fn a_cylindrical_joint_is_a_screw_on_its_own() { + let mut app = discs(); + app.start("joint.screw"); + let d = app.dialog.take().unwrap(); + let c = apply_commands(&app, &d).unwrap(); + assert_eq!(c[0], ("joint.screw".to_string(), json!({"joint": joint(&app, "Nut"), "pitch": "1.5 mm", "reverse": false}))); + app.run(&c[0].0, c[0].1.clone()).unwrap(); + app.run("joint.drive", json!({"joint": "Nut", "values": [360]})).unwrap(); + let nut = solidcraft_engine::doc::joints::joint_values(&app.session.doc.assembly).get(&joint(&app, "Nut")).cloned().unwrap_or_default(); + assert!((nut.get(1).copied().unwrap_or(f64::NAN) - 1.5).abs() < 1e-9, "{nut:?}"); +} + +/// Play turns the pinion's angle, wherever it is among its joint's values: a planar joint's is +/// the third, after its x and y, which keep theirs. +#[test] +fn play_turns_the_angle_of_a_planar_pinion() { + let mut app = discs(); + let at = |o: &str| json!({"occurrence": o, "point": [0, 0, 0], "z": [0, 0, 1]}); + app.run("joint.edit", json!({"joint": "G1", "type": "planar"})).unwrap(); + app.run("joint.drive", json!({"joint": "G1", "values": [2, 0, 0]})).unwrap(); + app.run("joint.create", json!({"type": "slider", "a": at("Base"), "b": at("G2"), "flip": true, "name": "Rail"})).unwrap(); + app.start("joint.rack_pinion"); + let mut d = app.dialog.take().unwrap(); + if let Kind::Assembly(Asm::Coupling(f)) = &mut d.kind { + (f.a, f.animate, f.phase) = (joint(&app, "G1"), true, 0.5); + } + let cmds = preview_commands(&app, &d).unwrap(); + assert_eq!(cmds.last().map(|c| c.1["values"].clone()), Some(json!(["2 mm", "0 mm", "180 deg"])), "{cmds:?}"); +} + +/// The dialog's rows pass their English text, which the catalogs translate by its key. +#[test] +fn the_dialog_labels_translate() { + let de = crate::i18n::Locale::for_language("de"); + for (english, german) in [ + ("Gear 1", "Zahnrad 1"), + ("Radius 2", "Radius 2"), + ("Internal gear", "Innenzahnrad"), + ("Pitch", "Steigung"), + ("Rack and Pinion", "Zahnstange und Ritzel"), + ] { + assert_eq!(de.label(english), german); + } + assert_eq!(crate::i18n::Locale::for_language("ja").label("not a round face"), "円筒面ではありません"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 05366d1c..ede551ac 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -25,6 +25,7 @@ pub mod design_properties; pub mod dialogs; pub mod dialogs_appearance; pub mod dialogs_assembly; +pub mod dialogs_coupling; pub mod dialogs_library; pub mod dialogs_motion; pub mod dialogs_move; diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs index 2d6d28d3..25af7b69 100644 --- a/crates/ui-egui/src/preview.rs +++ b/crates/ui-egui/src/preview.rs @@ -187,7 +187,19 @@ impl PreviewState { /// limits, feature dialog or document cannot reuse an obsolete pose. fn motion_key(cmds: &[(String, Value)], revision: u64, colors: Colors) -> Option { if !cmds.iter().any(|(c, p)| c == "joint.drive" || c == "joint.create" && p.get("values").is_some()) - || !cmds.iter().all(|(c, _)| matches!(c.as_str(), "joint.drive" | "joint.create" | "joint.as_built" | "joint.edit" | "joint.limits")) + || !cmds.iter().all(|(c, _)| { + matches!( + c.as_str(), + "joint.drive" + | "joint.create" + | "joint.as_built" + | "joint.edit" + | "joint.limits" + | "joint.gear" + | "joint.rack_pinion" + | "joint.screw" + ) + }) { return None; } @@ -826,6 +838,15 @@ mod tests { motion_key(&[(command.into(), json!({"joint": 1, "values": [90]}))], 7, colors()), ); } + // A gear dialog playing its driving joint streams its poses too. + let gear = |angle: &str| { + vec![ + ("joint.gear".to_string(), json!({"a": 1, "b": 2, "ra": 10, "rb": 20})), + ("joint.drive".to_string(), json!({"joint": 1, "values": [angle]})), + ] + }; + 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())); } /// 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 953fc3dc..a3b04c2b 100644 --- a/crates/ui-egui/src/workspace.rs +++ b/crates/ui-egui/src/workspace.rs @@ -16,6 +16,9 @@ const ASSEMBLE: &[&str] = &[ "joint.rigid_group", "joint.drive", "joint.motion_link", + "joint.gear", + "joint.rack_pinion", + "joint.screw", "motion.study", "explode.create", "contact.enable_sets", diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index e33e79b0..9b2ff893 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -28,6 +28,33 @@ made. Contact sets stop motion where bodies would pass through each other, **Motion Study** keys joint values along a timeline, and **Interference** shows overlapping volumes in red. +**Gear**, **Rack and Pinion** and **Screw** (on the **ASSEMBLE** panel) are motion links whose +ratio comes from the parts rather than a typed number: + +- **Gear** (`joint.gear {a, b, ra, rb}`) couples two joints that turn. Joint 2 turns ra/rb as far + as joint 1, the other way round; **Internal gear** (one of them is a ring gear) turns both the + same way. Pick the round face of each gear, such as its pitch cylinder, or type the radii. Tooth + counts work as well as radii, since only their proportion counts. Picking a gear's face also + chooses the joint that gear turns on. +- **Rack and Pinion** (`joint.rack_pinion {a, b, radius}`) couples a joint that turns with one + that slides: the rack travels the pinion's pitch radius times its turn in radians (r·θ). Either + joint may drive the other. In `joint.list` the ratio is given per degree, as for any link between + an angle and a distance. +- **Screw** (`joint.screw {joint, pitch}`, or `{a, b, pitch}` for a revolute joint and a slider) + advances `pitch` per turn. A cylindrical joint is a screw on its own: its turn drives its travel. + The thread is right-handed; **Reverse** makes it left-handed. + +A radius or pitch may be an expression of the design's parameters, and a picked face is found +again whenever the design changes. Change the parameter or the gear's diameter, and the ratio +follows. The new link leaves the driven joint where it is, so teeth modelled in mesh stay in mesh. +**Reverse** turns the driven joint the other way, for joints whose axes point opposite ways. These +couplings are motion links, so they work in closed loops, with contact sets and in motion studies: +a rack can push the piston of a slider-crank, or a pinion can steer through a rack and its tie +rods. Drive the joint that drives the coupling (joint 1, the pinion, or the screw's turn), not the +driven one. A joint follows one link at most, and a coupling that would drive its own driver is +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. + 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/docs/design-container.md b/docs/design-container.md index b4f27588..9bde0bfe 100644 --- a/docs/design-container.md +++ b/docs/design-container.md @@ -7,7 +7,7 @@ self-contained input/output format. `io::write_design` keeps its JSON contract; `io::write_design_container` is the fallible container writer. Packages have `container.json` with `{"format":"solidcraft-container/1"}` and -`document.json` with the versioned parametric document (currently `solidcraft/22`). +`document.json` with the versioned parametric document (currently `solidcraft/23`). The independent versions let document migrations evolve without changing blob encodings. Existing serialized document fields are retained automatically, without a second copy of the document schema or a deep clone of embedded payloads. diff --git a/docs/features.md b/docs/features.md index e07ed6b8..304b2008 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,6 +1,6 @@ # Features -SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **373 commands.** Planned (registered, not built yet): 3. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). +SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **376 commands.** Planned (registered, not built yet): 3. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). ## Sketch (102) @@ -246,7 +246,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Break Link | `component.break_link` | – | | List Links | `component.links` | – | -## Assembly joints (11) +## Assembly joints (14) | Command | Id | Toolbar | |---|---|---| @@ -256,6 +256,9 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Rigid Group | `joint.rigid_group` | SOLID › ASSEMBLE | | Drive Joints | `joint.drive` | SOLID › ASSEMBLE | | Motion Link | `joint.motion_link` | SOLID › ASSEMBLE | +| Gear | `joint.gear` | SOLID › ASSEMBLE | +| Rack and Pinion | `joint.rack_pinion` | SOLID › ASSEMBLE | +| Screw | `joint.screw` | SOLID › ASSEMBLE | | Joint Limits | `joint.limits` | – | | Edit Joint | `joint.edit` | – | | Delete Joint | `joint.delete` | – | diff --git a/skills/solidcraft-mechanisms/SKILL.md b/skills/solidcraft-mechanisms/SKILL.md index 60485bf5..56b29217 100644 --- a/skills/solidcraft-mechanisms/SKILL.md +++ b/skills/solidcraft-mechanisms/SKILL.md @@ -41,6 +41,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. - **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 cbc8ee61..99adc387 100644 --- a/skills/solidcraft-mechanisms/reference.md +++ b/skills/solidcraft-mechanisms/reference.md @@ -62,6 +62,7 @@ A slider's value grows as b moves along +z of its origin. Give its two origins t - `joint.as_built` takes occurrences, not origins. `at` is the joint's frame (default: b's component origin). - `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.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`. - `motion.study {"study":..., ...}` edits a study; `keys` given replace all keys. - `motion.play` sets the joints to the study's values at a step (fractions work) and changes the design. `motion.export` does not change it. @@ -123,6 +124,8 @@ The same pattern moved the solar wings of the SELENE rover (`Rover-v7.solidcraft | A part turns by some other angle when joined | The two origins have different forms or x axes. Use `point` + `z` on both sides, or `joint.as_built`. | | `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`. | +| `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. | | A drive stops short, `stopped_by_contact` | Two bodies of a contact set touch. `fraction` is how far the move got. | | `occurrence.ground` says there is no occurrence `Frame` | It takes occurrence names only: `Frame:1`. | @@ -140,7 +143,7 @@ The same pattern moved the solar wings of the SELENE rover (`Rover-v7.solidcraft | `joint.list` | about 0.5 KB per joint: 16 KB for 29 joints | | `motion.play` | about 0.2 KB per occurrence, per step: 9 KB on SELENE | | `motion.export` | the same per sample; pass `samples`, or `path` to write it to a file | -| `joint.create`, `joint.drive`, `joint.motion_link` | the `dof` list of every occurrence again, 40 B each; `quiet: true` leaves it out | +| `joint.create`, `joint.drive`, `joint.motion_link`, `joint.gear`, `joint.rack_pinion`, `joint.screw` | the `dof` list of every occurrence again, 40 B each; `quiet: true` leaves it out | | `occurrence.properties` | under 1 KB per occurrence | ## Showing it moving