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:
29 files changed
+1476
-54
No files matched your search
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "需要一个旋转接头和一个滑动接头"
|
||||
}
|
||||
@@ -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
@@ -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)]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"), "円筒面ではありません");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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` | – |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user