Skip to main content

oxihuman_export/
variant_pack.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Export multiple character mesh variants as a named pack with a JSON manifest.
5
6#![allow(dead_code)]
7
8use anyhow::{Context, Result};
9use oxihuman_mesh::MeshBuffers;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14// ── Types ────────────────────────────────────────────────────────────────────
15
16/// One character variant in the pack.
17#[derive(Serialize, Deserialize, Debug, Clone)]
18pub struct VariantEntry {
19    pub id: String,
20    pub name: String,
21    pub glb_filename: String,
22    pub params: HashMap<String, f32>,
23    pub tags: Vec<String>,
24    pub metadata: HashMap<String, String>,
25}
26
27/// The pack manifest.
28#[derive(Serialize, Deserialize, Debug, Clone)]
29pub struct VariantPackManifest {
30    pub version: String,
31    pub pack_name: String,
32    pub variant_count: usize,
33    pub variants: Vec<VariantEntry>,
34    pub created_at: String,
35}
36
37/// Result of writing a variant pack.
38pub struct VariantPackResult {
39    pub output_dir: PathBuf,
40    pub manifest_path: PathBuf,
41    pub glb_paths: Vec<PathBuf>,
42    pub total_bytes: usize,
43}
44
45// ── Public API ───────────────────────────────────────────────────────────────
46
47/// Build a manifest from a list of variants.
48/// `version` is fixed to "1.0"; `created_at` uses the static ISO 8601 string
49/// "2026-01-01T00:00:00Z" (no external time dependency).
50pub fn build_manifest(pack_name: &str, variants: Vec<VariantEntry>) -> VariantPackManifest {
51    let variant_count = variants.len();
52    VariantPackManifest {
53        version: "1.0".to_string(),
54        pack_name: pack_name.to_string(),
55        variant_count,
56        variants,
57        created_at: "2026-01-01T00:00:00Z".to_string(),
58    }
59}
60
61/// Convenience constructor for `VariantEntry` with empty tags and metadata.
62pub fn variant_entry(
63    id: &str,
64    name: &str,
65    glb_filename: &str,
66    params: HashMap<String, f32>,
67) -> VariantEntry {
68    VariantEntry {
69        id: id.to_string(),
70        name: name.to_string(),
71        glb_filename: glb_filename.to_string(),
72        params,
73        tags: Vec::new(),
74        metadata: HashMap::new(),
75    }
76}
77
78/// Export each mesh variant to a GLB file and write a JSON manifest.
79///
80/// For each `(entry, mesh)` pair the GLB is written to
81/// `output_dir/<entry.glb_filename>`.  After all GLB files are written the
82/// manifest is serialised as `manifest.json` in `output_dir`.
83#[allow(clippy::too_many_arguments)]
84pub fn write_variant_pack(
85    meshes: &[(VariantEntry, &MeshBuffers)],
86    output_dir: &Path,
87    pack_name: &str,
88) -> Result<VariantPackResult> {
89    std::fs::create_dir_all(output_dir)
90        .with_context(|| format!("creating output dir {}", output_dir.display()))?;
91
92    let mut glb_paths = Vec::new();
93    let mut entries = Vec::new();
94    let mut total_bytes: usize = 0;
95
96    for (entry, mesh) in meshes {
97        let glb_path = output_dir.join(&entry.glb_filename);
98        crate::glb::export_glb(mesh, &glb_path)
99            .with_context(|| format!("exporting GLB for variant '{}'", entry.id))?;
100
101        let file_size = std::fs::metadata(&glb_path)
102            .map(|m| m.len() as usize)
103            .unwrap_or(0);
104        total_bytes += file_size;
105
106        glb_paths.push(glb_path);
107        entries.push(entry.clone());
108    }
109
110    let manifest = build_manifest(pack_name, entries);
111    let manifest_path = output_dir.join("manifest.json");
112    let manifest_json =
113        serde_json::to_string_pretty(&manifest).context("serialising manifest to JSON")?;
114    std::fs::write(&manifest_path, manifest_json)
115        .with_context(|| format!("writing manifest to {}", manifest_path.display()))?;
116
117    let manifest_size = std::fs::metadata(&manifest_path)
118        .map(|m| m.len() as usize)
119        .unwrap_or(0);
120    total_bytes += manifest_size;
121
122    Ok(VariantPackResult {
123        output_dir: output_dir.to_path_buf(),
124        manifest_path,
125        glb_paths,
126        total_bytes,
127    })
128}
129
130/// Load and parse a JSON manifest from disk.
131pub fn load_manifest(path: &Path) -> Result<VariantPackManifest> {
132    let content = std::fs::read_to_string(path)
133        .with_context(|| format!("reading manifest at {}", path.display()))?;
134    serde_json::from_str(&content)
135        .with_context(|| format!("parsing manifest at {}", path.display()))
136}
137
138/// Validate a pack directory against a manifest.
139///
140/// Returns a (possibly empty) list of error strings.  Checks:
141/// - `variant_count` matches `variants.len()`
142/// - each GLB file listed in the manifest actually exists in `dir`
143pub fn validate_pack(dir: &Path, manifest: &VariantPackManifest) -> Vec<String> {
144    let mut errors = Vec::new();
145
146    if manifest.variant_count != manifest.variants.len() {
147        errors.push(format!(
148            "variant_count ({}) does not match variants array length ({})",
149            manifest.variant_count,
150            manifest.variants.len()
151        ));
152    }
153
154    for variant in &manifest.variants {
155        let glb_path = dir.join(&variant.glb_filename);
156        if !glb_path.exists() {
157            errors.push(format!(
158                "GLB file missing for variant '{}': {}",
159                variant.id,
160                glb_path.display()
161            ));
162        }
163    }
164
165    errors
166}
167
168/// Return all variants whose `tags` list contains `tag`.
169pub fn filter_variants_by_tag<'a>(
170    manifest: &'a VariantPackManifest,
171    tag: &str,
172) -> Vec<&'a VariantEntry> {
173    manifest
174        .variants
175        .iter()
176        .filter(|v| v.tags.iter().any(|t| t == tag))
177        .collect()
178}
179
180/// Find a variant by its unique `id`.
181pub fn find_variant_by_id<'a>(
182    manifest: &'a VariantPackManifest,
183    id: &str,
184) -> Option<&'a VariantEntry> {
185    manifest.variants.iter().find(|v| v.id == id)
186}
187
188// ── Tests ────────────────────────────────────────────────────────────────────
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use oxihuman_mesh::suit::apply_suit_flag;
194    use oxihuman_mesh::MeshBuffers;
195    use oxihuman_morph::engine::MeshBuffers as MB;
196
197    // ── helpers ──────────────────────────────────────────────────────────────
198
199    fn make_suit_mesh() -> MeshBuffers {
200        let raw = MB {
201            positions: vec![[0.0f32, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
202            normals: vec![[0.0f32, 0.0, 1.0]; 3],
203            uvs: vec![[0.0f32, 0.0]; 3],
204            indices: vec![0, 1, 2],
205            has_suit: false,
206        };
207        let mut mesh = MeshBuffers::from_morph(raw);
208        apply_suit_flag(&mut mesh);
209        mesh
210    }
211
212    fn sample_entry(id: &str, name: &str, glb: &str) -> VariantEntry {
213        VariantEntry {
214            id: id.to_string(),
215            name: name.to_string(),
216            glb_filename: glb.to_string(),
217            params: HashMap::new(),
218            tags: vec!["default".to_string()],
219            metadata: HashMap::new(),
220        }
221    }
222
223    // ── build_manifest ───────────────────────────────────────────────────────
224
225    #[test]
226    fn build_manifest_version_is_1_0() {
227        let m = build_manifest("TestPack", vec![]);
228        assert_eq!(m.version, "1.0");
229    }
230
231    #[test]
232    fn build_manifest_pack_name_stored() {
233        let m = build_manifest("MyPack", vec![]);
234        assert_eq!(m.pack_name, "MyPack");
235    }
236
237    #[test]
238    fn build_manifest_variant_count_matches() {
239        let variants = vec![sample_entry("v0", "Var 0", "v0.glb")];
240        let m = build_manifest("P", variants);
241        assert_eq!(m.variant_count, 1);
242        assert_eq!(m.variants.len(), 1);
243    }
244
245    #[test]
246    fn build_manifest_created_at_static() {
247        let m = build_manifest("P", vec![]);
248        assert_eq!(m.created_at, "2026-01-01T00:00:00Z");
249    }
250
251    #[test]
252    fn build_manifest_empty_variants() {
253        let m = build_manifest("Empty", vec![]);
254        assert_eq!(m.variant_count, 0);
255        assert!(m.variants.is_empty());
256    }
257
258    // ── variant_entry constructor ─────────────────────────────────────────────
259
260    #[test]
261    fn variant_entry_constructor_fields() {
262        let mut params = HashMap::new();
263        params.insert("height".to_string(), 1.75f32);
264        let e = variant_entry("id1", "Human 1", "h1.glb", params.clone());
265        assert_eq!(e.id, "id1");
266        assert_eq!(e.name, "Human 1");
267        assert_eq!(e.glb_filename, "h1.glb");
268        assert_eq!(e.params["height"], 1.75);
269    }
270
271    #[test]
272    fn variant_entry_constructor_empty_tags_and_metadata() {
273        let e = variant_entry("x", "X", "x.glb", HashMap::new());
274        assert!(e.tags.is_empty());
275        assert!(e.metadata.is_empty());
276    }
277
278    // ── filter_variants_by_tag ───────────────────────────────────────────────
279
280    #[test]
281    fn filter_by_tag_returns_matching() {
282        let mut v1 = sample_entry("v1", "V1", "v1.glb");
283        v1.tags = vec!["hero".to_string(), "male".to_string()];
284        let mut v2 = sample_entry("v2", "V2", "v2.glb");
285        v2.tags = vec!["npc".to_string()];
286        let manifest = build_manifest("P", vec![v1, v2]);
287
288        let heroes = filter_variants_by_tag(&manifest, "hero");
289        assert_eq!(heroes.len(), 1);
290        assert_eq!(heroes[0].id, "v1");
291    }
292
293    #[test]
294    fn filter_by_tag_no_match_returns_empty() {
295        let v = sample_entry("v1", "V1", "v1.glb");
296        let manifest = build_manifest("P", vec![v]);
297        let result = filter_variants_by_tag(&manifest, "alien");
298        assert!(result.is_empty());
299    }
300
301    #[test]
302    fn filter_by_tag_multiple_matches() {
303        let mut v1 = sample_entry("v1", "V1", "v1.glb");
304        v1.tags = vec!["shared".to_string()];
305        let mut v2 = sample_entry("v2", "V2", "v2.glb");
306        v2.tags = vec!["shared".to_string()];
307        let manifest = build_manifest("P", vec![v1, v2]);
308
309        let shared = filter_variants_by_tag(&manifest, "shared");
310        assert_eq!(shared.len(), 2);
311    }
312
313    // ── find_variant_by_id ───────────────────────────────────────────────────
314
315    #[test]
316    fn find_variant_by_id_found() {
317        let v = sample_entry("abc", "ABC", "abc.glb");
318        let manifest = build_manifest("P", vec![v]);
319        let found = find_variant_by_id(&manifest, "abc");
320        assert!(found.is_some());
321        assert_eq!(found.expect("should succeed").name, "ABC");
322    }
323
324    #[test]
325    fn find_variant_by_id_not_found() {
326        let manifest = build_manifest("P", vec![]);
327        assert!(find_variant_by_id(&manifest, "missing").is_none());
328    }
329
330    // ── validate_pack ────────────────────────────────────────────────────────
331
332    #[test]
333    fn validate_pack_count_mismatch_reported() {
334        let v = sample_entry("v0", "V0", "v0.glb");
335        let mut manifest = build_manifest("P", vec![v]);
336        // Manually corrupt variant_count
337        manifest.variant_count = 99;
338        let tmp = std::path::PathBuf::from("/tmp");
339        let errors = validate_pack(&tmp, &manifest);
340        assert!(
341            errors.iter().any(|e| e.contains("variant_count")),
342            "expected count-mismatch error, got: {errors:?}"
343        );
344    }
345
346    #[test]
347    fn validate_pack_missing_glb_reported() {
348        let v = sample_entry("v0", "V0", "nonexistent_variant_xyz.glb");
349        let manifest = build_manifest("P", vec![v]);
350        let tmp = std::path::PathBuf::from("/tmp");
351        let errors = validate_pack(&tmp, &manifest);
352        assert!(
353            errors
354                .iter()
355                .any(|e| e.contains("nonexistent_variant_xyz.glb")),
356            "expected missing-GLB error, got: {errors:?}"
357        );
358    }
359
360    // ── write_variant_pack + load_manifest roundtrip ─────────────────────────
361
362    #[test]
363    fn write_and_load_manifest_roundtrip() {
364        let mesh = make_suit_mesh();
365        let entry = variant_entry("rt0", "Roundtrip 0", "rt0.glb", HashMap::new());
366        let out_dir = std::path::PathBuf::from("/tmp/oxihuman_variant_pack_roundtrip");
367
368        let result = write_variant_pack(&[(entry, &mesh)], &out_dir, "RoundtripPack")
369            .expect("write_variant_pack should succeed");
370
371        assert!(result.manifest_path.exists(), "manifest.json must exist");
372        assert_eq!(result.glb_paths.len(), 1);
373        assert!(result.glb_paths[0].exists(), "GLB file must exist");
374
375        let loaded = load_manifest(&result.manifest_path).expect("load_manifest should succeed");
376        assert_eq!(loaded.pack_name, "RoundtripPack");
377        assert_eq!(loaded.variant_count, 1);
378        assert_eq!(loaded.variants[0].id, "rt0");
379    }
380
381    #[test]
382    fn write_variant_pack_total_bytes_nonzero() {
383        let mesh = make_suit_mesh();
384        let entry = variant_entry("b0", "Bytes 0", "b0.glb", HashMap::new());
385        let out_dir = std::path::PathBuf::from("/tmp/oxihuman_variant_pack_bytes");
386
387        let result = write_variant_pack(&[(entry, &mesh)], &out_dir, "BytesPack")
388            .expect("write should succeed");
389
390        assert!(result.total_bytes > 0, "total_bytes should be non-zero");
391    }
392
393    #[test]
394    fn validate_pack_valid_returns_no_errors() {
395        let mesh = make_suit_mesh();
396        let entry = variant_entry("vv0", "Valid 0", "vv0.glb", HashMap::new());
397        let out_dir = std::path::PathBuf::from("/tmp/oxihuman_variant_pack_valid");
398
399        let result = write_variant_pack(&[(entry, &mesh)], &out_dir, "ValidPack")
400            .expect("write should succeed");
401
402        let loaded = load_manifest(&result.manifest_path).expect("load should succeed");
403        let errors = validate_pack(&out_dir, &loaded);
404        assert!(
405            errors.is_empty(),
406            "valid pack should have no errors, got: {errors:?}"
407        );
408    }
409}