use hisab::Quat;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum JointType {
Ball, Hinge, Pivot, Saddle, Fixed, Planar, }
impl JointType {
#[must_use]
pub fn degrees_of_freedom(&self) -> u8 {
match self {
Self::Ball => 3,
Self::Hinge => 1,
Self::Pivot => 1,
Self::Saddle => 2,
Self::Fixed => 0,
Self::Planar => 2,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct AxisLimit {
pub min_rad: f32,
pub max_rad: f32,
}
impl AxisLimit {
#[must_use]
pub fn new(min_deg: f32, max_deg: f32) -> Self {
Self {
min_rad: min_deg.to_radians(),
max_rad: max_deg.to_radians(),
}
}
#[must_use]
#[inline]
pub fn clamp(&self, angle_rad: f32) -> f32 {
angle_rad.clamp(self.min_rad, self.max_rad)
}
#[must_use]
#[inline]
pub fn range_degrees(&self) -> f32 {
(self.max_rad - self.min_rad).to_degrees()
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct JointLimits {
pub x: Option<AxisLimit>, pub y: Option<AxisLimit>, pub z: Option<AxisLimit>, }
impl JointLimits {
#[must_use]
pub fn free() -> Self {
Self {
x: None,
y: None,
z: None,
}
}
#[must_use]
pub fn hinge(min_deg: f32, max_deg: f32) -> Self {
Self {
x: Some(AxisLimit::new(min_deg, max_deg)),
y: None,
z: None,
}
}
#[must_use]
pub fn clamp_rotation(&self, rotation: Quat) -> Quat {
let (x, y, z) = rotation.to_euler(hisab::transforms::glam::EulerRot::XYZ);
let cx = self.x.map_or(x, |lim| lim.clamp(x));
let cy = self.y.map_or(y, |lim| lim.clamp(y));
let cz = self.z.map_or(z, |lim| lim.clamp(z));
Quat::from_euler(hisab::transforms::glam::EulerRot::XYZ, cx, cy, cz)
}
#[must_use]
pub fn violation(&self, rotation: Quat) -> f32 {
let (x, y, z) = rotation.to_euler(hisab::transforms::glam::EulerRot::XYZ);
let mut total = 0.0_f32;
if let Some(lim) = &self.x {
let clamped = lim.clamp(x);
total += (x - clamped).abs();
}
if let Some(lim) = &self.y {
let clamped = lim.clamp(y);
total += (y - clamped).abs();
}
if let Some(lim) = &self.z {
let clamped = lim.clamp(z);
total += (z - clamped).abs();
}
total
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Joint {
pub name: String,
pub joint_type: JointType,
pub parent_bone: super::skeleton::BoneId,
pub child_bone: super::skeleton::BoneId,
pub limits: JointLimits,
pub stiffness: f32, pub damping: f32, }
impl Joint {
#[must_use]
pub fn human_knee(parent: super::skeleton::BoneId, child: super::skeleton::BoneId) -> Self {
Self {
name: "knee".into(),
joint_type: JointType::Hinge,
parent_bone: parent,
child_bone: child,
limits: JointLimits::hinge(0.0, 135.0),
stiffness: 0.1,
damping: 0.3,
}
}
#[must_use]
#[inline]
pub fn clamp_rotation(&self, rotation: Quat) -> Quat {
self.limits.clamp_rotation(rotation)
}
#[must_use]
#[inline]
pub fn violation(&self, rotation: Quat) -> f32 {
self.limits.violation(rotation)
}
#[must_use]
pub fn human_shoulder(parent: super::skeleton::BoneId, child: super::skeleton::BoneId) -> Self {
Self {
name: "shoulder".into(),
joint_type: JointType::Ball,
parent_bone: parent,
child_bone: child,
limits: JointLimits {
x: Some(AxisLimit::new(-60.0, 180.0)),
y: Some(AxisLimit::new(-45.0, 180.0)),
z: Some(AxisLimit::new(-90.0, 90.0)),
},
stiffness: 0.05,
damping: 0.2,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn joint_dof() {
assert_eq!(JointType::Ball.degrees_of_freedom(), 3);
assert_eq!(JointType::Hinge.degrees_of_freedom(), 1);
assert_eq!(JointType::Fixed.degrees_of_freedom(), 0);
}
#[test]
fn axis_limit_clamp() {
let limit = AxisLimit::new(0.0, 90.0);
let clamped = limit.clamp(2.0); assert!(clamped <= limit.max_rad + 0.001);
}
#[test]
fn axis_range() {
let limit = AxisLimit::new(-45.0, 135.0);
assert!((limit.range_degrees() - 180.0).abs() < 0.1);
}
#[test]
fn knee_is_hinge() {
let knee = Joint::human_knee(
super::super::skeleton::BoneId(0),
super::super::skeleton::BoneId(1),
);
assert_eq!(knee.joint_type, JointType::Hinge);
assert!(knee.limits.x.is_some());
assert!(knee.limits.y.is_none());
}
#[test]
fn shoulder_is_ball() {
let shoulder = Joint::human_shoulder(
super::super::skeleton::BoneId(0),
super::super::skeleton::BoneId(1),
);
assert_eq!(shoulder.joint_type, JointType::Ball);
assert_eq!(shoulder.joint_type.degrees_of_freedom(), 3);
}
#[test]
fn hinge_limits() {
let limits = JointLimits::hinge(0.0, 135.0);
assert!(limits.x.is_some());
assert!((limits.x.unwrap().range_degrees() - 135.0).abs() < 0.1);
}
#[test]
fn clamp_rotation_within_limits() {
let limits = JointLimits::hinge(0.0, 90.0);
let rot = Quat::from_rotation_x(0.5); let clamped = limits.clamp_rotation(rot);
assert!(
rot.dot(clamped).abs() > 0.999,
"rotation within limits should not change"
);
}
#[test]
fn clamp_rotation_exceeds_limits() {
let limits = JointLimits::hinge(0.0, 90.0);
let rot = Quat::from_rotation_x(2.5); let clamped = limits.clamp_rotation(rot);
let (x, _, _) = clamped.to_euler(hisab::transforms::glam::EulerRot::XYZ);
let max_rad = 90.0_f32.to_radians();
assert!(
(x - max_rad).abs() < 0.01,
"should clamp to 90°, got {:.1}°",
x.to_degrees()
);
}
#[test]
fn violation_zero_within_limits() {
let limits = JointLimits::hinge(0.0, 90.0);
let rot = Quat::from_rotation_x(0.5);
assert!(
limits.violation(rot) < 0.01,
"should have zero violation within limits"
);
}
#[test]
fn violation_positive_outside_limits() {
let limits = JointLimits::hinge(0.0, 90.0);
let rot = Quat::from_rotation_x(2.0); let v = limits.violation(rot);
assert!(v > 0.1, "should have positive violation, got {v}");
}
#[test]
fn free_limits_no_clamping() {
let limits = JointLimits::free();
let rot = Quat::from_rotation_x(3.0);
let clamped = limits.clamp_rotation(rot);
assert!(
rot.dot(clamped).abs() > 0.999,
"free limits should not clamp"
);
}
#[test]
fn joint_clamp_delegates_to_limits() {
let knee = Joint::human_knee(
super::super::skeleton::BoneId(0),
super::super::skeleton::BoneId(1),
);
let rot = Quat::from_rotation_x(-0.5);
let clamped = knee.clamp_rotation(rot);
let (x, _, _) = clamped.to_euler(hisab::transforms::glam::EulerRot::XYZ);
assert!(x.abs() < 0.01, "knee should clamp negative angle to 0°");
}
}