1 parent
88f76adbc2
commit
6232fa33c2
27 files changed
+2176
-47
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, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two boundaries exact, three/four refused pending a Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), and every remaining #757–#772 item | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
|
||||
| M11 | 2D drawings | deferred | — |
|
||||
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
|
||||
|
||||
@@ -1338,6 +1338,20 @@ pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: C
|
||||
pen.poly(&[(6.0, 10.0), (18.0, 10.0), (18.0, 15.0), (6.0, 15.0)], pen.fill, a);
|
||||
pen.arrow((21.0, 17.0), (21.0, 6.0), a);
|
||||
}
|
||||
"cam" => {
|
||||
// A cam on its shaft, its lobe up under the follower it lifts.
|
||||
let cam: Vec<(f32, f32)> = (0..36)
|
||||
.map(|k| {
|
||||
let t = std::f32::consts::TAU * k as f32 / 36.0;
|
||||
let r = 5.0 + 3.0 * (0.5 + 0.5 * t.sin()).powi(3);
|
||||
(11.0 + r * t.cos(), 16.0 - r * t.sin())
|
||||
})
|
||||
.collect();
|
||||
pen.poly(&cam, pen.fill, ink);
|
||||
pen.dot(11.0, 16.0, ink);
|
||||
pen.poly(&[(9.0, 2.0), (13.0, 2.0), (13.0, 8.0), (9.0, 8.0)], pen.fill, a);
|
||||
pen.arrow((18.5, 9.5), (18.5, 2.5), a);
|
||||
}
|
||||
"interference" => {
|
||||
pen.closed(&[(3.0, 5.0), (14.0, 5.0), (14.0, 16.0), (3.0, 16.0)], ink);
|
||||
pen.poly(&[(9.0, 10.0), (14.0, 10.0), (14.0, 16.0), (9.0, 16.0)], ic.interference, ic.interference);
|
||||
|
||||
@@ -94,6 +94,13 @@
|
||||
"browser.precision": "Genauigkeit: {precision}",
|
||||
"browser.sketch_name": "Skizze {number}",
|
||||
"browser.units": "Einheiten: {units}",
|
||||
"cam.flat": "Flachstößel",
|
||||
"cam.follower": "Stößel",
|
||||
"cam.knife": "Schneide",
|
||||
"cam.needs_joints": "braucht ein Gelenk, das sich dreht, und eines, das gleitet oder schwenkt",
|
||||
"cam.roller": "Rolle",
|
||||
"cam.roller_pick": "Rolle (optional)",
|
||||
"cam.tip_pick": "Spitze (optional)",
|
||||
"cmd.appearance_assign": "Erscheinungsbild",
|
||||
"cmd.appearance_library": "Erscheinungsbild-Bibliothek",
|
||||
"cmd.appearance_list": "Erscheinungsbilder auflisten",
|
||||
@@ -197,6 +204,7 @@
|
||||
"cmd.inspect_minimum_radius": "Mindestradiusanalyse",
|
||||
"cmd.inspect_zebra": "Zebra-Analyse",
|
||||
"cmd.joint_as_built": "Fertigungsgelenk",
|
||||
"cmd.joint_cam": "Nocken",
|
||||
"cmd.joint_create": "Gelenk",
|
||||
"cmd.joint_delete": "Gelenk löschen",
|
||||
"cmd.joint_drive": "Gelenke antreiben",
|
||||
|
||||
@@ -295,6 +295,13 @@
|
||||
"browser.precision": "Precision: {precision}",
|
||||
"browser.sketch_name": "Sketch{number}",
|
||||
"browser.units": "Units: {units}",
|
||||
"cam.flat": "Flat face",
|
||||
"cam.follower": "Follower",
|
||||
"cam.knife": "Knife edge",
|
||||
"cam.needs_joints": "needs a joint that turns and one that slides or swings",
|
||||
"cam.roller": "Roller",
|
||||
"cam.roller_pick": "Roller (optional)",
|
||||
"cam.tip_pick": "Tip (optional)",
|
||||
"cmd.appearance_assign": "Appearance",
|
||||
"cmd.appearance_library": "Appearance Library",
|
||||
"cmd.appearance_list": "List Appearances",
|
||||
@@ -398,6 +405,7 @@
|
||||
"cmd.inspect_minimum_radius": "Minimum Radius Analysis",
|
||||
"cmd.inspect_zebra": "Zebra Analysis",
|
||||
"cmd.joint_as_built": "As-Built Joint",
|
||||
"cmd.joint_cam": "Cam",
|
||||
"cmd.joint_create": "Joint",
|
||||
"cmd.joint_delete": "Delete Joint",
|
||||
"cmd.joint_drive": "Drive Joints",
|
||||
|
||||
@@ -275,6 +275,13 @@
|
||||
"browser.precision": "Précision : {precision}",
|
||||
"browser.sketch_name": "Esquisse{number}",
|
||||
"browser.units": "Unités : {units}",
|
||||
"cam.flat": "Plateau",
|
||||
"cam.follower": "Suiveur",
|
||||
"cam.knife": "Arête",
|
||||
"cam.needs_joints": "nécessite une liaison qui tourne et une qui glisse ou pivote",
|
||||
"cam.roller": "Galet",
|
||||
"cam.roller_pick": "Galet (facultatif)",
|
||||
"cam.tip_pick": "Pointe (facultatif)",
|
||||
"cmd.appearance_assign": "Apparence",
|
||||
"cmd.appearance_library": "Bibliothèque d'apparences",
|
||||
"cmd.appearance_list": "Lister les apparences",
|
||||
@@ -378,6 +385,7 @@
|
||||
"cmd.inspect_minimum_radius": "Analyse du rayon minimal",
|
||||
"cmd.inspect_zebra": "Analyse zébrée",
|
||||
"cmd.joint_as_built": "Liaison telle que construite",
|
||||
"cmd.joint_cam": "Came",
|
||||
"cmd.joint_create": "Liaison",
|
||||
"cmd.joint_delete": "Supprimer la liaison",
|
||||
"cmd.joint_drive": "Piloter les liaisons",
|
||||
|
||||
@@ -295,6 +295,13 @@
|
||||
"browser.precision": "精度: {precision}",
|
||||
"browser.sketch_name": "スケッチ{number}",
|
||||
"browser.units": "単位: {units}",
|
||||
"cam.flat": "平面",
|
||||
"cam.follower": "従動節",
|
||||
"cam.knife": "ナイフエッジ",
|
||||
"cam.needs_joints": "回転するジョイントと、スライドまたは揺動するジョイントが必要",
|
||||
"cam.roller": "ローラー",
|
||||
"cam.roller_pick": "ローラー (オプション)",
|
||||
"cam.tip_pick": "先端 (オプション)",
|
||||
"cmd.appearance_assign": "外観",
|
||||
"cmd.appearance_library": "外観ライブラリ",
|
||||
"cmd.appearance_list": "外観を一覧表示",
|
||||
@@ -398,6 +405,7 @@
|
||||
"cmd.inspect_minimum_radius": "最小半径解析",
|
||||
"cmd.inspect_zebra": "ゼブラ解析",
|
||||
"cmd.joint_as_built": "現位置ジョイント",
|
||||
"cmd.joint_cam": "カム",
|
||||
"cmd.joint_create": "ジョイント",
|
||||
"cmd.joint_delete": "ジョイントを削除",
|
||||
"cmd.joint_drive": "ジョイントを駆動",
|
||||
|
||||
@@ -94,6 +94,13 @@
|
||||
"browser.precision": "정밀도: {precision}",
|
||||
"browser.sketch_name": "스케치{number}",
|
||||
"browser.units": "단위: {units}",
|
||||
"cam.flat": "평면",
|
||||
"cam.follower": "종동절",
|
||||
"cam.knife": "나이프 에지",
|
||||
"cam.needs_joints": "회전하는 관절과 미끄러지거나 흔들리는 관절이 필요함",
|
||||
"cam.roller": "롤러",
|
||||
"cam.roller_pick": "롤러 (선택 사항)",
|
||||
"cam.tip_pick": "끝점 (선택 사항)",
|
||||
"cmd.appearance_assign": "외관",
|
||||
"cmd.appearance_library": "외관 라이브러리",
|
||||
"cmd.appearance_list": "외관 목록",
|
||||
@@ -197,6 +204,7 @@
|
||||
"cmd.inspect_minimum_radius": "최소 반경 분석",
|
||||
"cmd.inspect_zebra": "얼룩말줄무늬 분석",
|
||||
"cmd.joint_as_built": "조립 상태 관절",
|
||||
"cmd.joint_cam": "캠",
|
||||
"cmd.joint_create": "관절",
|
||||
"cmd.joint_delete": "관절 삭제",
|
||||
"cmd.joint_drive": "관절 구동",
|
||||
|
||||
@@ -251,6 +251,13 @@
|
||||
"browser.precision": "Точность: {precision}",
|
||||
"browser.sketch_name": "Эскиз{number}",
|
||||
"browser.units": "Единицы: {units}",
|
||||
"cam.flat": "Плоский",
|
||||
"cam.follower": "Толкатель",
|
||||
"cam.knife": "Острие",
|
||||
"cam.needs_joints": "нужны вращающееся и скользящее или качающееся сопряжения",
|
||||
"cam.roller": "Ролик",
|
||||
"cam.roller_pick": "Ролик (необязательно)",
|
||||
"cam.tip_pick": "Острие (необязательно)",
|
||||
"cmd.appearance_assign": "Внешний вид",
|
||||
"cmd.appearance_library": "Библиотека внешних видов",
|
||||
"cmd.appearance_list": "Список внешних видов",
|
||||
@@ -354,6 +361,7 @@
|
||||
"cmd.inspect_minimum_radius": "Анализ минимального радиуса",
|
||||
"cmd.inspect_zebra": "Зебра-анализ",
|
||||
"cmd.joint_as_built": "Сопряжение на месте",
|
||||
"cmd.joint_cam": "Кулачок",
|
||||
"cmd.joint_create": "Сопряжение",
|
||||
"cmd.joint_delete": "Удалить сопряжение",
|
||||
"cmd.joint_drive": "Привод сопряжений",
|
||||
|
||||
@@ -295,6 +295,13 @@
|
||||
"browser.precision": "Точність: {precision}",
|
||||
"browser.sketch_name": "Ескіз{number}",
|
||||
"browser.units": "Одиниці: {units}",
|
||||
"cam.flat": "Плоский",
|
||||
"cam.follower": "Штовхач",
|
||||
"cam.knife": "Вістря",
|
||||
"cam.needs_joints": "потрібні обертове й ковзне або хитне з'єднання",
|
||||
"cam.roller": "Ролик",
|
||||
"cam.roller_pick": "Ролик (необов'язково)",
|
||||
"cam.tip_pick": "Вістря (необов'язково)",
|
||||
"cmd.appearance_assign": "Вигляд",
|
||||
"cmd.appearance_library": "Бібліотека виглядів",
|
||||
"cmd.appearance_list": "Список виглядів",
|
||||
@@ -398,6 +405,7 @@
|
||||
"cmd.inspect_minimum_radius": "Аналіз мінімального радіуса",
|
||||
"cmd.inspect_zebra": "Зебра-аналіз",
|
||||
"cmd.joint_as_built": "З'єднання за поточним положенням",
|
||||
"cmd.joint_cam": "Кулачок",
|
||||
"cmd.joint_create": "З'єднання",
|
||||
"cmd.joint_delete": "Видалити з'єднання",
|
||||
"cmd.joint_drive": "Керувати з'єднаннями",
|
||||
|
||||
@@ -295,6 +295,13 @@
|
||||
"browser.precision": "精度: {precision}",
|
||||
"browser.sketch_name": "草图{number}",
|
||||
"browser.units": "单位: {units}",
|
||||
"cam.flat": "平底",
|
||||
"cam.follower": "从动件",
|
||||
"cam.knife": "尖顶",
|
||||
"cam.needs_joints": "需要一个旋转接头和一个滑动或摆动接头",
|
||||
"cam.roller": "滚子",
|
||||
"cam.roller_pick": "滚子(可选)",
|
||||
"cam.tip_pick": "尖端(可选)",
|
||||
"cmd.appearance_assign": "外观",
|
||||
"cmd.appearance_library": "外观库",
|
||||
"cmd.appearance_list": "列出外观",
|
||||
@@ -398,6 +405,7 @@
|
||||
"cmd.inspect_minimum_radius": "最小半径分析",
|
||||
"cmd.inspect_zebra": "斑马纹分析",
|
||||
"cmd.joint_as_built": "现状接头",
|
||||
"cmd.joint_cam": "凸轮",
|
||||
"cmd.joint_create": "接头",
|
||||
"cmd.joint_delete": "删除接头",
|
||||
"cmd.joint_drive": "驱动接头",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
//! A motion link can carry a `cam` coupling (`joint.cam`): the cam's `profile` (a sketch's closed
|
||||
//! profile by its outer loop's curve ids, or picked edges of the cam's bodies with their names),
|
||||
//! the `follower` (`knife`, `roller` with a `radius`, or `flat`), an optional `tip` (a joint origin
|
||||
//! where the follower touches) and `reverse` (the side it rides from). The link then also saves
|
||||
//! `lift`, the follower's values over one turn of the cam, worked out from the profile again
|
||||
//! whenever the design changes. Older designs need no transformation: a link without them is a
|
||||
//! plain motion link, as it always was; 0.13.x asks for an update instead of reading a cam's
|
||||
//! follower as a plain link of ratio 1.
|
||||
+61
-16
@@ -1,8 +1,8 @@
|
||||
//! Joints between occurrences: joint origins (snap frames on component geometry, re-found on
|
||||
//! every solve so they follow edits), joints (rigid, revolute, slider, cylindrical, pin-slot,
|
||||
//! planar, ball) with their motion values and limits, motion links (gears, racks and screws are
|
||||
//! links whose ratio comes from radii or a pitch), and the solver that places occurrences from
|
||||
//! the joint graph.
|
||||
//! links whose ratio comes from radii or a pitch; a cam's follower follows a table worked out from
|
||||
//! its profile, `joints_cam.rs`), and the solver that places occurrences from the joint graph.
|
||||
//!
|
||||
//! A joint makes B's joint frame coincide with A's frame moved by the joint's motion:
|
||||
//! `T_B · F_b = T_A · F_a · M(q) · Flip`, so `T_B = T_A · F_a · M(q) · Flip · F_b⁻¹`, where `T`
|
||||
@@ -140,7 +140,8 @@ pub struct Joint {
|
||||
pub suppressed: bool,
|
||||
}
|
||||
|
||||
/// Value of joint `b` (index `ib`) = ratio · value of joint `a` (index `ia`) + offset.
|
||||
/// Value of joint `b` (index `ib`) = ratio · value of joint `a` (index `ia`) + offset, or for a
|
||||
/// cam, its `lift` table at a's value + offset.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MotionLink {
|
||||
pub a: u64,
|
||||
@@ -152,11 +153,29 @@ pub struct MotionLink {
|
||||
pub ratio: f64,
|
||||
#[serde(default)]
|
||||
pub offset: f64,
|
||||
/// Gears, a rack and pinion or a screw: what the ratio comes from. The ratio is worked out
|
||||
/// from it again whenever the design changes (`coupling_ratio`), so it follows the radii and
|
||||
/// the pitch; the last one worked out is kept in `ratio`.
|
||||
/// Gears, a rack and pinion, a screw or a cam: what the ratio (or a cam's table) comes from.
|
||||
/// It is worked out from it again whenever the design changes (`coupling_values`), so it
|
||||
/// follows the radii, the pitch and the cam's profile; the last one worked out is kept in
|
||||
/// `ratio` and `lift`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub coupling: Option<Coupling>,
|
||||
/// A cam's follower values over one turn of a (`cam::CAM_SAMPLES` of them, in b's units, the
|
||||
/// offset left out); empty for every other link.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub lift: Vec<f64>,
|
||||
}
|
||||
|
||||
impl MotionLink {
|
||||
/// b's value for a's value `x` (before b's limits). A cam's table that is not whole (a
|
||||
/// hand-edited file) gives no value, so b keeps where it is; a table on any other link is
|
||||
/// not read.
|
||||
pub fn follow(&self, x: f64) -> f64 {
|
||||
match &self.coupling {
|
||||
Some(Coupling::Cam(_)) if self.lift.len() == cam::CAM_SAMPLES => cam::lift_at(&self.lift, x) + self.offset,
|
||||
Some(Coupling::Cam(_)) => f64::NAN,
|
||||
_ => self.ratio * x + self.offset,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A length a coupling's ratio comes from: millimetres, an expression over the design's
|
||||
@@ -199,14 +218,22 @@ pub enum Coupling {
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
reverse: bool,
|
||||
},
|
||||
/// A cam on a revolute joint (a) and its follower (b, a slider or a revolute): b rides the
|
||||
/// cam's profile (`joints_cam.rs`).
|
||||
Cam(cam::Cam),
|
||||
}
|
||||
|
||||
impl Coupling {
|
||||
/// The lengths the ratio comes from: a's gear and b's, or the pinion's radius or the pitch.
|
||||
/// The lengths the ratio comes from: a's gear and b's, the pinion's radius, the pitch, or a
|
||||
/// cam's roller radius.
|
||||
pub fn sizes(&self) -> Vec<&Size> {
|
||||
match self {
|
||||
Coupling::Gear { ra, rb, .. } => vec![ra, rb],
|
||||
Coupling::RackPinion { radius: s, .. } | Coupling::Screw { pitch: s, .. } => vec![s],
|
||||
Coupling::Cam(c) => match &c.follower {
|
||||
cam::Follower::Roller { radius } => vec![radius],
|
||||
_ => Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +241,10 @@ impl Coupling {
|
||||
match self {
|
||||
Coupling::Gear { ra, rb, .. } => vec![ra, rb],
|
||||
Coupling::RackPinion { radius: s, .. } | Coupling::Screw { pitch: s, .. } => vec![s],
|
||||
Coupling::Cam(c) => match &mut c.follower {
|
||||
cam::Follower::Roller { radius } => vec![radius],
|
||||
_ => Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,14 +262,18 @@ fn size_of(doc: &Document, st: &ModelState, s: &Size) -> Result<f64> {
|
||||
if v.is_finite() && v > 1e-9 && v <= MAX_SIZE {
|
||||
Ok(v)
|
||||
} else {
|
||||
Err(DocError::Invalid(format!("a gear, rack or screw needs a positive length, not {v}")))
|
||||
Err(DocError::Invalid(format!("a gear, rack, screw or roller needs a positive length, not {v}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// The ratio a link's coupling gives it (b's value per a's, in radians and mm), worked out from
|
||||
/// the geometry and parameters it is defined by; `None` for a plain link.
|
||||
pub fn coupling_ratio(doc: &Document, st: &ModelState, l: &MotionLink) -> Option<Result<f64>> {
|
||||
l.coupling.as_ref().map(|c| coupled(doc, st, l, c))
|
||||
/// The ratio a link's coupling gives it (b's value per a's, in radians and mm) and a cam's table
|
||||
/// (`MotionLink::lift`; empty for the others), worked out from the geometry and parameters it is
|
||||
/// defined by; `None` for a plain link.
|
||||
pub fn coupling_values(doc: &Document, st: &ModelState, l: &MotionLink) -> Option<Result<(f64, Vec<f64>)>> {
|
||||
l.coupling.as_ref().map(|c| match c {
|
||||
Coupling::Cam(c) => cam::table(doc, st, l, c).map(|t| (1.0, t)),
|
||||
c => coupled(doc, st, l, c).map(|r| (r, Vec::new())),
|
||||
})
|
||||
}
|
||||
|
||||
fn coupled(doc: &Document, st: &ModelState, l: &MotionLink, c: &Coupling) -> Result<f64> {
|
||||
@@ -260,6 +295,8 @@ fn coupled(doc: &Document, st: &ModelState, l: &MotionLink, c: &Coupling) -> Res
|
||||
}
|
||||
Coupling::RackPinion { radius, reverse } => Ok(travel(size_of(doc, st, radius)?)? * sign(*reverse)),
|
||||
Coupling::Screw { pitch, reverse } => Ok(travel(size_of(doc, st, pitch)? / std::f64::consts::TAU)? * sign(*reverse)),
|
||||
// A cam's follower follows its table (`coupling_values`), not a ratio.
|
||||
Coupling::Cam(_) => Ok(1.0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,7 +528,11 @@ fn apply_links(asm: &Assembly, vals: &mut Values) {
|
||||
let before = linked(vals);
|
||||
for l in &asm.links {
|
||||
let Some(src) = vals.get(&l.a).and_then(|q| q.get(l.ia)).copied() else { continue };
|
||||
let mut next = l.ratio * src + l.offset;
|
||||
let mut next = l.follow(src);
|
||||
// A hand-edited table (or source) that gives no number leaves b where it is.
|
||||
if !next.is_finite() {
|
||||
continue;
|
||||
}
|
||||
if let Some((lo, hi)) = limits.get(&(l.b, l.ib)) {
|
||||
next = next.max(*lo).min(*hi);
|
||||
}
|
||||
@@ -1388,6 +1429,9 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
#[path = "joints_cam.rs"]
|
||||
pub mod cam;
|
||||
|
||||
#[path = "joints_reach.rs"]
|
||||
mod reach;
|
||||
pub use reach::{REACH_TOL, Reach, Reached, reach};
|
||||
@@ -1425,7 +1469,8 @@ mod tests {
|
||||
suppressed: false,
|
||||
})
|
||||
.collect();
|
||||
let links = (1..n).rev().map(|a| MotionLink { a, ia: 0, b: a + 1, ib: 0, ratio: 2.0, offset: 0.0, coupling: None }).collect();
|
||||
let links =
|
||||
(1..n).rev().map(|a| MotionLink { a, ia: 0, b: a + 1, ib: 0, ratio: 2.0, offset: 0.0, coupling: None, lift: Vec::new() }).collect();
|
||||
Assembly { joints, links, ..Assembly::default() }
|
||||
}
|
||||
|
||||
@@ -1477,8 +1522,8 @@ mod tests {
|
||||
}
|
||||
let st = ModelState::default();
|
||||
let ratio = |a: u64, b: u64, c: Coupling| {
|
||||
let l = MotionLink { a, ia: 0, b, ib: 0, ratio: 0.0, offset: 0.0, coupling: Some(c) };
|
||||
coupling_ratio(&doc, &st, &l).map(|r| r.map_err(|e| e.to_string()))
|
||||
let l = MotionLink { a, ia: 0, b, ib: 0, ratio: 0.0, offset: 0.0, coupling: Some(c), lift: Vec::new() };
|
||||
coupling_values(&doc, &st, &l).map(|r| r.map(|(ratio, _)| ratio).map_err(|e| e.to_string()))
|
||||
};
|
||||
let len = Size::Length;
|
||||
assert_eq!(ratio(1, 2, Coupling::Gear { ra: len(10.0), rb: len(40.0), internal: false, reverse: false }), Some(Ok(-0.25)));
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use solidcraft_doc::Document;
|
||||
use solidcraft_doc::joints::cam::CamProfile;
|
||||
use solidcraft_doc::joints::{Assembly, Coupling, JointOrigin, Size, Snap};
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
@@ -102,9 +103,31 @@ fn budget(assembly: &Assembly) -> Result<()> {
|
||||
return Err(invalid("exploded view contains non-finite moves"));
|
||||
}
|
||||
}
|
||||
if !assembly.links.iter().all(|l| l.ratio.is_finite() && l.offset.is_finite()) {
|
||||
if !assembly.links.iter().all(|l| l.ratio.is_finite() && l.offset.is_finite() && l.lift.iter().all(|v| v.is_finite())) {
|
||||
return Err(invalid("motion link contains non-finite values"));
|
||||
}
|
||||
for cam in assembly.links.iter().filter_map(|l| match &l.coupling {
|
||||
Some(Coupling::Cam(c)) => Some(c),
|
||||
_ => None,
|
||||
}) {
|
||||
add_references(&mut references, cam.tip.iter().count())?;
|
||||
if let Some(tip) = &cam.tip {
|
||||
finite_origin(tip)?;
|
||||
}
|
||||
match &cam.profile {
|
||||
CamProfile::Sketch { curves, .. } => {
|
||||
add_references(&mut references, curves.len())?;
|
||||
curves.iter().try_for_each(|c| text(c))?;
|
||||
}
|
||||
CamProfile::Edges { picks, names, .. } => {
|
||||
add_references(&mut references, picks.len() + names.len())?;
|
||||
if !picks.iter().all(|p| p.is_finite()) {
|
||||
return Err(invalid("motion link contains non-finite values"));
|
||||
}
|
||||
names.iter().try_for_each(|n| text(n))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
for size in assembly.links.iter().filter_map(|l| l.coupling.as_ref()).flat_map(Coupling::sizes) {
|
||||
match size {
|
||||
Size::Length(v) if !v.is_finite() => return Err(invalid("motion link contains non-finite values")),
|
||||
@@ -300,12 +323,26 @@ pub(super) fn clone_assembly(
|
||||
continue;
|
||||
}
|
||||
let mut item = old.clone();
|
||||
// A gear's picked round faces are on occurrences of the copy too.
|
||||
// A gear's picked round faces are on occurrences of the copy too, and so are a cam's
|
||||
// profile and its follower's tip.
|
||||
let faces = item.coupling.as_mut().is_none_or(|c| {
|
||||
c.sizes_mut().into_iter().all(|size| match size {
|
||||
let sizes = c.sizes_mut().into_iter().all(|size| match size {
|
||||
Size::Circle(o) => origin(o, occurrences, features, bodies).map(|new| *o = new).is_some(),
|
||||
_ => true,
|
||||
})
|
||||
});
|
||||
let Coupling::Cam(cam) = c else { return sizes };
|
||||
let tip = match &mut cam.tip {
|
||||
Some(t) => origin(t, occurrences, features, bodies).map(|new| *t = new).is_some(),
|
||||
None => true,
|
||||
};
|
||||
let profile = match &mut cam.profile {
|
||||
CamProfile::Sketch { sketch, .. } => features.get(sketch).map(|new| *sketch = *new).is_some(),
|
||||
CamProfile::Edges { occurrence, names, .. } => {
|
||||
occurrences.get(occurrence).map(|new| *occurrence = *new).is_some()
|
||||
&& names.iter_mut().all(|n| n.is_empty() || face_name(n, features, bodies).map(|new| *n = new).is_some())
|
||||
}
|
||||
};
|
||||
sizes && tip && profile
|
||||
});
|
||||
match (joints.get(&old.a), joints.get(&old.b)) {
|
||||
(Some(aa), Some(bb)) if faces => {
|
||||
|
||||
@@ -44,8 +44,8 @@ fn documents() -> (Document, Document) {
|
||||
NamedOrigin { name: "Outside".into(), origin: at(9) },
|
||||
];
|
||||
source.assembly.links = vec![
|
||||
MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.3, coupling: None },
|
||||
MotionLink { a: 1, ia: 0, b: 3, ib: 0, ratio: 1.0, offset: 0.0, coupling: None },
|
||||
MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.3, coupling: None, lift: Vec::new() },
|
||||
MotionLink { a: 1, ia: 0, b: 3, ib: 0, ratio: 1.0, offset: 0.0, coupling: None, lift: Vec::new() },
|
||||
];
|
||||
source.assembly.contact_sets = vec![
|
||||
ContactSet { id: 1, name: "Internal contact".into(), occurrences: vec![1, 2], suppressed: true },
|
||||
@@ -106,8 +106,8 @@ fn copied_gears_take_their_picked_faces_along() {
|
||||
let circle = |face: &str| Size::Circle(JointOrigin { occurrence: 1, snap: Snap::CircleCenter { pick: Vec3::X, face: Some(face.into()) } });
|
||||
let gear = |ra| Coupling::Gear { ra, rb: Size::Length(5.0), internal: false, reverse: false };
|
||||
source.assembly.links = vec![
|
||||
MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.0, coupling: Some(gear(circle("F1:side"))) },
|
||||
MotionLink { a: 2, ia: 0, b: 1, ib: 0, ratio: -0.5, offset: 0.0, coupling: Some(gear(circle("F99:side"))) },
|
||||
MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.0, coupling: Some(gear(circle("F1:side"))), lift: Vec::new() },
|
||||
MotionLink { a: 2, ia: 0, b: 1, ib: 0, ratio: -0.5, offset: 0.0, coupling: Some(gear(circle("F99:side"))), lift: Vec::new() },
|
||||
];
|
||||
let mut destination = source.clone();
|
||||
destination.occurrences.extend([101, 102].map(occurrence));
|
||||
|
||||
+142
-13
@@ -7,6 +7,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_doc::joints::cam::{self, Cam, CamProfile, Follower};
|
||||
use solidcraft_doc::joints::{self, Coupling, Joint, JointKind, JointOrigin, MotionLink, NamedOrigin, Size, Snap};
|
||||
use solidcraft_geom::{Aabb3, Vec3};
|
||||
|
||||
@@ -60,6 +61,9 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
travel per turn (mm or an expression); reverse?: bool (a left-hand thread); link?: index (replace that motion link) → link, \
|
||||
ratio (mm per degree, or degrees per mm); b keeps where it is",
|
||||
),
|
||||
CommandSpec::new("joint.cam", "Cam", cam_cmd).at("SOLID", "ASSEMBLE").icon("cam").params(
|
||||
"a: the cam's joint (revolute); b: the follower's joint, a slider (a translating follower) or a revolute (an oscillating one), b driven by a; profile: {sketch: id|name, index?: the profile (default: the largest)} | {edges: [[x,y,z]…] (points on edges of the cam's bodies forming one closed loop, world), occurrence?}: the cam's outline, in a plane square to its axis, read again whenever the design changes; follower?: knife|roller|flat (default knife; flat: a face square to a slider's line); radius?: the roller's radius (mm, an expression or {circle: [x,y,z], occurrence?}: the roller's round face, which is its tip too); tip?: a joint origin where the follower touches: its knife's point or its roller's centre, such as {circle: [x,y,z]} on the roller (needed for an oscillating follower; a translating one's line runs through it, by default along its slider's axis); flip?: bool (ride the cam from the other side than the one the follower is on now); link?: index (replace that motion link) → link, lift: {min, max, rise, unit} (b's travel over a turn of the cam), reverse (the side it rides from), follower_offset (mm: how far a translating follower's line passes from the cam's axis); b keeps where it is",
|
||||
),
|
||||
CommandSpec::new("joint.limits", "Joint Limits", limits).params("joint; index?: value index (default 0); min?, max? (omit both to clear)"),
|
||||
CommandSpec::new("joint.edit", "Edit Joint", edit).params("joint; type?, flip?, offset?, angle?, suppressed?, name?"),
|
||||
CommandSpec::new("joint.delete", "Delete Joint", delete).params("joint: id|name (or origin: name, link: index)"),
|
||||
@@ -80,29 +84,30 @@ pub fn resolve(s: &mut Session) -> joints::Solution {
|
||||
resolve_holding(s, &BTreeMap::new())
|
||||
}
|
||||
|
||||
/// Work out the ratios of the coupled motion links again (a radius or a parameter may have
|
||||
/// changed). A coupling that can't be worked out (its face gone) keeps its last ratio;
|
||||
/// `joint.list` says why.
|
||||
/// Work out the ratios (and cam tables) of the coupled motion links again (a radius, a parameter
|
||||
/// or a cam's profile may have changed). A coupling that can't be worked out (its face gone)
|
||||
/// keeps its last ratio and table; `joint.list` says why.
|
||||
fn recouple(s: &mut Session) {
|
||||
if s.doc.assembly.links.iter().all(|l| l.coupling.is_none()) {
|
||||
return;
|
||||
}
|
||||
let st = s.model.state();
|
||||
let changed: Vec<(usize, f64)> = s
|
||||
let changed: Vec<(usize, (f64, Vec<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)))
|
||||
.filter_map(|(i, l)| joints::coupling_values(&s.doc, &st, l)?.ok().filter(|(r, t)| *r != l.ratio || *t != l.lift).map(|v| (i, v)))
|
||||
.collect();
|
||||
if changed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let doc = s.doc_mut();
|
||||
for (i, r) in changed {
|
||||
for (i, (r, t)) in changed {
|
||||
if let Some(l) = doc.assembly.links.get_mut(i) {
|
||||
l.ratio = r;
|
||||
l.lift = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -709,7 +714,7 @@ fn motion_link(s: &mut Session, p: &Value) -> Result<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, coupling: None });
|
||||
s.doc_mut().assembly.links.push(MotionLink { a, ia, b, ib, ratio, offset, coupling: None, lift: Vec::new() });
|
||||
let sol = resolve(s);
|
||||
Ok(sol_json(&sol, p))
|
||||
}
|
||||
@@ -766,7 +771,7 @@ fn gear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
internal: bool_(p, "internal").unwrap_or(false),
|
||||
reverse: reverse(p),
|
||||
};
|
||||
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c) })
|
||||
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })
|
||||
}
|
||||
|
||||
fn rack_pinion(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -774,7 +779,7 @@ fn rack_pinion(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (a, b) = joint_pair(s, p, cmd)?;
|
||||
let (ia, ib) = turn_and_travel(s, a, b, cmd)?;
|
||||
let c = Coupling::RackPinion { radius: size_param(s, p, "radius", cmd)?, reverse: reverse(p) };
|
||||
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c) })
|
||||
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })
|
||||
}
|
||||
|
||||
fn screw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -794,7 +799,120 @@ fn screw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
let c = Coupling::Screw { pitch: size_param(s, p, "pitch", cmd)?, reverse: reverse(p) };
|
||||
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c) })
|
||||
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })
|
||||
}
|
||||
|
||||
fn cam_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "joint.cam";
|
||||
let (a, b) = joint_pair(s, p, cmd)?;
|
||||
let kind = |id: u64| s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.kind);
|
||||
if kind(a) != Some(JointKind::Revolute) {
|
||||
return Err(bad(cmd, "the cam (a) turns on a revolute joint"));
|
||||
}
|
||||
let oscillating = match kind(b) {
|
||||
Some(JointKind::Slider) => false,
|
||||
Some(JointKind::Revolute) => true,
|
||||
_ => return Err(bad(cmd, "the follower (b) is a slider or a revolute joint")),
|
||||
};
|
||||
let follower = match str_(p, "follower").unwrap_or("knife") {
|
||||
"knife" => Follower::Knife,
|
||||
"roller" => Follower::Roller { radius: size_param(s, p, "radius", cmd)? },
|
||||
"flat" => Follower::Flat,
|
||||
_ => return Err(bad(cmd, "`follower` is knife, roller or flat")),
|
||||
};
|
||||
if oscillating && p.get("tip").is_none() && !matches!(follower, Follower::Roller { radius: Size::Circle(_) }) {
|
||||
return Err(bad(cmd, "an oscillating follower needs `tip`: its knife's point or its roller's centre ({circle: [x, y, z]} on the roller)"));
|
||||
}
|
||||
let tip = match p.get("tip") {
|
||||
Some(v) => Some(origin_param(s, Some(v), cmd)?),
|
||||
None => None,
|
||||
};
|
||||
let mut c = Cam { profile: cam_profile(s, p, cmd)?, follower, tip, reverse: false };
|
||||
let probe = MotionLink { a, ia: 0, b, ib: 0, ratio: 1.0, offset: 0.0, coupling: None, lift: Vec::new() };
|
||||
let st = s.model.state();
|
||||
c.reverse = cam::default_reverse(&s.doc, &st, &probe, &c).map_err(|e| bad(cmd, e.to_string()))? != bool_(p, "flip").unwrap_or(false);
|
||||
let offset = cam::follower_offset(&s.doc, &st, &probe, &c).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let reverse = c.reverse;
|
||||
let mut out = couple(s, p, cmd, MotionLink { coupling: Some(Coupling::Cam(c)), ..probe })?;
|
||||
let index = out["link"].as_u64().and_then(|i| usize::try_from(i).ok()).unwrap_or(usize::MAX);
|
||||
if let (Some(o), Some(l)) = (out.as_object_mut(), s.doc.assembly.links.get(index)) {
|
||||
o.remove("ratio");
|
||||
o.insert("lift".into(), lift_json(l, oscillating));
|
||||
o.insert("reverse".into(), json!(reverse));
|
||||
if !oscillating {
|
||||
o.insert("follower_offset".into(), json!(offset));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A cam's follower travel over a turn of the cam: b's lowest and highest values and their
|
||||
/// difference, in degrees (`angle`) or mm.
|
||||
fn lift_json(l: &MotionLink, angle: bool) -> Value {
|
||||
// A table that is not whole (a hand-edited file) moves nothing, and says so.
|
||||
if l.lift.len() != cam::CAM_SAMPLES {
|
||||
return Value::Null;
|
||||
}
|
||||
let unit = |v: f64| if angle { v.to_degrees() } else { v };
|
||||
let (lo, hi) = l.lift.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
|
||||
let (lo, hi) = (unit(lo + l.offset), unit(hi + l.offset));
|
||||
json!({"min": lo, "max": hi, "rise": hi - lo, "unit": if angle { "deg" } else { "mm" }})
|
||||
}
|
||||
|
||||
/// A cam's profile: a sketch's closed profile (kept by its outer loop's curve ids, so the sketch's
|
||||
/// other profiles may change), or picked edges of the cam's bodies (kept in the component's
|
||||
/// coordinates with the edges' names).
|
||||
fn cam_profile(s: &Session, p: &Value, cmd: &str) -> Result<CamProfile> {
|
||||
let v = p.get("profile").ok_or_else(|| bad(cmd, "`profile` is required: {sketch: id|name} or {edges: [[x, y, z], …]}"))?;
|
||||
if let Some(sk) = v.get("sketch") {
|
||||
let sketch = super::features::sketch_id(s, Some(sk), cmd, "profile.sketch")?;
|
||||
let st = s.model.state();
|
||||
let solved =
|
||||
st.sketches.iter().find(|x| x.feature == sketch).ok_or_else(|| bad(cmd, "that sketch is not evaluated (it is after the marker?)"))?;
|
||||
let picked = match v.get("index") {
|
||||
None => cam::sketch_profile(solved, &[]),
|
||||
Some(i) => i.as_u64().and_then(|i| usize::try_from(i).ok()).and_then(|i| solved.profiles.get(i)),
|
||||
};
|
||||
let profile = picked.ok_or_else(|| bad(cmd, "the sketch has no such closed profile (an open curve makes none)"))?;
|
||||
return Ok(CamProfile::Sketch { sketch, curves: profile.outer_curves.clone() });
|
||||
}
|
||||
let Some(Value::Array(pts)) = v.get("edges") else {
|
||||
return Err(bad(cmd, "`profile` is {sketch: id|name} or {edges: [[x, y, z], …]}"));
|
||||
};
|
||||
if pts.is_empty() || pts.len() > cam::MAX_PROFILE_EDGES {
|
||||
return Err(bad(cmd, format!("pick 1 to {} edges", cam::MAX_PROFILE_EDGES)));
|
||||
}
|
||||
let world: Vec<Vec3> = pts.iter().map(vec3).collect::<Option<_>>().ok_or_else(|| bad(cmd, "`edges` are finite [x, y, z] points"))?;
|
||||
let occurrence = match v.get("occurrence") {
|
||||
Some(o) => occurrence_id(s, Some(o), cmd)?,
|
||||
None => world.first().and_then(|w| occurrence_at(s, *w)).ok_or_else(|| bad(cmd, "no body there"))?,
|
||||
};
|
||||
let t = if occurrence == 0 { solidcraft_doc::IDENTITY } else { joints::occurrence_world(&s.doc, occurrence) };
|
||||
let inv = solidcraft_doc::mat_inverse(&t).ok_or_else(|| bad(cmd, "occurrence transform"))?;
|
||||
let picks: Vec<Vec3> = world.iter().map(|w| solidcraft_doc::apply_point(&inv, *w)).collect();
|
||||
let comp = s.doc.occurrences.iter().find(|o| o.id == occurrence).map(|o| o.component).unwrap_or(0);
|
||||
let st = s.model.state();
|
||||
let bodies: Vec<&solidcraft_doc::ModelBody> = st.bodies.iter().filter(|b| s.doc.model_body_component(b) == comp).collect();
|
||||
// Each pick's edge name on the body whose edges pass nearest it.
|
||||
let names = picks
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let near = |b: &&solidcraft_doc::ModelBody| {
|
||||
b.mesh()
|
||||
.edges
|
||||
.iter()
|
||||
.flat_map(|e| e.windows(2))
|
||||
.filter_map(|w| Some(q.dist_to_segment(*w.first()?, *w.get(1)?)))
|
||||
.fold(f64::MAX, f64::min)
|
||||
};
|
||||
bodies
|
||||
.iter()
|
||||
.min_by(|x, y| near(x).total_cmp(&near(y)))
|
||||
.and_then(|b| solidcraft_doc::naming::edge_names_at(b, &[*q]).into_iter().next())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect();
|
||||
Ok(CamProfile::Edges { occurrence, picks, names })
|
||||
}
|
||||
|
||||
/// Add a coupling's motion link, or put it in place of link `link`: its ratio worked out from its
|
||||
@@ -826,14 +944,18 @@ fn couple(s: &mut Session, p: &Value, cmd: &str, mut l: MotionLink) -> Result<Va
|
||||
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()))?;
|
||||
(l.ratio, l.lift) =
|
||||
joints::coupling_values(&s.doc, &s.model.state(), &l).unwrap_or(Ok((l.ratio, Vec::new()))).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let vals = joints::joint_values(&s.doc.assembly);
|
||||
let value = |id: u64, i: usize| vals.get(&id).and_then(|q| q.get(i)).copied().unwrap_or(0.0);
|
||||
// A link put in place of another keeps its offset, as a radius edited in the design does.
|
||||
l.offset = match replace.and_then(|i| links.get(i)) {
|
||||
Some(old) => old.offset,
|
||||
None => value(l.b, l.ib) - l.ratio * value(l.a, l.ia),
|
||||
None => value(l.b, l.ib) - MotionLink { offset: 0.0, ..l.clone() }.follow(value(l.a, l.ia)),
|
||||
};
|
||||
if !l.offset.is_finite() {
|
||||
return Err(bad(cmd, "the coupling gives the driven joint no value where it is"));
|
||||
}
|
||||
let shown = l.ratio / ratio_scale(s, l.a, l.ia, l.b, l.ib);
|
||||
let doc = s.doc_mut();
|
||||
let index = match replace {
|
||||
@@ -994,10 +1116,17 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
json!({"a": l.a, "ia": l.ia, "b": l.b, "ib": l.ib, "ratio": ratio, "offset": offset, "unit": if angle { "deg" } else { "mm" }});
|
||||
if let Some(c) = &l.coupling {
|
||||
v["coupling"] = json!(c);
|
||||
if let Some(Err(e)) = joints::coupling_ratio(&s.doc, &st, l) {
|
||||
if let Some(Err(e)) = joints::coupling_values(&s.doc, &st, l) {
|
||||
v["error"] = json!(e.to_string());
|
||||
}
|
||||
}
|
||||
// A cam's follower follows its table, not a ratio: its travel instead.
|
||||
if matches!(l.coupling, Some(Coupling::Cam(_)))
|
||||
&& let Some(o) = v.as_object_mut()
|
||||
{
|
||||
o.remove("ratio");
|
||||
o.insert("lift".into(), lift_json(l, angle));
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
//! Cams: a revolute joint drives a follower that rides the cam's profile. Expected values are
|
||||
//! analytic: an eccentric circle of radius R whose centre is e from its axis puts a knife-edge
|
||||
//! follower on a line h from the axis at e·cos θ + √(R² − (h − e·sin θ)²), a roller of radius r
|
||||
//! where a knife on the circle of radius R + r would be, a flat face at e·cos θ + R; an
|
||||
//! oscillating follower's roller sits where its arm's circle meets the circle of radius R + r.
|
||||
|
||||
use super::*;
|
||||
use solidcraft_doc::joints::Coupling;
|
||||
use solidcraft_doc::joints::cam::CamProfile;
|
||||
|
||||
/// The cam's eccentricity and radius (mm).
|
||||
const E: f64 = 10.0;
|
||||
const R: f64 = 30.0;
|
||||
|
||||
fn joint_id(s: &Session, name: &str) -> u64 {
|
||||
s.doc.assembly.joints.iter().find(|j| j.name == name).map(|j| j.id).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// A joint's value as `joint.list` gives it (degrees or mm).
|
||||
fn shown(s: &mut Session, joint: &str) -> f64 {
|
||||
let l = run(s, "joint.list", json!({"quiet": true}));
|
||||
let j = l["joints"].as_array().into_iter().flatten().find(|j| j["name"] == joint).cloned().unwrap_or_default();
|
||||
j["values"][0]["value"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
/// Where a knife edge on a line `h` from the axis stands on the circle of radius `r` about the
|
||||
/// cam's centre turned by `deg`.
|
||||
fn knife(deg: f64, h: f64, r: f64) -> f64 {
|
||||
let t = deg.to_radians();
|
||||
E * t.cos() + (r * r - (h - E * t.sin()).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// A grounded base, and a cam: a disc of radius `rc` (a parameter, 30 mm) about (10, 0), 5 mm thick,
|
||||
/// drawn in sketch `CamSketch` of its own component, on a revolute joint `Cam` about Z through the
|
||||
/// origin (the cam is its b side).
|
||||
fn cam_on_base() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "rc", "expression": "30 mm"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "th", "expression": "5 mm"}));
|
||||
part(&mut s, "Base", [-90.0, -90.0, -20.0]);
|
||||
let g = occ(&s, "Base");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Cam"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "CamSketch"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [E, 0], "radius": R, "id": "c1"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["c1"], "type": "radius", "value": "rc"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "th"}));
|
||||
pin(&mut s, "revolute", "Cam", "Base", "Cam", Vec3::ZERO, Vec3::Z);
|
||||
s
|
||||
}
|
||||
|
||||
/// A tappet (a 20 mm bar, 4 wide) sliding along +X on the line y = `h`, its end at x = `tip`, on a
|
||||
/// slider `Tappet`.
|
||||
fn tappet(s: &mut Session, h: f64, tip: f64) {
|
||||
run(s, "component.activate", json!({"component": "root"}));
|
||||
run(s, "component.create", json!({"name": "Tappet"}));
|
||||
run(s, "solid.box", json!({"length": 20, "width": 4, "height": 5, "corner": [tip, h - 2.0, 0]}));
|
||||
pin(s, "slider", "Tappet", "Base", "Tappet", Vec3::new(0.0, h, 2.5), Vec3::X);
|
||||
}
|
||||
|
||||
fn close(got: f64, want: f64, tol: f64) -> bool {
|
||||
(got - want).abs() < tol
|
||||
}
|
||||
|
||||
/// A knife-edge tappet on the eccentric circle, its line through the axis and 6 mm past it, rises
|
||||
/// and falls as e·cos θ + √(R² − (h − e·sin θ)²); the tappet's body goes with it.
|
||||
#[test]
|
||||
fn a_knife_edge_tappet_rides_an_eccentric_circle() {
|
||||
for h in [0.0, 6.0] {
|
||||
let mut s = cam_on_base();
|
||||
let tip = knife(0.0, h, R);
|
||||
tappet(&mut s, h, tip);
|
||||
let r = run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}}));
|
||||
assert_eq!((r["link"].clone(), r["reverse"].clone()), (json!(0), json!(false)), "{r}");
|
||||
assert!(close(r["follower_offset"].as_f64().unwrap_or(f64::NAN), h, 1e-9), "{r}");
|
||||
// It stays where it is, and its travel over a turn is the cam's.
|
||||
assert!(close(shown(&mut s, "Tappet"), 0.0, 1e-9), "{r}");
|
||||
let over_a_turn: Vec<f64> = (0..3600).map(|k| knife(f64::from(k) / 10.0, h, R)).collect();
|
||||
let rise = over_a_turn.iter().fold(f64::MIN, |a, b| a.max(*b)) - over_a_turn.iter().fold(f64::MAX, |a, b| a.min(*b));
|
||||
assert!(close(r["lift"]["rise"].as_f64().unwrap_or(f64::NAN), rise, 2e-3), "{r} vs {rise}");
|
||||
for deg in [30.0f64, 90.0, 135.0, 200.0, 300.0, -45.0, 400.0] {
|
||||
let d = run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg}));
|
||||
assert_eq!(d["conflicts"].as_array().map(Vec::len), Some(0), "{d}");
|
||||
let want = knife(deg, h, R) - tip;
|
||||
assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "h {h} θ {deg}: {} vs {want}", shown(&mut s, "Tappet"));
|
||||
let end = place(&s, "Tappet", Vec3::new(tip, h, 2.5));
|
||||
assert!(close(end.x, tip + want, 1e-3) && close(end.y, h, 1e-9), "{end:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The follower's line is through its tip, wherever its slider's origin is across the line; a
|
||||
/// guide of its own (grounded, the slider's b side) makes the tappet the slider's a side, and it
|
||||
/// rides the same way.
|
||||
#[test]
|
||||
fn a_tappet_on_a_guide_of_its_own_rides_its_tip_s_line() {
|
||||
let h = 6.0;
|
||||
let tip = knife(0.0, h, R);
|
||||
let mut s = cam_on_base();
|
||||
part(&mut s, "Guide", [70.0, 20.0, 0.0]);
|
||||
let g = occ(&s, "Guide");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Tappet"}));
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 4, "height": 5, "corner": [tip, h - 2.0, 0]}));
|
||||
// The slider's origins 9 mm across from the tappet's line, a the tappet, b the guide.
|
||||
pin(&mut s, "slider", "Tappet", "Tappet", "Guide", Vec3::new(0.0, h + 9.0, 2.5), Vec3::X);
|
||||
let r = run(
|
||||
&mut s,
|
||||
"joint.cam",
|
||||
json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "tip": {"occurrence": "Tappet", "point": [tip, h, 2.5]}}),
|
||||
);
|
||||
assert!(close(r["follower_offset"].as_f64().unwrap_or(f64::NAN), h, 1e-9), "{r}");
|
||||
for deg in [70.0f64, 160.0, 290.0] {
|
||||
run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg}));
|
||||
let end = place(&s, "Tappet", Vec3::new(tip, h, 2.5));
|
||||
assert!(close(end.x, knife(deg, h, R), 1e-3), "θ {deg}: {end:?} vs {}", knife(deg, h, R));
|
||||
}
|
||||
}
|
||||
|
||||
/// A roller of radius 5 rides the circle of radius R + 5; a flat face e·cos θ + R, and when the
|
||||
/// cam's radius parameter grows by 2 the flat face stands 2 further out at every angle.
|
||||
#[test]
|
||||
fn rollers_and_flat_faces_ride_their_offset_circles() {
|
||||
let mut s = cam_on_base();
|
||||
let tip = knife(0.0, 0.0, R + 5.0);
|
||||
tappet(&mut s, 0.0, tip);
|
||||
run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": "5 mm"}));
|
||||
for deg in [60.0f64, 150.0, 250.0] {
|
||||
run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg}));
|
||||
let want = knife(deg, 0.0, R + 5.0) - tip;
|
||||
assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "roller θ {deg}: {} vs {want}", shown(&mut s, "Tappet"));
|
||||
}
|
||||
let mut s = cam_on_base();
|
||||
tappet(&mut s, 3.0, E + R);
|
||||
run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "flat"}));
|
||||
for deg in [60.0f64, 150.0, 250.0] {
|
||||
run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg}));
|
||||
let want = E * deg.to_radians().cos() - E;
|
||||
assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "flat θ {deg}: {} vs {want}", shown(&mut s, "Tappet"));
|
||||
}
|
||||
// Editing the profile: the table is worked out again.
|
||||
run(&mut s, "parameters.change", json!({"name": "rc", "expression": "32 mm"}));
|
||||
let want = E * 250f64.to_radians().cos() - E + 2.0;
|
||||
assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "after the edit: {} vs {want}", shown(&mut s, "Tappet"));
|
||||
}
|
||||
|
||||
/// The cam's top edge picked on its body gives the table its sketch gives, and follows the
|
||||
/// design the same way.
|
||||
#[test]
|
||||
fn picked_edges_give_the_sketch_s_table() {
|
||||
let table = |profile: Value| {
|
||||
let mut s = cam_on_base();
|
||||
tappet(&mut s, 0.0, E + R);
|
||||
run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": profile}));
|
||||
s.doc.assembly.links.first().map(|l| (l.lift.clone(), l.offset)).unwrap_or_default()
|
||||
};
|
||||
let (sketch, a) = table(json!({"sketch": "CamSketch"}));
|
||||
let (edges, b) = table(json!({"edges": [[E + R, 0, 5]]}));
|
||||
assert_eq!((sketch.len(), edges.len()), (360, 360));
|
||||
assert!(sketch.iter().zip(&edges).all(|(x, y)| close(*x, *y, 1e-3)) && close(a, b, 1e-3));
|
||||
// A second disc of the cam's component touching it in the same plane is not part of its loop.
|
||||
let mut s = cam_on_base();
|
||||
run(&mut s, "component.activate", json!({"component": "Cam"}));
|
||||
run(&mut s, "solid.cylinder", json!({"base": [E + R + 20.0, 0, 0], "axis": [0, 0, 1], "radius": 20, "height": 5}));
|
||||
tappet(&mut s, 0.0, E + R);
|
||||
run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[E - R, 0, 5]]}}));
|
||||
let touching = s.doc.assembly.links.first().map(|l| l.lift.clone()).unwrap_or_default();
|
||||
assert!(touching.len() == 360 && touching.iter().zip(&sketch).all(|(x, y)| close(*x, *y, 1e-3)), "{touching:?}");
|
||||
let mut s = cam_on_base();
|
||||
tappet(&mut s, 0.0, E + R);
|
||||
run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[E + R, 0, 5]]}}));
|
||||
assert!(matches!(
|
||||
s.doc.assembly.links.first().and_then(|l| l.coupling.clone()),
|
||||
Some(Coupling::Cam(c)) if matches!(&c.profile, CamProfile::Edges { names, .. } if names.first().is_some_and(|n| !n.is_empty()))
|
||||
));
|
||||
run(&mut s, "parameters.change", json!({"name": "rc", "expression": "32 mm"}));
|
||||
// A thicker cam: the picked edge is found at its new height, and its loop with it.
|
||||
run(&mut s, "parameters.change", json!({"name": "th", "expression": "7 mm"}));
|
||||
let l = run(&mut s, "joint.list", json!({"quiet": true}));
|
||||
assert!(l["links"][0].get("error").is_none(), "{l}");
|
||||
run(&mut s, "joint.drive", json!({"joint": "Cam", "value": 90}));
|
||||
let want = knife(90.0, 0.0, 32.0) - (E + R);
|
||||
assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "{} vs {want}", shown(&mut s, "Tappet"));
|
||||
}
|
||||
|
||||
/// An arm of 50 about (−50, 40) with a roller of 5: its roller's centre is where the arm's circle
|
||||
/// meets the circle of radius R + 5 about the cam's centre, the meeting it turns into first from
|
||||
/// pointing away from the cam's axis.
|
||||
#[test]
|
||||
fn an_oscillating_roller_follower_swings_to_the_offset_circle() {
|
||||
let (pivot, arm, roller) = (Vec3::new(-50.0, 40.0, 2.5), 50.0, 5.0);
|
||||
let away = pivot.y.atan2(pivot.x);
|
||||
let wrap = |a: f64| (a + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
|
||||
let angle = |p: Vec3| (p.y - pivot.y).atan2(p.x - pivot.x);
|
||||
// Where the roller's centre is at cam angle `deg`, turning the way `turn` says.
|
||||
let centre = |deg: f64, turn: f64| {
|
||||
let c = Vec3::new(E * deg.to_radians().cos(), E * deg.to_radians().sin(), 2.5);
|
||||
let [p, q] = meet(pivot, arm, c, R + roller);
|
||||
let swept = |x: Vec3| (turn * (angle(x) - away)).rem_euclid(std::f64::consts::TAU);
|
||||
if swept(p) < swept(q) { p } else { q }
|
||||
};
|
||||
let [p, q] = meet(pivot, arm, Vec3::new(E, 0.0, 2.5), R + roller);
|
||||
let start = if wrap(angle(p) - away).abs() < wrap(angle(q) - away).abs() { p } else { q };
|
||||
let turn = wrap(angle(start) - away).signum();
|
||||
let mut s = cam_on_base();
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Arm"}));
|
||||
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 5, "corner": [start.x - 2.0, start.y - 2.0, 0]}));
|
||||
pin(&mut s, "revolute", "Arm", "Base", "Arm", pivot, Vec3::Z);
|
||||
// An oscillating follower needs its tip.
|
||||
assert!(s.execute("joint.cam", &json!({"a": "Cam", "b": "Arm", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": 5})).is_err());
|
||||
let r = run(
|
||||
&mut s,
|
||||
"joint.cam",
|
||||
json!({"a": "Cam", "b": "Arm", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": 5,
|
||||
"tip": {"occurrence": "Arm", "point": [start.x, start.y, 2.5]}}),
|
||||
);
|
||||
assert_eq!((r["reverse"].clone(), r["lift"]["unit"].clone()), (json!(turn < 0.0), json!("deg")), "{r}");
|
||||
for deg in [40.0f64, 120.0, 210.0, 330.0] {
|
||||
run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg}));
|
||||
let want = wrap(angle(centre(deg, turn)) - angle(start)).to_degrees();
|
||||
assert!(close(shown(&mut s, "Arm"), want, 1e-3), "θ {deg}: {} vs {want}", shown(&mut s, "Arm"));
|
||||
let at = place(&s, "Arm", start);
|
||||
assert!(at.dist(centre(deg, turn)) < 1e-3, "θ {deg}: {at:?}");
|
||||
}
|
||||
// The roller's round face picked on its arm is where it touches, and its radius: the same table.
|
||||
let table = s.doc.assembly.links.first().map(|l| l.lift.clone()).unwrap_or_default();
|
||||
let mut t = cam_on_base();
|
||||
run(&mut t, "component.activate", json!({"component": "root"}));
|
||||
run(&mut t, "component.create", json!({"name": "Arm"}));
|
||||
run(&mut t, "solid.cylinder", json!({"base": [start.x, start.y, 0], "axis": [0, 0, 1], "radius": roller, "height": 5}));
|
||||
pin(&mut t, "revolute", "Arm", "Base", "Arm", pivot, Vec3::Z);
|
||||
let face = [start.x + roller, start.y, 2.5];
|
||||
run(
|
||||
&mut t,
|
||||
"joint.cam",
|
||||
json!({"a": "Cam", "b": "Arm", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": {"occurrence": "Arm", "circle": face}}),
|
||||
);
|
||||
let picked = t.doc.assembly.links.first().map(|l| l.lift.clone()).unwrap_or_default();
|
||||
assert!(picked.len() == 360 && picked.iter().zip(&table).all(|(x, y)| close(*x, *y, 1e-6)));
|
||||
}
|
||||
|
||||
/// A stationary cam: the profile on the grounded part, a rotor turning about it with the tappet's
|
||||
/// guide on it. Turned by +θ against the cam, the tappet stands where it would on a cam turned by
|
||||
/// −θ. A guide on the cam's own (grounded) side would turn with the profile: refused.
|
||||
#[test]
|
||||
fn a_stationary_cam_drives_a_follower_on_a_turning_rotor() {
|
||||
let h = 6.0;
|
||||
let tip = knife(0.0, h, R);
|
||||
let mut s = cam_on_base();
|
||||
// The cam is the fixed part: ground it, and take the base off its joint.
|
||||
let (cam, base) = (occ(&s, "Cam"), occ(&s, "Base"));
|
||||
run(&mut s, "joint.delete", json!({"joint": "Cam"}));
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": cam, "grounded": true}));
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": base, "grounded": false}));
|
||||
pin(&mut s, "revolute", "Turret", "Cam", "Base", Vec3::ZERO, Vec3::Z);
|
||||
tappet(&mut s, h, tip);
|
||||
run(&mut s, "joint.cam", json!({"a": "Turret", "b": "Tappet", "profile": {"sketch": "CamSketch"}}));
|
||||
for deg in [50.0f64, 140.0, 260.0] {
|
||||
let d = run(&mut s, "joint.drive", json!({"joint": "Turret", "value": deg}));
|
||||
assert_eq!(d["conflicts"].as_array().map(Vec::len), Some(0), "{d}");
|
||||
let want = knife(-deg, h, R) - tip;
|
||||
assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "θ {deg}: {} vs {want}", shown(&mut s, "Tappet"));
|
||||
}
|
||||
let mut s = cam_on_base();
|
||||
let (cam, base) = (occ(&s, "Cam"), occ(&s, "Base"));
|
||||
run(&mut s, "joint.delete", json!({"joint": "Cam"}));
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": cam, "grounded": true}));
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": base, "grounded": false}));
|
||||
part(&mut s, "Rotor", [-90.0, 60.0, -20.0]);
|
||||
pin(&mut s, "revolute", "Turret", "Cam", "Rotor", Vec3::ZERO, Vec3::Z);
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": base, "grounded": true}));
|
||||
tappet(&mut s, h, tip);
|
||||
let e = s.execute("joint.cam", &json!({"a": "Turret", "b": "Tappet", "profile": {"sketch": "CamSketch"}})).map_err(|e| e.to_string()).err();
|
||||
assert!(e.as_deref().is_some_and(|e| e.contains("turns with the cam")), "{e:?}");
|
||||
}
|
||||
|
||||
/// A motion study turning the cam plays the tappet through its analytic travel.
|
||||
#[test]
|
||||
fn a_cam_plays_in_a_motion_study() {
|
||||
let mut s = cam_on_base();
|
||||
let tip = knife(0.0, 0.0, R);
|
||||
tappet(&mut s, 0.0, tip);
|
||||
run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}}));
|
||||
run(&mut s, "motion.study", json!({"name": "Turn", "steps": 10, "keys": [{"joint": "Cam", "points": [[0, 0], [10, 360]]}]}));
|
||||
for step in [0, 2, 5, 7, 10] {
|
||||
run(&mut s, "motion.play", json!({"study": "Turn", "step": step}));
|
||||
let want = knife(36.0 * f64::from(step), 0.0, R) - tip;
|
||||
assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "step {step}: {} vs {want}", shown(&mut s, "Tappet"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The tappet is the piston of a slider-crank: a 40 mm rod from its end to a 20 mm crank about
|
||||
/// (95, 0). Turning the cam moves the tappet, and the loop turns the crank to where the rod's
|
||||
/// circle meets the crank's, on the branch it started on. A stop in a contact set with the cam and
|
||||
/// the tappet halts the cam where the tappet reaches it; the cam and the flat tappet only touch.
|
||||
#[test]
|
||||
fn a_cam_drives_a_closed_loop_and_stops_at_a_contact() {
|
||||
let mut s = cam_on_base();
|
||||
let tip = knife(0.0, 0.0, R);
|
||||
tappet(&mut s, 0.0, tip);
|
||||
let (q, end) = (Vec3::new(95.0, 0.0, 2.5), Vec3::new(tip + 20.0, 0.0, 2.5));
|
||||
let crank_pin = |x: f64| meet(q, 20.0, Vec3::new(x, 0.0, 2.5), 40.0).into_iter().find(|p| p.y > 0.0).unwrap_or(Vec3::ZERO);
|
||||
let pin0 = crank_pin(end.x);
|
||||
for (name, at) in [("Rod", end), ("Crank", pin0)] {
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": name}));
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [at.x - 1.0, at.y - 1.0, 10]}));
|
||||
}
|
||||
pin(&mut s, "revolute", "Crank", "Base", "Crank", q, Vec3::Z);
|
||||
pin(&mut s, "revolute", "Wrist", "Tappet", "Rod", end, Vec3::Z);
|
||||
pin(&mut s, "revolute", "Big end", "Rod", "Crank", pin0, Vec3::Z);
|
||||
run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}}));
|
||||
for deg in [45.0f64, 120.0, 180.0] {
|
||||
let d = run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg}));
|
||||
assert_eq!(d["conflicts"].as_array().map(Vec::len), Some(0), "{d}");
|
||||
let x = end.x + knife(deg, 0.0, R) - tip;
|
||||
let at = place(&s, "Crank", pin0);
|
||||
assert!(at.dist(crank_pin(x)) < 1e-3, "θ {deg}: {at:?} vs {:?}", crank_pin(x));
|
||||
}
|
||||
// A flat tappet, a stop 5 mm past where its end is at the top, and contact on.
|
||||
let mut s = cam_on_base();
|
||||
tappet(&mut s, 0.0, E + R);
|
||||
run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "flat"}));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Cam", "value": 180}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Stop"}));
|
||||
run(&mut s, "solid.box", json!({"length": 3, "width": 4, "height": 5, "corner": [E + R + 15.0, -2, 0]}));
|
||||
let st = occ(&s, "Stop");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": st, "grounded": true}));
|
||||
run(&mut s, "contact.create", json!({"occurrences": ["Cam", "Tappet", "Stop"]}));
|
||||
// Touching the cam stops nothing: half a turn moves the tappet 20 mm back up, as far as the stop.
|
||||
let d = run(&mut s, "joint.drive", json!({"joint": "Cam", "value": 90}));
|
||||
assert!(d["stopped_by_contact"].is_null(), "{d}");
|
||||
let d = run(&mut s, "joint.drive", json!({"joint": "Cam", "value": 0}));
|
||||
assert!(d["stopped_by_contact"].is_object(), "{d}");
|
||||
let reached = shown(&mut s, "Tappet");
|
||||
assert!(reached > -5.5 && reached < -4.5, "{reached}");
|
||||
}
|
||||
|
||||
/// What a cam can't be made from is refused, and a table edited by hand never puts a NaN into a
|
||||
/// joint's value.
|
||||
#[test]
|
||||
fn hostile_cams_are_refused() {
|
||||
let mut s = cam_on_base();
|
||||
tappet(&mut s, 0.0, E + R);
|
||||
let refused = |s: &mut Session, p: Value| s.execute("joint.cam", &p).map_err(|e| e.to_string()).err().unwrap_or_else(|| panic!("accepted {p}"));
|
||||
// An open curve makes no closed profile.
|
||||
run(&mut s, "component.activate", json!({"component": "Cam"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Open"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 5], [10, 20]]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let e = refused(&mut s, json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "Open"}}));
|
||||
assert!(e.contains("closed profile"), "{e}");
|
||||
// Two closed edges are not one loop; a line past the cam misses it.
|
||||
let e = refused(&mut s, json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[E + R, 0, 5], [E + R, 0, 0]]}}));
|
||||
assert!(e.contains("closed loop"), "{e}");
|
||||
let mut far = cam_on_base();
|
||||
tappet(&mut far, 50.0, 0.0);
|
||||
let e = refused(&mut far, json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}}));
|
||||
assert!(e.contains("misses"), "{e}");
|
||||
for p in [
|
||||
json!({"a": "Tappet", "b": "Cam", "profile": {"sketch": "CamSketch"}}),
|
||||
json!({"a": "Cam", "b": "Cam", "profile": {"sketch": "CamSketch"}}),
|
||||
json!({"a": "Cam", "b": "Tappet"}),
|
||||
json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch", "index": 7}}),
|
||||
json!({"a": "Cam", "b": "Tappet", "profile": {"edges": []}}),
|
||||
json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[1, "x", 0]]}}),
|
||||
json!({"a": "Cam", "b": "Tappet", "profile": {"edges": vec![[E + R, 0.0, 5.0]; 65]}}),
|
||||
json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller"}),
|
||||
json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": -1}),
|
||||
json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": "nope"}),
|
||||
json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": 1e300}),
|
||||
json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "spring"}),
|
||||
json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "link": 3}),
|
||||
] {
|
||||
refused(&mut s, p);
|
||||
}
|
||||
assert!(s.doc.assembly.links.is_empty());
|
||||
// A table edited by hand: NaN leaves the follower where it is, any length reads.
|
||||
run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}}));
|
||||
let before = solidcraft_doc::joints::joint_values(&s.doc.assembly);
|
||||
for lift in [vec![f64::NAN; 360], vec![1.0, 2.0, 3.0], vec![f64::INFINITY]] {
|
||||
let mut d = (*s.doc).clone();
|
||||
if let Some(l) = d.assembly.links.first_mut() {
|
||||
l.lift = lift;
|
||||
}
|
||||
let vals = solidcraft_doc::joints::joint_values(&d.assembly);
|
||||
assert!(vals.values().flatten().all(|v| v.is_finite()), "{vals:?}");
|
||||
// The follower keeps where it is.
|
||||
assert_eq!(vals.get(&joint_id(&s, "Tappet")), d.assembly.joints.iter().find(|j| j.name == "Tappet").map(|j| &j.values), "{before:?}");
|
||||
}
|
||||
// A table on a plain link is not read.
|
||||
let mut d = (*s.doc).clone();
|
||||
if let Some(l) = d.assembly.links.first_mut() {
|
||||
(l.coupling, l.ratio, l.offset) = (None, 0.0, 3.0);
|
||||
}
|
||||
assert_eq!(solidcraft_doc::joints::joint_values(&d.assembly).get(&joint_id(&s, "Tappet")).and_then(|q| q.first()).copied(), Some(3.0));
|
||||
// A roller's picked face must turn about an axis parallel to the cam's, a tip given or not.
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Pin"}));
|
||||
run(&mut s, "solid.cylinder", json!({"base": [60, 30, 0], "axis": [1, 0, 0], "radius": 3, "height": 10}));
|
||||
run(&mut s, "joint.delete", json!({"link": 0}));
|
||||
let tilted = json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller",
|
||||
"radius": {"occurrence": "Pin", "circle": [65, 33, 0]}, "tip": {"occurrence": "Tappet", "point": [E + R, 0, 2.5]}});
|
||||
let e = refused(&mut s, tilted);
|
||||
assert!(e.contains("roller's axis"), "{e}");
|
||||
}
|
||||
|
||||
/// A cam saves and opens with its definition and table; a link saved before cams reads as a
|
||||
/// plain link.
|
||||
#[test]
|
||||
fn cams_save_and_open_with_their_table() {
|
||||
let mut s = cam_on_base();
|
||||
tappet(&mut s, 0.0, E + R);
|
||||
run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": 2}));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Cam", "value": 30}));
|
||||
let text = s.doc.to_json();
|
||||
let back = solidcraft_doc::format::read(&text).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert_eq!(back.assembly.links, s.doc.assembly.links);
|
||||
assert_eq!(back.assembly.links.first().map(|l| l.lift.len()), Some(360));
|
||||
let l = run(&mut s, "joint.list", json!({}));
|
||||
let link = &l["links"][0];
|
||||
assert!(link.get("ratio").is_none() && link["lift"]["unit"] == "mm" && link["coupling"]["type"] == "cam", "{link}");
|
||||
assert!(link["coupling"].get("lift").is_none(), "{link}");
|
||||
let mut v: Value = serde_json::from_str(&text).unwrap_or_default();
|
||||
for l in v["assembly"]["links"].as_array_mut().into_iter().flatten() {
|
||||
l.as_object_mut().map(|o| (o.remove("coupling"), o.remove("lift")));
|
||||
}
|
||||
let old = solidcraft_doc::format::read(&v.to_string()).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(old.assembly.links.iter().all(|l| l.coupling.is_none() && l.lift.is_empty()));
|
||||
}
|
||||
@@ -1445,3 +1445,6 @@ fn hostile_trace_params_are_refused() {
|
||||
|
||||
#[path = "joints_coupling_tests.rs"]
|
||||
mod coupling;
|
||||
|
||||
#[path = "joints_cam_tests.rs"]
|
||||
mod cam;
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Assembly dialogs: New Component, Joint, As-built Joint, Joint Origin, Rigid Group, Drive
|
||||
//! Joints, Motion Link, Gear, Rack and Pinion, Screw (`dialogs_coupling`) and Interference. Joint origins are picked by hovering geometry: a face
|
||||
//! Joints, Motion Link, Gear, Rack and Pinion, Screw, Cam (`dialogs_coupling`) and Interference. Joint origins are picked by hovering geometry: a face
|
||||
//! snaps to its centre (or a round face to the centre of its nearer end), an edge to its middle
|
||||
//! (a round edge to its centre), a vertex to itself; the snap shows as a glyph with its z axis
|
||||
//! before the click. Joints and drives preview on the placed (world) model: the moving
|
||||
@@ -31,7 +31,7 @@ const TYPE_LABELS: [&str; 7] = ["Rigid", "Revolute", "Slider", "Cylindrical", "P
|
||||
/// What a joint origin input accepts (snaps on faces, edges and vertices).
|
||||
const SNAPS: u16 = FACES | EDGES | VERTICES;
|
||||
/// Commands whose effect is where occurrences sit (previewed on the placed model).
|
||||
const MOVES: [&str; 11] = [
|
||||
const MOVES: [&str; 12] = [
|
||||
"joint.create",
|
||||
"joint.as_built",
|
||||
"joint.drive",
|
||||
@@ -41,6 +41,7 @@ const MOVES: [&str; 11] = [
|
||||
"joint.gear",
|
||||
"joint.rack_pinion",
|
||||
"joint.screw",
|
||||
"joint.cam",
|
||||
"parts.insert",
|
||||
"parts.fastener",
|
||||
];
|
||||
@@ -72,6 +73,7 @@ pub enum Asm {
|
||||
offset: String,
|
||||
},
|
||||
Coupling(Box<crate::dialogs_coupling::CouplingForm>),
|
||||
Cam(Box<crate::dialogs_coupling::cam::CamForm>),
|
||||
Interference {
|
||||
result: Option<Value>,
|
||||
overlaps: Vec<Mesh>,
|
||||
@@ -151,13 +153,14 @@ impl Asm {
|
||||
"joint.rack_pinion" => "RACK AND PINION",
|
||||
_ => "SCREW",
|
||||
},
|
||||
Asm::Cam(_) => "CAM",
|
||||
Asm::Interference { .. } => "INTERFERENCE",
|
||||
Asm::Trace(_) => "MOTION TRACE",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn previews(&self) -> bool {
|
||||
matches!(self, Asm::Joint(_) | Asm::Drive { .. } | Asm::RigidGroup | Asm::Coupling(_))
|
||||
matches!(self, Asm::Joint(_) | Asm::Drive { .. } | Asm::RigidGroup | Asm::Coupling(_) | Asm::Cam(_))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +188,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
(Asm::MotionLink { a: 0, b, ia: 0, ib: 0, ratio: "1".into(), offset: "0".into() }, vec![])
|
||||
}
|
||||
"joint.gear" | "joint.rack_pinion" | "joint.screw" => crate::dialogs_coupling::start(app, id)?,
|
||||
"joint.cam" => crate::dialogs_coupling::cam::start(app),
|
||||
"inspect.interference" => {
|
||||
(Asm::Interference { result: None, overlaps: Vec::new(), of: Vec::new() }, vec![SelInput::new("Bodies (all if none)", BODIES, true)])
|
||||
}
|
||||
@@ -686,6 +690,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
|
||||
ui.end_row();
|
||||
}
|
||||
Asm::Coupling(f) => enter |= crate::dialogs_coupling::rows(app, ui, f, inputs),
|
||||
Asm::Cam(f) => enter |= crate::dialogs_coupling::cam::rows(app, ui, f, inputs),
|
||||
Asm::Interference { result, overlaps, of } => {
|
||||
let picked: Vec<Sel> = inputs.first().map(|i| i.items.clone()).unwrap_or_default();
|
||||
row_label(ui, "");
|
||||
@@ -914,6 +919,7 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result<Vec<(Str
|
||||
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),
|
||||
Asm::Cam(f) => return crate::dialogs_coupling::cam::commands(app, f, inputs),
|
||||
// The check runs from its Compute button; OK only closes.
|
||||
Asm::Interference { .. } => Vec::new(),
|
||||
Asm::Trace(f) => cmd("motion.trace.keep", trace::params(s, f, inputs)?),
|
||||
@@ -925,6 +931,9 @@ pub fn animate(app: &SolveApp, k: &Asm, cmds: &mut Vec<(String, Value)>) {
|
||||
if let Asm::Coupling(f) = k {
|
||||
crate::dialogs_coupling::animate(&app.session, f, cmds);
|
||||
}
|
||||
if let Asm::Cam(f) = k {
|
||||
crate::dialogs_coupling::cam::animate(&app.session, f, cmds);
|
||||
}
|
||||
let Asm::Joint(f) = k else { return };
|
||||
if !f.animate {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! The Cam dialog (`joint.cam`): a cam turning on a revolute joint drives a follower, a slider or
|
||||
//! a revolute, along the cam's profile (a closed sketch profile, or edges of the cam's body). The
|
||||
//! follower is a knife edge, a roller (its radius typed, or its round face picked, which is also
|
||||
//! where it touches) or a flat face. Picking the profile on a part chooses the joint that part
|
||||
//! turns on. Play turns the cam a full turn so the follower shows its motion before OK.
|
||||
|
||||
use egui::RichText;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::doc::joints::JointKind;
|
||||
|
||||
use super::{ROUND, joint_row, occurrence, pick, picked_radius, trim, turn};
|
||||
use crate::SolveApp;
|
||||
use crate::dialogs::{check, combo, field, row_label};
|
||||
use crate::dialogs_assembly::Asm;
|
||||
use crate::selection::{EDGES, FACES, PROFILES, SelInput, VERTICES};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// The follower types, as `joint.cam` takes them, and their labels.
|
||||
const FOLLOWERS: [&str; 3] = ["knife", "roller", "flat"];
|
||||
const FOLLOWER_LABELS: [&str; 3] = ["Knife edge", "Roller", "Flat face"];
|
||||
|
||||
/// The inputs: the cam's profile, the roller's round face, the follower's tip.
|
||||
const PROFILE: usize = 0;
|
||||
const ROLLER: usize = 1;
|
||||
const TIP: usize = 2;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CamForm {
|
||||
/// The cam's joint and the follower's (ids).
|
||||
pub a: u64,
|
||||
pub b: u64,
|
||||
/// Index into `FOLLOWERS`.
|
||||
pub follower: usize,
|
||||
/// The roller's radius, used where no round face is picked.
|
||||
pub radius: String,
|
||||
/// Ride the cam from the other side.
|
||||
pub flip: bool,
|
||||
/// The body of the profile pick the cam's joint was last chosen from, so a pick chooses once.
|
||||
pub picked: Option<String>,
|
||||
pub animate: bool,
|
||||
/// Animation phase, 0…1.
|
||||
pub phase: f64,
|
||||
}
|
||||
|
||||
/// Joints of a kind: (id, label).
|
||||
fn joints_of(s: &Session, kinds: &[JointKind]) -> Vec<(u64, String)> {
|
||||
s.doc.assembly.joints.iter().filter(|j| kinds.contains(&j.kind)).map(|j| (j.id, format!("{} ({:?})", j.name, j.kind))).collect()
|
||||
}
|
||||
|
||||
pub fn start(app: &SolveApp) -> (Asm, Vec<SelInput>) {
|
||||
let s = &app.session;
|
||||
let cams = joints_of(s, &[JointKind::Revolute]);
|
||||
let followers = joints_of(s, &[JointKind::Slider, JointKind::Revolute]);
|
||||
let a = cams.first().map(|j| j.0).unwrap_or(0);
|
||||
// A slider first, else another revolute than the cam's.
|
||||
let b = followers.iter().find(|j| j.0 != a).map(|j| j.0).unwrap_or(0);
|
||||
let f = CamForm { a, b, follower: 0, radius: "5 mm".into(), flip: false, picked: None, animate: false, phase: 0.0 };
|
||||
let inputs = vec![
|
||||
SelInput::new("Profile", PROFILES | EDGES, true),
|
||||
SelInput::new("Roller (optional)", ROUND, false),
|
||||
SelInput::new("Tip (optional)", FACES | EDGES | VERTICES, false),
|
||||
];
|
||||
(Asm::Cam(Box::new(f)), inputs)
|
||||
}
|
||||
|
||||
/// The occurrence the profile picked in `inputs` is on: an edge's body's, or a sketch's component's
|
||||
/// first.
|
||||
fn profile_occurrence(s: &Session, inputs: &[SelInput]) -> Option<(String, u64)> {
|
||||
match inputs.get(PROFILE)?.items.first()? {
|
||||
Sel::Edge { body, .. } => Some((body.clone(), occurrence(s, body)?)),
|
||||
Sel::Profile { sketch, .. } => {
|
||||
let comp = s.doc.feature(*sketch).map(|f| f.component)?;
|
||||
let occ = if comp == 0 { 0 } else { s.doc.occurrence_of(comp)?.id };
|
||||
Some((format!("sketch {sketch}"), occ))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog's rows; true when Enter was pressed in a value.
|
||||
pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, f: &mut CamForm, inputs: &[SelInput]) -> bool {
|
||||
let t = Tokens::get();
|
||||
let s = &app.session;
|
||||
let (cams, followers) = (joints_of(s, &[JointKind::Revolute]), joints_of(s, &[JointKind::Slider, JointKind::Revolute]));
|
||||
if cams.is_empty() || followers.len() < 2 && followers.iter().all(|j| cams.iter().any(|c| c.0 == j.0)) {
|
||||
row_label(ui, "");
|
||||
ui.label(RichText::new(crate::i18n::tr_label("needs a joint that turns and one that slides or swings")).color(t.text_dim));
|
||||
ui.end_row();
|
||||
return false;
|
||||
}
|
||||
// A profile picked on a part chooses the joint that part turns on, once.
|
||||
let at = profile_occurrence(s, inputs);
|
||||
if at.as_ref().map(|x| &x.0) != f.picked.as_ref() {
|
||||
if let Some(j) = at.as_ref().and_then(|(_, o)| {
|
||||
let turning = s.doc.assembly.joints.iter().filter(|j| j.kind == JointKind::Revolute);
|
||||
turning.clone().find(|j| j.b.occurrence == *o).or(turning.clone().find(|j| j.a.occurrence == *o))
|
||||
}) {
|
||||
f.a = j.id;
|
||||
if f.b == f.a {
|
||||
f.b = followers.iter().find(|x| x.0 != f.a).map(|x| x.0).unwrap_or(0);
|
||||
}
|
||||
}
|
||||
f.picked = at.map(|x| x.0);
|
||||
}
|
||||
let mut enter = false;
|
||||
joint_row(ui, "Cam", "cam_a", &cams, &mut f.a);
|
||||
joint_row(ui, "Follower", "cam_b", &followers, &mut f.b);
|
||||
row_label(ui, "Type");
|
||||
let labels: Vec<&str> = FOLLOWER_LABELS.iter().map(|l| crate::i18n::tr_label(l)).collect();
|
||||
combo(ui, "cam_follower", &labels, &mut f.follower);
|
||||
ui.end_row();
|
||||
if FOLLOWERS.get(f.follower) == Some(&"roller") {
|
||||
row_label(ui, "Radius");
|
||||
match (pick(inputs, ROLLER), picked_radius(s, inputs, ROLLER)) {
|
||||
(_, Some(r)) => {
|
||||
ui.label(format!("{} mm ({})", trim(r), crate::i18n::tr_label("picked")));
|
||||
}
|
||||
(Some(_), None) => {
|
||||
ui.label(RichText::new(crate::i18n::tr_label("not a round face")).color(t.warning));
|
||||
}
|
||||
(None, None) => enter |= field(ui, &mut f.radius),
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
row_label(ui, "Other side");
|
||||
check(ui, &mut f.flip);
|
||||
ui.end_row();
|
||||
row_label(ui, "Animate");
|
||||
let label = if f.animate { "■ Stop" } else { "▶ Play" };
|
||||
let r = ui.button(label).on_hover_text(crate::i18n::tr_label("Play the joint's motion in the view"));
|
||||
crate::scenario::publish_handle("dialog:Animate", r.rect.center());
|
||||
if r.clicked() {
|
||||
f.animate = !f.animate;
|
||||
}
|
||||
ui.end_row();
|
||||
if f.animate {
|
||||
let now = ui.input(|i| i.time);
|
||||
// 72 steps over 6 s: each one a preview.
|
||||
f.phase = ((now / 6.0).fract() * 72.0).floor() / 72.0;
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(30));
|
||||
}
|
||||
enter
|
||||
}
|
||||
|
||||
/// The command OK runs.
|
||||
pub fn commands(app: &SolveApp, f: &CamForm, inputs: &[SelInput]) -> Result<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 cam's and the follower's joints".to_string())
|
||||
};
|
||||
let (a, b) = (joint(f.a)?, joint(f.b)?);
|
||||
let items = inputs.get(PROFILE).map(|i| i.items.as_slice()).unwrap_or_default();
|
||||
let profile = match items.first() {
|
||||
Some(Sel::Profile { sketch, index }) => json!({"sketch": sketch, "index": index}),
|
||||
Some(Sel::Edge { body, .. }) => {
|
||||
let pts: Vec<Value> =
|
||||
items.iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(json!([point.x, point.y, point.z])) } else { None }).collect();
|
||||
let mut p = json!({ "edges": pts });
|
||||
if let Some(o) = occurrence(s, body) {
|
||||
p["occurrence"] = json!(o);
|
||||
}
|
||||
p
|
||||
}
|
||||
_ => return Err("select the cam's profile (a sketch profile or the cam's edges)".into()),
|
||||
};
|
||||
let follower = FOLLOWERS.get(f.follower).copied().unwrap_or("knife");
|
||||
let mut p = json!({"a": a, "b": b, "profile": profile, "follower": follower, "flip": f.flip});
|
||||
if follower == "roller" {
|
||||
p["radius"] = match pick(inputs, ROLLER) {
|
||||
Some((body, at)) => {
|
||||
let mut v = json!({"circle": [at.x, at.y, at.z]});
|
||||
if let Some(o) = occurrence(s, body) {
|
||||
v["occurrence"] = json!(o);
|
||||
}
|
||||
v
|
||||
}
|
||||
None => json!(f.radius.trim()),
|
||||
};
|
||||
}
|
||||
if let Some(snap) = inputs.get(TIP).and_then(|i| i.items.first()).and_then(|x| crate::dialogs_assembly::snap_of(s, x)) {
|
||||
p["tip"] = snap.param;
|
||||
}
|
||||
Ok(vec![("joint.cam".to_string(), p)])
|
||||
}
|
||||
|
||||
/// While animating, the preview turns the cam a full turn.
|
||||
pub fn animate(s: &Session, f: &CamForm, cmds: &mut Vec<(String, Value)>) {
|
||||
if f.animate {
|
||||
turn(s, f.a, f.phase, cmds);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "dialogs_cam_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,112 @@
|
||||
use serde_json::json;
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
use crate::dialogs::{Kind, apply_commands, preview_commands};
|
||||
|
||||
/// A grounded base, a cam (a disc of radius 30 about (10, 0)) turning on a revolute joint about +Z
|
||||
/// through the origin, a tappet on a slider along +X, its end on the cam, and a spare part on a
|
||||
/// revolute joint of its own.
|
||||
fn valve() -> SolveApp {
|
||||
let mut s = Session::default();
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
run("component.create", json!({"name": "Base"}));
|
||||
run("solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [-80, -80, -20]}));
|
||||
run("component.activate", json!({"component": "root"}));
|
||||
run("component.create", json!({"name": "Cam"}));
|
||||
run("solid.cylinder", json!({"base": [10, 0, 0], "axis": [0, 0, 1], "radius": 30, "height": 5}));
|
||||
run("component.activate", json!({"component": "root"}));
|
||||
run("component.create", json!({"name": "Tappet"}));
|
||||
run("solid.box", json!({"length": 20, "width": 4, "height": 5, "corner": [40, -2, 0]}));
|
||||
run("component.activate", json!({"component": "root"}));
|
||||
run("component.create", json!({"name": "Spare"}));
|
||||
run("solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [-80, 60, 0]}));
|
||||
run("component.activate", json!({"component": "root"}));
|
||||
run("occurrence.ground", json!({"occurrence": "Base:1", "grounded": true}));
|
||||
for (kind, name, z) in [("revolute", "Cam", [0, 0, 1]), ("slider", "Tappet", [1, 0, 0]), ("revolute", "Spare", [0, 0, 1])] {
|
||||
let at = |o: &str| json!({"occurrence": o, "point": [0, 0, 2.5], "z": z});
|
||||
run("joint.create", json!({"type": kind, "a": at("Base"), "b": at(name), "flip": true, "name": name}));
|
||||
}
|
||||
SolveApp::new(s, Services::default())
|
||||
}
|
||||
|
||||
/// A pick of the mesh edge nearest a point.
|
||||
fn edge(app: &SolveApp, p: Vec3) -> Sel {
|
||||
let st = app.session.world_state();
|
||||
let mut best: Option<(f64, Sel)> = None;
|
||||
for b in &st.bodies {
|
||||
for (index, e) in b.mesh().edges.iter().enumerate() {
|
||||
let d = e.windows(2).filter_map(|w| Some(p.dist_to_segment(*w.first()?, *w.get(1)?))).fold(f64::MAX, f64::min);
|
||||
if best.as_ref().is_none_or(|(bd, _)| d < *bd) {
|
||||
best = Some((d, Sel::Edge { body: b.name.clone(), index, point: p }));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|x| x.1).unwrap()
|
||||
}
|
||||
|
||||
fn joint(app: &SolveApp, name: &str) -> u64 {
|
||||
app.session.doc.assembly.joints.iter().find(|j| j.name == name).map(|j| j.id).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Picking the cam's edge chooses the joint it turns on; OK makes the cam, and Play turns the cam
|
||||
/// in the preview.
|
||||
#[test]
|
||||
fn a_picked_cam_edge_makes_the_cam() {
|
||||
let mut app = valve();
|
||||
app.start("joint.cam");
|
||||
let mut d = app.dialog.take().unwrap();
|
||||
// A second revolute joint for the cam to start on: the pick puts the cam right.
|
||||
if let Kind::Assembly(Asm::Cam(f)) = &mut d.kind {
|
||||
(f.a, f.b) = (joint(&app, "Spare"), joint(&app, "Tappet"));
|
||||
}
|
||||
d.active = 0;
|
||||
// The far side of the cam's top edge (its near side touches the tappet).
|
||||
let pick = edge(&app, Vec3::new(-20.0, 0.0, 5.0));
|
||||
d.pick(&app.session, pick);
|
||||
app.dialog = Some(d);
|
||||
let ctx = egui::Context::default();
|
||||
for _ in 0..3 {
|
||||
let input =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| crate::dialogs::show(&mut app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
let mut d = app.dialog.clone().expect("the dialog stays open");
|
||||
if let Kind::Assembly(Asm::Cam(f)) = &mut d.kind {
|
||||
// The joint the picked part turns on, and a follower that is another joint.
|
||||
assert_eq!((f.a, f.b), (joint(&app, "Cam"), joint(&app, "Tappet")));
|
||||
(f.follower, f.radius) = (1, "4 mm".into());
|
||||
}
|
||||
let c = apply_commands(&app, &d).unwrap();
|
||||
let (id, p) = &c[0];
|
||||
assert_eq!((id.as_str(), p["follower"].clone(), p["radius"].clone()), ("joint.cam", json!("roller"), json!("4 mm")), "{p}");
|
||||
assert!(p["profile"]["edges"][0].is_array() && p["profile"]["occurrence"].is_u64(), "{p}");
|
||||
if let Kind::Assembly(Asm::Cam(f)) = &mut d.kind {
|
||||
(f.follower, f.animate, f.phase) = (0, true, 0.25);
|
||||
}
|
||||
let cmds = preview_commands(&app, &d).unwrap();
|
||||
assert_eq!(cmds.get(1).map(|c| (c.0.as_str(), c.1["values"].clone())), Some(("joint.drive", json!(["90 deg"]))), "{cmds:?}");
|
||||
let c = apply_commands(&app, &d).unwrap();
|
||||
for (id, p) in c {
|
||||
app.run(&id, p).unwrap();
|
||||
}
|
||||
let l = app.session.doc.assembly.links.first().cloned().unwrap();
|
||||
assert_eq!(l.lift.len(), 360);
|
||||
// A quarter turn lowers the knife tappet from 40 to √(30² − 10²).
|
||||
app.run("joint.drive", json!({"joint": "Cam", "value": 90})).unwrap();
|
||||
let v = solidcraft_engine::doc::joints::joint_values(&app.session.doc.assembly).get(&joint(&app, "Tappet")).cloned().unwrap_or_default();
|
||||
assert!((v.first().copied().unwrap_or(f64::NAN) - (800f64.sqrt() - 40.0)).abs() < 1e-3, "{v:?}");
|
||||
}
|
||||
|
||||
/// The dialog's rows pass their English text, which the catalogs translate by its key.
|
||||
#[test]
|
||||
fn the_cam_dialog_labels_translate() {
|
||||
let de = crate::i18n::Locale::for_language("de");
|
||||
for (english, german) in [("Cam", "Nocken"), ("Follower", "Stößel"), ("Roller", "Rolle"), ("Tip (optional)", "Spitze (optional)")] {
|
||||
assert_eq!(de.label(english), german);
|
||||
}
|
||||
assert_eq!(crate::i18n::Locale::for_language("zh").label("Knife edge"), "尖顶");
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The Gear, Rack and Pinion and Screw dialogs: two joints coupled by radii or a pitch
|
||||
//! (`joint.gear`, `joint.rack_pinion`, `joint.screw`). A radius is typed, or comes from the round
|
||||
//! (`joint.gear`, `joint.rack_pinion`, `joint.screw`); the Cam dialog is `dialogs_cam.rs`. A radius is typed, or comes from the round
|
||||
//! face picked on the part, and then follows that face when the design changes. Picking a gear's
|
||||
//! face also picks the joint it turns on. Play turns the driving joint so the coupling shows its
|
||||
//! motion before OK.
|
||||
@@ -259,21 +259,31 @@ pub fn commands(app: &SolveApp, f: &CouplingForm, inputs: &[SelInput]) -> Result
|
||||
Ok(vec![(f.command.to_string(), p)])
|
||||
}
|
||||
|
||||
/// While animating, the preview turns the driving joint a full turn: its first value that turns
|
||||
/// (a planar joint's is its third), the values before it as they are.
|
||||
/// While animating, the preview turns the driving joint a full turn.
|
||||
pub fn animate(s: &Session, f: &CouplingForm, cmds: &mut Vec<(String, Value)>) {
|
||||
let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == f.a).filter(|_| f.animate) else { return };
|
||||
if f.animate {
|
||||
turn(s, f.a, f.phase, cmds);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive `joint` to `phase` of a full turn: its first value that turns (a planar joint's is its
|
||||
/// third), the values before it as they are.
|
||||
fn turn(s: &Session, joint: u64, phase: f64, cmds: &mut Vec<(String, Value)>) {
|
||||
let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == joint) else { return };
|
||||
let Some(turn) = (0..j.kind.dofs().len()).find(|i| j.kind.is_angle(*i)) else { return };
|
||||
let values: Vec<String> = (0..=turn)
|
||||
.map(|i| match j.values.get(i).copied().unwrap_or(0.0) {
|
||||
_ if i == turn => format!("{} deg", f.phase * 360.0),
|
||||
_ if i == turn => format!("{} deg", phase * 360.0),
|
||||
v if j.kind.is_angle(i) => format!("{} deg", v.to_degrees()),
|
||||
v => format!("{v} mm"),
|
||||
})
|
||||
.collect();
|
||||
cmds.push(("joint.drive".into(), json!({"joint": f.a, "values": values})));
|
||||
cmds.push(("joint.drive".into(), json!({"joint": joint, "values": values})));
|
||||
}
|
||||
|
||||
#[path = "dialogs_cam.rs"]
|
||||
pub mod cam;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "dialogs_coupling_tests.rs"]
|
||||
mod tests;
|
||||
@@ -207,6 +207,7 @@ fn motion_key(cmds: &[(String, Value)], revision: u64, colors: Colors) -> Option
|
||||
| "joint.gear"
|
||||
| "joint.rack_pinion"
|
||||
| "joint.screw"
|
||||
| "joint.cam"
|
||||
)
|
||||
})
|
||||
{
|
||||
@@ -856,6 +857,15 @@ mod tests {
|
||||
};
|
||||
assert!(motion_key(&gear("0 deg"), 7, colors()).is_some());
|
||||
assert_eq!(motion_key(&gear("0 deg"), 7, colors()), motion_key(&gear("90 deg"), 7, colors()));
|
||||
// So does a cam dialog turning its cam.
|
||||
let cam = |angle: &str| {
|
||||
vec![
|
||||
("joint.cam".to_string(), json!({"a": 1, "b": 2, "profile": {"sketch": 3}})),
|
||||
("joint.drive".to_string(), json!({"joint": 1, "values": [angle]})),
|
||||
]
|
||||
};
|
||||
assert!(motion_key(&cam("0 deg"), 7, colors()).is_some());
|
||||
assert_eq!(motion_key(&cam("0 deg"), 7, colors()), motion_key(&cam("90 deg"), 7, colors()));
|
||||
}
|
||||
|
||||
/// Combine previews its result (cut, join or intersect between existing bodies) without an
|
||||
|
||||
@@ -19,6 +19,7 @@ const ASSEMBLE: &[&str] = &[
|
||||
"joint.gear",
|
||||
"joint.rack_pinion",
|
||||
"joint.screw",
|
||||
"joint.cam",
|
||||
"motion.study",
|
||||
"motion.trace",
|
||||
"explode.create",
|
||||
|
||||
@@ -55,6 +55,33 @@ driven one. A joint follows one link at most, and a coupling that would drive it
|
||||
refused. `joint.list` lists each coupling's definition under `links` with an `error` when it can't
|
||||
be worked out (the picked face is gone); the link then keeps its last ratio.
|
||||
|
||||
**Cam** (`joint.cam {a, b, profile}`, on the **ASSEMBLE** panel) makes a follower ride a cam. The
|
||||
cam turns on a revolute joint (a); the follower is a slider (b, a translating follower such as a
|
||||
valve tappet) or a revolute joint (b, an oscillating follower such as a rocker arm). The profile
|
||||
is the cam's outline in a plane square to its axis: a closed profile of a sketch in the cam's
|
||||
component, or the cam's edges picked on its body (one click on an edge takes the closed loop it is
|
||||
on in that plane). The follower is:
|
||||
|
||||
- a **Knife edge**: a point on the cam;
|
||||
- a **Roller** of a radius, typed or taken from the roller's round face (picking that face also
|
||||
says where the roller is);
|
||||
- a **Flat face** square to a slider's line, such as a bucket tappet.
|
||||
|
||||
A translating follower's line runs along its slider's axis, through its **Tip** when one is picked
|
||||
(its knife's point or its roller's centre), so a follower offset from the cam's axis rides the
|
||||
right flank. An oscillating follower needs its tip, or its roller's face, to know its arm. The
|
||||
follower rides the cam from the side it is on (**Other side** takes the other one). The follower's
|
||||
travel over a turn of the cam is worked out from the profile and kept with the link, one value a
|
||||
degree with a smooth curve between them that never overshoots its samples. It is worked out again
|
||||
whenever the design changes: change the cam's sketch, a parameter or the roller, and the follower
|
||||
follows. Like the gears, the new link leaves the follower where it is (model it touching the cam),
|
||||
and the cam is a motion link: it drives in **Drive Joints**, plays in motion studies, works in
|
||||
closed loops (a tappet can be the piston of a slider-crank) and stops at contact sets. The profile
|
||||
may also be on the grounded part, with the follower's guide on a turret that the cam joint turns.
|
||||
`joint.list` gives a cam's link `lift` (the follower's lowest and highest values and its rise)
|
||||
instead of a ratio. A profile that is not closed, a line or an arm that misses the cam somewhere,
|
||||
a follower that would jump, and axes that are not square or parallel are refused.
|
||||
|
||||
A joint moves one component against another, so its two picks must be on different components.
|
||||
A STEP file with several parts often comes in as bodies of one component, and picks on two of
|
||||
those bodies make no joint. The Joint and As-built Joint dialogs say so under the picks and offer
|
||||
|
||||
@@ -42,6 +42,7 @@ Written against SolidCraft `main` after 0.11.1, which counts the degrees of free
|
||||
## Linked and closed motion
|
||||
- **Motion links:** `joint.motion_link {"a":..., "b":..., "ratio":r, "offset":o}` sets b = r·a + o, clamped to b's own limits. `ratio` is b's units per a's: a pinion that moves its rack 0.5 mm per degree takes `ratio: 0.5`. `offset` is in b's units: degrees for an angle, mm for a distance, or an expression. A mirrored part takes `ratio: -1`, and limits mirrored too. Chains (a drives b, b drives c) work in any order of creation. Drive only the head of a chain.
|
||||
- **Gears, racks and screws:** links whose ratio comes from radii or a pitch, kept up to date when they change. `joint.gear {"a":..., "b":..., "ra":r1, "rb":r2}` turns b r1/r2 as far as a, the other way (`internal: true`: the same way). `joint.rack_pinion {"a":pinion, "b":rack, "radius":r}` moves the rack r·θ. `joint.screw {"joint":cylindrical, "pitch":p}` (or `a`, `b` for a revolute and a slider) advances p per turn. A size is mm, an expression (`"m*z1/2"`), or a round face `{"occurrence":..., "circle":[x,y,z]}` whose radius is read again after every edit. `reverse: true` turns b the other way (a left-hand thread). The driven joint keeps where it is. Drive a, never b.
|
||||
- **Cams:** `joint.cam {"a":camJoint, "b":follower, "profile":{"sketch":"CamSketch"}}` (or `{"edges":[[x,y,z]]}` on the cam's body) makes a slider or revolute follower ride the cam's outline. `follower`: `knife` (default), `roller` with `radius` (mm, an expression or the roller's round face), `flat`. A revolute follower needs `tip` (its knife's point or roller's centre). The table is worked out again after every edit; the follower keeps where it is, so model it touching. Drive the cam, never the follower.
|
||||
- **Closed loops:** an actuator between a frame and a hinged panel takes three joints besides the hinge:
|
||||
- a revolute root pin (frame and barrel)
|
||||
- a slider (barrel and rod), with z along the actuator from root to tip on both origins, both at the root pin, and the stroke as its limits
|
||||
|
||||
@@ -64,6 +64,7 @@ A slider's value grows as b moves along +z of its origin. Give its two origins t
|
||||
- `joint.rigid_group` joins every occurrence to the first one with a rigid as-built joint.
|
||||
- `joint.motion_link`: b = ratio·a + offset, clamped to b's limits. `ratio` is in b's units per a's: mm per degree when an angle drives a distance (a pinion of pitch radius r moves its rack r·π/180 mm per degree), degrees per mm the other way, and a plain factor between two angles or two distances. `offset` is in b's units: degrees for an angle, mm for a distance, or an expression such as `"10 deg"`. `joint.list` gives each link's `ratio` the same way and its `offset` with its `unit`. `ia`/`ib` pick a value of a multi-value joint (default 0). `joint.delete {"link": n}` removes link n (its index in `joint.list` `links`).
|
||||
- `joint.gear {a, b, ra, rb, internal?, reverse?}`, `joint.rack_pinion {a, b, radius, reverse?}` and `joint.screw {joint | a, b; pitch, reverse?}` make a motion link whose ratio comes from its sizes: gears -ra/rb (+ra/rb internal), a rack r mm per radian of its pinion (either joint may be `a`), a screw pitch per turn. Each size is mm, an expression, or `{occurrence, circle: [x,y,z]}`: the radius of the round face there, read again whenever the design changes. The reply gives `link` (its index) and `ratio` as `joint.list` does; `link: n` in the params replaces link n and keeps its offset. A new coupling's offset keeps b where it is. `joint.list` `links` carry the `coupling` and an `error` when it can't be worked out (a picked face gone: the last ratio stays). A value two links would set, or a coupling that would drive its own driver, is refused.
|
||||
- `joint.cam {a, b, profile, follower?, radius?, tip?, flip?, link?}`: a is the cam's revolute joint, b a slider or revolute follower. `profile` is `{sketch, index?}` (a closed profile of a sketch in the cam's component) or `{edges: [[x,y,z]...], occurrence?}` (edges of the cam's body; one edge continues around its closed loop in the plane). The reply gives `link`, `lift: {min, max, rise, unit}` (b's travel over a turn), `reverse` (the side it rides from; `flip: true` takes the other) and, for a slider, `follower_offset`. `joint.list` `links` show `lift` instead of `ratio` for a cam.
|
||||
- `joint.drive`: `values` drives the values it lists, in order (`Spindle` above is cylindrical: 30 degrees, then 5 mm), and `value` the first; values left out keep theirs, or follow in a closed loop. Values are clamped to the limits. The reply gives `values` as `joint.list` does (`{name, value, unit, limits}`), and `moved`; when the joint can move nothing, a `reason`.
|
||||
- `joint.reach` (inverse kinematics): moves the joints on the occurrence's chain so its point `at` (world, where it is now; default: the centre of its bodies) gets to `point`, from the pose it is in to the nearest one, within limits, through motion links, and with closed loops kept closed. `z` with `axis` (world, now; default its own z) also turns that axis. `joints: [...]` moves only those (loop followers still follow). The reply: `reached`, `error_mm` (and `error_deg`), `point` where it ended, `values` of the joints that moved, `moved`, and when short, `stopped_by_limit: [{joint, value, limit}]` or `stopped_by_loop: {joint}`. A part that nothing moves answers `moved: false` with a `reason`. Contact sets are not checked; check with `contact.check` afterwards.
|
||||
- `motion.study {"study":..., ...}` edits a study; `keys` given replace all keys.
|
||||
@@ -127,6 +128,8 @@ The same pattern moved the solar wings of the SELENE rover (`Rover-v7.solidcraft
|
||||
| `joint.drive` answers `moved: false`, or `motion.play` lists the joint under `unmoved` | Read `reason`. Most often the chain is joined to nothing grounded: `unconnected` names the part, and `joint.list` lists the chain under `floating`, its occurrences with `dof: 6`. Ground it, join it, or `joint.rigid_group` it with the structure. |
|
||||
| A drive stops short, `stopped_by_loop` | A loop reached a limit or can't close further; `joint` names the joint that stopped it. Check the limits against your stroke numbers. |
|
||||
| A gear, rack or screw turns its partner the wrong way | The joints' axes point opposite ways (or the rack lies on the other side of the pinion). Run the command again with `reverse: true` and `link: n`. |
|
||||
| `joint.cam` says the follower's line misses the cam | The slider's line (through `tip` when given) passes beside the cam at some angle. Pick the follower's tip, or move its slider's axis over the cam. |
|
||||
| A cam's follower rides the wrong flank or sinks into the cam | It took the other side of the cam's axis (`reverse` in the reply): run `joint.cam` again with `flip: true` and `link: n`. |
|
||||
| `already follows a motion link` from `joint.gear` | A joint follows one link at most. Make the joint that already follows the driving one (`a`), as in a gear train G1 → G2 → G3. |
|
||||
| A linked joint stops following | Its value is clamped to its own limits. Hinge 2 in the example has `[[-60, 0]]`, so a drive of Hinge below 0 leaves it at 0. Give a mirrored joint the mirrored limits. |
|
||||
| `joint.reach` answers `reached: false` | The point is out of reach: `point` and `error_mm` give the nearest pose. `stopped_by_limit` names the limits it rests on, `stopped_by_loop` the loop that can't follow further; with neither, the chain can't get there at all (too far, or out of its plane). |
|
||||
|
||||
Reference in new issue
Block a user