Joints: cam and follower coupling (joint.cam) (#1734)

Thanks @SychPL.
This commit is contained in:
Mateusz Sych authored and GitHub committed 2026-10-16 10:26:35 -04:00
1 parent 88f76adbc2
commit 6232fa33c2
27 files changed
+2176 -47

No files matched your search

+1 -1
View File
@@ -54,7 +54,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, 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 |
+14
View File
@@ -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);
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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": "ジョイントを駆動",
+8
View File
@@ -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": "관절 구동",
+8
View File
@@ -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": "Привод сопряжений",
+8
View File
@@ -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": "Керувати з'єднаннями",
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+435
View File
@@ -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()));
}
+3
View File
@@ -1445,3 +1445,6 @@ fn hostile_trace_params_are_refused() {
#[path = "joints_coupling_tests.rs"]
mod coupling;
#[path = "joints_cam_tests.rs"]
mod cam;
+12 -3
View File
@@ -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;
+197
View File
@@ -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;
+112
View File
@@ -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"), "尖顶");
}
+16 -6
View File
@@ -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;
+10
View File
@@ -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
+1
View File
@@ -19,6 +19,7 @@ const ASSEMBLE: &[&str] = &[
"joint.gear",
"joint.rack_pinion",
"joint.screw",
"joint.cam",
"motion.study",
"motion.trace",
"explode.create",
+27
View File
@@ -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
+1
View File
@@ -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). |