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}