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 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-15 18:47:38 -04:00
1 parent a519f3cf74
commit f7967b1ff6
29 files changed
+1476 -54

No files matched your search

+1 -1
View File
@@ -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 |
+43
View File
@@ -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);
+20 -1
View File
@@ -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"
}
+20 -1
View File
@@ -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"
}
+20 -1
View File
@@ -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"
}
+20 -1
View File
@@ -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": "回転するジョイントとスライドするジョイントが必要"
}
+20 -1
View File
@@ -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": "회전하는 관절과 미끄러지는 관절이 필요함"
}
+20 -1
View File
@@ -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": "нужны вращающееся и скользящее сопряжения"
}
+20 -1
View File
@@ -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": "потрібні обертове й ковзне з'єднання"
}
+20 -1
View File
@@ -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": "需要一个旋转接头和一个滑动接头"
}
+10 -2
View File
@@ -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<u32> {
/// 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
+183 -18
View File
@@ -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<Coupling>,
}
/// 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<f64> {
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<Result<f64>> {
l.coupling.as_ref().map(|c| coupled(doc, st, l, c))
}
fn coupled(doc: &Document, st: &ModelState, l: &MotionLink, c: &Coupling) -> Result<f64> {
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)));
@@ -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);
@@ -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)]);
+205 -5
View File
@@ -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<Value> {
.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<Vec<Value>> {
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<Value> {
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<Value> {
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<Size> {
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<usize> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> = s.doc.assembly.origins.iter().map(|o| json!({"name": o.name, "occurrence": o.origin.occurrence})).collect();
@@ -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);
}
+3
View File
@@ -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;
+2 -5
View File
@@ -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<f64>)], given: &BTreeSe
stopped = None;
stopped_by_loop = Some(json!({"joint": name, "fraction": lo}));
}
let values: Vec<Value> = 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<Value> = 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))
}
+27 -4
View File
@@ -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<crate::dialogs_coupling::CouplingForm>),
Interference {
result: Option<Value>,
overlaps: Vec<Mesh>,
@@ -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<SelInput>)> {
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<Sel> = 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<Vec<(Str
let ratio = eval(ratio, ValueKind::Unitless)?;
cmd("joint.motion_link", json!({"a": ja.id, "b": jb.id, "ia": ia, "ib": ib, "ratio": ratio, "offset": offset.trim()}))
}
Asm::Coupling(f) => 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<Vec<(Str
/// The preview's commands: OK's, with the joint's values playing its motion while animating.
pub fn animate(app: &SolveApp, k: &Asm, cmds: &mut Vec<(String, Value)>) {
if let Asm::Coupling(f) = k {
crate::dialogs_coupling::animate(&app.session, f, cmds);
}
let Asm::Joint(f) = k else { return };
if !f.animate {
return;
+279
View File
@@ -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<String>; 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<SelInput>)> {
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<u64> {
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<f64> {
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<f64> {
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<Vec<(String, Value)>, 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<String> = (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;
@@ -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<String> {
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"), "円筒面ではありません");
}
+1
View File
@@ -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;
+22 -1
View File
@@ -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<u64> {
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
+3
View File
@@ -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",
+27
View File
@@ -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
+1 -1
View File
@@ -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.
+5 -2
View File
@@ -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 <id> '<json>'`, 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 <id> '<json>'`, 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` | – |
+1
View File
@@ -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
+4 -1
View File
@@ -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