Joints: gears, rack and pinion, and screws (#1524)

Motion links can carry a coupling (gear pair, rack and pinion, screw) whose ratio follows radii, picked round faces or pitch. Design format 23.

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

No files matched your search

+10 -2
View File
@@ -222,13 +222,20 @@
//! parameters); the source's `at`, `height` and `angle` then hold the numbers they last came
//! to. A text without `exprs` is set as it always was; 0.12.0 asks for an update instead of
//! dropping the expressions and keeping the text at its last numbers.
//! - 23: a motion link can carry a `coupling`: a gear pair (`ra`, `rb`, `internal`), a rack and
//! pinion (`radius`) or a screw (`pitch`), each size a number in mm, an expression or a picked
//! round face, and `reverse`. The link's `ratio` is worked out from it whenever the design
//! changes and saved as well, so a link reads the same without it. Version 22 designs need no
//! transformation: a link without `coupling` is a plain motion link, as it always was; 0.13.0
//! (which reads up to 22) asks for an update instead of keeping a gear's ratio when its radius
//! changes.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 22;
pub const FORMAT: u32 = 23;
pub fn format_string(n: u32) -> String {
format!("solidcraft/{n}")
@@ -245,7 +252,7 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 21] = [
const JSON_STEPS: [JsonStep; 22] = [
|_| Ok(()),
v2_to_v3,
v3_to_v4,
@@ -267,6 +274,7 @@ const JSON_STEPS: [JsonStep; 21] = [
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
];
/// Stored face and edge names were worked out from geometry before format 14; they get the
+183 -18
View File
@@ -1,7 +1,8 @@
//! Joints between occurrences: joint origins (snap frames on component geometry, re-found on
//! every solve so they follow edits), joints (rigid, revolute, slider, cylindrical, pin-slot,
//! planar, ball) with their motion values and limits, motion links, and the solver that places
//! occurrences from the joint graph.
//! planar, ball) with their motion values and limits, motion links (gears, racks and screws are
//! links whose ratio comes from radii or a pitch), and the solver that places occurrences from
//! the joint graph.
//!
//! A joint makes B's joint frame coincide with A's frame moved by the joint's motion:
//! `T_B · F_b = T_A · F_a · M(q) · Flip`, so `T_B = T_A · F_a · M(q) · Flip · F_b⁻¹`, where `T`
@@ -151,6 +152,115 @@ pub struct MotionLink {
pub ratio: f64,
#[serde(default)]
pub offset: f64,
/// Gears, a rack and pinion or a screw: what the ratio comes from. The ratio is worked out
/// from it again whenever the design changes (`coupling_ratio`), so it follows the radii and
/// the pitch; the last one worked out is kept in `ratio`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub coupling: Option<Coupling>,
}
/// A length a coupling's ratio comes from: millimetres, an expression over the design's
/// parameters, or the radius of the round face picked on an occurrence (a circle-centre snap,
/// found again on every solve like a joint origin).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Size {
Length(f64),
Expr(String),
Circle(JointOrigin),
}
/// The geometry a motion link's ratio comes from. `reverse` turns the follower the other way.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Coupling {
/// Two gears on revolute joints, `ra` on a's and `rb` on b's (pitch radii, or any two lengths
/// in their proportion such as tooth counts): b turns ra/rb as far as a, the other way round,
/// or the same way when one is an internal (ring) gear.
Gear {
ra: Size,
rb: Size,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
internal: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
reverse: bool,
},
/// A pinion of pitch radius `radius` on a revolute joint and a rack on a slider: the rack
/// travels the radius times the pinion's turn in radians (either joint may drive).
RackPinion {
radius: Size,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
reverse: bool,
},
/// A screw: the travel advances `pitch` per turn (a cylindrical joint's angle and distance,
/// or a revolute joint and a slider). Right-handed unless reversed.
Screw {
pitch: Size,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
reverse: bool,
},
}
impl Coupling {
/// The lengths the ratio comes from: a's gear and b's, or the pinion's radius or the pitch.
pub fn sizes(&self) -> Vec<&Size> {
match self {
Coupling::Gear { ra, rb, .. } => vec![ra, rb],
Coupling::RackPinion { radius: s, .. } | Coupling::Screw { pitch: s, .. } => vec![s],
}
}
pub fn sizes_mut(&mut self) -> Vec<&mut Size> {
match self {
Coupling::Gear { ra, rb, .. } => vec![ra, rb],
Coupling::RackPinion { radius: s, .. } | Coupling::Screw { pitch: s, .. } => vec![s],
}
}
}
/// Largest length a coupling takes (mm).
const MAX_SIZE: f64 = 1e7;
/// A coupling length in millimetres: positive and finite.
fn size_of(doc: &Document, st: &ModelState, s: &Size) -> Result<f64> {
let v = match s {
Size::Length(v) => *v,
Size::Expr(e) => doc.eval(e, crate::expr::Kind::Length)?,
Size::Circle(o) => circle_at(doc, st, o)?.1.radius,
};
if v.is_finite() && v > 1e-9 && v <= MAX_SIZE {
Ok(v)
} else {
Err(DocError::Invalid(format!("a gear, rack or screw needs a positive length, not {v}")))
}
}
/// The ratio a link's coupling gives it (b's value per a's, in radians and mm), worked out from
/// the geometry and parameters it is defined by; `None` for a plain link.
pub fn coupling_ratio(doc: &Document, st: &ModelState, l: &MotionLink) -> Option<Result<f64>> {
l.coupling.as_ref().map(|c| coupled(doc, st, l, c))
}
fn coupled(doc: &Document, st: &ModelState, l: &MotionLink, c: &Coupling) -> Result<f64> {
let angle = |id: u64, i: usize| doc.assembly.joints.iter().find(|j| j.id == id).is_some_and(|j| j.kind.is_angle(i));
let (turn_a, turn_b) = (angle(l.a, l.ia), angle(l.b, l.ib));
// Travel per radian, for a turn coupled to a travel either way round.
let travel = |k: f64| match (turn_a, turn_b) {
(true, false) => Ok(k),
(false, true) => Ok(1.0 / k),
_ => Err(DocError::Invalid("a rack or a screw couples a turn and a travel".into())),
};
let sign = |reverse: bool| if reverse { -1.0 } else { 1.0 };
match c {
Coupling::Gear { ra, rb, internal, reverse } => {
if !(turn_a && turn_b) {
return Err(DocError::Invalid("gears couple two turns".into()));
}
Ok(size_of(doc, st, ra)? / size_of(doc, st, rb)? * sign(!internal) * sign(*reverse))
}
Coupling::RackPinion { radius, reverse } => Ok(travel(size_of(doc, st, radius)?)? * sign(*reverse)),
Coupling::Screw { pitch, reverse } => Ok(travel(size_of(doc, st, pitch)? / std::f64::consts::TAU)? * sign(*reverse)),
}
}
/// Joints, joint origins and motion links of a design.
@@ -272,27 +382,35 @@ pub fn resolve_origin(doc: &Document, st: &ModelState, o: &JointOrigin) -> Resul
let (_, c, n) = best.ok_or_else(|| DocError::Invalid("joint origin: no planar face there".into()))?;
frame(c, n, None).ok_or_else(bad)
}
Snap::CircleCenter { pick, face } => {
let pick = &named_pick(st, *pick, face.as_deref())?;
let comp = component_of(doc, o.occurrence)?;
let cyl = component_bodies(doc, st, comp)
.into_iter()
.filter_map(|b| solidcraft_kernel::cylinder_face_at(&b.body, *pick))
.min_by(|a, b| {
let off = |c: &solidcraft_kernel::CylinderFace| {
let v = *pick - c.axis_point;
((v - c.axis * v.dot(c.axis)).len() - c.radius).abs()
};
off(a).total_cmp(&off(b))
})
.ok_or_else(|| DocError::Invalid("joint origin: no cylindrical face there".into()))?;
let s = (*pick - cyl.axis_point).dot(cyl.axis);
Snap::CircleCenter { .. } => {
let (pick, cyl) = circle_at(doc, st, o)?;
let s = (pick - cyl.axis_point).dot(cyl.axis);
let (at, dir) = if (s - cyl.start).abs() <= (cyl.end - s).abs() { (cyl.start, -1.0) } else { (cyl.end, 1.0) };
frame(cyl.axis_point + cyl.axis * at, cyl.axis * dir, None).ok_or_else(bad)
}
}
}
/// The pick of a circle-centre snap (on its named face) and the cylindrical face there
/// (component coordinates). Any other snap is refused.
fn circle_at(doc: &Document, st: &ModelState, o: &JointOrigin) -> Result<(Vec3, solidcraft_kernel::CylinderFace)> {
let Snap::CircleCenter { pick, face } = &o.snap else { return Err(DocError::Invalid("pick a round face".into())) };
let pick = named_pick(st, *pick, face.as_deref())?;
let comp = component_of(doc, o.occurrence)?;
let cyl = component_bodies(doc, st, comp)
.into_iter()
.filter_map(|b| solidcraft_kernel::cylinder_face_at(&b.body, pick))
.min_by(|a, b| {
let off = |c: &solidcraft_kernel::CylinderFace| {
let v = pick - c.axis_point;
((v - c.axis * v.dot(c.axis)).len() - c.radius).abs()
};
off(a).total_cmp(&off(b))
})
.ok_or_else(|| DocError::Invalid("joint origin: no cylindrical face there".into()))?;
Ok((pick, cyl))
}
/// World transform of an occurrence (through its parents).
pub fn occurrence_world(doc: &Document, occ: u64) -> Mat {
let mut m = IDENTITY;
@@ -1283,7 +1401,7 @@ mod tests {
suppressed: false,
})
.collect();
let links = (1..n).rev().map(|a| MotionLink { a, ia: 0, b: a + 1, ib: 0, ratio: 2.0, offset: 0.0 }).collect();
let links = (1..n).rev().map(|a| MotionLink { a, ia: 0, b: a + 1, ib: 0, ratio: 2.0, offset: 0.0, coupling: None }).collect();
Assembly { joints, links, ..Assembly::default() }
}
@@ -1306,6 +1424,53 @@ mod tests {
assert_eq!(again, vals);
}
/// A coupling saves as its definition: sizes as numbers, expressions or picked faces, flags
/// only when set; a link without one (as every design before couplings has) reads as before.
#[test]
fn couplings_read_and_write_their_definition() {
let old: MotionLink = serde_json::from_str(r#"{"a": 1, "b": 2, "ratio": 2}"#).unwrap();
assert_eq!((old.coupling, old.offset, old.ia), (None, 0.0, 0));
let text = r#"{"a": 1, "b": 2, "ratio": -0.5, "coupling": {"type": "gear", "ra": 10, "rb": "r2 * 2",
"internal": true}}"#;
let l: MotionLink = serde_json::from_str(text).unwrap();
let Some(Coupling::Gear { ra, rb, internal, reverse }) = &l.coupling else { panic!("{l:?}") };
assert_eq!((ra, rb, *internal, *reverse), (&Size::Length(10.0), &Size::Expr("r2 * 2".into()), true, false));
let back = serde_json::to_value(&l).unwrap();
assert_eq!(back["coupling"], serde_json::json!({"type": "gear", "ra": 10.0, "rb": "r2 * 2", "internal": true}));
let picked: Size = serde_json::from_str(r#"{"occurrence": 3, "snap": {"type": "circle_center", "pick": [1, 0, 0]}}"#).unwrap();
assert!(matches!(picked, Size::Circle(JointOrigin { occurrence: 3, snap: Snap::CircleCenter { .. } })));
let screw: Coupling = serde_json::from_str(r#"{"type": "screw", "pitch": 1.5, "reverse": true}"#).unwrap();
assert_eq!(screw, Coupling::Screw { pitch: Size::Length(1.5), reverse: true });
}
/// Ratios from the sizes: gears turn by their radii (the other way unless internal), a rack
/// and a screw travel per radian whichever of the two joints drives.
#[test]
fn coupling_ratios_follow_their_sizes() {
let mut doc = Document { assembly: chain(3, 0.0), ..Document::default() };
if let Some(j) = doc.assembly.joints.get_mut(2) {
j.kind = JointKind::Slider;
}
let st = ModelState::default();
let ratio = |a: u64, b: u64, c: Coupling| {
let l = MotionLink { a, ia: 0, b, ib: 0, ratio: 0.0, offset: 0.0, coupling: Some(c) };
coupling_ratio(&doc, &st, &l).map(|r| r.map_err(|e| e.to_string()))
};
let len = Size::Length;
assert_eq!(ratio(1, 2, Coupling::Gear { ra: len(10.0), rb: len(40.0), internal: false, reverse: false }), Some(Ok(-0.25)));
assert_eq!(ratio(1, 2, Coupling::Gear { ra: len(10.0), rb: len(40.0), internal: true, reverse: false }), Some(Ok(0.25)));
assert_eq!(ratio(1, 3, Coupling::RackPinion { radius: len(8.0), reverse: false }), Some(Ok(8.0)));
assert_eq!(ratio(3, 1, Coupling::RackPinion { radius: len(8.0), reverse: true }), Some(Ok(-0.125)));
let screw = ratio(1, 3, Coupling::Screw { pitch: len(std::f64::consts::TAU), reverse: false });
assert_eq!(screw, Some(Ok(1.0)));
assert!(matches!(ratio(1, 2, Coupling::RackPinion { radius: len(8.0), reverse: false }), Some(Err(_))));
assert!(matches!(ratio(1, 3, Coupling::Gear { ra: len(1.0), rb: len(1.0), internal: false, reverse: false }), Some(Err(_))));
for bad in [0.0, -2.0, f64::NAN, f64::INFINITY, 1e300] {
assert!(matches!(ratio(1, 2, Coupling::Gear { ra: len(bad), rb: len(1.0), internal: false, reverse: false }), Some(Err(_))), "{bad}");
}
assert!(matches!(ratio(1, 2, Coupling::Gear { ra: Size::Expr("nope".into()), rb: len(1.0), internal: false, reverse: false }), Some(Err(_))));
}
#[test]
fn motions_compose() {
assert!(close(&motion(JointKind::Revolute, &[std::f64::consts::FRAC_PI_2]), &rot_z(std::f64::consts::FRAC_PI_2)));
@@ -3,7 +3,7 @@
use std::collections::{BTreeMap, BTreeSet};
use solidcraft_doc::Document;
use solidcraft_doc::joints::{Assembly, JointOrigin, Snap};
use solidcraft_doc::joints::{Assembly, Coupling, JointOrigin, Size, Snap};
use crate::{EngineError, Result};
@@ -105,6 +105,14 @@ fn budget(assembly: &Assembly) -> Result<()> {
if !assembly.links.iter().all(|l| l.ratio.is_finite() && l.offset.is_finite()) {
return Err(invalid("motion link contains non-finite values"));
}
for size in assembly.links.iter().filter_map(|l| l.coupling.as_ref()).flat_map(Coupling::sizes) {
match size {
Size::Length(v) if !v.is_finite() => return Err(invalid("motion link contains non-finite values")),
Size::Expr(e) => text(e)?,
Size::Circle(o) => finite_origin(o)?,
Size::Length(_) => {}
}
}
if references > MAX_REFERENCES {
return Err(invalid("assembly references exceed the processing budget"));
}
@@ -291,9 +299,16 @@ pub(super) fn clone_assembly(
if !touched_joints.contains(&old.a) && !touched_joints.contains(&old.b) {
continue;
}
let mut item = old.clone();
// A gear's picked round faces are on occurrences of the copy too.
let faces = item.coupling.as_mut().is_none_or(|c| {
c.sizes_mut().into_iter().all(|size| match size {
Size::Circle(o) => origin(o, occurrences, features, bodies).map(|new| *o = new).is_some(),
_ => true,
})
});
match (joints.get(&old.a), joints.get(&old.b)) {
(Some(aa), Some(bb)) => {
let mut item = old.clone();
(Some(aa), Some(bb)) if faces => {
item.a = *aa;
item.b = *bb;
a.links.push(item);
@@ -1,6 +1,6 @@
#![allow(clippy::unwrap_used, clippy::panic)]
use super::*;
use solidcraft_doc::joints::{Joint, JointKind, MotionLink, NamedOrigin};
use solidcraft_doc::joints::{Coupling, Joint, JointKind, MotionLink, NamedOrigin, Size};
use solidcraft_doc::motion::{ContactSet, ExplodedView, MotionStudy, StudyKey};
use solidcraft_doc::{IDENTITY, Occurrence};
use solidcraft_geom::Vec3;
@@ -43,8 +43,10 @@ fn documents() -> (Document, Document) {
},
NamedOrigin { name: "Outside".into(), origin: at(9) },
];
source.assembly.links =
vec![MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.3 }, MotionLink { a: 1, ia: 0, b: 3, ib: 0, ratio: 1.0, offset: 0.0 }];
source.assembly.links = vec![
MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.3, coupling: None },
MotionLink { a: 1, ia: 0, b: 3, ib: 0, ratio: 1.0, offset: 0.0, coupling: None },
];
source.assembly.contact_sets = vec![
ContactSet { id: 1, name: "Internal contact".into(), occurrences: vec![1, 2], suppressed: true },
ContactSet { id: 2, name: "Cross contact".into(), occurrences: vec![1, 9], suppressed: false },
@@ -96,6 +98,29 @@ fn internal_relationships_receive_independent_ids_and_remapped_typed_references(
assert_eq!(source.assembly.joints[0].values[0], 0.25);
}
/// A gear's picked round face is on the copy's occurrence and face; one picked on geometry the
/// copy doesn't have is reported, not pointed back at the original.
#[test]
fn copied_gears_take_their_picked_faces_along() {
let (mut source, _) = documents();
let circle = |face: &str| Size::Circle(JointOrigin { occurrence: 1, snap: Snap::CircleCenter { pick: Vec3::X, face: Some(face.into()) } });
let gear = |ra| Coupling::Gear { ra, rb: Size::Length(5.0), internal: false, reverse: false };
source.assembly.links = vec![
MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.0, coupling: Some(gear(circle("F1:side"))) },
MotionLink { a: 2, ia: 0, b: 1, ib: 0, ratio: -0.5, offset: 0.0, coupling: Some(gear(circle("F99:side"))) },
];
let mut destination = source.clone();
destination.occurrences.extend([101, 102].map(occurrence));
let report =
clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 101), (2, 102)]), &BTreeMap::from([(1, 11), (10, 110)]), &BTreeMap::new())
.unwrap();
let copied = destination.assembly.links.get(2).and_then(|l| l.coupling.clone());
let Some(Coupling::Gear { ra: Size::Circle(o), .. }) = copied else { panic!("{:?}", destination.assembly.links) };
assert!(o.occurrence == 101 && matches!(&o.snap, Snap::CircleCenter { face: Some(f), .. } if f == "F11:side"), "{o:?}");
assert_eq!(destination.assembly.links.len(), 3, "the gear on foreign geometry stays behind");
assert!(report.iter().any(|m| m.contains("motion link")), "{report:?}");
}
#[test]
fn naming_remaps_exact_tokens_unicode_and_legacy_body_identity() {
let features = BTreeMap::from([(1, 21), (10, 30)]);
+205 -5
View File
@@ -7,7 +7,7 @@ use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use solidcraft_doc::expr::Kind;
use solidcraft_doc::joints::{self, Joint, JointKind, JointOrigin, MotionLink, NamedOrigin, Snap};
use solidcraft_doc::joints::{self, Coupling, Joint, JointKind, JointOrigin, MotionLink, NamedOrigin, Size, Snap};
use solidcraft_geom::{Aabb3, Vec3};
use super::CommandSpec;
@@ -40,6 +40,23 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "ASSEMBLE")
.icon("motion_link")
.params("a, b: joints; ratio (b = ratio · a + offset): b's units per a's (mm per deg for an angle driving a distance); offset?: in b's units (deg for an angle, mm for a distance, or an expression); ia?, ib?: value indices (default 0)"),
CommandSpec::new("joint.gear", "Gear", gear).at("SOLID", "ASSEMBLE").icon("gear").params(
"a, b: joints that turn (revolute), b driven by a; ra, rb: the gears' pitch radii (or tooth counts): mm, an expression, or \
{circle: [x,y,z], occurrence?}: the round face there, its radius (read again whenever the design changes, so the ratio follows \
edits); internal?: bool (one is a ring gear: both turn the same way; by default b turns the other way); reverse?: bool; \
link?: index (replace that motion link) → link, ratio (b's degrees per a's); b keeps where it is",
),
CommandSpec::new("joint.rack_pinion", "Rack and Pinion", rack_pinion).at("SOLID", "ASSEMBLE").icon("rack_pinion").params(
"a, b: a joint that turns (revolute: the pinion) and one that slides (slider: the rack), in either order, b driven by a; \
radius: the pinion's pitch radius (mm, an expression, or {circle: [x,y,z], occurrence?}): the rack travels radius × the turn in \
radians; reverse?: bool; link?: index (replace that motion link) → link, ratio (mm per degree, or degrees per mm); b keeps \
where it is",
),
CommandSpec::new("joint.screw", "Screw", screw).at("SOLID", "ASSEMBLE").icon("screw").params(
"joint: a cylindrical joint (its turn drives its travel) | a, b: a joint that turns and one that slides, b driven by a; pitch: \
travel per turn (mm or an expression); reverse?: bool (a left-hand thread); link?: index (replace that motion link) → link, \
ratio (mm per degree, or degrees per mm); b keeps where it is",
),
CommandSpec::new("joint.limits", "Joint Limits", limits).params("joint; index?: value index (default 0); min?, max? (omit both to clear)"),
CommandSpec::new("joint.edit", "Edit Joint", edit).params("joint; type?, flip?, offset?, angle?, suppressed?, name?"),
CommandSpec::new("joint.delete", "Delete Joint", delete).params("joint: id|name (or origin: name, link: index)"),
@@ -53,11 +70,40 @@ pub static COMMANDS: &[CommandSpec] = &[
),
];
/// Re-solve the joints and move the occurrences they place (after any edit).
/// Re-solve the joints and move the occurrences they place (after any edit). Gears, racks and
/// screws first take their ratios from the radii and pitches they are defined by.
pub fn resolve(s: &mut Session) -> joints::Solution {
recouple(s);
resolve_holding(s, &BTreeMap::new())
}
/// Work out the ratios of the coupled motion links again (a radius or a parameter may have
/// changed). A coupling that can't be worked out (its face gone) keeps its last ratio;
/// `joint.list` says why.
fn recouple(s: &mut Session) {
if s.doc.assembly.links.iter().all(|l| l.coupling.is_none()) {
return;
}
let st = s.model.state();
let changed: Vec<(usize, f64)> = s
.doc
.assembly
.links
.iter()
.enumerate()
.filter_map(|(i, l)| joints::coupling_ratio(&s.doc, &st, l)?.ok().filter(|r| *r != l.ratio).map(|r| (i, r)))
.collect();
if changed.is_empty() {
return;
}
let doc = s.doc_mut();
for (i, r) in changed {
if let Some(l) = doc.assembly.links.get_mut(i) {
l.ratio = r;
}
}
}
/// Re-solve the joints, keeping the `held` values (the ones being driven, mapped to the values
/// they move from) where loops are closed.
pub fn resolve_holding(s: &mut Session, held: &joints::Held) -> joints::Solution {
@@ -101,6 +147,14 @@ pub(crate) fn named_values(j: &Joint, q: &[f64]) -> Vec<Value> {
.collect()
}
/// A joint's values as the solver places it (after motion links, so a screw's travel follows its
/// turn), named as `joint.list` gives them.
pub(crate) fn effective_values(s: &Session, id: u64) -> Option<Vec<Value>> {
let j = s.doc.assembly.joints.iter().find(|j| j.id == id)?;
let vals = joints::joint_values(&s.doc.assembly);
Some(named_values(j, vals.get(&id).map(Vec::as_slice).unwrap_or(&j.values)))
}
fn occurrence_name(s: &Session, id: u64) -> String {
s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_else(|| id.to_string())
}
@@ -495,7 +549,7 @@ fn drive(s: &mut Session, p: &Value) -> Result<Value> {
let sol = joints::solve(&s.doc, &s.model.state());
let mut out = sol_json(&sol, p);
out["joint"] = json!(id);
out["values"] = json!(s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| named_values(j, &j.values)));
out["values"] = json!(effective_values(s, id));
out["stopped_by_contact"] = r["stopped_by_contact"].clone();
out["stopped_by_loop"] = r["stopped_by_loop"].clone();
out["moved"] = json!(super::motion::moved(&before, &super::motion::positions(s)).next().is_some());
@@ -526,11 +580,149 @@ fn motion_link(s: &mut Session, p: &Value) -> Result<Value> {
None => 0.0,
};
let ratio = ratio * ratio_scale(s, a, ia, b, ib);
s.doc_mut().assembly.links.push(MotionLink { a, ia, b, ib, ratio, offset });
s.doc_mut().assembly.links.push(MotionLink { a, ia, b, ib, ratio, offset, coupling: None });
let sol = resolve(s);
Ok(sol_json(&sol, p))
}
/// A coupling length: a number (mm), an expression (kept as typed, so it follows the
/// parameters), or `{circle: [x, y, z], occurrence?}`: the round face there, by its radius.
fn size_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<Size> {
match p.get(key) {
Some(Value::Number(n)) => n.as_f64().map(Size::Length).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite length"))),
Some(Value::String(t)) if t.len() <= 1000 => Ok(Size::Expr(t.trim().to_string())),
Some(o @ Value::Object(m)) if m.contains_key("circle") => Ok(Size::Circle(origin_param(s, Some(o), cmd)?)),
_ => Err(bad(cmd, format!("`{key}` must be a length (mm or an expression) or a round face ({{circle: [x, y, z]}})"))),
}
}
/// A joint's first value that turns (an angle), or that travels (a distance).
fn value_index(s: &Session, id: u64, turn: bool) -> Option<usize> {
let j = s.doc.assembly.joints.iter().find(|j| j.id == id)?;
(0..j.kind.dofs().len()).find(|i| j.kind.is_angle(*i) == turn)
}
/// The values a rack or a screw couples: a's turn and b's travel, or a's travel and b's turn.
fn turn_and_travel(s: &Session, a: u64, b: u64, cmd: &str) -> Result<(usize, usize)> {
match (value_index(s, a, true), value_index(s, b, false), value_index(s, a, false), value_index(s, b, true)) {
(Some(ia), Some(ib), ..) | (_, _, Some(ia), Some(ib)) => Ok((ia, ib)),
_ => Err(bad(cmd, "couple a joint that turns (revolute) with one that slides (slider)")),
}
}
/// Two different joints `a` and `b`.
fn joint_pair(s: &Session, p: &Value, cmd: &str) -> Result<(u64, u64)> {
let (a, b) = (joint_id(s, p.get("a"), cmd)?, joint_id(s, p.get("b"), cmd)?);
if a == b {
return Err(bad(cmd, "couple two different joints"));
}
Ok((a, b))
}
fn reverse(p: &Value) -> bool {
bool_(p, "reverse").unwrap_or(false)
}
fn gear(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.gear";
let (a, b) = joint_pair(s, p, cmd)?;
let turn = |id: u64| value_index(s, id, true).ok_or_else(|| bad(cmd, "gears need two joints that turn (revolute)"));
let (ia, ib) = (turn(a)?, turn(b)?);
let c = Coupling::Gear {
ra: size_param(s, p, "ra", cmd)?,
rb: size_param(s, p, "rb", cmd)?,
internal: bool_(p, "internal").unwrap_or(false),
reverse: reverse(p),
};
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c) })
}
fn rack_pinion(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.rack_pinion";
let (a, b) = joint_pair(s, p, cmd)?;
let (ia, ib) = turn_and_travel(s, a, b, cmd)?;
let c = Coupling::RackPinion { radius: size_param(s, p, "radius", cmd)?, reverse: reverse(p) };
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c) })
}
fn screw(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.screw";
let (a, ia, b, ib) = match p.get("joint") {
Some(v) => {
let id = joint_id(s, Some(v), cmd)?;
match (value_index(s, id, true), value_index(s, id, false)) {
(Some(ia), Some(ib)) => (id, ia, id, ib),
_ => return Err(bad(cmd, "a screw on one joint needs a joint that turns and slides (cylindrical); or give a and b")),
}
}
None => {
let (a, b) = joint_pair(s, p, cmd)?;
let (ia, ib) = turn_and_travel(s, a, b, cmd)?;
(a, ia, b, ib)
}
};
let c = Coupling::Screw { pitch: size_param(s, p, "pitch", cmd)?, reverse: reverse(p) };
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c) })
}
/// Add a coupling's motion link, or put it in place of link `link`: its ratio worked out from its
/// sizes, a new link's offset leaving b where it is (the teeth mesh as they are modelled).
fn couple(s: &mut Session, p: &Value, cmd: &str, mut l: MotionLink) -> Result<Value> {
let links = &s.doc.assembly.links;
let replace = match p.get("link") {
None => None,
Some(v) => {
Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < links.len()).ok_or_else(|| bad(cmd, "no such motion link"))?)
}
};
if replace.is_none() && links.len() >= joints::MAX_JOINTS {
return Err(bad(cmd, "too many motion links"));
}
let others: Vec<&MotionLink> = links.iter().enumerate().filter(|(i, _)| Some(*i) != replace).map(|(_, k)| k).collect();
let name = |id: u64| s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.name.clone()).unwrap_or_default();
if others.iter().any(|k| (k.b, k.ib) == (l.b, l.ib)) {
return Err(bad(cmd, format!("{} already follows a motion link: couple it the other way round (as a)", name(l.b))));
}
// The values that set a's, back through the links: b among them would make a cycle.
let mut at = (l.a, l.ia);
for _ in 0..=others.len() {
if at == (l.b, l.ib) {
return Err(bad(cmd, format!("{} already drives {} through motion links", name(l.b), name(l.a))));
}
match others.iter().find(|k| (k.b, k.ib) == at) {
Some(k) => at = (k.a, k.ia),
None => break,
}
}
l.ratio = joints::coupling_ratio(&s.doc, &s.model.state(), &l).unwrap_or(Ok(l.ratio)).map_err(|e| bad(cmd, e.to_string()))?;
let vals = joints::joint_values(&s.doc.assembly);
let value = |id: u64, i: usize| vals.get(&id).and_then(|q| q.get(i)).copied().unwrap_or(0.0);
// A link put in place of another keeps its offset, as a radius edited in the design does.
l.offset = match replace.and_then(|i| links.get(i)) {
Some(old) => old.offset,
None => value(l.b, l.ib) - l.ratio * value(l.a, l.ia),
};
let shown = l.ratio / ratio_scale(s, l.a, l.ia, l.b, l.ib);
let doc = s.doc_mut();
let index = match replace {
Some(i) => {
if let Some(slot) = doc.assembly.links.get_mut(i) {
*slot = l;
}
i
}
None => {
doc.assembly.links.push(l);
doc.assembly.links.len() - 1
}
};
let sol = resolve(s);
let mut out = sol_json(&sol, p);
out["link"] = json!(index);
out["ratio"] = json!(shown);
Ok(out)
}
fn is_angle(s: &Session, joint: u64, i: usize) -> bool {
s.doc.assembly.joints.iter().find(|j| j.id == joint).is_some_and(|j| j.kind.is_angle(i))
}
@@ -666,7 +858,15 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
let angle = is_angle(s, l.b, l.ib);
let offset = if angle { l.offset.to_degrees() } else { l.offset };
let ratio = l.ratio / ratio_scale(s, l.a, l.ia, l.b, l.ib);
json!({"a": l.a, "ia": l.ia, "b": l.b, "ib": l.ib, "ratio": ratio, "offset": offset, "unit": if angle { "deg" } else { "mm" }})
let mut v =
json!({"a": l.a, "ia": l.ia, "b": l.b, "ib": l.ib, "ratio": ratio, "offset": offset, "unit": if angle { "deg" } else { "mm" }});
if let Some(c) = &l.coupling {
v["coupling"] = json!(c);
if let Some(Err(e)) = joints::coupling_ratio(&s.doc, &st, l) {
v["error"] = json!(e.to_string());
}
}
v
})
.collect();
let origins: Vec<Value> = s.doc.assembly.origins.iter().map(|o| json!({"name": o.name, "occurrence": o.origin.occurrence})).collect();
@@ -0,0 +1,255 @@
//! Gears, racks and screws: motion links whose ratio comes from radii or a pitch. Expected values
//! are analytic: a gear train turns by the ratio of its radii, a rack travels r·θ, a screw
//! advances its pitch per turn.
use super::*;
use solidcraft_doc::joints::{Coupling, Size};
/// A joint's value as `joint.list` gives it (degrees or mm).
fn shown(s: &mut Session, joint: &str) -> f64 {
let l = run(s, "joint.list", json!({"quiet": true}));
let j = l["joints"].as_array().into_iter().flatten().find(|j| j["name"] == joint).cloned().unwrap_or_default();
j["values"][0]["value"].as_f64().unwrap_or(f64::NAN)
}
fn close_to(got: f64, want: f64) -> bool {
(got - want).abs() < 1e-6
}
/// A round disc (a gear's pitch circle) of radius `r` about a vertical axis through (x, 0), 5 mm
/// thick, in a component of its own, on a revolute joint to the grounded base.
fn disc(s: &mut Session, name: &str, x: f64, r: &str) {
run(s, "component.activate", json!({"component": "root"}));
run(s, "component.create", json!({"name": name}));
run(s, "solid.cylinder", json!({"base": [x, 0, 0], "axis": [0, 0, 1], "radius": r, "height": 5}));
pin(s, "revolute", name, "Base", name, Vec3::new(x, 0.0, 0.0), Vec3::Z);
}
fn base(s: &mut Session) {
part(s, "Base", [-60.0, -60.0, -20.0]);
let g = occ(s, "Base");
run(s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
}
/// Gears of pitch radii r1 (a parameter), 20 and 15 in a row: G1 meshes G2 (radii picked on their
/// discs), G2 meshes G3 (radii typed).
fn gear_train() -> Session {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "r1", "expression": "10 mm"}));
base(&mut s);
disc(&mut s, "G1", 0.0, "r1");
disc(&mut s, "G2", 30.0, "20 mm");
disc(&mut s, "G3", 65.0, "15 mm");
let r = run(
&mut s,
"joint.gear",
json!({"a": "G1", "b": "G2", "ra": {"occurrence": "G1", "circle": [10, 0, 2.5]}, "rb": {"occurrence": "G2", "circle": [50, 0, 2.5]}}),
);
assert_eq!((r["link"].clone(), r["ratio"].as_f64().map(|x| close_to(x, -0.5))), (json!(0), Some(true)), "{r}");
run(&mut s, "joint.gear", json!({"a": "G2", "b": "G3", "ra": 20, "rb": "15 mm"}));
s
}
/// A point on a disc's rim, where the disc is placed.
fn rim(s: &Session, name: &str, x: f64, r: f64) -> Vec3 {
place(s, name, Vec3::new(x + r, 0.0, 2.5))
}
fn turned(x: f64, r: f64, deg: f64) -> Vec3 {
let a = deg.to_radians();
Vec3::new(x + r * a.cos(), r * a.sin(), 2.5)
}
#[test]
fn a_gear_train_of_three_turns_by_its_radii() {
let mut s = gear_train();
let r = run(&mut s, "joint.drive", json!({"joint": "G1", "value": 90}));
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
// ω2 = −ω1·r1/r2, ω3 = −ω2·r2/r3 = ω1·r1/r3: external gears turn the other way.
assert!(close_to(shown(&mut s, "G2"), -45.0) && close_to(shown(&mut s, "G3"), 60.0), "{}", run(&mut s, "joint.list", json!({})));
for (name, x, r, deg) in [("G1", 0.0, 10.0, 90.0), ("G2", 30.0, 20.0, -45.0), ("G3", 65.0, 15.0, 60.0)] {
assert!(near(rim(&s, name, x, r), turned(x, r, deg)), "{name}: {:?}", rim(&s, name, x, r));
}
// The picked radius follows the design: G1 grows to 12, so G2 turns 90·12/20.
run(&mut s, "parameters.change", json!({"name": "r1", "expression": "12 mm"}));
assert!(close_to(shown(&mut s, "G2"), -54.0) && close_to(shown(&mut s, "G3"), 72.0), "{}", run(&mut s, "joint.list", json!({})));
let l = run(&mut s, "joint.list", json!({}));
assert!(close_to(l["links"][0]["ratio"].as_f64().unwrap_or(f64::NAN), -0.6), "{l}");
assert_eq!(l["links"][0]["coupling"]["type"], "gear", "{l}");
// A motion study playing G1 turns the train the same way.
run(&mut s, "motion.study", json!({"name": "Spin", "steps": 10, "keys": [{"joint": "G1", "points": [[0, 0], [10, 30]]}]}));
run(&mut s, "motion.play", json!({"study": "Spin", "step": 10}));
assert!(close_to(shown(&mut s, "G3"), 24.0), "{}", shown(&mut s, "G3"));
// An internal (ring) gear turns its mate the same way; redefining a link keeps its offset.
let r = run(&mut s, "joint.gear", json!({"a": "G2", "b": "G3", "ra": 20, "rb": 15, "internal": true, "link": 1}));
assert_eq!(r["link"], 1, "{r}");
run(&mut s, "joint.drive", json!({"joint": "G1", "value": 60}));
assert!(close_to(shown(&mut s, "G2"), -36.0) && close_to(shown(&mut s, "G3"), -48.0), "{}", run(&mut s, "joint.list", json!({})));
// Undo takes the redefinition back.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.assembly.links.get(1).and_then(|l| l.coupling.clone()).map(|c| matches!(c, Coupling::Gear { internal: false, .. })), Some(true));
}
#[test]
fn gears_save_and_open_with_their_definition() {
let mut s = gear_train();
run(&mut s, "joint.drive", json!({"joint": "G1", "value": 30}));
let text = s.doc.to_json();
let back = solidcraft_doc::format::read(&text).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(back.assembly.links, s.doc.assembly.links);
assert!(matches!(back.assembly.links.first().and_then(|l| l.coupling.as_ref()), Some(Coupling::Gear { ra: Size::Circle(_), .. })));
// A link saved before couplings (no `coupling`) opens as a plain link.
let mut v: Value = serde_json::from_str(&text).unwrap_or_default();
for l in v["assembly"]["links"].as_array_mut().into_iter().flatten() {
l.as_object_mut().map(|o| o.remove("coupling"));
}
let old = solidcraft_doc::format::read(&v.to_string()).unwrap_or_else(|e| panic!("{e}"));
assert!(old.assembly.links.iter().all(|l| l.coupling.is_none()) && old.assembly.links.len() == 2);
}
/// A pinion of radius 8 about +Z at the origin, a rack under it sliding along +X, and a stop the
/// rack runs into after 10 mm.
fn rack() -> Session {
let mut s = Session::default();
base(&mut s);
disc(&mut s, "Pinion", 0.0, "8 mm");
for (name, corner, length) in [("Rack", [-20.0, -14.0, 0.0], 40.0), ("Stop", [30.0, -14.0, 0.0], 4.0)] {
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": length, "width": 4, "height": 4, "corner": corner}));
}
pin(&mut s, "slider", "Rail", "Base", "Rack", Vec3::new(0.0, -10.0, 0.0), Vec3::X);
pin(&mut s, "rigid", "Stop", "Base", "Stop", Vec3::new(30.0, -10.0, 0.0), Vec3::Z);
s
}
#[test]
fn a_rack_travels_r_times_theta() {
let mut s = rack();
let r = run(&mut s, "joint.rack_pinion", json!({"a": "Pinion", "b": "Rail", "radius": {"occurrence": "Pinion", "circle": [8, 0, 2]}}));
// Per degree in the answer, as joint.list gives a link between an angle and a distance.
assert!(close_to(r["ratio"].as_f64().unwrap_or(f64::NAN), 8f64.to_radians()), "{r}");
run(&mut s, "joint.drive", json!({"joint": "Pinion", "value": 45}));
let travel = 8.0 * std::f64::consts::FRAC_PI_4;
assert!(close_to(shown(&mut s, "Rail"), travel), "{}", shown(&mut s, "Rail"));
// The pinion turns counter-clockwise about +Z; its bottom, where the rack is, moves along +X.
assert!(near(place(&s, "Rack", Vec3::new(-20.0, -14.0, 0.0)), Vec3::new(-20.0 + travel, -14.0, 0.0)));
// Contact sets hold the rack at the stop, and the pinion with it: 10 mm is 10/8 rad.
run(&mut s, "contact.create", json!({"occurrences": ["Rack", "Stop"]}));
let r = run(&mut s, "joint.drive", json!({"joint": "Pinion", "value": 180}));
assert!(r["stopped_by_contact"].is_object(), "{r}");
let (angle, travel) = (shown(&mut s, "Pinion"), shown(&mut s, "Rail"));
assert!((angle.to_radians() - 1.25).abs() < 0.01, "{angle}");
assert!(close_to(travel, 8.0 * angle.to_radians()), "{travel} at {angle}°");
// Either may drive: the rack turning the pinion, reversed.
let mut s = rack();
let r = run(&mut s, "joint.rack_pinion", json!({"a": "Rail", "b": "Pinion", "radius": "8 mm", "reverse": true}));
assert!(close_to(r["ratio"].as_f64().unwrap_or(f64::NAN), -1.0 / 8f64.to_radians()), "{r}");
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 5}));
assert!(close_to(shown(&mut s, "Pinion"), -(5.0f64 / 8.0).to_degrees()), "{}", shown(&mut s, "Pinion"));
}
#[test]
fn a_screw_advances_its_pitch_per_turn() {
let mut s = Session::default();
base(&mut s);
part(&mut s, "Nut", [-1.0, -1.0, 0.0]);
pin(&mut s, "cylindrical", "Thread", "Base", "Nut", Vec3::ZERO, Vec3::Z);
run(&mut s, "joint.screw", json!({"joint": "Thread", "pitch": 1.5}));
let r = run(&mut s, "joint.drive", json!({"joint": "Thread", "values": [720]}));
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
let travel = r["values"][1]["value"].as_f64().unwrap_or(f64::NAN);
assert!(close_to(travel, 3.0), "{r}");
assert!(near(place(&s, "Nut", Vec3::new(-1.0, -1.0, 0.0)), Vec3::new(-1.0, -1.0, 3.0)));
// A left-hand thread goes the other way.
run(&mut s, "joint.screw", json!({"joint": "Thread", "pitch": 1.5, "reverse": true, "link": 0}));
let r = run(&mut s, "joint.drive", json!({"joint": "Thread", "values": [360]}));
assert!(close_to(r["values"][1]["value"].as_f64().unwrap_or(f64::NAN), -1.5), "{r}");
// A revolute joint and a slider, the pitch a parameter: changing it changes the lead.
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "lead", "expression": "2 mm"}));
base(&mut s);
part(&mut s, "Shaft", [-1.0, -1.0, 0.0]);
part(&mut s, "Carriage", [10.0, -1.0, 0.0]);
pin(&mut s, "revolute", "Spin", "Base", "Shaft", Vec3::ZERO, Vec3::Z);
pin(&mut s, "slider", "Lift", "Base", "Carriage", Vec3::new(11.0, 0.0, 0.0), Vec3::Z);
run(&mut s, "joint.screw", json!({"a": "Spin", "b": "Lift", "pitch": "lead"}));
run(&mut s, "joint.drive", json!({"joint": "Spin", "value": 900}));
assert!(close_to(shown(&mut s, "Lift"), 5.0), "{}", shown(&mut s, "Lift"));
run(&mut s, "parameters.change", json!({"name": "lead", "expression": "4 mm"}));
assert!(close_to(shown(&mut s, "Lift"), 10.0), "{}", shown(&mut s, "Lift"));
}
/// A motor gear turns the four-bar's crank: half as fast, the other way round, and the loop
/// follows.
#[test]
fn a_gear_drives_a_closed_loop() {
let (mut s, _, q0) = four_bar();
part(&mut s, "Motor", [80.0, -1.0, -5.0]);
pin(&mut s, "revolute", "Motor", "Ground", "Motor", Vec3::new(80.0, 0.0, 0.0), Vec3::Z);
run(&mut s, "joint.gear", json!({"a": "Motor", "b": "Crank", "ra": 10, "rb": 20}));
let r = run(&mut s, "joint.drive", json!({"joint": "Motor", "value": -120}));
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
assert!(r["stopped_by_loop"].is_null(), "{r}");
assert!(close_to(shown(&mut s, "Crank"), 60.0), "{}", shown(&mut s, "Crank"));
assert!(near(place(&s, "Rocker", q0), rocker_pin(0.0, 60.0, q0)), "{:?}", place(&s, "Rocker", q0));
}
/// A pinion moves the piston of a slider-crank: the piston travels r·θ along its bore and the
/// crank follows, the rod keeping its length.
#[test]
fn a_rack_drives_a_slider_crank_from_its_piston() {
let (mut s, p0, s0) = slider_crank();
part(&mut s, "Pinion", [0.0, -30.0, -5.0]);
pin(&mut s, "revolute", "Pinion", "Ground", "Pinion", Vec3::new(0.0, -30.0, 0.0), Vec3::Z);
run(&mut s, "joint.rack_pinion", json!({"a": "Pinion", "b": "Bore", "radius": 5}));
let r = run(&mut s, "joint.drive", json!({"joint": "Pinion", "value": 60}));
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
assert!(r["stopped_by_loop"].is_null(), "{r}");
let travel = 5.0 * 60f64.to_radians();
assert!(close_to(shown(&mut s, "Bore"), travel), "{}", shown(&mut s, "Bore"));
let wrist = place(&s, "Piston", s0);
assert!(near(wrist, s0 + Vec3::X * travel), "{wrist:?}");
let pin_at = place(&s, "Rod", p0);
assert!((pin_at.dist(wrist) - 40.0).abs() < 1e-6 && (pin_at.len() - 15.0).abs() < 1e-6, "{pin_at:?}");
}
#[test]
fn couplings_refuse_what_they_cannot_couple() {
let mut s = gear_train();
let refused = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).err().map(|e| e.to_string()).unwrap_or_default();
// G3 already follows G2; a second writer, or a cycle back to G1, is refused.
assert!(refused(&mut s, "joint.gear", json!({"a": "G1", "b": "G3", "ra": 1, "rb": 1})).contains("already follows"));
assert!(refused(&mut s, "joint.gear", json!({"a": "G3", "b": "G1", "ra": 1, "rb": 1})).contains("already drives"));
assert!(refused(&mut s, "joint.gear", json!({"a": "G1", "b": "G1", "ra": 1, "rb": 1})).contains("two different"));
// A gear needs turns, a rack a turn and a travel.
assert!(refused(&mut s, "joint.rack_pinion", json!({"a": "G1", "b": "G3", "radius": 5})).contains("slides"));
// Hostile sizes and indices never panic and change nothing.
let before = s.doc.assembly.clone();
for bad in [
json!(-3),
json!(0),
json!(1e300),
json!("nope"),
json!("1/0"),
json!({"circle": [1e308, 0, 0]}),
json!({"circle": "x"}),
json!([1]),
Value::Null,
] {
for (id, p) in [
("joint.gear", json!({"a": "G3", "b": "G1", "ra": bad, "rb": 1})),
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 1, "rb": bad, "link": 0})),
("joint.rack_pinion", json!({"a": "G1", "b": "G2", "radius": bad})),
("joint.screw", json!({"joint": "G1", "pitch": bad})),
] {
assert!(s.execute(id, &p).is_err(), "{id} {p}");
}
}
for bad in [json!(-1), json!(2), json!(1.5), json!(1e300), json!("0"), json!([0]), Value::Null] {
let p = json!({"a": "G1", "b": "G2", "ra": 1, "rb": 1, "link": bad});
assert!(s.execute("joint.gear", &p).is_err(), "{p}");
}
assert_eq!(s.doc.assembly, before);
}
+3
View File
@@ -1178,3 +1178,6 @@ fn a_new_joint_starts_inside_its_limits_and_says_so() {
let r = run(&mut s, "joint.as_built", json!({"type": "cylindrical", "a": "A", "b": "B", "name": "Spindle", "limits": [[-30, 30], [0, 5]]}));
assert!(r.get("warnings").is_none(), "{r}");
}
#[path = "joints_coupling_tests.rs"]
mod coupling;
+2 -5
View File
@@ -10,7 +10,7 @@ use solidcraft_geom::Vec3;
use solidcraft_kernel::Body;
use super::CommandSpec;
use super::joints::{cannot_move, joint_id, named_values, occurrence_id, resolve_holding, value};
use super::joints::{cannot_move, effective_values, joint_id, occurrence_id, resolve_holding, value};
use crate::params::{bad, bool_, str_, vec3};
use crate::{EngineError, Result, Session};
@@ -350,10 +350,7 @@ fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)], given: &BTreeSe
stopped = None;
stopped_by_loop = Some(json!({"joint": name, "fraction": lo}));
}
let values: Vec<Value> = want
.iter()
.map(|(id, _)| json!({"joint": id, "values": s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| named_values(j, &j.values))}))
.collect();
let values: Vec<Value> = want.iter().map(|(id, _)| json!({"joint": id, "values": effective_values(s, *id)})).collect();
let out = json!({"values": values, "stopped_by_contact": stopped, "stopped_by_loop": stopped_by_loop, "conflicts": sol.conflicts.len()});
Ok((out, sol))
}
+27 -4
View File
@@ -1,5 +1,5 @@
//! Assembly dialogs: New Component, Joint, As-built Joint, Joint Origin, Rigid Group, Drive
//! Joints, Motion Link and Interference. Joint origins are picked by hovering geometry: a face
//! Joints, Motion Link, Gear, Rack and Pinion, Screw (`dialogs_coupling`) and Interference. Joint origins are picked by hovering geometry: a face
//! snaps to its centre (or a round face to the centre of its nearer end), an edge to its middle
//! (a round edge to its centre), a vertex to itself; the snap shows as a glyph with its z axis
//! before the click. Joints and drives preview on the placed (world) model: the moving
@@ -27,8 +27,19 @@ const TYPE_LABELS: [&str; 7] = ["Rigid", "Revolute", "Slider", "Cylindrical", "P
/// What a joint origin input accepts (snaps on faces, edges and vertices).
const SNAPS: u16 = FACES | EDGES | VERTICES;
/// Commands whose effect is where occurrences sit (previewed on the placed model).
const MOVES: [&str; 8] =
["joint.create", "joint.as_built", "joint.drive", "joint.edit", "joint.limits", "joint.rigid_group", "parts.insert", "parts.fastener"];
const MOVES: [&str; 11] = [
"joint.create",
"joint.as_built",
"joint.drive",
"joint.edit",
"joint.limits",
"joint.rigid_group",
"joint.gear",
"joint.rack_pinion",
"joint.screw",
"parts.insert",
"parts.fastener",
];
#[derive(Clone, Debug)]
pub enum Asm {
@@ -56,6 +67,7 @@ pub enum Asm {
ratio: String,
offset: String,
},
Coupling(Box<crate::dialogs_coupling::CouplingForm>),
Interference {
result: Option<Value>,
overlaps: Vec<Mesh>,
@@ -128,12 +140,17 @@ impl Asm {
Asm::RigidGroup => "RIGID GROUP",
Asm::Drive { .. } => "DRIVE JOINTS",
Asm::MotionLink { .. } => "MOTION LINK",
Asm::Coupling(f) => match f.command {
"joint.gear" => "GEAR",
"joint.rack_pinion" => "RACK AND PINION",
_ => "SCREW",
},
Asm::Interference { .. } => "INTERFERENCE",
}
}
pub fn previews(&self) -> bool {
matches!(self, Asm::Joint(_) | Asm::Drive { .. } | Asm::RigidGroup)
matches!(self, Asm::Joint(_) | Asm::Drive { .. } | Asm::RigidGroup | Asm::Coupling(_))
}
}
@@ -166,6 +183,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
let b = usize::from(movable(s).len() > 1);
(Asm::MotionLink { a: 0, b, ia: 0, ib: 0, ratio: "1".into(), offset: "0".into() }, vec![])
}
"joint.gear" | "joint.rack_pinion" | "joint.screw" => crate::dialogs_coupling::start(app, id)?,
"inspect.interference" => {
(Asm::Interference { result: None, overlaps: Vec::new(), of: Vec::new() }, vec![SelInput::new("Bodies (all if none)", BODIES, true)])
}
@@ -657,6 +675,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
enter |= field(ui, offset);
ui.end_row();
}
Asm::Coupling(f) => enter |= crate::dialogs_coupling::rows(app, ui, f, inputs),
Asm::Interference { result, overlaps, of } => {
let picked: Vec<Sel> = inputs.first().map(|i| i.items.clone()).unwrap_or_default();
row_label(ui, "");
@@ -883,6 +902,7 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result<Vec<(Str
let ratio = eval(ratio, ValueKind::Unitless)?;
cmd("joint.motion_link", json!({"a": ja.id, "b": jb.id, "ia": ia, "ib": ib, "ratio": ratio, "offset": offset.trim()}))
}
Asm::Coupling(f) => return crate::dialogs_coupling::commands(app, f, inputs),
// The check runs from its Compute button; OK only closes.
Asm::Interference { .. } => Vec::new(),
})
@@ -890,6 +910,9 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result<Vec<(Str
/// The preview's commands: OK's, with the joint's values playing its motion while animating.
pub fn animate(app: &SolveApp, k: &Asm, cmds: &mut Vec<(String, Value)>) {
if let Asm::Coupling(f) = k {
crate::dialogs_coupling::animate(&app.session, f, cmds);
}
let Asm::Joint(f) = k else { return };
if !f.animate {
return;
+279
View File
@@ -0,0 +1,279 @@
//! The Gear, Rack and Pinion and Screw dialogs: two joints coupled by radii or a pitch
//! (`joint.gear`, `joint.rack_pinion`, `joint.screw`). A radius is typed, or comes from the round
//! face picked on the part, and then follows that face when the design changes. Picking a gear's
//! face also picks the joint it turns on. Play turns the driving joint so the coupling shows its
//! motion before OK.
use egui::RichText;
use serde_json::{Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::Session;
use solidcraft_engine::doc::expr::Kind as ValueKind;
use crate::SolveApp;
use crate::dialogs::{check, combo, field, row_label};
use crate::dialogs_assembly::Asm;
use crate::selection::{EDGES, FACES, SelInput};
use crate::theme::Tokens;
/// What a round-face input takes: a face, or an edge of one.
const ROUND: u16 = FACES | EDGES;
#[derive(Clone, Debug)]
pub struct CouplingForm {
/// `joint.gear`, `joint.rack_pinion` or `joint.screw`.
pub command: &'static str,
/// The driving joint and the driven one (ids; the same cylindrical joint for a screw on one).
pub a: u64,
pub b: u64,
/// Typed sizes, used where no round face is picked: gear 1's and gear 2's radii, the pinion's
/// radius, or the screw's pitch.
pub sizes: [String; 2],
pub internal: bool,
pub reverse: bool,
/// The bodies of the picks the joints were last chosen from, so a pick chooses once.
pub picked: [Option<String>; 2],
pub animate: bool,
/// Animation phase, 0…1.
pub phase: f64,
}
/// Joints with a value that turns (`turn`) or slides: (id, label).
fn joints_that(s: &Session, turn: bool) -> Vec<(u64, String)> {
s.doc
.assembly
.joints
.iter()
.filter(|j| (0..j.kind.dofs().len()).any(|i| j.kind.is_angle(i) == turn))
.map(|j| (j.id, format!("{} ({:?})", j.name, j.kind)))
.collect()
}
/// The joint roles and whether the driven one turns (a gear) or slides.
fn roles(command: &str) -> ([&'static str; 2], bool) {
match command {
"joint.gear" => (["Gear 1", "Gear 2"], true),
"joint.rack_pinion" => (["Pinion", "Rack"], false),
_ => (["Screw", "Nut"], false),
}
}
pub fn start(app: &SolveApp, id: &str) -> Option<(Asm, Vec<SelInput>)> {
let s = &app.session;
let (_, turn_b) = roles(id);
let (turns, b_list) = (joints_that(s, true), joints_that(s, turn_b));
let first = |l: &[(u64, String)]| l.first().map(|j| j.0).unwrap_or(0);
let (command, a, b, sizes, inputs) = match id {
"joint.gear" => (
"joint.gear",
first(&turns),
turns.get(1).map(|j| j.0).unwrap_or(0),
["10 mm".to_string(), "20 mm".to_string()],
vec![SelInput::new("Gear 1 (optional)", ROUND, false), SelInput::new("Gear 2 (optional)", ROUND, false)],
),
"joint.rack_pinion" => (
"joint.rack_pinion",
first(&turns),
first(&b_list),
["10 mm".to_string(), String::new()],
vec![SelInput::new("Pinion (optional)", ROUND, false)],
),
"joint.screw" => {
// A joint that turns and slides is a screw on its own.
let both = turns.iter().find(|t| b_list.iter().any(|l| l.0 == t.0)).map(|t| t.0);
let a = both.unwrap_or(first(&turns));
("joint.screw", a, both.unwrap_or(first(&b_list)), ["1.5 mm".to_string(), String::new()], vec![])
}
_ => return None,
};
let f = CouplingForm { command, a, b, sizes, internal: false, reverse: false, picked: [None, None], animate: false, phase: 0.0 };
Some((Asm::Coupling(Box::new(f)), inputs))
}
/// The occurrence placing a picked body (0: the root), by the body's placement: each instance of
/// a copied gear is an occurrence of its own.
fn occurrence(s: &Session, body: &str) -> Option<u64> {
s.doc.body_frames(&s.model.state()).into_iter().find(|f| f.0 == body).map(|(_, _, path, _)| path.last().copied().unwrap_or(0))
}
fn pick(inputs: &[SelInput], i: usize) -> Option<(&str, solidcraft_engine::geom::Vec3)> {
match inputs.get(i)?.items.first()? {
Sel::Face { body, point, .. } | Sel::Edge { body, point, .. } => Some((body.as_str(), *point)),
_ => None,
}
}
/// The radius of the round face picked in input `i`.
fn picked_radius(s: &Session, inputs: &[SelInput], i: usize) -> Option<f64> {
let (body, point) = pick(inputs, i)?;
let st = s.world_state();
solidcraft_engine::kernel::cylinder_face_at(&st.body(body)?.body, point).map(|c| c.radius)
}
/// Size `i` in mm: the picked face's radius, or the typed one.
fn size(s: &Session, f: &CouplingForm, inputs: &[SelInput], i: usize) -> Option<f64> {
picked_radius(s, inputs, i).or_else(|| s.doc.eval(f.sizes.get(i)?, ValueKind::Length).ok()).filter(|v| v.is_finite() && *v > 0.0)
}
/// A joint row: the joints `list` offers, `sel` the chosen id.
fn joint_row(ui: &mut egui::Ui, label: &str, salt: &str, list: &[(u64, String)], sel: &mut u64) {
let labels: Vec<&str> = list.iter().map(|j| j.1.as_str()).collect();
let mut pos = list.iter().position(|j| j.0 == *sel).unwrap_or(0);
row_label(ui, label);
combo(ui, salt, &labels, &mut pos);
ui.end_row();
*sel = list.get(pos).map(|j| j.0).unwrap_or(0);
}
/// The dialog's rows; true when Enter was pressed in a value.
pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, f: &mut CouplingForm, inputs: &[SelInput]) -> bool {
let t = Tokens::get();
let s = &app.session;
let ([role_a, role_b], turn_b) = roles(f.command);
let (la, lb) = (joints_that(s, true), joints_that(s, turn_b));
if la.is_empty() || lb.is_empty() {
row_label(ui, "");
let need = if turn_b { "needs two joints that turn" } else { "needs a joint that turns and one that slides" };
ui.label(RichText::new(crate::i18n::tr_label(need)).color(t.text_dim));
ui.end_row();
return false;
}
// A face picked on a part chooses the joint that part turns on, once.
for (i, slot) in [&mut f.a, &mut f.b].into_iter().enumerate() {
let body = pick(inputs, i).map(|(b, _)| b.to_string());
if body.is_some() && body != f.picked.get(i).cloned().flatten() {
let turning = s.doc.assembly.joints.iter().filter(|j| la.iter().any(|l| l.0 == j.id));
if let Some(j) = body
.as_deref()
.and_then(|b| occurrence(s, b))
.and_then(|o| turning.clone().find(|j| j.b.occurrence == o).or(turning.clone().find(|j| j.a.occurrence == o)))
{
*slot = j.id;
}
}
if let Some(p) = f.picked.get_mut(i) {
*p = body;
}
}
let mut enter = false;
let size_row = |ui: &mut egui::Ui, label: &str, i: usize, text: &mut String, enter: &mut bool| {
row_label(ui, label);
match (pick(inputs, i), picked_radius(s, inputs, i)) {
(_, Some(r)) => {
ui.label(format!("{} mm ({})", trim(r), crate::i18n::tr_label("picked")));
}
// A pick on a flat face or a straight edge has no radius: say so rather than fail on OK.
(Some(_), None) => {
ui.label(RichText::new(crate::i18n::tr_label("not a round face")).color(t.warning));
}
(None, None) => *enter |= field(ui, text),
}
ui.end_row();
};
joint_row(ui, role_a, "cp_a", &la, &mut f.a);
let [s0, s1] = &mut f.sizes;
match f.command {
"joint.gear" => {
size_row(ui, "Radius 1", 0, s0, &mut enter);
joint_row(ui, role_b, "cp_b", &lb, &mut f.b);
size_row(ui, "Radius 2", 1, s1, &mut enter);
row_label(ui, "Internal gear");
check(ui, &mut f.internal);
ui.end_row();
}
"joint.rack_pinion" => {
size_row(ui, "Radius", 0, s0, &mut enter);
joint_row(ui, role_b, "cp_b", &lb, &mut f.b);
}
_ => {
joint_row(ui, role_b, "cp_b", &lb, &mut f.b);
row_label(ui, "Pitch");
enter |= field(ui, s0);
ui.end_row();
}
}
row_label(ui, "Reverse");
check(ui, &mut f.reverse);
ui.end_row();
// What the coupling does, as joint.list gives a link's ratio.
let sign = |neg: bool| if neg { -1.0 } else { 1.0 };
let shown = match f.command {
"joint.gear" => size(s, f, inputs, 0)
.zip(size(s, f, inputs, 1))
.map(|(ra, rb)| format!("{} deg / deg", trim(ra / rb * sign(!f.internal) * sign(f.reverse)))),
"joint.rack_pinion" => size(s, f, inputs, 0).map(|r| format!("{} mm / deg", trim(r.to_radians() * sign(f.reverse)))),
_ => size(s, f, inputs, 0).map(|p| format!("{} mm / deg", trim(p / 360.0 * sign(f.reverse)))),
};
row_label(ui, "Ratio");
ui.label(RichText::new(shown.unwrap_or_else(|| "-".into())).color(t.text_dim));
ui.end_row();
row_label(ui, "Animate");
let label = if f.animate { "■ Stop" } else { "▶ Play" };
let r = ui.button(label).on_hover_text(crate::i18n::tr_label("Play the joint's motion in the view"));
crate::scenario::publish_handle("dialog:Animate", r.rect.center());
if r.clicked() {
f.animate = !f.animate;
}
ui.end_row();
if f.animate {
let now = ui.input(|i| i.time);
// 48 steps over 4 s: each one a preview.
f.phase = ((now / 4.0).fract() * 48.0).floor() / 48.0;
ui.ctx().request_repaint_after(std::time::Duration::from_millis(30));
}
enter
}
/// A number without trailing zeros.
fn trim(v: f64) -> String {
let s = format!("{v:.4}");
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".into() } else { s.into() }
}
/// The command OK runs.
pub fn commands(app: &SolveApp, f: &CouplingForm, inputs: &[SelInput]) -> Result<Vec<(String, Value)>, String> {
let s = &app.session;
let joint = |id: u64| s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.id).ok_or_else(|| "choose the joints to couple".to_string());
let (a, b) = (joint(f.a)?, joint(f.b)?);
// A picked face, or the typed size.
let size = |i: usize| -> Value {
match pick(inputs, i) {
Some((body, p)) => {
let mut v = json!({"circle": [p.x, p.y, p.z]});
if let Some(o) = occurrence(s, body) {
v["occurrence"] = json!(o);
}
v
}
None => json!(f.sizes.get(i).map(|t| t.trim()).unwrap_or("")),
}
};
let mut p = match f.command {
"joint.gear" => json!({"a": a, "b": b, "ra": size(0), "rb": size(1), "internal": f.internal}),
"joint.rack_pinion" => json!({"a": a, "b": b, "radius": size(0)}),
_ if a == b => json!({"joint": a, "pitch": f.sizes[0].trim()}),
_ => json!({"a": a, "b": b, "pitch": f.sizes[0].trim()}),
};
p["reverse"] = json!(f.reverse);
Ok(vec![(f.command.to_string(), p)])
}
/// While animating, the preview turns the driving joint a full turn: its first value that turns
/// (a planar joint's is its third), the values before it as they are.
pub fn animate(s: &Session, f: &CouplingForm, cmds: &mut Vec<(String, Value)>) {
let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == f.a).filter(|_| f.animate) else { return };
let Some(turn) = (0..j.kind.dofs().len()).find(|i| j.kind.is_angle(*i)) else { return };
let values: Vec<String> = (0..=turn)
.map(|i| match j.values.get(i).copied().unwrap_or(0.0) {
_ if i == turn => format!("{} deg", f.phase * 360.0),
v if j.kind.is_angle(i) => format!("{} deg", v.to_degrees()),
v => format!("{v} mm"),
})
.collect();
cmds.push(("joint.drive".into(), json!({"joint": f.a, "values": values})));
}
#[cfg(test)]
#[path = "dialogs_coupling_tests.rs"]
mod tests;
@@ -0,0 +1,198 @@
use serde_json::json;
use solidcraft_engine::Session;
use solidcraft_engine::geom::Vec3;
use super::*;
use crate::Services;
use crate::dialogs::{Kind, apply_commands, preview_commands};
/// A grounded base with discs G1 (radius 10 at x = 0) and G2 (radius 20 at x = 30) turning on
/// revolute joints about +Z, and a nut on a cylindrical joint.
fn discs() -> SolveApp {
let mut s = Session::default();
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
for (name, base, r) in [("Base", [0, -60, -20], 2), ("G1", [0, 0, 0], 10), ("G2", [30, 0, 0], 20), ("Nut", [0, 60, 0], 3)] {
run("component.activate", json!({"component": "root"}));
run("component.create", json!({ "name": name }));
run("solid.cylinder", json!({"base": base, "axis": [0, 0, 1], "radius": r, "height": 5}));
}
run("component.activate", json!({"component": "root"}));
run("occurrence.ground", json!({"occurrence": "Base:1", "grounded": true}));
for (kind, name, x, y) in [("revolute", "G1", 0, 0), ("revolute", "G2", 30, 0), ("cylindrical", "Nut", 0, 60)] {
let at = |o: &str| json!({"occurrence": o, "point": [x, y, 0], "z": [0, 0, 1]});
run("joint.create", json!({"type": kind, "a": at("Base"), "b": at(name), "flip": true, "name": name}));
}
SolveApp::new(s, Services::default())
}
/// A pick of the round wall a point of the exact geometry lies on (the facets of the wall's mesh
/// cut inside it, so the nearest face of any body).
fn wall(app: &SolveApp, p: Vec3) -> Sel {
let st = app.session.world_state();
let mut best: Option<(f64, Sel)> = None;
for b in &st.bodies {
let m = b.mesh();
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
let Some([a, bb, c]) = m.tri(t) else { continue };
let d = crate::preview::point_triangle_dist(p, a, bb, c);
if best.as_ref().is_none_or(|(bd, _)| d < *bd) {
best = Some((d, Sel::Face { body: b.name.clone(), index: *f as usize, point: p }));
}
}
}
best.map(|x| x.1).unwrap()
}
fn joint(app: &SolveApp, name: &str) -> u64 {
app.session.doc.assembly.joints.iter().find(|j| j.name == name).map(|j| j.id).unwrap_or(0)
}
/// The text of every text shape in a frame's output.
fn texts(out: &egui::FullOutput) -> Vec<String> {
out.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Text(t) => Some(t.galley.text().to_string()),
_ => None,
})
.collect()
}
/// Picking a gear's round face chooses the joint it turns on and takes its radius; the ratio
/// row says what the pair does, and OK makes the coupling.
#[test]
fn picked_gear_faces_choose_their_joints_and_radii() {
let mut app = discs();
app.start("joint.gear");
let mut d = app.dialog.take().unwrap();
// G2 first, G1 second: the joints follow the picks, whatever the dialog started on.
for (i, p) in [Vec3::new(50.0, 0.0, 2.5), Vec3::new(10.0, 0.0, 2.5)].into_iter().enumerate() {
d.active = i;
let pick = wall(&app, p);
d.pick(&app.session, pick);
}
app.dialog = Some(d);
let ctx = egui::Context::default();
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
let frame = |app: &mut SolveApp| {
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
let mut out = ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx()));
// Nothing is painted: drop the texture changes (an unapplied delta panics on drop).
out.textures_delta.clear();
out
};
frame(&mut app);
let out = frame(&mut app);
let shown = texts(&out);
assert!(shown.iter().any(|t| t == "-2 deg / deg"), "{shown:?}");
assert!(shown.iter().any(|t| t.starts_with("20 mm")), "{shown:?}");
let d = app.dialog.as_ref().expect("the dialog stays open");
let c = apply_commands(&app, d).unwrap();
let (id, p) = &c[0];
assert_eq!((id.as_str(), p["a"].clone(), p["b"].clone()), ("joint.gear", json!(joint(&app, "G2")), json!(joint(&app, "G1"))), "{p}");
assert!(p["ra"]["circle"].is_array() && p["rb"]["occurrence"].is_u64(), "{p}");
// Play turns the driving gear in the preview.
let mut d = d.clone();
if let Kind::Assembly(Asm::Coupling(f)) = &mut d.kind {
f.animate = true;
f.phase = 0.25;
}
let cmds = preview_commands(&app, &d).unwrap();
assert_eq!(cmds.get(1).map(|c| c.1["values"].clone()), Some(json!(["90 deg"])), "{cmds:?}");
for (id, p) in c {
app.run(&id, p).unwrap();
}
let l = app.session.doc.assembly.links.first().cloned().unwrap();
assert!((l.ratio + 2.0).abs() < 1e-9, "{l:?}");
}
/// Two instances of one gear (a copied component) are two occurrences, each on its own joint:
/// picks on each choose each one's joint, and the engine finds each pick on its own instance.
#[test]
fn instances_of_one_gear_are_coupled_as_two() {
let mut s = Session::default();
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
for (name, r) in [("Base", 2), ("Twin", 15)] {
run("component.activate", json!({"component": "root"}));
run("component.create", json!({ "name": name }));
run("solid.cylinder", json!({"base": [0, 0, if name == "Base" { -20 } else { 0 }], "axis": [0, 0, 1], "radius": r, "height": 5}));
}
run("component.activate", json!({"component": "root"}));
run("occurrence.copy", json!({"component": "Twin", "translate": [30, 0, 0]}));
run("occurrence.ground", json!({"occurrence": "Base:1", "grounded": true}));
for (name, x) in [("Twin:1", 0), ("Twin:2", 30)] {
let at = |o: &str| json!({"occurrence": o, "point": [x, 0, 0], "z": [0, 0, 1]});
run("joint.create", json!({"type": "revolute", "a": at("Base:1"), "b": at(name), "flip": true, "name": name}));
}
let mut app = SolveApp::new(s, Services::default());
let occ = |app: &SolveApp, name: &str| app.session.doc.occurrences.iter().find(|o| o.name == name).map(|o| o.id).unwrap_or(0);
app.start("joint.gear");
let mut d = app.dialog.take().unwrap();
for (i, p) in [Vec3::new(-15.0, 0.0, 2.5), Vec3::new(45.0, 0.0, 2.5)].into_iter().enumerate() {
d.active = i;
let pick = wall(&app, p);
d.pick(&app.session, pick);
}
app.dialog = Some(d);
let ctx = egui::Context::default();
for _ in 0..2 {
let input =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), ..Default::default() };
let mut out = ctx.run_ui(input, |ui| crate::dialogs::show(&mut app, ui.ctx()));
out.textures_delta.clear();
}
let c = apply_commands(&app, app.dialog.as_ref().unwrap()).unwrap();
let p = &c[0].1;
assert_eq!((p["a"].clone(), p["b"].clone()), (json!(joint(&app, "Twin:1")), json!(joint(&app, "Twin:2"))), "{p}");
assert_eq!((p["ra"]["occurrence"].clone(), p["rb"]["occurrence"].clone()), (json!(occ(&app, "Twin:1")), json!(occ(&app, "Twin:2"))), "{p}");
app.run(&c[0].0, p.clone()).unwrap();
assert!((app.session.doc.assembly.links[0].ratio + 1.0).abs() < 1e-9);
}
/// A cylindrical joint is a screw on its own: the dialog sends it as `joint` with the pitch.
#[test]
fn a_cylindrical_joint_is_a_screw_on_its_own() {
let mut app = discs();
app.start("joint.screw");
let d = app.dialog.take().unwrap();
let c = apply_commands(&app, &d).unwrap();
assert_eq!(c[0], ("joint.screw".to_string(), json!({"joint": joint(&app, "Nut"), "pitch": "1.5 mm", "reverse": false})));
app.run(&c[0].0, c[0].1.clone()).unwrap();
app.run("joint.drive", json!({"joint": "Nut", "values": [360]})).unwrap();
let nut = solidcraft_engine::doc::joints::joint_values(&app.session.doc.assembly).get(&joint(&app, "Nut")).cloned().unwrap_or_default();
assert!((nut.get(1).copied().unwrap_or(f64::NAN) - 1.5).abs() < 1e-9, "{nut:?}");
}
/// Play turns the pinion's angle, wherever it is among its joint's values: a planar joint's is
/// the third, after its x and y, which keep theirs.
#[test]
fn play_turns_the_angle_of_a_planar_pinion() {
let mut app = discs();
let at = |o: &str| json!({"occurrence": o, "point": [0, 0, 0], "z": [0, 0, 1]});
app.run("joint.edit", json!({"joint": "G1", "type": "planar"})).unwrap();
app.run("joint.drive", json!({"joint": "G1", "values": [2, 0, 0]})).unwrap();
app.run("joint.create", json!({"type": "slider", "a": at("Base"), "b": at("G2"), "flip": true, "name": "Rail"})).unwrap();
app.start("joint.rack_pinion");
let mut d = app.dialog.take().unwrap();
if let Kind::Assembly(Asm::Coupling(f)) = &mut d.kind {
(f.a, f.animate, f.phase) = (joint(&app, "G1"), true, 0.5);
}
let cmds = preview_commands(&app, &d).unwrap();
assert_eq!(cmds.last().map(|c| c.1["values"].clone()), Some(json!(["2 mm", "0 mm", "180 deg"])), "{cmds:?}");
}
/// The dialog's rows pass their English text, which the catalogs translate by its key.
#[test]
fn the_dialog_labels_translate() {
let de = crate::i18n::Locale::for_language("de");
for (english, german) in [
("Gear 1", "Zahnrad 1"),
("Radius 2", "Radius 2"),
("Internal gear", "Innenzahnrad"),
("Pitch", "Steigung"),
("Rack and Pinion", "Zahnstange und Ritzel"),
] {
assert_eq!(de.label(english), german);
}
assert_eq!(crate::i18n::Locale::for_language("ja").label("not a round face"), "円筒面ではありません");
}
+1
View File
@@ -25,6 +25,7 @@ pub mod design_properties;
pub mod dialogs;
pub mod dialogs_appearance;
pub mod dialogs_assembly;
pub mod dialogs_coupling;
pub mod dialogs_library;
pub mod dialogs_motion;
pub mod dialogs_move;
+22 -1
View File
@@ -187,7 +187,19 @@ impl PreviewState {
/// limits, feature dialog or document cannot reuse an obsolete pose.
fn motion_key(cmds: &[(String, Value)], revision: u64, colors: Colors) -> Option<u64> {
if !cmds.iter().any(|(c, p)| c == "joint.drive" || c == "joint.create" && p.get("values").is_some())
|| !cmds.iter().all(|(c, _)| matches!(c.as_str(), "joint.drive" | "joint.create" | "joint.as_built" | "joint.edit" | "joint.limits"))
|| !cmds.iter().all(|(c, _)| {
matches!(
c.as_str(),
"joint.drive"
| "joint.create"
| "joint.as_built"
| "joint.edit"
| "joint.limits"
| "joint.gear"
| "joint.rack_pinion"
| "joint.screw"
)
})
{
return None;
}
@@ -826,6 +838,15 @@ mod tests {
motion_key(&[(command.into(), json!({"joint": 1, "values": [90]}))], 7, colors()),
);
}
// A gear dialog playing its driving joint streams its poses too.
let gear = |angle: &str| {
vec![
("joint.gear".to_string(), json!({"a": 1, "b": 2, "ra": 10, "rb": 20})),
("joint.drive".to_string(), json!({"joint": 1, "values": [angle]})),
]
};
assert!(motion_key(&gear("0 deg"), 7, colors()).is_some());
assert_eq!(motion_key(&gear("0 deg"), 7, colors()), motion_key(&gear("90 deg"), 7, colors()));
}
/// Combine previews its result (cut, join or intersect between existing bodies) without an
+3
View File
@@ -16,6 +16,9 @@ const ASSEMBLE: &[&str] = &[
"joint.rigid_group",
"joint.drive",
"joint.motion_link",
"joint.gear",
"joint.rack_pinion",
"joint.screw",
"motion.study",
"explode.create",
"contact.enable_sets",