Skip to main content

thread_avatar/
manifest.rs

1//! The **Portable Item Convention** — Rust reference implementation.
2//!
3//! The unit of exchange across the whole Pixygon ecosystem (Unity games, the
4//! web Avatar Studio, and the Thread browser) is **one GLB file that carries
5//! its own meaning**: mesh, materials, textures, AND identity/stats/lore baked
6//! into the file at `asset.extras.pixygonItem` (schema 1, append-only).
7//!
8//! Keep in sync with: `com.pixygon.avatar` `ItemManifest` (C#) and
9//! `@pixygon/avatar/parts` (TypeScript) — this module mirrors those types
10//! field-for-field. See the package's `CONVENTION.md` for the normative rules
11//! (meters · Y-up · +Z forward · origin at grip/anchor/feet · PBR
12//! metallic-roughness · textures embedded · skinned parts bind to the shared
13//! skeleton **by bone name**).
14//!
15//! Everything keys by **partId** (`AvatarAsset.partId` — the stable IdObject
16//! id): never by name, never by database `_id`.
17
18use std::collections::HashMap;
19
20use serde::{Deserialize, Serialize};
21
22/// Item kind — mirrors the Unity `ItemManifest` kinds.
23#[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/// One stat modifier. `id` is the stable Pixygon.Stats catalog id (identity);
34/// `key` is documentation.
35#[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/// Worn/held placement offsets (applied when equipping, not on load).
44#[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    /// metres `[x, y, z]`
50    #[serde(default)]
51    pub offset: Option<[f32; 3]>,
52    /// degrees `[x, y, z]`
53    #[serde(default)]
54    pub euler: Option<[f32; 3]>,
55}
56
57/// GLB spatial declaration — meters, Y-up, +Z forward per the convention.
58#[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/// The item's meaning, baked into the GLB at `asset.extras.pixygonItem`.
71/// Schema 1 — append-only; never mutate client-side.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct ItemManifest {
75    pub schema: u32,
76    /// IdObject full id — same value as `AvatarAsset.partId`. THE shared key.
77    pub id: u32,
78    pub kind: ItemKind,
79    /// [`crate::AvatarSlot`] name; `"hand"` for weapons; `"Body"` for bodies.
80    pub slot: String,
81    pub title: String,
82    #[serde(default)]
83    pub description: Option<String>,
84    #[serde(default)]
85    pub lore: Vec<String>,
86    /// The Codex entry this item IS.
87    #[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/// How a part attaches to the shared skeleton.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "lowercase")]
104pub enum AttachType {
105    /// Skinned mesh — rebinds to the shared skeleton's bones **by name**.
106    Skinned,
107    /// Rigid prop — parents to [`AvatarAssetDoc::snap_bone`].
108    Bone,
109}
110
111/// One bone rename in [`AvatarAssetFix::bone_map`].
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub struct BoneRename {
114    pub from: String,
115    pub to: String,
116}
117
118/// Per-asset load-time corrections stored on `AvatarAsset.fix` — fix a bad
119/// export in data, not by re-exporting. Applied on EVERY load, before
120/// binding, in order: scale → rotation → position, then boneMap renames.
121#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub struct AvatarAssetFix {
124    #[serde(default)]
125    pub scale: Option<f32>,
126    /// euler DEGREES `[x, y, z]`
127    #[serde(default)]
128    pub rotation: Option<[f32; 3]>,
129    /// metres `[x, y, z]`
130    #[serde(default)]
131    pub position: Option<[f32; 3]>,
132    #[serde(default)]
133    pub bone_map: Vec<BoneRename>,
134}
135
136/// The server `AvatarAsset` document (PixygonAPI `/v1/avatar/assets`): one
137/// hosted part GLB plus its catalog metadata.
138#[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    /// `mesh` | `texture` | `animation` | `skeleton`.
146    #[serde(rename = "type", default)]
147    pub asset_type: String,
148    /// `face` | `body` | `clothing` | `hair` | `accessory` | `other`.
149    #[serde(default)]
150    pub category: Option<String>,
151    /// [`crate::AvatarSlot`] name this part fills.
152    #[serde(default)]
153    pub slot: Option<String>,
154    /// The stable IdObject id — the shared key between web, Unity, and saved
155    /// avatar specs. Key everything by this; never by `name` or `_id`.
156    #[serde(default)]
157    pub part_id: Option<u32>,
158    /// The GLB's CDN URL — fetch it like any Thread asset.
159    #[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    /// Bone name rigid parts snap to (attach type `bone`).
166    #[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
180/// Index a catalog by partId. Assets without a partId are skipped (they need
181/// the server-side backfill before they're wearable).
182pub 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
192/// Resolve an [`crate::AvatarSpec`] into asset docs through a partId index —
193/// the same semantics as the web `resolveAvatarConfig`. Slots whose partId the
194/// catalog doesn't know are omitted.
195pub 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
208// --- Reading the meaning out of a GLB ---
209
210const GLB_MAGIC: u32 = 0x4654_6C67; // "glTF"
211const CHUNK_JSON: u32 = 0x4E4F_534A; // "JSON"
212
213/// Read the schema-1 `pixygonItem` manifest baked into a GLB's
214/// `asset.extras.pixygonItem`. Returns `None` for non-GLB bytes or a GLB
215/// without the manifest (fall back to the `.json` sidecar, then to the
216/// catalog's `AvatarAsset` doc — the same order the web reader uses).
217pub 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    // First chunk is required to be the JSON chunk (glTF 2.0 §4.4.3).
227    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
235/// Read the manifest out of glTF JSON text (a `.gltf`, or a GLB's JSON chunk).
236pub 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    /// The schema-1 example straight from CONVENTION.md.
248    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        // Minimal spec-correct GLB: header + one JSON chunk, 4-byte padded.
265        let mut json = json_body.as_bytes().to_vec();
266        while json.len() % 4 != 0 {
267            json.push(b' '); // JSON chunks pad with spaces
268        }
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        // The manifest's slot names resolve to the shared slot enum.
295        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        // The unkeyed asset is skipped — partId is the only key.
322        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); // unknown to the catalog
328        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}