Skip to main content

oxihuman_morph/
engine.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! The core morph engine: [`HumanEngine`].
5//!
6//! `HumanEngine` drives procedural human-body generation. It holds the base
7//! mesh in Structure-of-Arrays (SoA) form for cache-friendly scatter-add, a
8//! [`TargetLibrary`] of morph targets (each paired with a weight function
9//! evaluated against [`ParamState`]), a result cache keyed on the current
10//! params, and an optional incremental position buffer for fast per-frame
11//! updates when only a subset of target weights changes.
12//!
13//! # Build modes
14//!
15//! | Method | Description |
16//! |---|---|
17//! | [`HumanEngine::build_mesh`] | Single-threaded; uses result cache. |
18//! | [`HumanEngine::build_mesh_parallel`] | Rayon parallel scatter-add. |
19//! | [`HumanEngine::build_mesh_incremental`] | Reuses last position buffer; only re-applies changed-weight targets. |
20
21use 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/// Intermediate mesh buffers returned by [`HumanEngine::build_mesh`].
36///
37/// These are raw SoA→AoS converted buffers straight from the morph engine.
38/// The `oxihuman-mesh` crate wraps them in `oxihuman_mesh::MeshBuffers`,
39/// which recomputes normals and adds tangents before export.
40///
41/// `has_suit` is a safety flag: exporters that produce dressed/clothed output
42/// check this field and refuse to write if `false`, preventing accidental
43/// export of an unclothed mesh.
44#[derive(Debug, Clone, PartialEq)]
45pub struct MeshBuffers {
46    /// Per-vertex XYZ positions after morph application.
47    pub positions: Vec<[f32; 3]>,
48    /// Per-vertex normals (copied verbatim from base mesh; recomputed by `oxihuman-mesh`).
49    pub normals: Vec<[f32; 3]>,
50    /// Per-vertex UV texture coordinates.
51    pub uvs: Vec<[f32; 2]>,
52    /// Triangle index list (groups of 3).
53    pub indices: Vec<u32>,
54    /// Safety flag: `true` when a suit/clothing mesh has been applied.
55    pub has_suit: bool,
56}
57
58impl MeshBuffers {
59    /// Returns true if all position values between `self` and `other` differ by less than `eps`.
60    /// Normals, UVs, and indices are not compared.
61    #[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
75/// Main entry point for the OxiHuman morph engine.
76///
77/// Manages the base mesh, a library of morph targets, parameter state, and
78/// three levels of result caching (exact-params cache, incremental SoA buffer,
79/// and rayon parallel build). All parameter values are clamped to `[0.0, 1.0]`
80/// before any computation.
81///
82/// # Examples
83///
84/// ```rust
85/// use oxihuman_core::parser::obj::parse_obj;
86/// use oxihuman_core::policy::{Policy, PolicyProfile};
87/// use oxihuman_morph::engine::HumanEngine;
88/// use oxihuman_morph::params::ParamState;
89///
90/// let obj = "v 0 0 0\nv 1 0 0\nv 0 1 0\nvn 0 0 1\nvt 0 0\nf 1/1/1 2/1/1 3/1/1\n";
91/// let base = parse_obj(obj).unwrap();
92/// let policy = Policy::new(PolicyProfile::Standard);
93/// let mut engine = HumanEngine::new(base, policy);
94///
95/// engine.set_params(ParamState::new(0.8, 0.5, 0.5, 0.5));
96/// let mesh = engine.build_mesh();
97/// assert_eq!(mesh.positions.len(), 3);
98/// ```
99pub struct HumanEngine {
100    /// Base positions stored as SoA (Structure of Arrays) for cache-friendly scatter-add.
101    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 from the last incremental or full build.
112    cached_positions: Option<Vec<[f32; 3]>>,
113    /// Params used for the last incremental build (to compute weight deltas).
114    last_params: Option<ParamState>,
115}
116
117impl HumanEngine {
118    /// Create engine from a parsed base mesh and a policy.
119    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    /// Load a morph target with a weight function (if policy permits).
142    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        // Invalidate incremental cache: target library changed, positions are stale.
153        self.cached_positions = None;
154        self.last_params = None;
155    }
156
157    /// Load all `.target` files from a directory using a shared weight function factory.
158    /// Targets that fail to parse are skipped with a warning (never panics).
159    /// Returns the number of targets successfully loaded.
160    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    /// Load all `.target` files from a directory using automatic weight functions
192    /// inferred from target filenames (via `weight_curves::auto_weight_fn_for_target`).
193    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    /// Set the current morph parameters, clamped to `[0.0, 1.0]`.
199    ///
200    /// This does **not** invalidate the incremental position cache, so the next
201    /// call to [`Self::build_mesh_incremental`] can compute only the delta
202    /// against the previous parameters.
203    ///
204    /// # Examples
205    ///
206    /// ```rust
207    /// # use oxihuman_core::parser::obj::parse_obj;
208    /// # use oxihuman_core::policy::{Policy, PolicyProfile};
209    /// # use oxihuman_morph::engine::HumanEngine;
210    /// # use oxihuman_morph::params::ParamState;
211    /// # let obj = "v 0 0 0\nv 1 0 0\nv 0 1 0\nvn 0 0 1\nvt 0 0\nf 1/1/1 2/1/1 3/1/1\n";
212    /// # let base = parse_obj(obj).unwrap();
213    /// # let policy = Policy::new(PolicyProfile::Standard);
214    /// # let mut engine = HumanEngine::new(base, policy);
215    /// // Out-of-range values are silently clamped.
216    /// engine.set_params(ParamState::new(2.0, -0.5, 0.5, 0.5));
217    /// let mesh = engine.build_mesh();
218    /// // height was clamped to 1.0, weight to 0.0
219    /// ```
220    pub fn set_params(&mut self, mut p: ParamState) {
221        clamp_params(&mut p);
222        self.params = p;
223    }
224
225    /// Explicitly clear the incremental position cache and last-params snapshot.
226    /// The next call to `build_mesh_incremental` will fall back to a full rebuild.
227    #[allow(dead_code)]
228    pub fn clear_incremental_cache(&mut self) {
229        self.cached_positions = None;
230        self.last_params = None;
231    }
232
233    /// Number of vertices in base mesh.
234    pub fn vertex_count(&self) -> usize {
235        self.base_x.len()
236    }
237
238    /// Number of morph targets loaded into the library.
239    pub fn target_count(&self) -> usize {
240        self.targets.len()
241    }
242
243    /// Replace the policy used for target filtering.
244    pub fn set_policy(&mut self, policy: Policy) {
245        self.policy = policy;
246    }
247
248    /// Apply all active morph targets and return the blended mesh.
249    ///
250    /// If the current [`ParamState`] matches the last call's state the cached
251    /// result is returned immediately without any arithmetic. Otherwise, all
252    /// target weight functions are evaluated and their deltas are scatter-added
253    /// into a clone of the SoA base buffers.
254    ///
255    /// Use [`Self::build_mesh_parallel`] when many targets are loaded and you
256    /// need maximum throughput. Use [`Self::build_mesh_incremental`] for
257    /// interactive sliders where only one or two params change per frame.
258    pub fn build_mesh(&self) -> MeshBuffers {
259        // Return cached result if params haven't changed
260        {
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    /// Build the morphed mesh using rayon parallel target application.
292    /// Faster than `build_mesh()` when many targets are active.
293    /// Uses the same cache as `build_mesh()`.
294    pub fn build_mesh_parallel(&self) -> MeshBuffers {
295        // Check cache first
296        {
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        // Collect all (deltas, weight) pairs
310        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    /// Build the morphed mesh incrementally, reusing the cached position buffer from
329    /// the previous call and only reapplying deltas for targets whose weight changed.
330    ///
331    /// # Strategy
332    /// For each target:
333    ///   - Compute `old_weight` = weight evaluated at `last_params`
334    ///   - Compute `new_weight` = weight evaluated at current `params`
335    ///   - If `old_weight == new_weight`: skip (no change)
336    ///   - Otherwise: subtract old contribution, add new contribution
337    ///
338    /// Falls back to a full `build_mesh()` on the first call (no cache) or after
339    /// `load_target()` / `clear_incremental_cache()`.
340    pub fn build_mesh_incremental(&mut self) -> MeshBuffers {
341        // --- First call or cache invalidated: full rebuild ---
342        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        // Early-out: params haven't changed at all
355        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        // Convert cached AoS positions back to SoA for scatter-add
370        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        // Collect old and new weights for each target (two O(targets) passes).
380        // Targets yielded in stable insertion order — indices line up.
381        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            // Subtract old contribution
395            if old_w != 0.0 {
396                apply_target(&mut x, &mut y, &mut z, deltas, -old_w);
397            }
398            // Add new contribution
399            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        // blocked target → position unchanged
478        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        // Use a small base (3 verts) just to test loading, not positions
485        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            // Build with different params — should work without panic
510            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(); // should hit cache
527        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        // Add a target that shifts vertex 0
538        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(); // must rebuild, not use stale cache
549
550        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        // Invalidate cache by changing params and back
571        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    // ---- Incremental update tests ----
581
582    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    /// `build_mesh()` and `build_mesh_incremental()` must produce identical positions.
590    #[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    /// Change one param, verify result matches a fresh full build.
612    #[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        // First call — initialises cache
622        engine.set_params(ParamState::new(0.0, 0.5, 0.5, 0.5));
623        let _ = engine.build_mesh_incremental();
624
625        // Change height param
626        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        // vertex 1 x should be 1.0 + 2.0 * 0.75 = 2.5
637        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    /// After `load_target`, the incremental cache must be cleared so the next
645    /// `build_mesh_incremental` reflects the newly added target.
646    #[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        // Build with no targets — seeds the cache
653        let _ = engine.build_mesh_incremental();
654        assert!(engine.cached_positions.is_some());
655
656        // Add a target that moves vertex 2 significantly
657        engine.load_target(
658            make_target("new_target", 2, 0.0, 10.0, 0.0),
659            Box::new(|_: &ParamState| 1.0),
660        );
661
662        // Cache must have been cleared
663        assert!(
664            engine.cached_positions.is_none(),
665            "incremental cache should be None after load_target"
666        );
667
668        // Rebuild — must include the new target
669        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    /// Make 3 successive param changes; each incremental result must match a full build.
683    #[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 &param_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    /// With 0 targets loaded, `build_mesh_incremental` must return base positions.
717    #[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        // All positions should equal the base mesh
731        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    /// A target with weight 0.0 must not displace any vertex.
737    #[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        // height param = 0.0 → weight = 0.0
742        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        // vertex 0 should be at base position (0, 0, 0)
750        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            // After clamping, positions must be finite
796            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}