Skip to main content

concinnity_core/components/
physics_joint.rs

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