1use std::cell::RefCell;
22
23use anyhow::Result;
24use oxihuman_core::parser::obj::ObjMesh;
25use oxihuman_core::parser::target::TargetFile;
26use oxihuman_core::policy::Policy;
27
28use crate::apply::{apply_target, apply_targets_parallel, reset_from_base, soa_to_aos};
29use crate::cache::MeshCache;
30use crate::constraint::clamp_params;
31use crate::params::ParamState;
32use crate::target_lib::TargetLibrary;
33use oxihuman_core::parser::target::Delta;
34
35#[derive(Debug, Clone, PartialEq)]
45pub struct MeshBuffers {
46 pub positions: Vec<[f32; 3]>,
48 pub normals: Vec<[f32; 3]>,
50 pub uvs: Vec<[f32; 2]>,
52 pub indices: Vec<u32>,
54 pub has_suit: bool,
56}
57
58impl MeshBuffers {
59 #[allow(dead_code)]
62 pub fn approx_eq(&self, other: &Self, eps: f32) -> bool {
63 if self.positions.len() != other.positions.len() {
64 return false;
65 }
66 self.positions
67 .iter()
68 .zip(other.positions.iter())
69 .all(|(a, b)| {
70 (a[0] - b[0]).abs() < eps && (a[1] - b[1]).abs() < eps && (a[2] - b[2]).abs() < eps
71 })
72 }
73}
74
75pub struct HumanEngine {
100 base_x: Vec<f32>,
102 base_y: Vec<f32>,
103 base_z: Vec<f32>,
104 base_normals: Vec<[f32; 3]>,
105 base_uvs: Vec<[f32; 2]>,
106 indices: Vec<u32>,
107 targets: TargetLibrary,
108 policy: Policy,
109 params: ParamState,
110 cache: RefCell<MeshCache>,
111 cached_positions: Option<Vec<[f32; 3]>>,
113 last_params: Option<ParamState>,
115}
116
117impl HumanEngine {
118 pub fn new(base: ObjMesh, policy: Policy) -> Self {
120 let n = base.positions.len();
121 let mut base_x = vec![0.0f32; n];
122 let mut base_y = vec![0.0f32; n];
123 let mut base_z = vec![0.0f32; n];
124 reset_from_base(&mut base_x, &mut base_y, &mut base_z, &base.positions);
125 HumanEngine {
126 base_x,
127 base_y,
128 base_z,
129 base_normals: base.normals,
130 base_uvs: base.uvs,
131 indices: base.indices,
132 targets: TargetLibrary::new(),
133 policy,
134 params: ParamState::default(),
135 cache: RefCell::new(MeshCache::new()),
136 cached_positions: None,
137 last_params: None,
138 }
139 }
140
141 pub fn load_target(
143 &mut self,
144 t: TargetFile,
145 weight_fn: Box<dyn Fn(&ParamState) -> f32 + Send + Sync>,
146 ) {
147 if !self.policy.is_target_allowed(&t.name, &[]) {
148 return;
149 }
150 self.targets.add(t, weight_fn);
151 self.cache.borrow_mut().invalidate();
152 self.cached_positions = None;
154 self.last_params = None;
155 }
156
157 pub fn load_targets_from_dir<F>(
161 &mut self,
162 dir: &std::path::Path,
163 weight_fn_factory: F,
164 ) -> Result<usize>
165 where
166 F: Fn(&str) -> Box<dyn Fn(&ParamState) -> f32 + Send + Sync>,
167 {
168 use oxihuman_core::parser::target::parse_target;
169 let mut count = 0usize;
170 for entry in std::fs::read_dir(dir)? {
171 let entry = entry?;
172 let path = entry.path();
173 if path.extension().map(|e| e == "target").unwrap_or(false) {
174 let name = path
175 .file_stem()
176 .and_then(|s| s.to_str())
177 .unwrap_or("unknown")
178 .to_string();
179 if let Ok(src) = std::fs::read_to_string(&path) {
180 if let Ok(target) = parse_target(&name, &src) {
181 let wf = weight_fn_factory(&name);
182 self.load_target(target, wf);
183 count += 1;
184 }
185 }
186 }
187 }
188 Ok(count)
189 }
190
191 pub fn load_targets_from_dir_auto(&mut self, dir: &std::path::Path) -> Result<usize> {
194 use crate::weight_curves::auto_weight_fn_for_target;
195 self.load_targets_from_dir(dir, |name| auto_weight_fn_for_target(name))
196 }
197
198 pub fn set_params(&mut self, mut p: ParamState) {
221 clamp_params(&mut p);
222 self.params = p;
223 }
224
225 #[allow(dead_code)]
228 pub fn clear_incremental_cache(&mut self) {
229 self.cached_positions = None;
230 self.last_params = None;
231 }
232
233 pub fn vertex_count(&self) -> usize {
235 self.base_x.len()
236 }
237
238 pub fn target_count(&self) -> usize {
240 self.targets.len()
241 }
242
243 pub fn set_policy(&mut self, policy: Policy) {
245 self.policy = policy;
246 }
247
248 pub fn build_mesh(&self) -> MeshBuffers {
259 {
261 let cache = self.cache.borrow();
262 if cache.is_valid(&self.params) {
263 if let Some(mesh) = cache.get() {
264 return mesh.clone();
265 }
266 }
267 }
268
269 let mut x = self.base_x.clone();
270 let mut y = self.base_y.clone();
271 let mut z = self.base_z.clone();
272
273 for (deltas, weight) in self.targets.iter_weighted(&self.params) {
274 apply_target(&mut x, &mut y, &mut z, deltas, weight);
275 }
276
277 let mesh = MeshBuffers {
278 positions: soa_to_aos(&x, &y, &z),
279 normals: self.base_normals.clone(),
280 uvs: self.base_uvs.clone(),
281 indices: self.indices.clone(),
282 has_suit: false,
283 };
284
285 self.cache
286 .borrow_mut()
287 .store(self.params.clone(), mesh.clone());
288 mesh
289 }
290
291 pub fn build_mesh_parallel(&self) -> MeshBuffers {
295 {
297 let cache = self.cache.borrow();
298 if cache.is_valid(&self.params) {
299 if let Some(mesh) = cache.get() {
300 return mesh.clone();
301 }
302 }
303 }
304
305 let mut x = self.base_x.clone();
306 let mut y = self.base_y.clone();
307 let mut z = self.base_z.clone();
308
309 let weighted: Vec<(&[Delta], f32)> = self.targets.iter_weighted(&self.params).collect();
311
312 apply_targets_parallel(&mut x, &mut y, &mut z, &weighted);
313
314 let mesh = MeshBuffers {
315 positions: soa_to_aos(&x, &y, &z),
316 normals: self.base_normals.clone(),
317 uvs: self.base_uvs.clone(),
318 indices: self.indices.clone(),
319 has_suit: false,
320 };
321
322 self.cache
323 .borrow_mut()
324 .store(self.params.clone(), mesh.clone());
325 mesh
326 }
327
328 pub fn build_mesh_incremental(&mut self) -> MeshBuffers {
341 if self.cached_positions.is_none() || self.last_params.is_none() {
343 let mesh = self.build_mesh();
344 self.cached_positions = Some(mesh.positions.clone());
345 self.last_params = Some(self.params.clone());
346 return mesh;
347 }
348
349 let last = match self.last_params.as_ref() {
350 Some(p) => p.clone(),
351 None => return self.build_mesh(),
352 };
353
354 if last == self.params {
356 let positions = match self.cached_positions.as_ref() {
357 Some(p) => p.clone(),
358 None => return self.build_mesh(),
359 };
360 return MeshBuffers {
361 positions,
362 normals: self.base_normals.clone(),
363 uvs: self.base_uvs.clone(),
364 indices: self.indices.clone(),
365 has_suit: false,
366 };
367 }
368
369 let cached = match self.cached_positions.as_ref() {
371 Some(p) => p,
372 None => return self.build_mesh(),
373 };
374 let n = cached.len();
375 let mut x: Vec<f32> = (0..n).map(|i| cached[i][0]).collect();
376 let mut y: Vec<f32> = (0..n).map(|i| cached[i][1]).collect();
377 let mut z: Vec<f32> = (0..n).map(|i| cached[i][2]).collect();
378
379 let old_weights: Vec<f32> = self.targets.iter_weighted(&last).map(|(_, w)| w).collect();
382 let new_weights: Vec<f32> = self
383 .targets
384 .iter_weighted(&self.params)
385 .map(|(_, w)| w)
386 .collect();
387
388 for (i, (deltas, _)) in self.targets.iter_weighted(&self.params).enumerate() {
389 let old_w = old_weights[i];
390 let new_w = new_weights[i];
391 if (old_w - new_w).abs() < f32::EPSILON {
392 continue;
393 }
394 if old_w != 0.0 {
396 apply_target(&mut x, &mut y, &mut z, deltas, -old_w);
397 }
398 if new_w != 0.0 {
400 apply_target(&mut x, &mut y, &mut z, deltas, new_w);
401 }
402 }
403
404 let new_positions = soa_to_aos(&x, &y, &z);
405 self.cached_positions = Some(new_positions.clone());
406 self.last_params = Some(self.params.clone());
407
408 MeshBuffers {
409 positions: new_positions,
410 normals: self.base_normals.clone(),
411 uvs: self.base_uvs.clone(),
412 indices: self.indices.clone(),
413 has_suit: false,
414 }
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421 use oxihuman_core::parser::target::{Delta, TargetFile};
422 use oxihuman_core::policy::PolicyProfile;
423
424 fn simple_base() -> ObjMesh {
425 ObjMesh {
426 positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
427 normals: vec![[0.0, 0.0, 1.0]; 3],
428 uvs: vec![[0.0, 0.0]; 3],
429 indices: vec![0, 1, 2],
430 }
431 }
432
433 #[test]
434 fn build_mesh_no_targets() {
435 let policy = Policy::new(PolicyProfile::Standard);
436 let engine = HumanEngine::new(simple_base(), policy);
437 let mesh = engine.build_mesh();
438 assert_eq!(mesh.positions.len(), 3);
439 assert!((mesh.positions[0][0] - 0.0).abs() < 1e-6);
440 }
441
442 #[test]
443 fn build_mesh_with_target() {
444 let policy = Policy::new(PolicyProfile::Standard);
445 let mut engine = HumanEngine::new(simple_base(), policy);
446 let t = TargetFile {
447 name: "height".to_string(),
448 deltas: vec![Delta {
449 vid: 1,
450 dx: 0.5,
451 dy: 0.0,
452 dz: 0.0,
453 }],
454 };
455 engine.load_target(t, Box::new(|p: &ParamState| p.height));
456 engine.set_params(ParamState::new(1.0, 0.5, 0.5, 0.5));
457
458 let mesh = engine.build_mesh();
459 assert!((mesh.positions[1][0] - 1.5).abs() < 1e-6);
460 }
461
462 #[test]
463 fn policy_blocks_explicit_target() {
464 let policy = Policy::new(PolicyProfile::Standard);
465 let mut engine = HumanEngine::new(simple_base(), policy);
466 let t = TargetFile {
467 name: "explicit-body".to_string(),
468 deltas: vec![Delta {
469 vid: 0,
470 dx: 100.0,
471 dy: 0.0,
472 dz: 0.0,
473 }],
474 };
475 engine.load_target(t, Box::new(|_: &ParamState| 1.0));
476 let mesh = engine.build_mesh();
477 assert!((mesh.positions[0][0] - 0.0).abs() < 1e-6);
479 }
480
481 #[test]
482 fn load_targets_from_dir_loads_real_targets() {
483 let policy = Policy::new(PolicyProfile::Standard);
484 let mut engine = HumanEngine::new(simple_base(), policy);
486 let dir = std::path::Path::new(
487 "/media/kitasan/Backup/resource/makehuman/makehuman/data/targets/bodyshapes",
488 );
489 if dir.exists() {
490 let count = engine
491 .load_targets_from_dir(dir, |_name| Box::new(|_p: &ParamState| 0.5f32))
492 .expect("should succeed");
493 assert!(count > 0, "should load at least one target");
494 }
495 }
496
497 #[test]
498 fn load_targets_auto_weight() {
499 let policy = Policy::new(PolicyProfile::Standard);
500 let mut engine = HumanEngine::new(simple_base(), policy);
501 let dir = std::path::Path::new(
502 "/media/kitasan/Backup/resource/makehuman/makehuman/data/targets/bodyshapes",
503 );
504 if dir.exists() {
505 let count = engine
506 .load_targets_from_dir_auto(dir)
507 .expect("should succeed");
508 assert!(count > 0);
509 engine.set_params(ParamState::new(0.3, 0.8, 0.2, 0.6));
511 let mesh = engine.build_mesh();
512 for pos in &mesh.positions {
513 assert!(pos[0].is_finite());
514 assert!(pos[1].is_finite());
515 assert!(pos[2].is_finite());
516 }
517 }
518 }
519
520 #[test]
521 fn build_mesh_returns_cached_result() {
522 let policy = Policy::new(PolicyProfile::Standard);
523 let mut engine = HumanEngine::new(simple_base(), policy);
524 engine.set_params(ParamState::new(0.5, 0.5, 0.5, 0.5));
525 let mesh1 = engine.build_mesh();
526 let mesh2 = engine.build_mesh(); assert_eq!(mesh1.positions, mesh2.positions);
528 }
529
530 #[test]
531 fn cache_invalidated_after_new_target() {
532 let policy = Policy::new(PolicyProfile::Standard);
533 let mut engine = HumanEngine::new(simple_base(), policy);
534 engine.set_params(ParamState::new(1.0, 0.5, 0.5, 0.5));
535 let mesh_before = engine.build_mesh();
536
537 let t = TargetFile {
539 name: "shift".to_string(),
540 deltas: vec![Delta {
541 vid: 0,
542 dx: 5.0,
543 dy: 0.0,
544 dz: 0.0,
545 }],
546 };
547 engine.load_target(t, Box::new(|_: &ParamState| 1.0));
548 let mesh_after = engine.build_mesh(); assert_ne!(mesh_before.positions[0], mesh_after.positions[0]);
551 }
552
553 #[test]
554 fn parallel_build_matches_sequential() {
555 let policy = Policy::new(PolicyProfile::Standard);
556 let mut engine = HumanEngine::new(simple_base(), policy);
557 let t = TargetFile {
558 name: "height".to_string(),
559 deltas: vec![Delta {
560 vid: 0,
561 dx: 0.5,
562 dy: 0.0,
563 dz: 0.0,
564 }],
565 };
566 engine.load_target(t, Box::new(|p: &ParamState| p.height));
567 engine.set_params(ParamState::new(0.8, 0.5, 0.5, 0.5));
568
569 let seq = engine.build_mesh();
570 engine.set_params(ParamState::new(0.8, 0.5, 0.5, 0.5));
572 let par = engine.build_mesh_parallel();
573
574 assert_eq!(seq.positions.len(), par.positions.len());
575 for (s, p) in seq.positions.iter().zip(par.positions.iter()) {
576 assert!((s[0] - p[0]).abs() < 1e-5);
577 }
578 }
579
580 fn make_target(name: &str, vid: u32, dx: f32, dy: f32, dz: f32) -> TargetFile {
583 TargetFile {
584 name: name.to_string(),
585 deltas: vec![Delta { vid, dx, dy, dz }],
586 }
587 }
588
589 #[test]
591 fn incremental_matches_full_build() {
592 let policy = Policy::new(PolicyProfile::Standard);
593 let mut engine = HumanEngine::new(simple_base(), policy);
594 engine.load_target(
595 make_target("height", 0, 0.3, 0.0, 0.0),
596 Box::new(|p: &ParamState| p.height),
597 );
598 engine.set_params(ParamState::new(0.8, 0.5, 0.5, 0.5));
599
600 let full = engine.build_mesh();
601 let inc = engine.build_mesh_incremental();
602
603 assert!(
604 full.approx_eq(&inc, 1e-5),
605 "incremental diverged from full build: {:?} vs {:?}",
606 full.positions,
607 inc.positions
608 );
609 }
610
611 #[test]
613 fn incremental_updates_correctly() {
614 let policy = Policy::new(PolicyProfile::Standard);
615 let mut engine = HumanEngine::new(simple_base(), policy);
616 engine.load_target(
617 make_target("height", 1, 2.0, 0.0, 0.0),
618 Box::new(|p: &ParamState| p.height),
619 );
620
621 engine.set_params(ParamState::new(0.0, 0.5, 0.5, 0.5));
623 let _ = engine.build_mesh_incremental();
624
625 engine.set_params(ParamState::new(0.75, 0.5, 0.5, 0.5));
627 let inc = engine.build_mesh_incremental();
628 let full = engine.build_mesh();
629
630 assert!(
631 full.approx_eq(&inc, 1e-5),
632 "after param change: incremental={:?}, full={:?}",
633 inc.positions,
634 full.positions
635 );
636 assert!(
638 (inc.positions[1][0] - 2.5).abs() < 1e-5,
639 "expected 2.5, got {}",
640 inc.positions[1][0]
641 );
642 }
643
644 #[test]
647 fn incremental_cache_invalidated_on_load_target() {
648 let policy = Policy::new(PolicyProfile::Standard);
649 let mut engine = HumanEngine::new(simple_base(), policy);
650 engine.set_params(ParamState::new(1.0, 0.5, 0.5, 0.5));
651
652 let _ = engine.build_mesh_incremental();
654 assert!(engine.cached_positions.is_some());
655
656 engine.load_target(
658 make_target("new_target", 2, 0.0, 10.0, 0.0),
659 Box::new(|_: &ParamState| 1.0),
660 );
661
662 assert!(
664 engine.cached_positions.is_none(),
665 "incremental cache should be None after load_target"
666 );
667
668 let inc = engine.build_mesh_incremental();
670 let full = engine.build_mesh();
671 assert!(
672 full.approx_eq(&inc, 1e-5),
673 "after load_target, incremental should match full build"
674 );
675 assert!(
676 (inc.positions[2][1] - 11.0).abs() < 1e-5,
677 "expected y=11.0, got {}",
678 inc.positions[2][1]
679 );
680 }
681
682 #[test]
684 fn incremental_multiple_param_changes() {
685 let policy = Policy::new(PolicyProfile::Standard);
686 let mut engine = HumanEngine::new(simple_base(), policy);
687 engine.load_target(
688 make_target("height", 0, 1.0, 0.0, 0.0),
689 Box::new(|p: &ParamState| p.height),
690 );
691 engine.load_target(
692 make_target("weight", 1, 0.0, 1.0, 0.0),
693 Box::new(|p: &ParamState| p.weight),
694 );
695
696 let param_sets = [
697 ParamState::new(0.2, 0.8, 0.5, 0.5),
698 ParamState::new(0.6, 0.3, 0.5, 0.5),
699 ParamState::new(1.0, 1.0, 0.5, 0.5),
700 ];
701
702 for params in ¶m_sets {
703 engine.set_params(params.clone());
704 let inc = engine.build_mesh_incremental();
705 let full = engine.build_mesh();
706 assert!(
707 full.approx_eq(&inc, 1e-5),
708 "params {:?}: incremental={:?}, full={:?}",
709 params,
710 inc.positions,
711 full.positions
712 );
713 }
714 }
715
716 #[test]
718 fn incremental_with_no_targets() {
719 let policy = Policy::new(PolicyProfile::Standard);
720 let mut engine = HumanEngine::new(simple_base(), policy);
721 engine.set_params(ParamState::new(0.5, 0.5, 0.5, 0.5));
722
723 let inc = engine.build_mesh_incremental();
724 let full = engine.build_mesh();
725
726 assert!(
727 full.approx_eq(&inc, 1e-6),
728 "no-target incremental should equal full build"
729 );
730 assert!((inc.positions[0][0] - 0.0).abs() < 1e-6);
732 assert!((inc.positions[1][0] - 1.0).abs() < 1e-6);
733 assert!((inc.positions[2][1] - 1.0).abs() < 1e-6);
734 }
735
736 #[test]
738 fn incremental_zero_weight_target_has_no_effect() {
739 let policy = Policy::new(PolicyProfile::Standard);
740 let mut engine = HumanEngine::new(simple_base(), policy);
741 engine.load_target(
743 make_target("height", 0, 999.0, 999.0, 999.0),
744 Box::new(|p: &ParamState| p.height),
745 );
746 engine.set_params(ParamState::new(0.0, 0.5, 0.5, 0.5));
747
748 let inc = engine.build_mesh_incremental();
749 assert!(
751 (inc.positions[0][0] - 0.0).abs() < 1e-6,
752 "zero-weight target shifted vertex 0 x: {}",
753 inc.positions[0][0]
754 );
755 assert!(
756 (inc.positions[0][1] - 0.0).abs() < 1e-6,
757 "zero-weight target shifted vertex 0 y: {}",
758 inc.positions[0][1]
759 );
760 }
761
762 use proptest::prelude::*;
763
764 proptest! {
765 #[test]
766 fn random_params_no_nan(
767 h in 0.0f32..=1.0f32,
768 w in 0.0f32..=1.0f32,
769 m in 0.0f32..=1.0f32,
770 a in 0.0f32..=1.0f32,
771 ) {
772 let policy = Policy::new(PolicyProfile::Standard);
773 let base = simple_base();
774 let mut engine = HumanEngine::new(base, policy);
775 engine.set_params(ParamState::new(h, w, m, a));
776 let mesh = engine.build_mesh();
777 for pos in &mesh.positions {
778 prop_assert!(!pos[0].is_nan(), "NaN in x");
779 prop_assert!(!pos[1].is_nan(), "NaN in y");
780 prop_assert!(!pos[2].is_nan(), "NaN in z");
781 }
782 }
783
784 #[test]
785 fn params_always_clamped(
786 h in -10.0f32..10.0f32,
787 w in -10.0f32..10.0f32,
788 m in -10.0f32..10.0f32,
789 a in -10.0f32..10.0f32,
790 ) {
791 let policy = Policy::new(PolicyProfile::Standard);
792 let mut engine = HumanEngine::new(simple_base(), policy);
793 engine.set_params(ParamState::new(h, w, m, a));
794 let mesh = engine.build_mesh();
795 for pos in &mesh.positions {
797 prop_assert!(pos[0].is_finite());
798 prop_assert!(pos[1].is_finite());
799 prop_assert!(pos[2].is_finite());
800 }
801 }
802 }
803}
804
805#[cfg(test)]
806mod integration_tests {
807 use super::*;
808 use oxihuman_core::parser::obj::parse_obj;
809 use oxihuman_core::parser::target::parse_target;
810 use oxihuman_core::policy::PolicyProfile;
811
812 const TARGETS_DIR: &str = "/media/kitasan/Backup/resource/makehuman/makehuman/data/targets";
813 const BASE_OBJ: &str =
814 "/media/kitasan/Backup/resource/makehuman/makehuman/data/3dobjs/base.obj";
815
816 #[allow(dead_code)]
817 fn walk_targets(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
818 if let Ok(entries) = std::fs::read_dir(dir) {
819 for entry in entries.flatten() {
820 let path = entry.path();
821 if path.is_dir() {
822 walk_targets(&path, out);
823 } else if path.extension().and_then(|e| e.to_str()) == Some("target") {
824 out.push(path);
825 }
826 }
827 }
828 }
829
830 #[test]
831 fn all_targets_parse_without_error() {
832 let dir = std::path::Path::new(TARGETS_DIR);
833 if !dir.exists() {
834 return;
835 }
836 let mut paths = Vec::new();
837 walk_targets(dir, &mut paths);
838 let mut count = 0usize;
839 for path in &paths {
840 let name = path
841 .file_stem()
842 .and_then(|s| s.to_str())
843 .unwrap_or("unknown");
844 let src = std::fs::read_to_string(path)
845 .unwrap_or_else(|_| panic!("Failed to read {:?}", path));
846 let result = parse_target(name, &src);
847 assert!(
848 result.is_ok(),
849 "Failed to parse {:?}: {:?}",
850 path,
851 result.err()
852 );
853 count += 1;
854 }
855 println!("Parsed {} target files successfully", count);
856 }
857
858 #[test]
859 fn all_targets_apply_no_nan() {
860 let base_path = std::path::Path::new(BASE_OBJ);
861 if !base_path.exists() {
862 return;
863 }
864 let dir = std::path::Path::new(TARGETS_DIR);
865 if !dir.exists() {
866 return;
867 }
868 let base_src = std::fs::read_to_string(base_path).expect("Failed to read base.obj");
869 let base_mesh = parse_obj(&base_src).expect("Failed to parse base.obj");
870
871 let mut paths = Vec::new();
872 walk_targets(dir, &mut paths);
873 paths.sort();
874
875 for path in paths.iter().take(50) {
876 let name = path
877 .file_stem()
878 .and_then(|s| s.to_str())
879 .unwrap_or("unknown");
880 let src = match std::fs::read_to_string(path) {
881 Ok(s) => s,
882 Err(_) => continue,
883 };
884 let target = match parse_target(name, &src) {
885 Ok(t) => t,
886 Err(_) => continue,
887 };
888 let policy = Policy::new(PolicyProfile::Standard);
889 let mut engine = HumanEngine::new(base_mesh.clone(), policy);
890 engine.load_target(target, Box::new(|_: &ParamState| 1.0));
891 let mesh = engine.build_mesh();
892 for pos in &mesh.positions {
893 assert!(pos[0].is_finite(), "NaN/Inf in x for {:?}", path);
894 assert!(pos[1].is_finite(), "NaN/Inf in y for {:?}", path);
895 assert!(pos[2].is_finite(), "NaN/Inf in z for {:?}", path);
896 }
897 }
898 }
899
900 #[test]
901 fn multi_target_blend_no_nan() {
902 let base_path = std::path::Path::new(BASE_OBJ);
903 if !base_path.exists() {
904 return;
905 }
906 let dir = std::path::Path::new(TARGETS_DIR);
907 if !dir.exists() {
908 return;
909 }
910 let base_src = std::fs::read_to_string(base_path).expect("Failed to read base.obj");
911 let base_mesh = parse_obj(&base_src).expect("Failed to parse base.obj");
912
913 let mut paths = Vec::new();
914 walk_targets(dir, &mut paths);
915 paths.sort();
916
917 let policy = Policy::new(PolicyProfile::Standard);
918 let mut engine = HumanEngine::new(base_mesh, policy);
919
920 for path in paths.iter().take(20) {
921 let name = path
922 .file_stem()
923 .and_then(|s| s.to_str())
924 .unwrap_or("unknown");
925 let src = match std::fs::read_to_string(path) {
926 Ok(s) => s,
927 Err(_) => continue,
928 };
929 let target = match parse_target(name, &src) {
930 Ok(t) => t,
931 Err(_) => continue,
932 };
933 engine.load_target(target, Box::new(|_: &ParamState| 0.5));
934 }
935
936 let mesh = engine.build_mesh();
937 for pos in &mesh.positions {
938 assert!(pos[0].is_finite(), "NaN/Inf in x after multi-blend");
939 assert!(pos[1].is_finite(), "NaN/Inf in y after multi-blend");
940 assert!(pos[2].is_finite(), "NaN/Inf in z after multi-blend");
941 }
942 }
943
944 #[test]
945 fn target_count_reasonable() {
946 let dir = std::path::Path::new(TARGETS_DIR);
947 if !dir.exists() {
948 return;
949 }
950 let mut paths = Vec::new();
951 walk_targets(dir, &mut paths);
952 assert!(
953 paths.len() > 100,
954 "Expected more than 100 target files, found {}",
955 paths.len()
956 );
957 }
958}