1use anyhow::{bail, Context, Result};
8use std::path::PathBuf;
9
10use oxihuman_core::policy::{Policy, PolicyProfile};
11use oxihuman_core::{
12 read_signature_file, sign_pack_dir, verify_pack_signature, write_signature_file,
13};
14use oxihuman_export::asset_bundle::{bundle_from_dir, export_bundle, AssetBundle as OxbBundle};
15use oxihuman_export::pack::{build_pack, PackBuilderConfig};
16use oxihuman_export::{
17 from_target_files, morph_delta_stats, pack_mesh_assets, quantize_mesh, quantize_stats,
18 write_morph_delta_bin, write_quantized_bin,
19};
20use oxihuman_mesh::MeshBuffers;
21
22pub fn cmd_pack_build(args: &[String]) -> Result<()> {
25 let mut targets_dir: Option<PathBuf> = None;
26 let mut output: Option<PathBuf> = None;
27 let mut max_targets: Option<usize> = None;
28 let mut strict = false;
29
30 let mut i = 0;
31 while i < args.len() {
32 match args[i].as_str() {
33 "--targets" => {
34 i += 1;
35 targets_dir = Some(PathBuf::from(&args[i]));
36 }
37 "--output" => {
38 i += 1;
39 output = Some(PathBuf::from(&args[i]));
40 }
41 "--max-targets" => {
42 i += 1;
43 max_targets = Some(args[i].parse()?);
44 }
45 "--strict" => {
46 strict = true;
47 }
48 other => bail!("unknown option: {}", other),
49 }
50 i += 1;
51 }
52
53 let targets_dir = targets_dir.context("--targets is required for pack-build")?;
54 if !targets_dir.exists() {
55 bail!("targets directory not found: {}", targets_dir.display());
56 }
57
58 let policy = if strict {
59 Policy::new(PolicyProfile::Strict)
60 } else {
61 Policy::new(PolicyProfile::Standard)
62 };
63
64 eprintln!("OxiHuman: scanning {}...", targets_dir.display());
65 let manifest = build_pack(PackBuilderConfig {
66 targets_dir,
67 policy,
68 max_files: max_targets,
69 })?;
70
71 eprintln!(
72 "OxiHuman: {} files ({} allowed, {} blocked), {} deltas, ~{} KB",
73 manifest.stats.total_files,
74 manifest.stats.allowed_files,
75 manifest.stats.blocked_files,
76 manifest.stats.total_deltas,
77 manifest.stats.estimated_memory_bytes / 1024,
78 );
79
80 if let Some(out) = output {
81 manifest.write_to(&out)?;
82 eprintln!("OxiHuman: manifest -> {}", out.display());
83 } else {
84 println!("{}", manifest.to_toml()?);
85 }
86
87 Ok(())
88}
89
90pub fn cmd_quantize(args: &[String]) -> Result<()> {
93 use oxihuman_core::parser::obj::parse_obj;
94
95 let mut base: Option<PathBuf> = None;
96 let mut output: Option<PathBuf> = None;
97 let mut stats = false;
98
99 let mut i = 0;
100 while i < args.len() {
101 match args[i].as_str() {
102 "--base" => {
103 i += 1;
104 base = Some(PathBuf::from(&args[i]));
105 }
106 "--output" => {
107 i += 1;
108 output = Some(PathBuf::from(&args[i]));
109 }
110 "--stats" => {
111 stats = true;
112 }
113 other => bail!("unknown option: {}", other),
114 }
115 i += 1;
116 }
117
118 let base = base.context("--base is required for quantize")?;
119 let output = output.context("--output is required for quantize")?;
120
121 if !base.exists() {
122 bail!("base mesh not found: {}", base.display());
123 }
124
125 let src = std::fs::read_to_string(&base)
126 .with_context(|| format!("reading OBJ: {}", base.display()))?;
127 let obj = parse_obj(&src).context("parsing OBJ")?;
128 let morph_buf = oxihuman_morph::engine::MeshBuffers {
129 positions: obj.positions,
130 normals: obj.normals,
131 uvs: obj.uvs,
132 indices: obj.indices,
133 has_suit: false,
134 };
135 let mesh = MeshBuffers::from_morph(morph_buf);
136
137 let qmesh = quantize_mesh(&mesh);
138
139 if stats {
140 let qs = quantize_stats(&mesh, &qmesh);
141 println!("Quantize stats:");
142 println!(" position_error_rms: {:.6}", qs.position_error_rms);
143 println!(" normal_error_rms: {:.6}", qs.normal_error_rms);
144 println!(" uv_error_rms: {:.6}", qs.uv_error_rms);
145 println!(" compression_ratio: {:.3}x", qs.compression_ratio);
146 }
147
148 let bytes = write_quantized_bin(&qmesh, &output)
149 .with_context(|| format!("writing QMSH to {}", output.display()))?;
150
151 println!(
152 "Written QMSH: {} vertices, {} indices → {} bytes",
153 qmesh.positions.len(),
154 qmesh.indices.len(),
155 bytes
156 );
157
158 Ok(())
159}
160
161pub fn cmd_morph_export(args: &[String]) -> Result<()> {
164 use oxihuman_core::parser::obj::parse_obj;
165 use oxihuman_core::parser::target::parse_target;
166
167 let mut base: Option<PathBuf> = None;
168 let mut targets_dir: Option<PathBuf> = None;
169 let mut output: Option<PathBuf> = None;
170 let mut max_targets: Option<usize> = None;
171
172 let mut i = 0;
173 while i < args.len() {
174 match args[i].as_str() {
175 "--base" => {
176 i += 1;
177 base = Some(PathBuf::from(&args[i]));
178 }
179 "--targets" => {
180 i += 1;
181 targets_dir = Some(PathBuf::from(&args[i]));
182 }
183 "--output" => {
184 i += 1;
185 output = Some(PathBuf::from(&args[i]));
186 }
187 "--max-targets" => {
188 i += 1;
189 max_targets = Some(args[i].parse()?);
190 }
191 other => bail!("unknown option: {}", other),
192 }
193 i += 1;
194 }
195
196 let base = base.context("--base is required for morph-export")?;
197 let targets_dir = targets_dir.context("--targets is required for morph-export")?;
198 let output = output.context("--output is required for morph-export")?;
199
200 if !base.exists() {
201 bail!("base mesh not found: {}", base.display());
202 }
203 if !targets_dir.exists() {
204 bail!("targets directory not found: {}", targets_dir.display());
205 }
206
207 let src = std::fs::read_to_string(&base)
208 .with_context(|| format!("reading OBJ: {}", base.display()))?;
209 let obj = parse_obj(&src).context("parsing OBJ")?;
210 let vertex_count = obj.positions.len() as u32;
211
212 let mut entries = std::fs::read_dir(&targets_dir)
214 .with_context(|| format!("reading targets dir: {}", targets_dir.display()))?
215 .flatten()
216 .filter(|e| e.path().extension().map(|x| x == "target").unwrap_or(false))
217 .collect::<Vec<_>>();
218 entries.sort_by_key(|e| e.path());
219
220 if let Some(max) = max_targets {
221 entries.truncate(max);
222 }
223
224 let mut target_pairs: Vec<(String, oxihuman_core::parser::target::TargetFile)> = Vec::new();
225 for entry in &entries {
226 let path = entry.path();
227 let name = path
228 .file_stem()
229 .and_then(|s| s.to_str())
230 .unwrap_or_default()
231 .to_string();
232 let text = std::fs::read_to_string(&path)
233 .with_context(|| format!("reading target: {}", path.display()))?;
234 let tf = parse_target(&name, &text)
235 .with_context(|| format!("parsing target: {}", path.display()))?;
236 target_pairs.push((name, tf));
237 }
238
239 let ref_pairs: Vec<(String, &oxihuman_core::parser::target::TargetFile)> =
240 target_pairs.iter().map(|(n, t)| (n.clone(), t)).collect();
241
242 let bin = from_target_files(&ref_pairs, vertex_count);
243 let stats = morph_delta_stats(&bin);
244
245 write_morph_delta_bin(&bin, &output)
246 .with_context(|| format!("writing OXMD to {}", output.display()))?;
247
248 let file_size = std::fs::metadata(&output)?.len() as usize;
249
250 println!(
251 "Written OXMD: {} targets, {} total deltas → {} bytes",
252 bin.targets.len(),
253 stats.total_deltas,
254 file_size
255 );
256
257 Ok(())
258}
259
260pub fn cmd_zip_pack(args: &[String]) -> Result<()> {
263 use oxihuman_core::parser::obj::parse_obj;
264
265 let mut base: Option<PathBuf> = None;
266 let mut targets_dir: Option<PathBuf> = None;
267 let mut output: Option<PathBuf> = None;
268
269 let mut i = 0;
270 while i < args.len() {
271 match args[i].as_str() {
272 "--base" => {
273 i += 1;
274 base = Some(PathBuf::from(&args[i]));
275 }
276 "--targets" => {
277 i += 1;
278 targets_dir = Some(PathBuf::from(&args[i]));
279 }
280 "--output" => {
281 i += 1;
282 output = Some(PathBuf::from(&args[i]));
283 }
284 other => bail!("unknown option: {}", other),
285 }
286 i += 1;
287 }
288
289 let base = base.context("--base is required for zip-pack")?;
290 let targets_dir = targets_dir.context("--targets is required for zip-pack")?;
291 let output = output.context("--output is required for zip-pack")?;
292
293 if !base.exists() {
294 bail!("base mesh not found: {}", base.display());
295 }
296 if !targets_dir.exists() {
297 bail!("targets directory not found: {}", targets_dir.display());
298 }
299
300 let src = std::fs::read_to_string(&base)
302 .with_context(|| format!("reading OBJ: {}", base.display()))?;
303 let obj = parse_obj(&src).context("parsing OBJ")?;
304 let morph_buf = oxihuman_morph::engine::MeshBuffers {
305 positions: obj.positions,
306 normals: obj.normals,
307 uvs: obj.uvs,
308 indices: obj.indices,
309 has_suit: false,
310 };
311 let mut mesh = MeshBuffers::from_morph(morph_buf);
312
313 oxihuman_mesh::normals::compute_normals(&mut mesh);
315 oxihuman_mesh::suit::apply_suit_flag(&mut mesh);
316
317 let tmp_glb = output.with_extension("_tmp.glb");
319 oxihuman_export::glb::export_glb(&mesh, &tmp_glb)
320 .with_context(|| format!("exporting GLB to {}", tmp_glb.display()))?;
321 let glb_bytes = std::fs::read(&tmp_glb)?;
322 let _ = std::fs::remove_file(&tmp_glb);
323
324 let params_json = serde_json::to_vec(&serde_json::json!({
326 "base": base.display().to_string(),
327 "targets": targets_dir.display().to_string(),
328 }))?;
329
330 let target_names: Vec<String> = std::fs::read_dir(&targets_dir)
332 .with_context(|| format!("reading targets dir: {}", targets_dir.display()))?
333 .flatten()
334 .filter(|e| e.path().extension().map(|x| x == "target").unwrap_or(false))
335 .map(|e| e.file_name().to_string_lossy().into_owned())
336 .collect();
337 let manifest_json = serde_json::to_vec(&serde_json::json!({ "targets": target_names }))?;
338
339 let result = pack_mesh_assets(&glb_bytes, ¶ms_json, &manifest_json, &output)
340 .with_context(|| format!("writing ZIP to {}", output.display()))?;
341
342 println!(
343 "Written ZIP pack: {} entries → {}",
344 result.entry_count,
345 output.display()
346 );
347
348 Ok(())
349}
350
351#[allow(dead_code)]
354pub fn cmd_asset_bundle(args: &[String]) -> Result<()> {
355 use oxihuman_core::parser::obj::parse_obj;
356
357 let mut base: Option<PathBuf> = None;
358 let mut targets_dir: Option<PathBuf> = None;
359 let mut output: Option<PathBuf> = None;
360 let mut manifest: Option<PathBuf> = None;
361
362 let mut i = 0;
363 while i < args.len() {
364 match args[i].as_str() {
365 "--base" => {
366 i += 1;
367 base = Some(PathBuf::from(&args[i]));
368 }
369 "--targets" => {
370 i += 1;
371 targets_dir = Some(PathBuf::from(&args[i]));
372 }
373 "--output" => {
374 i += 1;
375 output = Some(PathBuf::from(&args[i]));
376 }
377 "--manifest" => {
378 i += 1;
379 manifest = Some(PathBuf::from(&args[i]));
380 }
381 other => bail!("unknown option: {}", other),
382 }
383 i += 1;
384 }
385
386 let base = base.context("--base is required for asset-bundle")?;
387 let targets_dir = targets_dir.context("--targets is required for asset-bundle")?;
388 let output = output.context("--output is required for asset-bundle")?;
389
390 if !base.exists() {
391 bail!("base mesh not found: {}", base.display());
392 }
393 if !targets_dir.exists() {
394 bail!("targets directory not found: {}", targets_dir.display());
395 }
396
397 let obj_bytes =
399 std::fs::read(&base).with_context(|| format!("reading OBJ: {}", base.display()))?;
400
401 let mut bundle = OxbBundle::new();
403 let base_name = base
404 .file_name()
405 .and_then(|n| n.to_str())
406 .unwrap_or("base.obj")
407 .to_string();
408 bundle
409 .add_bytes(&base_name, obj_bytes)
410 .with_context(|| format!("adding base OBJ '{}' to bundle", base_name))?;
411
412 let obj_src = std::fs::read_to_string(&base)
414 .with_context(|| format!("reading OBJ: {}", base.display()))?;
415 let _obj = parse_obj(&obj_src).context("parsing OBJ")?;
416
417 let target_bundle = bundle_from_dir(&targets_dir)
419 .with_context(|| format!("scanning targets dir: {}", targets_dir.display()))?;
420 let target_count = target_bundle.entry_count();
421 for name in target_bundle.entry_names() {
422 if let Some(entry) = target_bundle.get(name) {
423 let entry_data = entry.data.clone();
424 let bundle_name = format!("targets/{}", name);
426 bundle.add_bytes(bundle_name, entry_data).ok(); }
428 }
429
430 if let Some(ref mp) = manifest {
432 if !mp.exists() {
433 bail!("manifest file not found: {}", mp.display());
434 }
435 let manifest_bytes =
436 std::fs::read(mp).with_context(|| format!("reading manifest: {}", mp.display()))?;
437 bundle.add_bytes("manifest.toml", manifest_bytes).ok();
438 }
439
440 let total_assets = bundle.entry_count();
441 export_bundle(&bundle, &output)
442 .with_context(|| format!("writing bundle: {}", output.display()))?;
443
444 println!(
445 "Written bundle: {} assets → {}",
446 total_assets,
447 output.display()
448 );
449 let _ = target_count; Ok(())
451}
452
453#[allow(dead_code)]
456pub fn cmd_validate_pack(args: &[String]) -> Result<()> {
457 use oxihuman_core::{scan_pack, verify_manifest_present, verify_pack};
458
459 let mut pack_dir: Option<PathBuf> = None;
460
461 let mut i = 0;
462 while i < args.len() {
463 match args[i].as_str() {
464 "--pack-dir" => {
465 i += 1;
466 pack_dir = Some(PathBuf::from(&args[i]));
467 }
468 other => bail!("unknown option: {}", other),
469 }
470 i += 1;
471 }
472
473 let pack_dir = pack_dir.context("--pack-dir is required for validate-pack")?;
474
475 if !pack_dir.exists() {
476 bail!("pack directory not found: {}", pack_dir.display());
477 }
478
479 match verify_manifest_present(&pack_dir) {
481 Ok(()) => println!("Manifest: present"),
482 Err(e) => println!("Manifest: MISSING — {}", e),
483 }
484
485 let records =
487 scan_pack(&pack_dir).with_context(|| format!("scanning pack: {}", pack_dir.display()))?;
488 println!("Scanned {} file(s)", records.len());
489
490 let report = verify_pack(&pack_dir, &records);
491 println!("{}", report.summary());
492
493 if !report.is_valid {
494 if !report.failed_files.is_empty() {
495 println!("Failed files:");
496 for f in &report.failed_files {
497 println!(" FAIL: {}", f);
498 }
499 }
500 if !report.missing_files.is_empty() {
501 println!("Missing files:");
502 for f in &report.missing_files {
503 println!(" MISS: {}", f);
504 }
505 }
506 bail!("pack validation failed");
507 }
508 Ok(())
509}
510
511#[allow(dead_code)]
514pub fn cmd_sign_pack(args: &[String]) -> Result<()> {
515 let mut pack_dir: Option<PathBuf> = None;
516 let mut key_str: Option<String> = None;
517 let mut signer_id = String::from("oxihuman-cli");
518 let mut output: Option<PathBuf> = None;
519
520 let mut i = 0;
521 while i < args.len() {
522 match args[i].as_str() {
523 "--pack-dir" => {
524 i += 1;
525 pack_dir = Some(PathBuf::from(&args[i]));
526 }
527 "--key" => {
528 i += 1;
529 key_str = Some(args[i].clone());
530 }
531 "--signer-id" => {
532 i += 1;
533 signer_id = args[i].clone();
534 }
535 "--output" => {
536 i += 1;
537 output = Some(PathBuf::from(&args[i]));
538 }
539 other => bail!("sign-pack: unknown option: {}", other),
540 }
541 i += 1;
542 }
543
544 let pack_dir = pack_dir.context("--pack-dir is required for sign-pack")?;
545 let key_str = key_str.context("--key is required for sign-pack")?;
546 let output = output.context("--output is required for sign-pack")?;
547
548 if !pack_dir.exists() {
549 bail!("pack-dir not found: {}", pack_dir.display());
550 }
551
552 let signed = sign_pack_dir(&pack_dir, key_str.as_bytes(), &signer_id)
553 .with_context(|| format!("signing pack dir: {}", pack_dir.display()))?;
554 write_signature_file(&signed, &output)
555 .with_context(|| format!("writing signature file: {}", output.display()))?;
556 println!("Pack signed. Signature written to: {}", output.display());
557 Ok(())
558}
559
560#[allow(dead_code)]
563pub fn cmd_verify_sign(args: &[String]) -> Result<()> {
564 let mut pack_dir: Option<PathBuf> = None;
565 let mut sig_file: Option<PathBuf> = None;
566 let mut key_str: Option<String> = None;
567
568 let mut i = 0;
569 while i < args.len() {
570 match args[i].as_str() {
571 "--pack-dir" => {
572 i += 1;
573 pack_dir = Some(PathBuf::from(&args[i]));
574 }
575 "--sig-file" => {
576 i += 1;
577 sig_file = Some(PathBuf::from(&args[i]));
578 }
579 "--key" => {
580 i += 1;
581 key_str = Some(args[i].clone());
582 }
583 other => bail!("verify-sign: unknown option: {}", other),
584 }
585 i += 1;
586 }
587
588 let pack_dir = pack_dir.context("--pack-dir is required for verify-sign")?;
589 let sig_file = sig_file.context("--sig-file is required for verify-sign")?;
590 let key_str = key_str.context("--key is required for verify-sign")?;
591
592 if !pack_dir.exists() {
593 bail!("pack-dir not found: {}", pack_dir.display());
594 }
595 if !sig_file.exists() {
596 bail!("sig-file not found: {}", sig_file.display());
597 }
598
599 let signed = read_signature_file(&sig_file)
600 .with_context(|| format!("reading sig file: {}", sig_file.display()))?;
601 if verify_pack_signature(&pack_dir, &signed, key_str.as_bytes()) {
602 println!("VALID");
603 } else {
604 println!("INVALID");
605 }
606 Ok(())
607}
608
609#[allow(dead_code)]
617pub fn cmd_pack_dist_manifest(args: &[String]) -> Result<()> {
618 let mut pack_dir: Option<PathBuf> = None;
619
620 let mut i = 0;
621 while i < args.len() {
622 match args[i].as_str() {
623 "--pack-dir" => {
624 i += 1;
625 if i >= args.len() {
626 bail!("pack-dist-manifest: --pack-dir requires an argument");
627 }
628 pack_dir = Some(PathBuf::from(&args[i]));
629 }
630 other => bail!("pack-dist-manifest: unknown option: {}", other),
631 }
632 i += 1;
633 }
634
635 let pack_dir = pack_dir.context("--pack-dir is required for pack-dist-manifest")?;
636 if !pack_dir.exists() {
637 bail!("pack-dir not found: {}", pack_dir.display());
638 }
639
640 let manifest = oxihuman_core::asset_pack_builder::generate_distribution_manifest(&pack_dir)
641 .with_context(|| {
642 format!(
643 "generating distribution manifest for: {}",
644 pack_dir.display()
645 )
646 })?;
647 println!("{manifest}");
648 Ok(())
649}
650
651#[allow(dead_code)]
659pub fn cmd_pack_verify_dist(args: &[String]) -> Result<()> {
660 let mut manifest_path: Option<PathBuf> = None;
661 let mut pack_dir: Option<PathBuf> = None;
662
663 let mut i = 0;
664 while i < args.len() {
665 match args[i].as_str() {
666 "--manifest" => {
667 i += 1;
668 if i >= args.len() {
669 bail!("pack-verify-dist: --manifest requires an argument");
670 }
671 manifest_path = Some(PathBuf::from(&args[i]));
672 }
673 "--pack-dir" => {
674 i += 1;
675 if i >= args.len() {
676 bail!("pack-verify-dist: --pack-dir requires an argument");
677 }
678 pack_dir = Some(PathBuf::from(&args[i]));
679 }
680 other => bail!("pack-verify-dist: unknown option: {}", other),
681 }
682 i += 1;
683 }
684
685 let manifest_path = manifest_path.context("--manifest is required for pack-verify-dist")?;
686 let pack_dir = pack_dir.context("--pack-dir is required for pack-verify-dist")?;
687
688 if !manifest_path.exists() {
689 bail!("manifest file not found: {}", manifest_path.display());
690 }
691 if !pack_dir.exists() {
692 bail!("pack-dir not found: {}", pack_dir.display());
693 }
694
695 let json = std::fs::read_to_string(&manifest_path)
696 .with_context(|| format!("reading manifest: {}", manifest_path.display()))?;
697
698 let ok = oxihuman_core::asset_pack_builder::verify_distribution_manifest(&json, &pack_dir)
699 .with_context(|| {
700 format!(
701 "verifying distribution manifest against: {}",
702 pack_dir.display()
703 )
704 })?;
705
706 if ok {
707 println!("Manifest verification: OK");
708 } else {
709 eprintln!("Manifest verification: FAILED");
710 std::process::exit(1);
711 }
712 Ok(())
713}