1use std::collections::HashMap;
19
20use serde::{Deserialize, Serialize};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum ItemKind {
26 Garment,
27 Weapon,
28 Body,
29 Part,
30 Consumable,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36pub struct ItemStat {
37 pub id: u32,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub key: Option<String>,
40 pub value: f32,
41}
42
43#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct ItemPlacement {
47 #[serde(default)]
48 pub worn_on_back: bool,
49 #[serde(default)]
51 pub offset: Option<[f32; 3]>,
52 #[serde(default)]
54 pub euler: Option<[f32; 3]>,
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
59pub struct GlbMeta {
60 #[serde(default)]
61 pub units: Option<String>,
62 #[serde(default)]
63 pub up: Option<String>,
64 #[serde(default)]
65 pub forward: Option<String>,
66 #[serde(default)]
67 pub origin: Option<String>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct ItemManifest {
75 pub schema: u32,
76 pub id: u32,
78 pub kind: ItemKind,
79 pub slot: String,
81 pub title: String,
82 #[serde(default)]
83 pub description: Option<String>,
84 #[serde(default)]
85 pub lore: Vec<String>,
86 #[serde(default)]
88 pub codex_slug: Option<String>,
89 #[serde(default)]
90 pub stats: Vec<ItemStat>,
91 #[serde(default)]
92 pub placement: Option<ItemPlacement>,
93 #[serde(default)]
94 pub casting_implement: bool,
95 #[serde(default)]
96 pub cast_power: Option<f32>,
97 #[serde(default)]
98 pub glb: Option<GlbMeta>,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "lowercase")]
104pub enum AttachType {
105 Skinned,
107 Bone,
109}
110
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub struct BoneRename {
114 pub from: String,
115 pub to: String,
116}
117
118#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub struct AvatarAssetFix {
124 #[serde(default)]
125 pub scale: Option<f32>,
126 #[serde(default)]
128 pub rotation: Option<[f32; 3]>,
129 #[serde(default)]
131 pub position: Option<[f32; 3]>,
132 #[serde(default)]
133 pub bone_map: Vec<BoneRename>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
139#[serde(rename_all = "camelCase")]
140pub struct AvatarAssetDoc {
141 #[serde(rename = "_id", default)]
142 pub id: String,
143 #[serde(default)]
144 pub name: String,
145 #[serde(rename = "type", default)]
147 pub asset_type: String,
148 #[serde(default)]
150 pub category: Option<String>,
151 #[serde(default)]
153 pub slot: Option<String>,
154 #[serde(default)]
157 pub part_id: Option<u32>,
158 #[serde(default)]
160 pub url: String,
161 #[serde(default)]
162 pub thumbnail_url: Option<String>,
163 #[serde(default)]
164 pub attach_type: Option<AttachType>,
165 #[serde(default)]
167 pub snap_bone: Option<String>,
168 #[serde(default)]
169 pub fix: Option<AvatarAssetFix>,
170 #[serde(default)]
171 pub tint: Option<String>,
172 #[serde(default)]
173 pub owned_by_default: bool,
174 #[serde(default)]
175 pub tags: Vec<String>,
176 #[serde(default)]
177 pub active: Option<bool>,
178}
179
180pub fn index_by_part_id(assets: &[AvatarAssetDoc]) -> HashMap<u32, &AvatarAssetDoc> {
183 let mut map = HashMap::new();
184 for a in assets {
185 if let Some(pid) = a.part_id {
186 map.insert(pid, a);
187 }
188 }
189 map
190}
191
192pub fn resolve_spec<'a>(
196 spec: &crate::AvatarSpec,
197 index: &HashMap<u32, &'a AvatarAssetDoc>,
198) -> HashMap<crate::AvatarSlot, &'a AvatarAssetDoc> {
199 let mut out = HashMap::new();
200 for (slot, part_id) in spec.iter() {
201 if let Some(asset) = index.get(&part_id) {
202 out.insert(slot, *asset);
203 }
204 }
205 out
206}
207
208const GLB_MAGIC: u32 = 0x4654_6C67; const CHUNK_JSON: u32 = 0x4E4F_534A; pub fn manifest_from_glb(bytes: &[u8]) -> Option<ItemManifest> {
218 let u32_at = |o: usize| -> Option<u32> {
219 bytes
220 .get(o..o + 4)
221 .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
222 };
223 if u32_at(0)? != GLB_MAGIC || u32_at(4)? != 2 {
224 return None;
225 }
226 let chunk_len = u32_at(12)? as usize;
228 if u32_at(16)? != CHUNK_JSON {
229 return None;
230 }
231 let json = bytes.get(20..20 + chunk_len)?;
232 manifest_from_gltf_json(std::str::from_utf8(json).ok()?)
233}
234
235pub fn manifest_from_gltf_json(json: &str) -> Option<ItemManifest> {
237 let doc: serde_json::Value = serde_json::from_str(json).ok()?;
238 let item = doc.get("asset")?.get("extras")?.get("pixygonItem")?;
239 serde_json::from_value(item.clone()).ok()
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use crate::{AvatarSlot, AvatarSpec};
246
247 const EXAMPLE: &str = r#"{
249 "schema": 1,
250 "id": 123456,
251 "kind": "garment",
252 "slot": "Jacket",
253 "title": "Wayfarer's Coat",
254 "description": "…",
255 "lore": ["…"],
256 "codexSlug": "wayfarers-coat",
257 "stats": [ {"id": 40001, "key": "Defense.Defense", "value": 6} ],
258 "placement": { "wornOnBack": false, "offset": [0,0,0], "euler": [0,0,0] },
259 "castingImplement": false, "castPower": 1.0,
260 "glb": { "units": "meters", "up": "+Y", "forward": "+Z", "origin": "anchor" }
261 }"#;
262
263 fn glb_with(json_body: &str) -> Vec<u8> {
264 let mut json = json_body.as_bytes().to_vec();
266 while json.len() % 4 != 0 {
267 json.push(b' '); }
269 let mut glb = Vec::new();
270 glb.extend_from_slice(&super::GLB_MAGIC.to_le_bytes());
271 glb.extend_from_slice(&2u32.to_le_bytes());
272 glb.extend_from_slice(&((12 + 8 + json.len()) as u32).to_le_bytes());
273 glb.extend_from_slice(&(json.len() as u32).to_le_bytes());
274 glb.extend_from_slice(&super::CHUNK_JSON.to_le_bytes());
275 glb.extend_from_slice(&json);
276 glb
277 }
278
279 #[test]
280 fn reads_the_convention_example_from_a_glb() {
281 let gltf = format!(
282 r#"{{ "asset": {{ "version": "2.0", "extras": {{ "pixygonItem": {EXAMPLE} }} }} }}"#
283 );
284 let m = manifest_from_glb(&glb_with(&gltf)).expect("manifest read");
285 assert_eq!(m.schema, 1);
286 assert_eq!(m.id, 123456);
287 assert_eq!(m.kind, ItemKind::Garment);
288 assert_eq!(m.slot, "Jacket");
289 assert_eq!(m.title, "Wayfarer's Coat");
290 assert_eq!(m.codex_slug.as_deref(), Some("wayfarers-coat"));
291 assert_eq!(m.stats[0].id, 40001);
292 assert_eq!(m.stats[0].value, 6.0);
293 assert!(!m.placement.unwrap().worn_on_back);
294 assert_eq!(AvatarSlot::from_name(&m.slot), Some(AvatarSlot::Jacket));
296 }
297
298 #[test]
299 fn non_glb_and_manifestless_glb_read_as_none() {
300 assert!(manifest_from_glb(b"not a glb").is_none());
301 let plain = r#"{ "asset": { "version": "2.0" } }"#;
302 assert!(manifest_from_glb(&glb_with(plain)).is_none());
303 }
304
305 #[test]
306 fn asset_docs_parse_and_resolve_a_spec_by_part_id() {
307 let docs: Vec<AvatarAssetDoc> = serde_json::from_str(
308 r#"[
309 { "_id": "a1", "name": "Wayfarer's Coat", "type": "mesh", "slot": "Jacket",
310 "partId": 123456, "url": "https://cdn.pixygon.io/parts/coat.glb",
311 "attachType": "skinned",
312 "fix": { "scale": 0.01, "rotation": [0, 180, 0],
313 "boneMap": [{ "from": "mixamorig:Hips", "to": "Hips" }] } },
314 { "_id": "a2", "name": "Unkeyed", "type": "mesh", "url": "x.glb" },
315 { "_id": "a3", "name": "Torch", "type": "mesh", "slot": "Offhand",
316 "partId": 777, "url": "torch.glb", "attachType": "bone", "snapBone": "HandR" }
317 ]"#,
318 )
319 .unwrap();
320 let index = index_by_part_id(&docs);
321 assert_eq!(index.len(), 2);
323
324 let mut spec = AvatarSpec::default();
325 spec.set(AvatarSlot::Jacket, 123456);
326 spec.set(AvatarSlot::Offhand, 777);
327 spec.set(AvatarSlot::Hair, 999); let resolved = resolve_spec(&spec, &index);
329 assert_eq!(resolved.len(), 2);
330 assert_eq!(
331 resolved[&AvatarSlot::Jacket].url,
332 "https://cdn.pixygon.io/parts/coat.glb"
333 );
334 let fix = resolved[&AvatarSlot::Jacket].fix.as_ref().unwrap();
335 assert_eq!(fix.scale, Some(0.01));
336 assert_eq!(fix.bone_map[0].to, "Hips");
337 assert_eq!(
338 resolved[&AvatarSlot::Offhand].attach_type,
339 Some(AttachType::Bone)
340 );
341 assert_eq!(
342 resolved[&AvatarSlot::Offhand].snap_bone.as_deref(),
343 Some("HandR")
344 );
345 assert!(!resolved.contains_key(&AvatarSlot::Hair));
346 }
347}