1#![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#[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#[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
37pub 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
45pub 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
61pub 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#[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
130pub 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
138pub 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
168pub 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
180pub 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#[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 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 #[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 #[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 #[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 #[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 #[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 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 #[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}