Skip to main content

thread_avatar/
lib.rs

1//! infinite-avatar — modular humanoid character definition.
2//!
3//! Rust port of the Pixygon `com.pixygon.avatar` Unity package. This crate holds
4//! the **portable data model** only — which part sits in which slot, plus body
5//! height. Turning a spec into pixels is the renderer's job, behind the
6//! [`AvatarRenderer`] seam (a 3D bone-assembler or a 2D sprite-stack).
7//!
8//! Key ideas carried over from the Unity blueprint:
9//! - An avatar is assembled from **slotted parts** ([`AvatarSlot`]).
10//! - A **race** ([`AvatarRaceMode`]) is a *body mode* (biology defaults + a morph
11//!   weight), not an outfit — a Reptilian and a Human can wear the same shirt.
12//! - Parts are keyed by [`StructuredId`] so they're stable across games.
13
14pub mod manifest;
15pub mod races;
16
17use std::collections::HashMap;
18
19use thread_id::StructuredId;
20use serde::{Deserialize, Serialize};
21
22/// One of the customization points on a humanoid avatar.
23///
24/// Slots fall into four [`SlotCategory`] groups: biology (race-defined), hair,
25/// clothing, and accessories.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub enum AvatarSlot {
28    // --- Biology (set by the race; tweakable) ---
29    Body,
30    SkinType,
31    SkinTone,
32    Eyes,
33    Ears,
34    Tail,
35    Claws,
36    Gills,
37    Webbing,
38    Horns,
39    Wings,
40    Snout,
41    Coat,
42    // --- Hair ---
43    Hair,
44    HairColor,
45    // --- Clothing ---
46    Shirt,
47    Pants,
48    Shoes,
49    Jacket,
50    Headgear,
51    Socks,
52    Gloves,
53    // --- Accessories ---
54    AccessoryHead,
55    AccessoryBody,
56    AccessoryLapel,
57    Offhand,
58}
59
60/// Coarse grouping of [`AvatarSlot`]s.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum SlotCategory {
63    Biology,
64    Hair,
65    Clothing,
66    Accessory,
67}
68
69impl AvatarSlot {
70    /// Every slot, in back-to-front assembly order (body first, accessories last).
71    pub const ALL: [AvatarSlot; 26] = [
72        AvatarSlot::Body,
73        AvatarSlot::SkinType,
74        AvatarSlot::SkinTone,
75        AvatarSlot::Eyes,
76        AvatarSlot::Ears,
77        AvatarSlot::Tail,
78        AvatarSlot::Claws,
79        AvatarSlot::Gills,
80        AvatarSlot::Webbing,
81        AvatarSlot::Horns,
82        AvatarSlot::Wings,
83        AvatarSlot::Snout,
84        AvatarSlot::Coat,
85        AvatarSlot::Hair,
86        AvatarSlot::HairColor,
87        AvatarSlot::Shirt,
88        AvatarSlot::Pants,
89        AvatarSlot::Shoes,
90        AvatarSlot::Jacket,
91        AvatarSlot::Headgear,
92        AvatarSlot::Socks,
93        AvatarSlot::Gloves,
94        AvatarSlot::AccessoryHead,
95        AvatarSlot::AccessoryBody,
96        AvatarSlot::AccessoryLapel,
97        AvatarSlot::Offhand,
98    ];
99
100    /// Which category this slot belongs to.
101    pub fn category(self) -> SlotCategory {
102        use AvatarSlot::*;
103        match self {
104            Body | SkinType | SkinTone | Eyes | Ears | Tail | Claws | Gills | Webbing | Horns
105            | Wings | Snout | Coat => SlotCategory::Biology,
106            Hair | HairColor => SlotCategory::Hair,
107            Shirt | Pants | Shoes | Jacket | Headgear | Socks | Gloves => SlotCategory::Clothing,
108            AccessoryHead | AccessoryBody | AccessoryLapel | Offhand => SlotCategory::Accessory,
109        }
110    }
111
112    /// The slot's wire name — identical to the Unity `AvatarSlot` enum name and
113    /// the slot keys the web Studio saves. This IS the serde form.
114    pub fn name(self) -> &'static str {
115        match self {
116            AvatarSlot::Body => "Body",
117            AvatarSlot::SkinType => "SkinType",
118            AvatarSlot::SkinTone => "SkinTone",
119            AvatarSlot::Eyes => "Eyes",
120            AvatarSlot::Ears => "Ears",
121            AvatarSlot::Tail => "Tail",
122            AvatarSlot::Claws => "Claws",
123            AvatarSlot::Gills => "Gills",
124            AvatarSlot::Webbing => "Webbing",
125            AvatarSlot::Horns => "Horns",
126            AvatarSlot::Wings => "Wings",
127            AvatarSlot::Snout => "Snout",
128            AvatarSlot::Coat => "Coat",
129            AvatarSlot::Hair => "Hair",
130            AvatarSlot::HairColor => "HairColor",
131            AvatarSlot::Shirt => "Shirt",
132            AvatarSlot::Pants => "Pants",
133            AvatarSlot::Shoes => "Shoes",
134            AvatarSlot::Jacket => "Jacket",
135            AvatarSlot::Headgear => "Headgear",
136            AvatarSlot::Socks => "Socks",
137            AvatarSlot::Gloves => "Gloves",
138            AvatarSlot::AccessoryHead => "AccessoryHead",
139            AvatarSlot::AccessoryBody => "AccessoryBody",
140            AvatarSlot::AccessoryLapel => "AccessoryLapel",
141            AvatarSlot::Offhand => "Offhand",
142        }
143    }
144
145    /// Parse a wire name back to a slot (exact match).
146    pub fn from_name(name: &str) -> Option<Self> {
147        Self::ALL.into_iter().find(|s| s.name() == name)
148    }
149
150    /// Is this slot part of the body/biology (set by the race)?
151    pub fn is_biology(self) -> bool {
152        self.category() == SlotCategory::Biology
153    }
154}
155
156/// The resolved, portable description of an avatar: which part id fills each
157/// slot, plus an overall body height. Part id `0` means "empty". Serialize it
158/// into a save, hand it to an [`AvatarBuilder`] to render.
159///
160/// **This is the shared wire shape** across the whole Pixygon ecosystem — the
161/// same JSON the Unity `AvatarSpec`/`AvatarData` bridge and the web
162/// `@pixygon/avatar` Studio speak (avatar-v0.1):
163///
164/// ```json
165/// { "bodyHeight": 0.5, "parts": { "Body": 11010001, "Jacket": 123456 } }
166/// ```
167///
168/// Slot keys are the [`AvatarSlot`] names; values are the stable **partId**s
169/// (`AvatarAsset.partId` — see [`manifest`]). Never key parts by name or
170/// database id.
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct AvatarSpec {
173    #[serde(default)]
174    parts: HashMap<AvatarSlot, u32>,
175    /// 0..1 — lerps the overall body scale.
176    #[serde(rename = "bodyHeight", default = "half")]
177    pub body_height: f32,
178}
179
180fn half() -> f32 {
181    0.5
182}
183
184impl Default for AvatarSpec {
185    fn default() -> Self {
186        Self {
187            parts: HashMap::new(),
188            body_height: 0.5,
189        }
190    }
191}
192
193impl AvatarSpec {
194    pub fn new() -> Self {
195        Self::default()
196    }
197
198    /// Part id in `slot`, or `0` if empty.
199    pub fn get(&self, slot: AvatarSlot) -> u32 {
200        self.parts.get(&slot).copied().unwrap_or(0)
201    }
202
203    /// Place `part_id` in `slot` (id `0` clears it).
204    pub fn set(&mut self, slot: AvatarSlot, part_id: u32) {
205        if part_id == 0 {
206            self.parts.remove(&slot);
207        } else {
208            self.parts.insert(slot, part_id);
209        }
210    }
211
212    pub fn has(&self, slot: AvatarSlot) -> bool {
213        self.parts.contains_key(&slot)
214    }
215
216    pub fn clear(&mut self, slot: AvatarSlot) {
217        self.parts.remove(&slot);
218    }
219
220    /// Iterate the filled (slot, part_id) pairs.
221    pub fn iter(&self) -> impl Iterator<Item = (AvatarSlot, u32)> + '_ {
222        self.parts.iter().map(|(&s, &id)| (s, id))
223    }
224
225    /// Build a spec from a flat `slot-name → partId` config (the shape the web
226    /// Studio saves and `resolveAvatarConfig` consumes). Unknown slot names and
227    /// zero ids are skipped.
228    pub fn from_config(config: &HashMap<String, u32>) -> Self {
229        let mut spec = Self::default();
230        for (name, part_id) in config {
231            if let Some(slot) = AvatarSlot::from_name(name) {
232                spec.set(slot, *part_id);
233            }
234        }
235        spec
236    }
237
238    /// The flat `slot-name → partId` view of this spec (filled slots only).
239    pub fn to_config(&self) -> HashMap<String, u32> {
240        self.parts
241            .iter()
242            .map(|(slot, id)| (slot.name().to_string(), *id))
243            .collect()
244    }
245
246    pub fn filled_count(&self) -> usize {
247        self.parts.len()
248    }
249}
250
251/// A catalog entry: one wearable/biological part, with its 3D and 2D assets.
252///
253/// Asset references are abstract string keys here — the renderer resolves them
254/// to a mesh/material or a sprite. (The Unity package held `GameObject`/`Sprite`
255/// directly; the Rust engine loads by key.)
256#[derive(Debug, Clone)]
257pub struct AvatarPart {
258    pub id: StructuredId,
259    pub slot: AvatarSlot,
260    pub display_name: String,
261    /// Tint applied to the part (skin tone, hair color, …). RGBA 0..1.
262    pub tint: [f32; 4],
263    /// `false` = locked until granted (SkinCard / NFT / account).
264    pub owned_by_default: bool,
265    /// 3D asset key (mesh + material), if this part has a 3D form.
266    pub mesh: Option<String>,
267    /// 2D asset key (sprite), if this part has a 2D form.
268    pub sprite: Option<String>,
269    /// Layer order for the 2D sprite stack (back-to-front).
270    pub sorting_order: i32,
271}
272
273impl AvatarPart {
274    /// A minimal 3D part.
275    pub fn mesh_part(id: StructuredId, slot: AvatarSlot, name: &str, mesh: &str) -> Self {
276        Self {
277            id,
278            slot,
279            display_name: name.to_string(),
280            tint: [1.0, 1.0, 1.0, 1.0],
281            owned_by_default: true,
282            mesh: Some(mesh.to_string()),
283            sprite: None,
284            sorting_order: 0,
285        }
286    }
287}
288
289/// A race = a set of biology slot defaults plus a morph weight. Applying it sets
290/// the biology slots; everything else stays custom.
291#[derive(Debug, Clone)]
292pub struct AvatarRaceMode {
293    pub id: StructuredId,
294    pub name: String,
295    /// Default part ids for (biology) slots.
296    pub slot_defaults: HashMap<AvatarSlot, u32>,
297    /// 1.0 = full race; `< 1.0` blends toward a proto-form.
298    pub morph_weight: f32,
299}
300
301impl AvatarRaceMode {
302    pub fn new(id: StructuredId, name: &str) -> Self {
303        Self {
304            id,
305            name: name.to_string(),
306            slot_defaults: HashMap::new(),
307            morph_weight: 1.0,
308        }
309    }
310
311    pub fn with_default(mut self, slot: AvatarSlot, part_id: u32) -> Self {
312        self.slot_defaults.insert(slot, part_id);
313        self
314    }
315
316    /// Stamp this race's biology defaults onto `spec`.
317    pub fn apply_to(&self, spec: &mut AvatarSpec) {
318        for (&slot, &id) in &self.slot_defaults {
319            spec.set(slot, id);
320        }
321    }
322}
323
324/// The per-game wardrobe: the parts available to choose from.
325#[derive(Debug, Clone, Default)]
326pub struct AvatarCatalog {
327    parts: Vec<AvatarPart>,
328}
329
330impl AvatarCatalog {
331    pub fn new() -> Self {
332        Self::default()
333    }
334
335    pub fn add(&mut self, part: AvatarPart) {
336        self.parts.push(part);
337    }
338
339    /// Look up a part by its structured id.
340    pub fn get(&self, id: StructuredId) -> Option<&AvatarPart> {
341        self.parts.iter().find(|p| p.id == id)
342    }
343
344    /// All parts that can go in `slot` (for a customizer menu).
345    pub fn parts_for_slot(&self, slot: AvatarSlot) -> impl Iterator<Item = &AvatarPart> {
346        self.parts.iter().filter(move |p| p.slot == slot)
347    }
348
349    pub fn len(&self) -> usize {
350        self.parts.len()
351    }
352
353    pub fn is_empty(&self) -> bool {
354        self.parts.is_empty()
355    }
356}
357
358/// The rendering seam. Implemented by a 3D rig-assembler (parent meshes to
359/// bones, swap skin material, scale by body height) or a 2D sprite-stacker.
360pub trait AvatarRenderer {
361    /// Tear down the current body.
362    fn clear(&mut self);
363    /// Set the base body and overall height (0..1).
364    fn set_body(&mut self, body: &AvatarPart, body_height: f32);
365    /// Mount one slot's part.
366    fn set_part(&mut self, slot: AvatarSlot, part: &AvatarPart);
367    /// Finalize (rebuild/bake).
368    fn commit(&mut self);
369}
370
371/// Resolves an [`AvatarSpec`] against a catalog and drives a renderer.
372pub struct AvatarBuilder<'a> {
373    catalog: &'a AvatarCatalog,
374}
375
376impl<'a> AvatarBuilder<'a> {
377    pub fn new(catalog: &'a AvatarCatalog) -> Self {
378        Self { catalog }
379    }
380
381    /// Build `spec` into `renderer`: clear, set the body, mount each filled slot
382    /// in assembly order, then commit. Slots whose part id isn't in the catalog
383    /// are skipped (a missing hat just doesn't render).
384    pub fn build(&self, spec: &AvatarSpec, renderer: &mut impl AvatarRenderer) {
385        renderer.clear();
386
387        // Body first (provides the rig / base layer).
388        let body_id = spec.get(AvatarSlot::Body);
389        if body_id != 0 {
390            if let Some(body) = self.catalog.get(StructuredId(body_id)) {
391                renderer.set_body(body, spec.body_height);
392            }
393        }
394
395        for &slot in AvatarSlot::ALL.iter() {
396            if slot == AvatarSlot::Body {
397                continue;
398            }
399            let id = spec.get(slot);
400            if id == 0 {
401                continue;
402            }
403            if let Some(part) = self.catalog.get(StructuredId(id)) {
404                renderer.set_part(slot, part);
405            }
406        }
407
408        renderer.commit();
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    fn part(id: u32, slot: AvatarSlot, name: &str) -> AvatarPart {
417        AvatarPart::mesh_part(StructuredId(id), slot, name, "mesh_key")
418    }
419
420    /// The 26 slot wire names, in Unity `AvatarSlot` declaration order — this
421    /// pins cross-runtime parity: Unity, `@pixygon/avatar`, and this crate must
422    /// all agree on these strings forever (append-only).
423    #[test]
424    fn slot_wire_names_match_the_unity_enum() {
425        let expected = [
426            "Body",
427            "SkinType",
428            "SkinTone",
429            "Eyes",
430            "Ears",
431            "Tail",
432            "Claws",
433            "Gills",
434            "Webbing",
435            "Horns",
436            "Wings",
437            "Snout",
438            "Coat",
439            "Hair",
440            "HairColor",
441            "Shirt",
442            "Pants",
443            "Shoes",
444            "Jacket",
445            "Headgear",
446            "Socks",
447            "Gloves",
448            "AccessoryHead",
449            "AccessoryBody",
450            "AccessoryLapel",
451            "Offhand",
452        ];
453        assert_eq!(AvatarSlot::ALL.len(), expected.len());
454        for (slot, want) in AvatarSlot::ALL.into_iter().zip(expected) {
455            assert_eq!(slot.name(), want);
456            assert_eq!(AvatarSlot::from_name(want), Some(slot));
457            // serde uses the same strings — the enum IS the wire format.
458            assert_eq!(serde_json::to_string(&slot).unwrap(), format!("\"{want}\""));
459        }
460    }
461
462    /// The shared avatar-v0.1 wire shape parses and round-trips.
463    #[test]
464    fn spec_speaks_the_shared_wire_shape() {
465        let spec: AvatarSpec = serde_json::from_str(
466            r#"{ "bodyHeight": 0.7, "parts": { "Body": 11010001, "Jacket": 123456 } }"#,
467        )
468        .unwrap();
469        assert_eq!(spec.body_height, 0.7);
470        assert_eq!(spec.get(AvatarSlot::Body), 11010001);
471        assert_eq!(spec.get(AvatarSlot::Jacket), 123456);
472        // Partial JSON gets defaults (bodyHeight 0.5, no parts).
473        let bare: AvatarSpec = serde_json::from_str("{}").unwrap();
474        assert_eq!(bare.body_height, 0.5);
475        assert_eq!(bare.filled_count(), 0);
476        // Round-trip preserves the shape.
477        let back: AvatarSpec =
478            serde_json::from_str(&serde_json::to_string(&spec).unwrap()).unwrap();
479        assert_eq!(back.get(AvatarSlot::Jacket), 123456);
480        assert_eq!(back.body_height, 0.7);
481        // Flat Studio config (slot-name → partId) converts both ways.
482        let config = spec.to_config();
483        assert_eq!(config["Jacket"], 123456);
484        let again = AvatarSpec::from_config(&config);
485        assert_eq!(again.get(AvatarSlot::Body), 11010001);
486    }
487
488    #[test]
489    fn slot_categories() {
490        assert_eq!(AvatarSlot::Tail.category(), SlotCategory::Biology);
491        assert!(AvatarSlot::Tail.is_biology());
492        assert_eq!(AvatarSlot::Hair.category(), SlotCategory::Hair);
493        assert_eq!(AvatarSlot::Shirt.category(), SlotCategory::Clothing);
494        assert_eq!(AvatarSlot::Offhand.category(), SlotCategory::Accessory);
495        assert!(!AvatarSlot::Shirt.is_biology());
496        // Every slot is in ALL exactly once.
497        assert_eq!(AvatarSlot::ALL.len(), 26);
498    }
499
500    #[test]
501    fn spec_get_set_clear() {
502        let mut spec = AvatarSpec::new();
503        assert_eq!(spec.body_height, 0.5);
504        assert_eq!(spec.get(AvatarSlot::Shirt), 0);
505        assert!(!spec.has(AvatarSlot::Shirt));
506
507        spec.set(AvatarSlot::Shirt, 42);
508        assert_eq!(spec.get(AvatarSlot::Shirt), 42);
509        assert!(spec.has(AvatarSlot::Shirt));
510        assert_eq!(spec.filled_count(), 1);
511
512        // Setting id 0 clears.
513        spec.set(AvatarSlot::Shirt, 0);
514        assert!(!spec.has(AvatarSlot::Shirt));
515        assert_eq!(spec.filled_count(), 0);
516    }
517
518    #[test]
519    fn race_applies_biology_defaults() {
520        let mut spec = AvatarSpec::new();
521        spec.set(AvatarSlot::Shirt, 100); // custom clothing stays
522
523        let reptilian = AvatarRaceMode::new(StructuredId(1), "Reptilian")
524            .with_default(AvatarSlot::SkinType, 7)
525            .with_default(AvatarSlot::Tail, 8)
526            .with_default(AvatarSlot::Claws, 9);
527        reptilian.apply_to(&mut spec);
528
529        assert_eq!(spec.get(AvatarSlot::SkinType), 7);
530        assert_eq!(spec.get(AvatarSlot::Tail), 8);
531        assert_eq!(spec.get(AvatarSlot::Claws), 9);
532        // Clothing untouched by race.
533        assert_eq!(spec.get(AvatarSlot::Shirt), 100);
534    }
535
536    #[test]
537    fn catalog_lookup() {
538        let mut cat = AvatarCatalog::new();
539        cat.add(part(10, AvatarSlot::Hair, "Spiky"));
540        cat.add(part(11, AvatarSlot::Hair, "Bob"));
541        cat.add(part(20, AvatarSlot::Shirt, "Tunic"));
542
543        assert_eq!(cat.len(), 3);
544        assert_eq!(cat.get(StructuredId(11)).unwrap().display_name, "Bob");
545        assert!(cat.get(StructuredId(999)).is_none());
546        assert_eq!(cat.parts_for_slot(AvatarSlot::Hair).count(), 2);
547        assert_eq!(cat.parts_for_slot(AvatarSlot::Shirt).count(), 1);
548    }
549
550    /// Records the calls the builder makes, so we can assert ordering.
551    #[derive(Default)]
552    struct RecordingRenderer {
553        log: Vec<String>,
554    }
555    impl AvatarRenderer for RecordingRenderer {
556        fn clear(&mut self) {
557            self.log.push("clear".into());
558        }
559        fn set_body(&mut self, body: &AvatarPart, h: f32) {
560            self.log.push(format!("body:{}:{}", body.display_name, h));
561        }
562        fn set_part(&mut self, slot: AvatarSlot, part: &AvatarPart) {
563            self.log
564                .push(format!("part:{:?}:{}", slot, part.display_name));
565        }
566        fn commit(&mut self) {
567            self.log.push("commit".into());
568        }
569    }
570
571    #[test]
572    fn builder_assembles_in_order() {
573        let mut cat = AvatarCatalog::new();
574        cat.add(part(1, AvatarSlot::Body, "HumanBody"));
575        cat.add(part(2, AvatarSlot::Hair, "Spiky"));
576        cat.add(part(3, AvatarSlot::Shirt, "Tunic"));
577
578        let mut spec = AvatarSpec::new();
579        spec.body_height = 0.7;
580        spec.set(AvatarSlot::Body, 1);
581        spec.set(AvatarSlot::Hair, 2);
582        spec.set(AvatarSlot::Shirt, 3);
583        spec.set(AvatarSlot::Pants, 999); // not in catalog → skipped
584
585        let mut r = RecordingRenderer::default();
586        AvatarBuilder::new(&cat).build(&spec, &mut r);
587
588        assert_eq!(r.log[0], "clear");
589        assert_eq!(r.log[1], "body:HumanBody:0.7");
590        // Body comes before parts; Hair (slot order) before Shirt.
591        let hair_idx = r.log.iter().position(|s| s.contains("Spiky")).unwrap();
592        let shirt_idx = r.log.iter().position(|s| s.contains("Tunic")).unwrap();
593        assert!(hair_idx < shirt_idx);
594        assert!(!r.log.iter().any(|s| s.contains("Pants"))); // skipped (missing)
595        assert_eq!(r.log.last().unwrap(), "commit");
596    }
597
598    #[test]
599    fn spec_serde_roundtrip() {
600        let mut spec = AvatarSpec::new();
601        spec.body_height = 0.9;
602        spec.set(AvatarSlot::Hair, 5);
603        spec.set(AvatarSlot::Tail, 8);
604
605        let json = serde_json::to_string(&spec).unwrap();
606        let back: AvatarSpec = serde_json::from_str(&json).unwrap();
607        assert_eq!(back.body_height, 0.9);
608        assert_eq!(back.get(AvatarSlot::Hair), 5);
609        assert_eq!(back.get(AvatarSlot::Tail), 8);
610    }
611}