1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
// Scene-object prop schema.
use crate::{
AssetId, MaterialHandle, MeshHandle, TextureHandle, de_opt_asset_ref, de_opt_material_handle,
de_opt_mesh_handle, de_opt_texture_handle,
};
use alloc::string::{String, ToString};
/// Collision volume attached to a [Prop](#prop).
///
/// The shape dimensions are in the prop's local space and are scaled by the
/// prop's `scale`. `ball` and `capsule` use the X scale component (they assume
/// uniform scaling).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct PropCollider {
/// Collision shape: "aabb" (alias "cuboid"), "ball", or "capsule".
pub shape: String,
/// Box half-extents in local space [x, y, z]. Used by cuboid shapes.
pub half_extents: [f32; 3],
/// Radius in local space. Used by ball and capsule shapes.
pub radius: f32,
/// Half the cylinder height in local space. Used by capsule shapes.
pub half_height: f32,
/// Collision layer name. Built-in layers are `world`, `prop`, `character`,
/// and `trigger`; extra names come from [PhysicsConfig](#physicsconfig)
/// `layers`. Empty derives the layer from the body kind: `world` for a
/// static prop, `prop` when a [PropBody](#propbody) makes it dynamic.
pub layer: String,
}
impl Default for PropCollider {
fn default() -> Self {
Self {
shape: "cuboid".to_string(),
half_extents: [0.5, 0.5, 0.5],
radius: 0.5,
half_height: 0.5,
layer: String::new(),
}
}
}
/// A scene object: places geometry at a world-space transform.
///
/// Reference either a [Model](#model) (multi-mesh) or a single
/// [Mesh](#mesh)/[ProceduralMesh](#proceduralmesh). `model` takes precedence
/// when both are set.
///
/// Rotation notes:
/// - `rotation_deg[0]` = pitch (tilt forward/back)
/// - `rotation_deg[1]` = yaw (spin on vertical axis), most common
/// - `rotation_deg[2]` = roll (tilt side-to-side)
///
/// ```rust
/// # use concinnity_asset::Prop;
/// Prop {
/// position: [4.0, 0.4, -8.0],
/// ..Default::default()
/// };
/// ```
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Prop {
/// Asset identity; injected via `inject_name`. Not part of `args`.
#[serde(skip)]
pub asset_id: AssetId,
/// A [Model](#model) asset. When set, the prop renders all sub-meshes of
/// that model (each with its own material) sharing this prop's transform.
/// Takes precedence over `mesh` and `material`.
#[serde(deserialize_with = "de_opt_asset_ref")]
pub model: Option<AssetId>,
/// A [Mesh](#mesh) or [ProceduralMesh](#proceduralmesh) asset this prop
/// renders. Used when `model` is unset.
#[serde(deserialize_with = "de_opt_mesh_handle")]
pub mesh: Option<MeshHandle>,
/// A [Material](#material) to use for this prop. When set it takes
/// precedence over `texture` and provides the albedo texture plus the
/// lighting parameters (roughness, metallic, tint, emissive). Used when
/// `model` is unset.
#[serde(deserialize_with = "de_opt_material_handle")]
pub material: Option<MaterialHandle>,
/// A [Texture](#texture) to use for this prop. Older field: ignored when
/// `material` is set. Unset uses the first declared texture (or a white
/// fallback).
#[serde(deserialize_with = "de_opt_texture_handle")]
pub texture: Option<TextureHandle>,
/// World-space position [x, y, z].
pub position: [f32; 3],
/// Euler rotation in degrees [pitch, yaw, roll], applied in YXZ order
/// (yaw first so that rotating around the vertical axis is intuitive).
pub rotation_deg: [f32; 3],
/// Non-uniform scale [x, y, z]. Defaults to [1, 1, 1].
pub scale: [f32; 3],
/// Optional collision volume. When present, the prop blocks the player; when
/// absent the prop is non-solid.
pub collider: Option<PropCollider>,
/// When true, the player can interact with this prop: pressing the interact
/// key (E) while close and facing it triggers its rotation behaviour.
pub interactable: bool,
/// When true, the player can pick up and carry this prop with the interact
/// key (E). A companion [PropBody](#propbody) must also be declared so the
/// prop falls correctly after being dropped.
pub pickup: bool,
/// Another [Prop](#prop) whose world transform this prop inherits. When set,
/// `position`, `rotation_deg`, and `scale` are relative to the parent's
/// world transform. The parent must be declared in the same world; circular
/// chains are treated as an error.
#[serde(deserialize_with = "de_opt_asset_ref")]
pub parent: Option<AssetId>,
/// [Scene](#scene) this prop belongs to. Resolved automatically from the
/// naming convention (a prop named `<scene>_*` belongs to scene `<scene>`);
/// you don't set this directly. `None` means the prop is visible in every
/// scene. Used by scene switches for per-scene visibility.
#[serde(default, deserialize_with = "de_opt_asset_ref")]
pub scene: Option<AssetId>,
/// Name of a [Prefab](#prefab) to instantiate at this prop's transform. When
/// set, it expands into concrete child props and lights, replacing this
/// prop. Cannot be combined with `model` or `mesh`.
pub prefab: String,
/// Optional view-distance cutoff in world units. When > 0 the prop is hidden
/// once the camera is further than this from it. 0 (default) keeps the prop
/// visible at any distance.
pub cull_distance: f32,
/// Set at runtime while the prop is being carried. Not serialised.
/// While true, PhysicsSystem drives the prop as a kinematic body that
/// follows the camera instead of simulating it dynamically.
#[serde(skip)]
pub is_held: bool,
}
impl Default for Prop {
fn default() -> Self {
Self {
asset_id: AssetId::default(),
model: None,
mesh: None,
material: None,
texture: None,
position: [0.0, 0.0, 0.0],
rotation_deg: [0.0, 0.0, 0.0],
scale: [1.0, 1.0, 1.0],
collider: None,
interactable: false,
pickup: false,
parent: None,
scene: None,
prefab: String::new(),
cull_distance: 0.0,
is_held: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_blank_collider_is_a_unit_cuboid() {
let c = PropCollider::default();
assert_eq!(c.shape, "cuboid");
assert_eq!(c.half_extents, [0.5, 0.5, 0.5]);
assert_eq!(c.radius, 0.5);
assert_eq!(c.half_height, 0.5);
}
#[test]
fn a_blank_prop_is_an_unscaled_non_interactive_placement() {
let p = Prop::default();
assert_eq!(p.position, [0.0, 0.0, 0.0]);
assert_eq!(p.rotation_deg, [0.0, 0.0, 0.0]);
assert_eq!(p.scale, [1.0, 1.0, 1.0]);
// No collider means the prop is decoration: physics ignores it.
assert!(p.collider.is_none());
assert!(!p.interactable);
assert!(!p.pickup);
assert!(!p.is_held);
assert_eq!(p.cull_distance, 0.0);
assert!(p.prefab.is_empty());
assert!(p.model.is_none());
assert!(p.mesh.is_none());
assert!(p.material.is_none());
assert!(p.texture.is_none());
assert!(p.parent.is_none());
assert!(p.scene.is_none());
}
#[test]
fn every_reference_resolves_through_its_own_seam() {
crate::test_support::install_resolvers();
let p: Prop = serde_json::from_str(
r#"{"model":"crate_model","mesh":"crate_mesh","material":"wood","texture":"tex_wood",
"parent":"shelf","scene":"vault"}"#,
)
.unwrap();
// A Model is still an interned name; the resource kinds are handles.
assert_eq!(p.model, Some(AssetId(11)));
assert_eq!(p.mesh, Some(MeshHandle(10)));
assert_eq!(p.material, Some(MaterialHandle(4)));
assert_eq!(p.texture, Some(TextureHandle(8)));
assert_eq!(p.parent, Some(AssetId(5)));
assert_eq!(p.scene, Some(AssetId(5)));
}
#[test]
fn a_pickup_with_a_ball_collider_round_trips_through_postcard() {
let p: Prop = serde_json::from_str(
r#"{"position":[1,2,3],"rotation_deg":[0,90,0],"scale":[2,2,2],
"collider":{"shape":"ball","radius":0.25},
"interactable":true,"pickup":true,"prefab":"lantern","cull_distance":60}"#,
)
.unwrap();
let collider = p.collider.as_ref().expect("collider");
assert_eq!(collider.shape, "ball");
assert_eq!(collider.radius, 0.25);
// Unmentioned collider dimensions keep the schema defaults.
assert_eq!(collider.half_height, 0.5);
let bytes = postcard::to_allocvec(&p).unwrap();
let back: Prop = postcard::from_bytes(&bytes).unwrap();
assert_eq!(back.position, [1.0, 2.0, 3.0]);
assert_eq!(back.rotation_deg, [0.0, 90.0, 0.0]);
assert_eq!(back.scale, [2.0, 2.0, 2.0]);
assert_eq!(back.collider.expect("collider").shape, "ball");
assert!(back.interactable);
assert!(back.pickup);
assert_eq!(back.prefab, "lantern");
assert_eq!(back.cull_distance, 60.0);
// Held state is runtime-only, so it never rides the wire.
assert!(!back.is_held);
assert_eq!(back.asset_id, AssetId::default());
}
}