//! 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. //! //! 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` //! are occurrence world transforms, `F` the joint frames in each component's own frame, and `q` //! the joint's values (angles in radians, distances in mm). The solver walks the graph from //! grounded occurrences (and the root, which never moves); joints that close a loop are checked //! and reported when they disagree. use std::collections::{BTreeMap, BTreeSet, VecDeque}; use serde::{Deserialize, Serialize}; use solvecraft_geom::Vec3; use crate::{DocError, Document, IDENTITY, Mat, ModelState, Result, apply_point, apply_vector, mat_inverse, mat_mul}; const MAX_JOINTS: usize = 10_000; /// Where a joint origin sits on a component's geometry (component coordinates). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Snap { /// An explicit frame (origin, z axis, x axis). Frame { origin: Vec3, z: Vec3, #[serde(default, skip_serializing_if = "Option::is_none")] x: Option, }, /// The centre of the planar face nearest `pick`, z along its outward normal. FaceCenter { pick: Vec3 }, /// The centre of the circular end nearest `pick` of the cylindrical face there, z along its /// axis (pointing out of the face's end). CircleCenter { pick: Vec3 }, /// A point, z along `z` (default +Z). Point { point: Vec3, #[serde(default, skip_serializing_if = "Option::is_none")] z: Option, }, } /// A joint origin: a snap on an occurrence's component (occurrence 0 = the root design). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct JointOrigin { pub occurrence: u64, pub snap: Snap, } /// A named joint origin (Joint Origin command), referenced by joints by name. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct NamedOrigin { pub name: String, pub origin: JointOrigin, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum JointKind { Rigid, Revolute, Slider, Cylindrical, PinSlot, Planar, Ball, } impl JointKind { pub fn parse(s: &str) -> Option { Some(match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { "rigid" => JointKind::Rigid, "revolute" | "rotate" => JointKind::Revolute, "slider" | "slide" => JointKind::Slider, "cylindrical" => JointKind::Cylindrical, "pinslot" => JointKind::PinSlot, "planar" => JointKind::Planar, "ball" => JointKind::Ball, _ => return None, }) } /// Names of the joint's values, in order (angles in radians, distances in mm). pub fn dofs(self) -> &'static [&'static str] { match self { JointKind::Rigid => &[], JointKind::Revolute => &["angle"], JointKind::Slider => &["distance"], JointKind::Cylindrical => &["angle", "distance"], JointKind::PinSlot => &["angle", "slide"], JointKind::Planar => &["x", "y", "angle"], JointKind::Ball => &["yaw", "pitch", "roll"], } } pub fn is_angle(self, i: usize) -> bool { matches!(self.dofs().get(i), Some(&("angle" | "yaw" | "pitch" | "roll"))) } } /// A joint between two occurrences. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Joint { pub id: u64, pub name: String, pub kind: JointKind, pub a: JointOrigin, pub b: JointOrigin, /// Current values of the joint's degrees of freedom. #[serde(default)] pub values: Vec, /// Per value: (min, max) limits. #[serde(default)] pub limits: Vec>, /// Turn B over (its z opposite to A's). #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub flip: bool, /// Distance from A's frame to B's along z, and a turn about it (radians). #[serde(default)] pub offset: f64, #[serde(default)] pub angle: f64, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub suppressed: bool, } /// Value of joint `b` (index `ib`) = ratio · value of joint `a` (index `ia`) + offset. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct MotionLink { pub a: u64, #[serde(default)] pub ia: usize, pub b: u64, #[serde(default)] pub ib: usize, pub ratio: f64, #[serde(default)] pub offset: f64, } /// Joints, joint origins and motion links of a design. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Assembly { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub joints: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub origins: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub links: Vec, } impl Assembly { pub fn is_empty(&self) -> bool { self.joints.is_empty() && self.origins.is_empty() && self.links.is_empty() } } /// A frame: origin and orthonormal axes, as a transform (local → parent). fn frame(o: Vec3, z: Vec3, x: Option) -> Option { let z = z.normalized()?; let x = x.and_then(|x| (x - z * x.dot(z)).normalized()).unwrap_or_else(|| z.any_perp()); let y = z.cross(x).normalized()?; Some([[x.x, x.y, x.z, 0.0], [y.x, y.y, y.z, 0.0], [z.x, z.y, z.z, 0.0], [o.x, o.y, o.z, 1.0]]) } fn rot_z(a: f64) -> Mat { let (s, c) = a.sin_cos(); [[c, s, 0.0, 0.0], [-s, c, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]] } fn rot_y(a: f64) -> Mat { let (s, c) = a.sin_cos(); [[c, 0.0, -s, 0.0], [0.0, 1.0, 0.0, 0.0], [s, 0.0, c, 0.0], [0.0, 0.0, 0.0, 1.0]] } fn shift(x: f64, y: f64, z: f64) -> Mat { let mut m = IDENTITY; m[3] = [x, y, z, 1.0]; m } /// The joint's motion in A's frame for its values. pub fn motion(kind: JointKind, q: &[f64]) -> Mat { let v = |i: usize| q.get(i).copied().unwrap_or(0.0); match kind { JointKind::Rigid => IDENTITY, JointKind::Revolute => rot_z(v(0)), JointKind::Slider => shift(0.0, 0.0, v(0)), JointKind::Cylindrical => mat_mul(&rot_z(v(0)), &shift(0.0, 0.0, v(1))), JointKind::PinSlot => mat_mul(&shift(v(1), 0.0, 0.0), &rot_z(v(0))), JointKind::Planar => mat_mul(&shift(v(0), v(1), 0.0), &rot_z(v(2))), JointKind::Ball => mat_mul(&mat_mul(&rot_z(v(0)), &rot_y(v(1))), &rot_z(v(2))), } } /// Bodies of a component in the model (its own frame). fn component_bodies<'a>(doc: &Document, st: &'a ModelState, component: u64) -> Vec<&'a crate::ModelBody> { st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == component).collect() } /// The component an occurrence places (0 for the root). fn component_of(doc: &Document, occ: u64) -> Result { if occ == 0 { return Ok(0); } doc.occurrences.iter().find(|o| o.id == occ).map(|o| o.component).ok_or_else(|| DocError::Unknown(format!("occurrence {occ}"))) } /// Resolve a joint origin to a frame in its component's coordinates. pub fn resolve_origin(doc: &Document, st: &ModelState, o: &JointOrigin) -> Result { let bad = || DocError::Invalid("joint origin: degenerate frame".into()); match &o.snap { Snap::Frame { origin, z, x } => frame(*origin, *z, *x).ok_or_else(bad), Snap::Point { point, z } => frame(*point, z.unwrap_or(Vec3::Z), None).ok_or_else(bad), Snap::FaceCenter { pick } => { let comp = component_of(doc, o.occurrence)?; let mut best: Option<(f64, Vec3, Vec3)> = None; for b in component_bodies(doc, st, comp) { let tol = (b.body.size() * 2e-3).max(1e-3); for f in b.body.faces(tol)? { let Some(n) = f.plane_normal else { continue }; let d = (*pick - f.centroid).dot(n).abs() + 1e-3 * (*pick - f.centroid).len(); if best.as_ref().is_none_or(|(bd, _, _)| d < *bd) { best = Some((d, f.centroid, n)); } } } 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 } => { let comp = component_of(doc, o.occurrence)?; let cyl = component_bodies(doc, st, comp) .into_iter() .filter_map(|b| solvecraft_kernel::cylinder_face_at(&b.body, *pick)) .min_by(|a, b| { let off = |c: &solvecraft_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); 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) } } } /// World transform of an occurrence (through its parents). pub fn occurrence_world(doc: &Document, occ: u64) -> Mat { let mut m = IDENTITY; let mut cur = doc.occurrences.iter().find(|o| o.id == occ); for _ in 0..1000 { let Some(o) = cur else { break }; m = mat_mul(&o.transform, &m); if o.parent == 0 { break; } cur = doc.occurrence_of(o.parent); } m } /// Clamp values to the joint's limits. pub fn clamp(j: &Joint, q: &mut [f64]) { for (i, v) in q.iter_mut().enumerate() { if let Some(Some((lo, hi))) = j.limits.get(i) { *v = v.clamp(lo.min(*hi), hi.max(*lo)); } } } /// The result of placing occurrences by their joints. #[derive(Clone, Debug, Default, Serialize)] pub struct Solution { /// New world transforms of occurrences the joints place. #[serde(skip)] pub world: BTreeMap, /// Joints that disagree with the placement found through others: (joint id, name, why). pub conflicts: Vec<(u64, String, String)>, /// Joints that could not be applied (bad origin…): (joint id, name, why). pub errors: Vec<(u64, String, String)>, /// Remaining degrees of freedom per occurrence (6 = free, 0 = fixed). pub dof: BTreeMap, /// Occurrences fixed in place (grounded, or the root's). pub fixed: Vec, } /// How far two placements differ: (translation mm, rotation rad). fn mismatch(a: &Mat, b: &Mat) -> (f64, f64) { let t = Vec3::new(a[3][0] - b[3][0], a[3][1] - b[3][1], a[3][2] - b[3][2]).len(); let mut tr = 0.0; for i in 0..3 { for k in 0..3 { tr += a[i][k] * b[i][k]; } } (t, ((tr - 1.0) / 2.0).clamp(-1.0, 1.0).acos()) } /// Effective values of every joint after motion links and limits. pub fn joint_values(asm: &Assembly) -> BTreeMap> { let mut vals: BTreeMap> = asm .joints .iter() .map(|j| { let mut q = j.values.clone(); q.resize(j.kind.dofs().len(), 0.0); (j.id, q) }) .collect(); // Links in order, a few passes for chains. for _ in 0..4 { for l in &asm.links { let Some(src) = vals.get(&l.a).and_then(|q| q.get(l.ia)).copied() else { continue }; if let Some(v) = vals.get_mut(&l.b).and_then(|q| q.get_mut(l.ib)) { *v = l.ratio * src + l.offset; } } } for j in &asm.joints { if let Some(q) = vals.get_mut(&j.id) { clamp(j, q); } } vals } /// Place occurrences from the joint graph. pub fn solve(doc: &Document, st: &ModelState) -> Solution { let asm = &doc.assembly; let mut sol = Solution::default(); let joints: Vec<&Joint> = asm.joints.iter().filter(|j| !j.suppressed).take(MAX_JOINTS).collect(); let vals = joint_values(asm); // Joint frames in component coordinates (errors leave the joint out). let mut frames: BTreeMap = BTreeMap::new(); for j in &joints { match (resolve_origin(doc, st, &j.a), resolve_origin(doc, st, &j.b)) { (Ok(fa), Ok(fb)) => { frames.insert(j.id, (fa, fb)); } (Err(e), _) | (_, Err(e)) => sol.errors.push((j.id, j.name.clone(), e.to_string())), } } // Fixed: the root, grounded occurrences; with neither involved, the first joint's A side. let mut placed: BTreeMap = BTreeMap::new(); let mut dof: BTreeMap = BTreeMap::new(); placed.insert(0, IDENTITY); dof.insert(0, 0); for o in doc.occurrences.iter().filter(|o| o.grounded) { placed.insert(o.id, occurrence_world(doc, o.id)); dof.insert(o.id, 0); } let involved: BTreeSet = joints.iter().flat_map(|j| [j.a.occurrence, j.b.occurrence]).collect(); if !involved.iter().any(|o| placed.contains_key(o)) && let Some(j) = joints.first() { placed.insert(j.a.occurrence, occurrence_world(doc, j.a.occurrence)); dof.insert(j.a.occurrence, 0); } sol.fixed = placed.keys().copied().collect(); let flip_m = |j: &Joint| { let mut m = rot_z(j.angle); m[3][2] = j.offset; if j.flip { mat_mul(&m, &[[1.0, 0.0, 0.0, 0.0], [0.0, -1.0, 0.0, 0.0], [0.0, 0.0, -1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]) } else { m } }; // B's world transform from A's (or A's from B's, going the other way). let through = |j: &Joint, from_a: bool, t: &Mat| -> Option { let (fa, fb) = frames.get(&j.id)?; let q = vals.get(&j.id).cloned().unwrap_or_default(); let link = mat_mul(&mat_mul(&motion(j.kind, &q), &flip_m(j)), &mat_inverse(fb)?); if from_a { Some(mat_mul(&mat_mul(t, fa), &link)) } else { // T_A = T_B · link⁻¹ · F_a⁻¹ Some(mat_mul(&mat_mul(t, &mat_inverse(&link)?), &mat_inverse(fa)?)) } }; let mut used: BTreeSet = BTreeSet::new(); let mut queue: VecDeque = placed.keys().copied().collect(); while let Some(occ) = queue.pop_front() { let Some(t) = placed.get(&occ).copied() else { continue }; for j in &joints { if used.contains(&j.id) || !frames.contains_key(&j.id) { continue; } let (other, from_a) = if j.a.occurrence == occ { (j.b.occurrence, true) } else if j.b.occurrence == occ { (j.a.occurrence, false) } else { continue; }; if placed.contains_key(&other) { continue; } let Some(m) = through(j, from_a, &t) else { continue }; used.insert(j.id); placed.insert(other, m); dof.insert(other, dof.get(&occ).copied().unwrap_or(0) + j.kind.dofs().len()); queue.push_back(other); } } // Joints that close loops: do they agree? for j in &joints { if used.contains(&j.id) || !frames.contains_key(&j.id) { continue; } let (Some(ta), Some(tb)) = (placed.get(&j.a.occurrence), placed.get(&j.b.occurrence)) else { continue }; let Some(want) = through(j, true, ta) else { continue }; let (dt, da) = mismatch(&want, tb); if dt > 1e-6 || da > 1e-8 { sol.conflicts.push(( j.id, j.name.clone(), format!("off by {dt:.4} mm and {:.3}° from the placement the other joints give", da.to_degrees()), )); } } for o in &doc.occurrences { dof.entry(o.id).or_insert(6); } placed.remove(&0); sol.world = placed; sol.dof = dof; sol } impl Document { /// Apply a solution: occurrence transforms (in their parents) for the occurrences it places. /// Returns true when something moved. pub fn apply_joint_solution(&mut self, sol: &Solution) -> bool { let mut changed = false; let parents: BTreeMap = self.occurrences.iter().map(|o| (o.id, o.parent)).collect(); for (occ, world) in &sol.world { let parent = parents.get(occ).copied().unwrap_or(0); let pw = if parent == 0 { IDENTITY } else { self.component_transform(parent) }; let Some(local) = mat_inverse(&pw).map(|inv| mat_mul(&inv, world)) else { continue }; if let Some(o) = self.occurrences.iter_mut().find(|o| o.id == *occ) { let (dt, da) = mismatch(&o.transform, &local); if dt > 1e-9 || da > 1e-10 { o.transform = local; changed = true; } } } changed } /// A frame for an as-built joint: the world frame `w` seen from an occurrence. pub fn frame_in_occurrence(&self, occ: u64, w: &Mat) -> Option { let t = if occ == 0 { IDENTITY } else { occurrence_world(self, occ) }; let local = mat_mul(&mat_inverse(&t)?, w); let o = apply_point(&local, Vec3::ZERO); let z = apply_vector(&local, Vec3::Z); let x = apply_vector(&local, Vec3::X); Some(Snap::Frame { origin: o, z, x: Some(x) }) } /// A joint origin's frame in the world (for as-built joints and display). pub fn origin_world(&self, st: &ModelState, o: &JointOrigin) -> Result { let f = resolve_origin(self, st, o)?; let t = if o.occurrence == 0 { IDENTITY } else { occurrence_world(self, o.occurrence) }; Ok(mat_mul(&t, &f)) } } /// The frame matrix for an explicit origin / z / x (public for commands). pub fn frame_of(o: Vec3, z: Vec3, x: Option) -> Option { frame(o, z, x) } #[cfg(test)] mod tests { use super::*; fn close(a: &Mat, b: &Mat) -> bool { let (t, r) = mismatch(a, b); t < 1e-9 && r < 1e-9 } #[test] fn motions_compose() { assert!(close(&motion(JointKind::Revolute, &[std::f64::consts::FRAC_PI_2]), &rot_z(std::f64::consts::FRAC_PI_2))); let m = motion(JointKind::Cylindrical, &[0.0, 5.0]); assert!((m[3][2] - 5.0).abs() < 1e-12); let m = motion(JointKind::PinSlot, &[0.0, 3.0]); assert!((m[3][0] - 3.0).abs() < 1e-12); assert_eq!(JointKind::Ball.dofs().len(), 3); assert!(JointKind::parse("Pin Slot").is_some() && JointKind::parse("nope").is_none()); let f = frame(Vec3::new(1.0, 2.0, 3.0), Vec3::Z, None).unwrap(); assert!(close(&mat_mul(&f, &mat_inverse(&f).unwrap()), &IDENTITY)); } }