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}