Skip to main content

concinnity_asset/
physics_joint.rs

1// Physics-joint constraint schema.
2
3use crate::{AssetId, de_opt_asset_ref};
4use alloc::string::{String, ToString};
5
6/// The constraint shape a `PhysicsJoint` declares.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum PhysicsJointKind {
9    /// All 6 degrees of freedom locked. The bodies move and rotate as one
10    /// rigid assembly relative to their anchors. Use to weld two props
11    /// together.
12    Fixed,
13    /// Single rotational axis. Rotation around `axis` (in each body's local
14    /// frame) is free; everything else is locked. The canonical door hinge.
15    Revolute,
16    /// Three rotational axes free, all translation locked. Ball-and-socket
17    /// joint: the canonical rope link or a hip socket.
18    Spherical,
19    /// Single translational axis. Sliding along `axis` is free; rotation and
20    /// the other two translational axes are locked. The canonical slider /
21    /// piston.
22    Prismatic,
23}
24
25impl PhysicsJointKind {
26    /// The kind an authored name selects, accepting the common synonyms
27    /// (`hinge`, `ball`, `slider`, ...). `None` for an unknown name.
28    pub fn from_str_norm(s: &str) -> Option<Self> {
29        match s.to_ascii_lowercase().as_str() {
30            "fixed" | "weld" => Some(Self::Fixed),
31            "revolute" | "hinge" => Some(Self::Revolute),
32            "spherical" | "ball" | "socket" => Some(Self::Spherical),
33            "prismatic" | "slider" | "piston" => Some(Self::Prismatic),
34            _ => None,
35        }
36    }
37
38    /// The kind's canonical authored name.
39    pub fn as_str(self) -> &'static str {
40        match self {
41            Self::Fixed => "fixed",
42            Self::Revolute => "revolute",
43            Self::Spherical => "spherical",
44            Self::Prismatic => "prismatic",
45        }
46    }
47}
48
49/// A physics constraint connecting two [Prop](#prop)s that own a `collider`.
50///
51/// The joint pins `anchor_a` on `body_a` to `anchor_b` on `body_b` and locks
52/// the relative motion of the two bodies according to its `kind`. Anchors are
53/// in each body's local frame: `[0, 0, 0]` is the body's own pivot.
54///
55/// To anchor a body to "the world" (no second prop), leave `body_b` empty: a
56/// hidden static anchor is created at `anchor_b` (interpreted as world space in
57/// that case) and the body joints to it. This is the pendulum / lamp / trapeze
58/// pattern.
59///
60/// `axis` only applies to `revolute` and `prismatic`: it is the single free
61/// axis (rotation or translation) in each body's local frame. The vector is
62/// normalised on load; a zero axis falls back to `[0, 1, 0]`.
63///
64/// `limits_enabled` clamps the free axis: angle in degrees for revolute,
65/// distance in world units for prismatic. `motor_target_velocity` and
66/// `motor_max_force` drive the free axis when `motor_max_force > 0`; the
67/// velocity is in degrees/sec for revolute, units/sec for prismatic.
68///
69/// ```rust
70/// # use concinnity_asset::PhysicsJoint;
71/// PhysicsJoint {
72///     kind: "revolute".into(),
73///     anchor_a: [0.0, 2.0, 0.0],
74///     anchor_b: [0.0, 5.0, 0.0],
75///     axis: [0.0, 0.0, 1.0],
76///     ..Default::default()
77/// };
78/// ```
79#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
80#[serde(default)]
81pub struct PhysicsJoint {
82    /// Asset identity; injected via `inject_name`. Not part of `args`.
83    #[serde(skip)]
84    pub asset_id: AssetId,
85    /// Constraint shape; defaults to "fixed".
86    pub kind: String,
87    /// First body: a [Prop](#prop) name. Required.
88    #[serde(deserialize_with = "de_opt_asset_ref")]
89    pub body_a: Option<AssetId>,
90    /// Second body: a [Prop](#prop) name. Empty means "world anchor", in which
91    /// case `anchor_b` is interpreted as a world-space position.
92    #[serde(deserialize_with = "de_opt_asset_ref")]
93    pub body_b: Option<AssetId>,
94    /// Attach point in `body_a`'s local frame.
95    pub anchor_a: [f32; 3],
96    /// Attach point in `body_b`'s local frame (or world space if `body_b` is
97    /// empty).
98    pub anchor_b: [f32; 3],
99    /// Free axis for revolute/prismatic, in each body's local frame.
100    pub axis: [f32; 3],
101    /// Whether the `limits` clamp is enforced.
102    pub limits_enabled: bool,
103    /// `[min, max]` clamp on the free axis: degrees for revolute, world units
104    /// for prismatic. Ignored unless `limits_enabled` is true.
105    pub limits: [f32; 2],
106    /// Motor target velocity: degrees/sec for revolute, world units/sec for
107    /// prismatic. Ignored unless `motor_max_force > 0`.
108    pub motor_target_velocity: f32,
109    /// Motor force budget. The motor is inactive when this is 0.
110    pub motor_max_force: f32,
111}
112
113impl Default for PhysicsJoint {
114    fn default() -> Self {
115        Self {
116            asset_id: AssetId::default(),
117            kind: "fixed".to_string(),
118            body_a: None,
119            body_b: None,
120            anchor_a: [0.0, 0.0, 0.0],
121            anchor_b: [0.0, 0.0, 0.0],
122            axis: [0.0, 1.0, 0.0],
123            limits_enabled: false,
124            limits: [0.0, 0.0],
125            motor_target_velocity: 0.0,
126            motor_max_force: 0.0,
127        }
128    }
129}
130
131impl PhysicsJoint {
132    /// Parse `kind`; falls back to `Fixed` for unrecognised values so a typo
133    /// degrades safely. Cross-reference validation flags bad kinds explicitly.
134    pub fn parsed_kind(&self) -> PhysicsJointKind {
135        PhysicsJointKind::from_str_norm(&self.kind).unwrap_or(PhysicsJointKind::Fixed)
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn each_kind_accepts_its_aliases_and_round_trips_through_its_canonical_name() {
145        let cases = [
146            (PhysicsJointKind::Fixed, "fixed", ["fixed", "weld", "WELD"]),
147            (
148                PhysicsJointKind::Revolute,
149                "revolute",
150                ["revolute", "hinge", "Hinge"],
151            ),
152            (
153                PhysicsJointKind::Spherical,
154                "spherical",
155                ["spherical", "ball", "socket"],
156            ),
157            (
158                PhysicsJointKind::Prismatic,
159                "prismatic",
160                ["prismatic", "slider", "piston"],
161            ),
162        ];
163        for (kind, canonical, aliases) in cases {
164            assert_eq!(kind.as_str(), canonical);
165            for alias in aliases {
166                assert_eq!(
167                    PhysicsJointKind::from_str_norm(alias),
168                    Some(kind),
169                    "{alias}"
170                );
171            }
172            assert_eq!(PhysicsJointKind::from_str_norm(kind.as_str()), Some(kind));
173        }
174    }
175
176    #[test]
177    fn an_unrecognised_kind_has_no_parse() {
178        assert_eq!(PhysicsJointKind::from_str_norm("bendy"), None);
179        assert_eq!(PhysicsJointKind::from_str_norm(""), None);
180    }
181
182    #[test]
183    fn a_blank_joint_welds_two_unset_bodies() {
184        let j = PhysicsJoint::default();
185        assert_eq!(j.kind, "fixed");
186        assert_eq!(j.parsed_kind(), PhysicsJointKind::Fixed);
187        assert_eq!(j.body_a, None);
188        assert_eq!(j.body_b, None);
189        assert_eq!(j.axis, [0.0, 1.0, 0.0]);
190        assert!(!j.limits_enabled);
191    }
192
193    #[test]
194    fn a_typo_in_kind_degrades_to_a_weld() {
195        // Cross-reference validation reports the bad kind; the accessor must not
196        // panic in the meantime.
197        let j: PhysicsJoint = serde_json::from_str(r#"{"kind":"hindge"}"#).unwrap();
198        assert_eq!(j.parsed_kind(), PhysicsJointKind::Fixed);
199    }
200
201    #[test]
202    fn an_authored_hinge_round_trips_through_postcard() {
203        crate::test_support::install_resolvers();
204        let j: PhysicsJoint = serde_json::from_str(
205            r#"{"kind":"hinge","body_a":"door","body_b":"frame","axis":[0,1,0],
206                "limits_enabled":true,"limits":[-90,0],"motor_max_force":12.5}"#,
207        )
208        .unwrap();
209        assert_eq!(j.parsed_kind(), PhysicsJointKind::Revolute);
210        assert_eq!(j.body_a, Some(crate::AssetId(4)));
211        assert_eq!(j.body_b, Some(crate::AssetId(5)));
212
213        let bytes = postcard::to_allocvec(&j).unwrap();
214        let back: PhysicsJoint = postcard::from_bytes(&bytes).unwrap();
215        assert_eq!(back.parsed_kind(), PhysicsJointKind::Revolute);
216        assert_eq!(back.limits, [-90.0, 0.0]);
217        assert_eq!(back.motor_max_force, 12.5);
218        // `asset_id` is injected, never authored, so it does not ride the wire.
219        assert_eq!(back.asset_id, crate::AssetId::default());
220    }
221}