diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index 3df518a7..a6c8e890 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -39,7 +39,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("joint.motion_link", "Motion Link", motion_link) .at("SOLID", "ASSEMBLE") .icon("motion_link") - .params("a, b: joints; ratio (b = ratio · a + offset); offset?: in b's units (deg for an angle, mm for a distance, or an expression); ia?, ib?: value indices (default 0)"), + .params("a, b: joints; ratio (b = ratio · a + offset): b's units per a's (mm per deg for an angle driving a distance); offset?: in b's units (deg for an angle, mm for a distance, or an expression); ia?, ib?: value indices (default 0)"), CommandSpec::new("joint.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)"), @@ -521,16 +521,27 @@ fn motion_link(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "the linked joints need moving values at those indices")); } // In b's units, like its values and limits (stored in radians for an angle). - let angle = s.doc.assembly.joints.iter().find(|j| j.id == b).is_some_and(|j| j.kind.is_angle(ib)); let offset = match p.get("offset") { - Some(v) => value(s, v, angle, cmd)?, + Some(v) => value(s, v, is_angle(s, b, ib), cmd)?, 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 }); let sol = resolve(s); Ok(sol_json(&sol, p)) } +fn is_angle(s: &Session, joint: u64, i: usize) -> bool { + s.doc.assembly.joints.iter().find(|j| j.id == joint).is_some_and(|j| j.kind.is_angle(i)) +} + +/// A link's `ratio` is in b's units per a's (mm per degree, degrees per mm) and stored per +/// radian: the given one times this is the stored one. +fn ratio_scale(s: &Session, a: u64, ia: usize, b: u64, ib: usize) -> f64 { + let unit = |angle: bool| if angle { 1f64.to_radians() } else { 1.0 }; + unit(is_angle(s, b, ib)) / unit(is_angle(s, a, ia)) +} + fn limits(s: &mut Session, p: &Value) -> Result { let cmd = "joint.limits"; let id = joint_id(s, p.get("joint"), cmd)?; @@ -652,9 +663,10 @@ fn list(s: &mut Session, p: &Value) -> Result { .links .iter() .map(|l| { - let angle = s.doc.assembly.joints.iter().find(|j| j.id == l.b).is_some_and(|j| j.kind.is_angle(l.ib)); + let angle = is_angle(s, l.b, l.ib); let offset = if angle { l.offset.to_degrees() } else { l.offset }; - json!({"a": l.a, "ia": l.ia, "b": l.b, "ib": l.ib, "ratio": l.ratio, "offset": offset, "unit": if angle { "deg" } else { "mm" }}) + let ratio = l.ratio / ratio_scale(s, l.a, l.ia, l.b, l.ib); + json!({"a": l.a, "ia": l.ia, "b": l.b, "ib": l.ib, "ratio": ratio, "offset": offset, "unit": if angle { "deg" } else { "mm" }}) }) .collect(); let origins: Vec = s.doc.assembly.origins.iter().map(|o| json!({"name": o.name, "occurrence": o.origin.occurrence})).collect(); diff --git a/crates/engine/src/cmd/joints_tests.rs b/crates/engine/src/cmd/joints_tests.rs index 08571ff5..7d7a3290 100644 --- a/crates/engine/src/cmd/joints_tests.rs +++ b/crates/engine/src/cmd/joints_tests.rs @@ -1122,6 +1122,35 @@ fn a_link_offset_is_in_the_linked_values_units() { let _ = std::fs::remove_dir_all(&dir); } +/// A link between an angle and a distance takes its ratio in b's units per a's, like its offset: +/// a pinion moves its rack 0.5 mm per degree, and a car turns its dial 2 degrees per mm. +/// `joint.list` answers the ratios the same way; they are stored per radian, as before. +#[test] +fn a_link_ratio_between_an_angle_and_a_distance_is_per_degree() { + let mut s = Session::default(); + for (name, x) in [("Ground", 0.0), ("Pinion", 80.0), ("Rack", 100.0), ("Car", 120.0), ("Dial", 140.0)] { + part(&mut s, name, [x, 0.0, -5.0]); + } + let g = occ(&s, "Ground"); + run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true})); + for (kind, name, x) in [("revolute", "Pinion", 80.0), ("slider", "Rack", 100.0), ("slider", "Car", 120.0), ("revolute", "Dial", 140.0)] { + pin(&mut s, kind, name, "Ground", name, Vec3::new(x, 0.0, 0.0), Vec3::Z); + } + run(&mut s, "joint.motion_link", json!({"a": "Pinion", "b": "Rack", "ratio": 0.5})); + run(&mut s, "joint.motion_link", json!({"a": "Car", "b": "Dial", "ratio": 2})); + run(&mut s, "joint.drive", json!({"joint": "Pinion", "value": 20})); + run(&mut s, "joint.drive", json!({"joint": "Car", "value": 3})); + let l = run(&mut s, "joint.list", json!({})); + let value = |name: &str| { + l["joints"].as_array().into_iter().flatten().find(|j| j["name"] == name).and_then(|j| j["values"][0]["value"].as_f64()).unwrap_or(f64::NAN) + }; + assert!((value("Rack") - 10.0).abs() < 1e-9 && (value("Dial") - 6.0).abs() < 1e-9, "{l}"); + let ratios: Vec = l["links"].as_array().into_iter().flatten().map(|k| k["ratio"].as_f64().unwrap_or(f64::NAN)).collect(); + assert!(ratios.len() == 2 && (ratios[0] - 0.5).abs() < 1e-9 && (ratios[1] - 2.0).abs() < 1e-9, "{l}"); + let stored: Vec = s.doc.assembly.links.iter().map(|k| k.ratio).collect(); + assert!((stored[0] - 0.5f64 / 1f64.to_radians()).abs() < 1e-9 && (stored[1] - 2f64.to_radians()).abs() < 1e-12, "{stored:?}"); +} + /// A new joint starts at its values inside its limits. Limits that leave out 0 (or the values /// given) start it at the nearer limit, which it keeps, and the answer says the part moved there. #[test] diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 962abd0f..5c6a7e15 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -878,7 +878,8 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result { let js = &s.doc.assembly.joints; let (Some(ja), Some(jb)) = (js.get(*a), js.get(*b)) else { return Err("needs two joints".into()) }; - // The offset as typed: the engine reads it in Joint 2's units (deg or mm). + // The offset as typed: the engine reads it in Joint 2's units (deg or mm), and the + // ratio in Joint 2's units per Joint 1's. let ratio = eval(ratio, ValueKind::Unitless)?; cmd("joint.motion_link", json!({"a": ja.id, "b": jb.id, "ia": ia, "ib": ib, "ratio": ratio, "offset": offset.trim()})) } diff --git a/skills/solidcraft-mechanisms/SKILL.md b/skills/solidcraft-mechanisms/SKILL.md index 89cc6f81..60485bf5 100644 --- a/skills/solidcraft-mechanisms/SKILL.md +++ b/skills/solidcraft-mechanisms/SKILL.md @@ -40,7 +40,7 @@ Written against SolidCraft `main` after 0.11.1, which counts the degrees of free - **Every chain must reach something grounded.** A part that is neither grounded nor joined to a grounded part is never placed. A drive on a joint in that chain moves nothing and answers `moved: false`, with a `reason` and the part joined to nothing as `unconnected`. `joint.list` lists such chains under `floating`, and their occurrences have `dof: 6`. Fix it with `joint.rigid_group {"occurrences":["Frame:1", ...]}`, or with a joint to the grounded structure. ## 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. `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. +- **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. - **Closed loops:** an actuator between a frame and a hinged panel takes three joints besides the hinge: - a revolute root pin (frame and barrel) - a slider (barrel and rod), with z along the actuator from root to tip on both origins, both at the root pin, and the stroke as its limits diff --git a/skills/solidcraft-mechanisms/reference.md b/skills/solidcraft-mechanisms/reference.md index 7b4062e4..cbc8ee61 100644 --- a/skills/solidcraft-mechanisms/reference.md +++ b/skills/solidcraft-mechanisms/reference.md @@ -61,7 +61,7 @@ A slider's value grows as b moves along +z of its origin. Give its two origins t - `joint.create` and `joint.as_built` start the joint at its `values` (default 0). A value outside the limits starts at the nearer limit, and the reply's `warnings` say so: the part turned as it was joined. - `joint.as_built` takes occurrences, not origins. `at` is the joint's frame (default: b's component origin). - `joint.rigid_group` joins every occurrence to the first one with a rigid as-built joint. -- `joint.motion_link`: b = ratio·a + offset, clamped to b's limits. `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 `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.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.drive`: `values` drives the values it lists, in order (`Spindle` above is cylindrical: 30 degrees, then 5 mm), and `value` the first; values left out keep theirs, or follow in a closed loop. Values are clamped to the limits. The reply gives `values` as `joint.list` does (`{name, value, unit, limits}`), and `moved`; when the joint can move nothing, a `reason`. - `motion.study {"study":..., ...}` edits a study; `keys` given replace all keys. - `motion.play` sets the joints to the study's values at a step (fractions work) and changes the design. `motion.export` does not change it.