Skip to main content

oxihuman_wasm/
engine_core.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Core `WasmEngine` struct definition, constructors, param setters, and mesh build methods.
5
6use anyhow::Result;
7use oxihuman_core::parser::obj::{parse_obj, ObjMesh};
8use oxihuman_core::policy::{Policy, PolicyProfile};
9use oxihuman_mesh::mesh::MeshBuffers;
10use oxihuman_mesh::normals::compute_normals;
11use oxihuman_mesh::suit::apply_suit_flag;
12use oxihuman_morph::engine::HumanEngine;
13use oxihuman_morph::params::ParamState;
14use oxihuman_morph::weight_curves::auto_weight_fn_for_target;
15use oxihuman_physics::{BodyProxies, ClothSim, WindConfig, WindField};
16
17use crate::buffer::serialize_quantized_to_bytes;
18use crate::pack::scan_zip_local_entries;
19use crate::BUFFER_FORMAT_VERSION;
20
21/// Delta tuple stored for a JSON-loaded morph target: (vertex_id, dx, dy, dz).
22pub(crate) type JsonDelta = (u32, f32, f32, f32);
23
24/// JSON-loaded target map: name -> (deltas, weight).
25pub(crate) type JsonTargetMap = std::collections::HashMap<String, (Vec<JsonDelta>, f32)>;
26
27/// A morph-target weight function: current param state → blend weight.
28type TargetWeightFn = Box<dyn Fn(&ParamState) -> f32 + Send + Sync>;
29
30/// Centimetres per model unit.
31///
32/// OxiHuman model units follow the MakeHuman convention: one unit is one
33/// decimetre (`MODEL_UNIT_MM = 100.0` in `oxihuman_export::core_pack`), i.e.
34/// 10 cm.  All WASM measurement APIs report centimetres.
35pub const MODEL_UNIT_CM: f32 = oxihuman_export::core_pack::MODEL_UNIT_MM / 10.0;
36
37/// Convert the normalised `age` parameter `[0, 1]` to modelled years.
38///
39/// Follows the MakeHuman macro-age convention: `0.0` → 1 year,
40/// `0.5` → 25 years, `1.0` → 90 years (piecewise linear).
41pub fn age_param_to_years(param: f32) -> f32 {
42    let p = param.clamp(0.0, 1.0);
43    if p <= 0.5 {
44        1.0 + p * 48.0
45    } else {
46        25.0 + (p - 0.5) * 130.0
47    }
48}
49
50/// Inverse of [`age_param_to_years`]: modelled years to the normalised `age`
51/// parameter, clamped to `[0, 1]`.
52pub fn age_years_to_param(years: f32) -> f32 {
53    if years <= 25.0 {
54        ((years - 1.0) / 48.0).clamp(0.0, 1.0)
55    } else {
56        (0.5 + (years - 25.0) / 130.0).clamp(0.0, 1.0)
57    }
58}
59
60/// Macro-slider level for a 3-way (min / average / max) MakeHuman modifier.
61#[derive(Clone, Copy, PartialEq, Eq)]
62enum MacroLevel {
63    Min,
64    Avg,
65    Max,
66}
67
68/// Partition-of-unity weight of a 3-level macro modifier at slider value `s`.
69///
70/// `w_min + w_avg + w_max == 1` for every `s in [0, 1]`:
71/// * `Min` ramps 1→0 over `[0, 0.5]`, then 0.
72/// * `Max` is 0 over `[0, 0.5]`, then ramps 0→1.
73/// * `Avg` is the triangular remainder (1 at `s = 0.5`, 0 at the ends).
74fn macro_level_weight(level: MacroLevel, s: f32) -> f32 {
75    let s = s.clamp(0.0, 1.0);
76    let w_min = (1.0 - 2.0 * s).clamp(0.0, 1.0);
77    let w_max = (2.0 * s - 1.0).clamp(0.0, 1.0);
78    match level {
79        MacroLevel::Min => w_min,
80        MacroLevel::Max => w_max,
81        MacroLevel::Avg => (1.0 - w_min - w_max).clamp(0.0, 1.0),
82    }
83}
84
85/// Read the androgyny slider from an extra param (`0` = male, `1` = female).
86/// Defaults to `0.5` (balanced) so male/female corner targets contribute
87/// equally unless a caller drives `set_param("gender", …)`.
88fn gender_slider(p: &ParamState) -> f32 {
89    p.extra
90        .get("gender")
91        .copied()
92        .unwrap_or(0.5)
93        .clamp(0.0, 1.0)
94}
95
96/// Detect the `min|average|max` level for a modifier keyword (e.g. `muscle`)
97/// inside a target basename such as `…-maxmuscle-averageweight`.
98fn detect_level(basename: &str, keyword: &str) -> Option<MacroLevel> {
99    if basename.contains(&format!("max{keyword}")) {
100        Some(MacroLevel::Max)
101    } else if basename.contains(&format!("min{keyword}")) {
102        Some(MacroLevel::Min)
103    } else if basename.contains(&format!("average{keyword}")) {
104        Some(MacroLevel::Avg)
105    } else {
106        None
107    }
108}
109
110/// MakeHuman macro-modifier weight function for a *macrodetails* target,
111/// implementing the partition-of-unity blend the corner targets were authored
112/// for. Returns `None` for targets that are not part of the macro system (the
113/// caller then falls back to the category/name heuristic).
114///
115/// Families handled:
116/// * `universal-{gender}-{age}-{min|average|max}muscle-{…}weight` — the body
117///   corner targets. Weight = `gender · age · muscle · weight` partition
118///   product, so at the neutral slider centre (all `0.5`) every present corner
119///   evaluates to ~0 and the mesh stays at the base (average) body instead of
120///   summing ~30 corners into a giant.
121/// * `…/height/{gender}-…-{min|max}height` — driven by the height slider,
122///   split across the two gender corners.
123/// * `{ethnicity}-{gender}-{age}` — the ethnicity base targets, blended at an
124///   equal `1/3` ethnicity share (there is no ethnicity slider) times the
125///   gender/age partition.
126fn macro_blend_weight_fn(full_name: &str) -> Option<TargetWeightFn> {
127    let basename = full_name
128        .rsplit('/')
129        .next()
130        .unwrap_or(full_name)
131        .to_lowercase();
132    // "female" contains "male", so test the more specific token first.
133    let is_female = basename.contains("female");
134    let is_male = !is_female && basename.contains("male");
135    let is_young = basename.contains("young");
136    let is_old = basename.contains("old");
137
138    let gender_term = move |p: &ParamState| -> f32 {
139        let g = gender_slider(p);
140        if is_female {
141            g
142        } else if is_male {
143            1.0 - g
144        } else {
145            1.0
146        }
147    };
148    let age_term = move |p: &ParamState| -> f32 {
149        if is_young {
150            1.0 - p.age.clamp(0.0, 1.0)
151        } else if is_old {
152            p.age.clamp(0.0, 1.0)
153        } else {
154            1.0
155        }
156    };
157
158    // Height corner targets (…/height/… or a `min|maxheight` suffix).
159    //
160    // In this core pack *both* the `minheight` and `maxheight` corner deltas
161    // increase stature relative to the base mesh (the base is already the
162    // shortest configuration — there is no genuine stature-reducing corner in
163    // the core tier). A naive partition-of-unity blend is therefore U-shaped
164    // (both slider ends taller than the centre), which is unusable for a
165    // height slider. We instead drive only the `maxheight` corner with
166    // `w_max(height)`, so stature is monotonic non-decreasing: the base body
167    // (~170 cm) for `height <= 0.5`, ramping up to the tall corner at
168    // `height = 1.0`. `minheight` is left inert (weight 0). Modelling
169    // below-base stature would require a dedicated shortening target the core
170    // pack does not ship.
171    if basename.contains("height") {
172        let level = detect_level(&basename, "height")?;
173        return Some(Box::new(move |p: &ParamState| match level {
174            MacroLevel::Max => gender_term(p) * macro_level_weight(MacroLevel::Max, p.height),
175            MacroLevel::Min | MacroLevel::Avg => 0.0,
176        }));
177    }
178
179    // Universal body corner targets.
180    if basename.contains("universal") {
181        let muscle = detect_level(&basename, "muscle");
182        let weight = detect_level(&basename, "weight");
183        if let (Some(ml), Some(wl)) = (muscle, weight) {
184            return Some(Box::new(move |p: &ParamState| {
185                gender_term(p)
186                    * age_term(p)
187                    * macro_level_weight(ml, p.muscle)
188                    * macro_level_weight(wl, p.weight)
189            }));
190        }
191    }
192
193    // Ethnicity base targets: `{ethnicity}-{gender}-{young}` with no
194    // muscle/weight tokens. Equal 1/3 ethnicity share (no ethnicity slider).
195    let is_ethnicity = ["african", "asian", "caucasian"]
196        .iter()
197        .any(|e| basename.contains(e));
198    if is_ethnicity && (is_male || is_female) {
199        const ETHNICITY_SHARE: f32 = 1.0 / 3.0;
200        return Some(Box::new(move |p: &ParamState| {
201            ETHNICITY_SHARE * gender_term(p) * age_term(p)
202        }));
203    }
204
205    None
206}
207
208/// Choose a weight function for a core-pack target.
209///
210/// Preference order:
211/// 1. A MakeHuman macro-modifier blend when the target name matches the
212///    macrodetails corner-target naming ([`macro_blend_weight_fn`]) — this
213///    composes the corner targets as a partition of unity instead of summing
214///    them, so `set_param("height"|"weight"|"muscle"|"age"|"gender")` drives a
215///    realistically-scaled body.
216/// 2. The pack-declared `category` when it names a known
217///    [`oxihuman_core::category::TargetCategory`] (so `set_param("height")`
218///    etc. drives the target).
219/// 3. When the declared category is unknown, a macro category
220///    (height/weight/muscle/age **only**) inferred from the target name
221///    (`weight_curves::infer_category_from_name`).
222/// 4. Fallback: an extra param keyed by the *target name*, defaulting to
223///    `0.0` so unknown targets stay inert until explicitly driven via
224///    `set_param(name, w)`.
225fn pack_weight_fn(name: &str, category: &str) -> TargetWeightFn {
226    use oxihuman_core::category::TargetCategory;
227    use oxihuman_morph::weight_curves::{auto_weight_fn, infer_category_from_name};
228
229    // MakeHuman macro corner targets: compose via partition of unity.
230    if let Some(wf) = macro_blend_weight_fn(name) {
231        return wf;
232    }
233
234    let cat = match TargetCategory::from_str(category) {
235        // Unknown declared category: trust only a *macro* inference from the
236        // name (the generic fallback of `infer_category_from_name` is
237        // BodyShapes, which would silently activate the target at default
238        // params — packs with unknown categories must stay inert instead).
239        TargetCategory::Other(_) => match infer_category_from_name(name) {
240            c @ (TargetCategory::Height
241            | TargetCategory::Weight
242            | TargetCategory::Muscle
243            | TargetCategory::Age) => c,
244            _ => TargetCategory::Other(name.to_string()),
245        },
246        known => known,
247    };
248    match cat {
249        TargetCategory::Other(_) => {
250            let key = name.to_string();
251            Box::new(move |p: &ParamState| p.extra.get(&key).copied().unwrap_or(0.0))
252        }
253        known => auto_weight_fn(known.as_str()),
254    }
255}
256
257/// A simple point particle system stored in the engine.
258#[derive(Debug, Clone)]
259pub struct ParticleSystem {
260    pub emit_rate: f32,
261    pub lifetime: f32,
262    pub particles: Vec<Particle>,
263    pub time_accum: f32,
264}
265
266/// A single active particle.
267#[derive(Debug, Clone)]
268pub struct Particle {
269    pub position: [f32; 3],
270    pub velocity: [f32; 3],
271    pub age: f32,
272    pub lifetime: f32,
273}
274
275/// A human body generator that can be driven from WASM (or native Rust).
276pub struct WasmEngine {
277    pub(crate) engine: HumanEngine,
278    pub(crate) params: ParamState,
279    pub(crate) last_mesh: Option<MeshBuffers>,
280    /// Names of currently loaded morph targets (in load order).
281    pub(crate) target_names: Vec<String>,
282    // -- JSON-loaded targets: name -> (deltas, weight) --
283    pub(crate) json_targets: JsonTargetMap,
284    // -- Animation state --
285    pub(crate) anim_frames: Vec<std::collections::HashMap<String, f32>>,
286    pub(crate) anim_current_frame: usize,
287    pub(crate) anim_fps: f32,
288    #[allow(dead_code)]
289    pub(crate) anim_playing: bool,
290    pub(crate) anim_accum: f32,
291    // -- Particle system --
292    pub(crate) particle_sys: Option<ParticleSystem>,
293    // -- Physics --
294    pub(crate) wind_config: Option<WindConfig>,
295    pub(crate) cloth_sim: Option<ClothSim>,
296    pub(crate) body_proxies: Option<BodyProxies>,
297    /// Accumulated simulation time (seconds), used for wind field sampling.
298    pub(crate) sim_time: f32,
299    // -- Persistent zero-copy geometry buffers (M4) --
300    /// Flat XYZ positions (`3 * n_verts` floats), kept at a stable address
301    /// between rebuilds so JS can hold a `Float32Array` view over them.
302    pub(crate) geo_positions: Vec<f32>,
303    /// Flat XYZ normals (`3 * n_verts` floats).
304    pub(crate) geo_normals: Vec<f32>,
305    /// Flat UV coordinates (`2 * n_verts` floats).
306    pub(crate) geo_uvs: Vec<f32>,
307    /// Triangle index list.
308    pub(crate) geo_indices: Vec<u32>,
309    /// Bumped whenever the persistent buffers are (re)allocated (topology or
310    /// vertex-count change) — JS must re-create its typed-array views then.
311    pub(crate) geo_generation: u32,
312    /// True when params changed since the last `refresh_geometry()`.
313    pub(crate) geo_dirty: bool,
314    // -- Safety --
315    /// Minimum modelled age in years (from a core-pack manifest).  When set,
316    /// the `age` parameter is clamped so the modelled age never goes below
317    /// this floor (see [`age_years_to_param`]).
318    pub(crate) age_floor_years: Option<f32>,
319    // -- Measurement acceleration --
320    /// Cached positions-independent measurer topology (body boundary +
321    /// triangle list). Morphs never change topology, so a fit's dozens of
322    /// re-measurements share one derivation; invalidated whenever the base
323    /// mesh is replaced (see [`Self::invalidate_geometry_buffers`]).
324    pub(crate) measurer_topology: Option<oxihuman_morph::measurements::MeasurerTopology>,
325}
326
327impl WasmEngine {
328    /// Shared constructor from a parsed base mesh and policy.
329    fn from_base(base: ObjMesh, policy: Policy) -> Self {
330        WasmEngine {
331            engine: HumanEngine::new(base, policy),
332            params: ParamState::default(),
333            last_mesh: None,
334            target_names: Vec::new(),
335            json_targets: std::collections::HashMap::new(),
336            anim_frames: Vec::new(),
337            anim_current_frame: 0,
338            anim_fps: 24.0,
339            anim_playing: false,
340            anim_accum: 0.0,
341            particle_sys: None,
342            wind_config: None,
343            cloth_sim: None,
344            body_proxies: None,
345            sim_time: 0.0,
346            geo_positions: Vec::new(),
347            geo_normals: Vec::new(),
348            geo_uvs: Vec::new(),
349            geo_indices: Vec::new(),
350            geo_generation: 0,
351            geo_dirty: true,
352            age_floor_years: None,
353            measurer_topology: None,
354        }
355    }
356
357    /// Create a new engine with a built-in minimal stub mesh (one triangle).
358    ///
359    /// Infallible by construction: the base mesh is built as an [`ObjMesh`]
360    /// literal, no parsing involved. Replace it with a real base mesh via
361    /// [`Self::load_core_pack_bytes`] or [`Self::load_zip_pack_bytes`].
362    pub fn new_stub() -> Self {
363        let base = ObjMesh {
364            positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
365            normals: vec![[0.0, 0.0, 1.0]; 3],
366            uvs: vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]],
367            indices: vec![0, 1, 2],
368        };
369        Self::from_base(base, Policy::new(PolicyProfile::Standard))
370    }
371
372    /// Create a new engine from raw OBJ bytes (UTF-8 text).
373    pub fn new_from_obj_bytes(obj_bytes: &[u8]) -> Result<Self> {
374        let src = std::str::from_utf8(obj_bytes)?;
375        let base = parse_obj(src)?;
376        Ok(Self::from_base(base, Policy::new(PolicyProfile::Standard)))
377    }
378
379    /// Create with a strict policy (only allowlisted targets accepted).
380    pub fn new_strict(obj_bytes: &[u8]) -> Result<Self> {
381        let src = std::str::from_utf8(obj_bytes)?;
382        let base = parse_obj(src)?;
383        Ok(Self::from_base(base, Policy::new(PolicyProfile::Strict)))
384    }
385
386    /// Create a new engine from OHPK core-pack bytes (see
387    /// [`Self::load_core_pack_bytes`]).
388    pub fn new_from_core_pack_bytes(pack_bytes: &[u8]) -> Result<Self> {
389        let mut engine = Self::new_stub();
390        engine.load_core_pack_bytes(pack_bytes)?;
391        Ok(engine)
392    }
393
394    /// Load an OHPK v1 core pack from bytes, replacing the base mesh and the
395    /// whole morph-target store.
396    ///
397    /// - The pack's base positions/indices/UVs become the new base mesh
398    ///   (normals are recomputed on every build, so placeholders are fine).
399    /// - Every pack target is loaded into the *engine* target store with a
400    ///   weight function derived from its category, so
401    ///   `set_param("height"|"weight"|"muscle"|"age", v)` drives it (see
402    ///   `pack_weight_fn`). Unknown categories are driven by an extra param
403    ///   keyed by the target name (inert until set).
404    /// - `manifest.age_floor_years` is honoured: the `age` param is clamped
405    ///   so the modelled age never drops below the floor.
406    ///
407    /// Returns the number of targets loaded.
408    pub fn load_core_pack_bytes(&mut self, pack_bytes: &[u8]) -> Result<usize> {
409        use oxihuman_core::parser::target::{Delta, TargetFile};
410        use oxihuman_export::CorePack;
411
412        let pack = CorePack::parse(pack_bytes)?;
413
414        let n_verts = pack.vertex_count();
415        let flat = pack.base_positions();
416        anyhow::ensure!(
417            flat.len() == n_verts * 3,
418            "core pack position buffer length {} != 3 * n_verts ({})",
419            flat.len(),
420            n_verts * 3
421        );
422        let positions: Vec<[f32; 3]> = flat.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect();
423        let uvs: Vec<[f32; 2]> = match pack.base_uvs() {
424            Some(flat_uv) => {
425                anyhow::ensure!(
426                    flat_uv.len() == n_verts * 2,
427                    "core pack UV buffer length {} != 2 * n_verts ({})",
428                    flat_uv.len(),
429                    n_verts * 2
430                );
431                flat_uv.chunks_exact(2).map(|c| [c[0], c[1]]).collect()
432            }
433            None => vec![[0.0, 0.0]; n_verts],
434        };
435        let base = ObjMesh {
436            positions,
437            // Placeholder normals: `build_mesh_prepared` / `refresh_geometry`
438            // recompute real normals from the morphed positions every build.
439            normals: vec![[0.0, 0.0, 1.0]; n_verts],
440            uvs,
441            indices: pack.base_indices().to_vec(),
442        };
443
444        self.engine = HumanEngine::new(base, Policy::new(PolicyProfile::Standard));
445        self.params = ParamState::default();
446        self.target_names.clear();
447        self.json_targets.clear();
448        self.last_mesh = None;
449        self.body_proxies = None;
450        self.cloth_sim = None;
451        self.invalidate_geometry_buffers();
452
453        // Safety floor from the manifest, applied before any param commit.
454        self.age_floor_years = pack.manifest().age_floor_years;
455
456        let mut loaded = 0usize;
457        for target in pack.targets() {
458            let deltas: Vec<Delta> = target
459                .sparse()
460                .into_iter()
461                .map(|(vid, d)| Delta {
462                    vid,
463                    dx: d[0],
464                    dy: d[1],
465                    dz: d[2],
466                })
467                .collect();
468            let tf = TargetFile {
469                name: target.name().to_string(),
470                deltas,
471            };
472            let weight_fn = pack_weight_fn(target.name(), target.category());
473            let before = self.engine.target_count();
474            self.engine.load_target(tf, weight_fn);
475            if self.engine.target_count() > before {
476                self.target_names.push(target.name().to_string());
477                loaded += 1;
478            }
479        }
480
481        // Re-commit params so the age floor takes effect immediately.
482        let params = self.params.clone();
483        self.commit_params(params);
484        Ok(loaded)
485    }
486
487    /// The minimum modelled age in years, if a core pack declared one.
488    pub fn age_floor_years(&self) -> Option<f32> {
489        self.age_floor_years
490    }
491
492    /// Load a morph target from raw .target file bytes.
493    /// The `name` is used to infer the category and auto-assign a weight function.
494    pub fn load_target_bytes(&mut self, name: &str, target_bytes: &[u8]) -> Result<()> {
495        use oxihuman_core::parser::target::parse_target;
496        let src = std::str::from_utf8(target_bytes)?;
497        let target = parse_target(name, src)?;
498        let before = self.engine.target_count();
499        let weight_fn = auto_weight_fn_for_target(name);
500        self.engine.load_target(target, weight_fn);
501        // Only record the name when the engine actually accepted the target.
502        if self.engine.target_count() > before {
503            self.target_names.push(name.to_string());
504        }
505        self.last_mesh = None; // Invalidate cached mesh
506        self.geo_dirty = true;
507        Ok(())
508    }
509
510    // -- ZIP pack loader --
511
512    /// Load a ZIP asset pack from raw bytes (in-memory).
513    ///
514    /// The ZIP must contain:
515    /// - One file named `base.obj` (or ending in `.obj`) -- the base mesh.
516    /// - Zero or more files ending in `.target` -- morph targets.
517    ///
518    /// Parses all entries inline by scanning local file headers
519    /// (signature `0x04034B50`, STORE compression only -- no decompression).
520    /// Re-initialises the engine with the new base mesh, then loads all targets.
521    ///
522    /// Returns the number of morph targets loaded.
523    pub fn load_zip_pack_bytes(&mut self, zip_bytes: &[u8]) -> Result<usize> {
524        let entries = scan_zip_local_entries(zip_bytes)?;
525
526        // Find the .obj entry.
527        let obj_entry = entries
528            .iter()
529            .find(|(name, _)| name == "base.obj" || name.ends_with(".obj"))
530            .ok_or_else(|| anyhow::anyhow!("ZIP pack contains no .obj entry"))?;
531
532        // Re-initialise engine with new base mesh.
533        let src = std::str::from_utf8(&obj_entry.1)
534            .map_err(|e| anyhow::anyhow!("base.obj is not valid UTF-8: {e}"))?;
535        let base = parse_obj(src)?;
536        let policy = Policy::new(PolicyProfile::Standard);
537        self.engine = HumanEngine::new(base, policy);
538        self.params = ParamState::default();
539        self.target_names.clear();
540        self.json_targets.clear();
541        self.last_mesh = None;
542        self.invalidate_geometry_buffers();
543
544        // Load all .target entries.
545        let mut loaded = 0usize;
546        for (name, data) in &entries {
547            if name.ends_with(".target") {
548                let stem = name
549                    .strip_suffix(".target")
550                    .unwrap_or(name.as_str())
551                    .rsplit('/')
552                    .next()
553                    .unwrap_or(name.as_str());
554                self.load_target_bytes(stem, data)?;
555                loaded += 1;
556            }
557        }
558
559        Ok(loaded)
560    }
561
562    // -- Target name listing --
563
564    /// Returns a JSON array of target names currently loaded.
565    ///
566    /// Example: `["height","weight","muscle"]`
567    ///
568    /// Falls back to `{"count":<n>}` only when the internal list is somehow
569    /// out of sync with the engine (should never occur in normal use).
570    pub fn list_loaded_targets(&self) -> String {
571        let count = self.engine.target_count();
572        if self.target_names.len() == count {
573            // Produce a JSON array.
574            let items: Vec<String> = self
575                .target_names
576                .iter()
577                .map(|n| format!("\"{}\"", n.replace('\\', "\\\\").replace('"', "\\\"")))
578                .collect();
579            format!("[{}]", items.join(","))
580        } else {
581            // Fallback: engine count differs from our tracking -- return count.
582            format!("{{\"count\":{count}}}")
583        }
584    }
585
586    // -- Quantized mesh export --
587
588    /// Build the morphed mesh, quantize it, and return the QMSH binary bytes.
589    ///
590    /// Binary layout (matches `write_quantized_bin`):
591    /// ```text
592    /// Bytes  0..4   : magic  b"QMSH"
593    /// Bytes  4..8   : version u32 LE  (= 1)
594    /// Bytes  8..12  : vertex_count u32 LE
595    /// Bytes 12..16  : index_count  u32 LE
596    /// Then: 6 f32s (3 x min/max for pos_range) LE
597    /// Then: vertex_count x 6 bytes  (u16x3 positions, LE)
598    /// Then: vertex_count x 3 bytes  (i8x3 normals)
599    /// Then: vertex_count x 4 bytes  (u16x2 uvs, LE)
600    /// Then: index_count  x 4 bytes  (u32 indices, LE)
601    /// Then: 1 byte has_suit flag
602    /// ```
603    pub fn export_quantized_bytes(&mut self) -> Vec<u8> {
604        use oxihuman_export::mesh_quantize::quantize_mesh;
605
606        let mesh = self.build_mesh_prepared();
607        let q = quantize_mesh(&mesh);
608        serialize_quantized_to_bytes(&q)
609    }
610
611    // -- Param setters --
612
613    /// Set the height parameter [0.0, 1.0].
614    pub fn set_height(&mut self, v: f32) {
615        self._update_param(|p| p.height = v);
616    }
617    /// Set the weight parameter [0.0, 1.0].
618    pub fn set_weight(&mut self, v: f32) {
619        self._update_param(|p| p.weight = v);
620    }
621    /// Set the muscle parameter [0.0, 1.0].
622    pub fn set_muscle(&mut self, v: f32) {
623        self._update_param(|p| p.muscle = v);
624    }
625    /// Set the age parameter [0.0, 1.0].
626    pub fn set_age(&mut self, v: f32) {
627        self._update_param(|p| p.age = v);
628    }
629
630    /// Set an arbitrary named parameter (for extra morph targets).
631    pub fn set_param(&mut self, name: &str, value: f32) {
632        self._update_param(|p| {
633            p.extra.insert(name.to_string(), value);
634        });
635    }
636
637    /// Commit a new parameter state: enforce the age floor, push the params
638    /// into the morph engine, and invalidate all cached geometry.
639    ///
640    /// Every param-mutating path (`set_param`, presets, JSON import,
641    /// animation seeking, resets) routes through this single method so the
642    /// age floor and geometry invalidation can never be bypassed.
643    pub(crate) fn commit_params(&mut self, mut p: ParamState) {
644        // D3: the core-pack manifest's `age_floor_years` is only the *trigger* —
645        // its presence declares that this pack must enforce the adult age floor.
646        // The enforced *value* is the single authoritative policy constant
647        // [`oxihuman_core::policy::AGE_ADULT_FLOOR_YR`], not the number embedded
648        // in the manifest, so the floor can never silently drift below policy by
649        // shipping a weaker manifest. A pack may declare a *stricter* (higher)
650        // floor than policy but never a weaker one, so we clamp the modelled age
651        // to the stricter of the two.
652        if let Some(manifest_floor) = self.age_floor_years {
653            let floor_years = manifest_floor.max(oxihuman_core::policy::AGE_ADULT_FLOOR_YR);
654            let min_age = age_years_to_param(floor_years);
655            if p.age < min_age {
656                p.age = min_age;
657            }
658        }
659        self.engine.set_params(p.clone());
660        self.params = p;
661        self.last_mesh = None;
662        self.geo_dirty = true;
663    }
664
665    pub(crate) fn _update_param<F: FnOnce(&mut ParamState)>(&mut self, f: F) {
666        let mut p = self.params.clone();
667        f(&mut p);
668        self.commit_params(p);
669    }
670
671    /// Reset all parameters to their default (mid-point) values and invalidate the mesh cache.
672    pub fn reset_params(&mut self) {
673        self.commit_params(ParamState::default());
674    }
675
676    /// Return how many morph targets are currently loaded.
677    pub fn target_count(&self) -> usize {
678        self.engine.target_count()
679    }
680
681    /// Scatter-add all JSON-loaded morph targets (weighted) into a position
682    /// buffer. This makes `load_target_from_json` / `set_target_weight_by_name`
683    /// actually affect every rendered/exported mesh — they were previously
684    /// stored but never applied.
685    pub(crate) fn apply_json_targets(&self, positions: &mut [[f32; 3]]) {
686        for (deltas, weight) in self.json_targets.values() {
687            let w = *weight;
688            if w == 0.0 {
689                continue;
690            }
691            for &(vid, dx, dy, dz) in deltas {
692                if let Some(p) = positions.get_mut(vid as usize) {
693                    p[0] += dx * w;
694                    p[1] += dy * w;
695                    p[2] += dz * w;
696                }
697            }
698        }
699    }
700
701    /// Build the morphed mesh and return raw bytes.
702    ///
703    /// Uses the engine's incremental build path (only re-applies targets
704    /// whose weight changed since the previous build) and applies
705    /// JSON-loaded targets on top.
706    ///
707    /// Format: `[format_version: u32][n_verts: u32][n_idx: u32]`
708    ///          `[positions: f32 * 3 * n_verts][normals: f32 * 3 * n_verts]`
709    ///          `[uvs: f32 * 2 * n_verts][indices: u32 * n_idx]`
710    pub fn build_mesh_bytes(&mut self) -> Vec<u8> {
711        let mesh = self.build_mesh_prepared();
712
713        let n_verts = mesh.positions.len() as u32;
714        let n_idx = mesh.indices.len() as u32;
715
716        let mut out =
717            Vec::with_capacity(12 + (n_verts as usize) * (3 + 3 + 2) * 4 + (n_idx as usize) * 4);
718
719        // Header
720        out.extend_from_slice(&BUFFER_FORMAT_VERSION.to_le_bytes());
721        out.extend_from_slice(&n_verts.to_le_bytes());
722        out.extend_from_slice(&n_idx.to_le_bytes());
723
724        // Positions
725        for p in &mesh.positions {
726            for &c in p {
727                out.extend_from_slice(&c.to_le_bytes());
728            }
729        }
730        // Normals
731        for n in &mesh.normals {
732            for &c in n {
733                out.extend_from_slice(&c.to_le_bytes());
734            }
735        }
736        // UVs
737        for uv in &mesh.uvs {
738            for &c in uv {
739                out.extend_from_slice(&c.to_le_bytes());
740            }
741        }
742        // Indices
743        for &i in &mesh.indices {
744            out.extend_from_slice(&i.to_le_bytes());
745        }
746
747        self.last_mesh = Some(mesh);
748        out
749    }
750
751    /// Number of vertices in the base mesh.
752    pub fn vertex_count(&self) -> usize {
753        self.engine.vertex_count()
754    }
755
756    /// Clear the incremental morph cache and the last-built mesh buffer.
757    ///
758    /// After calling this, the next `build_mesh_bytes()` will perform a full
759    /// rebuild even if params have not changed.
760    pub fn reset_incremental_cache(&mut self) {
761        self.engine.clear_incremental_cache();
762        self.last_mesh = None;
763        self.geo_dirty = true;
764    }
765
766    /// Returns true if a mesh has been built since the last param change.
767    pub fn has_cached_mesh(&self) -> bool {
768        self.last_mesh.is_some()
769    }
770
771    /// Build the morphed mesh and return a fully-prepared [`MeshBuffers`]
772    /// (JSON targets applied, normals computed, suit flag applied).
773    ///
774    /// This is the single central build path: every WASM-facing method that
775    /// needs a mesh (`build_mesh_bytes`, exports, measurements, physics,
776    /// LOD, curvature) routes through it, so JSON-loaded targets and the
777    /// incremental engine path apply uniformly.
778    ///
779    /// # `has_suit` truthfulness
780    ///
781    /// The engine's base mesh is the bodysuit-topology base mesh (the only
782    /// base geometry this crate loads), so the suit flag is applied here —
783    /// the flag is a statement about the base topology, not a per-build
784    /// computation.
785    pub fn build_mesh_prepared(&mut self) -> MeshBuffers {
786        let morph_buf = self.engine.build_mesh_incremental();
787        let mut mesh = MeshBuffers::from_morph(morph_buf);
788        self.apply_json_targets(&mut mesh.positions);
789        compute_normals(&mut mesh);
790        apply_suit_flag(&mut mesh);
791        self.last_mesh = Some(mesh.clone());
792        mesh
793    }
794
795    // -- Persistent zero-copy geometry (M4) -----------------------------------
796
797    /// Drop the persistent geometry buffers and bump the generation counter.
798    ///
799    /// Called whenever the base mesh (topology) is replaced; JS must
800    /// re-create its typed-array views afterwards.
801    pub(crate) fn invalidate_geometry_buffers(&mut self) {
802        self.geo_positions = Vec::new();
803        self.geo_normals = Vec::new();
804        self.geo_uvs = Vec::new();
805        self.geo_indices = Vec::new();
806        self.geo_generation = self.geo_generation.wrapping_add(1);
807        self.geo_dirty = true;
808        // Base mesh (topology) changed: the cached measurer topology no
809        // longer describes the mesh.
810        self.measurer_topology = None;
811    }
812
813    /// Recompute the persistent geometry buffers via the engine's
814    /// incremental build path, writing positions/normals **in place** (the
815    /// buffers keep their address unless the vertex count changed).
816    ///
817    /// Returns the current mesh generation. The generation is bumped only
818    /// when the buffers had to be (re)allocated (first build after a
819    /// topology change) — JS re-creates its `Float32Array`/`Uint32Array`
820    /// views when the returned generation differs from the one it captured,
821    /// and additionally after WebAssembly memory growth (see the crate
822    /// README / TypeScript docs).
823    ///
824    /// No-op when the geometry is not dirty.
825    pub fn refresh_geometry(&mut self) -> u32 {
826        if !self.geo_dirty && !self.geo_positions.is_empty() {
827            return self.geo_generation;
828        }
829
830        // Incremental engine rebuild (only re-applies changed-weight targets).
831        let morph_buf = self.engine.build_mesh_incremental();
832        let mut positions = morph_buf.positions;
833        self.apply_json_targets(&mut positions);
834
835        let n = positions.len();
836        let needs_realloc = self.geo_positions.len() != n * 3;
837        if needs_realloc {
838            self.geo_positions = vec![0.0; n * 3];
839            self.geo_normals = vec![0.0; n * 3];
840            self.geo_uvs = Vec::with_capacity(n * 2);
841            for uv in &morph_buf.uvs {
842                self.geo_uvs.push(uv[0]);
843                self.geo_uvs.push(uv[1]);
844            }
845            self.geo_uvs.resize(n * 2, 0.0);
846            self.geo_indices = morph_buf.indices.clone();
847            self.geo_generation = self.geo_generation.wrapping_add(1);
848        }
849
850        // Positions: flatten in place.
851        for (i, p) in positions.iter().enumerate() {
852            self.geo_positions[i * 3] = p[0];
853            self.geo_positions[i * 3 + 1] = p[1];
854            self.geo_positions[i * 3 + 2] = p[2];
855        }
856
857        // Normals: area-weighted face-normal accumulation, in place
858        // (zero-fill + accumulate + normalise; no per-frame allocation).
859        for v in self.geo_normals.iter_mut() {
860            *v = 0.0;
861        }
862        for tri in self.geo_indices.chunks_exact(3) {
863            let (i0, i1, i2) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
864            if i0 >= n || i1 >= n || i2 >= n {
865                continue;
866            }
867            let p0 = positions[i0];
868            let p1 = positions[i1];
869            let p2 = positions[i2];
870            let e1 = [p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]];
871            let e2 = [p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]];
872            let fnx = e1[1] * e2[2] - e1[2] * e2[1];
873            let fny = e1[2] * e2[0] - e1[0] * e2[2];
874            let fnz = e1[0] * e2[1] - e1[1] * e2[0];
875            for &vi in &[i0, i1, i2] {
876                self.geo_normals[vi * 3] += fnx;
877                self.geo_normals[vi * 3 + 1] += fny;
878                self.geo_normals[vi * 3 + 2] += fnz;
879            }
880        }
881        for chunk in self.geo_normals.chunks_exact_mut(3) {
882            let len = (chunk[0] * chunk[0] + chunk[1] * chunk[1] + chunk[2] * chunk[2]).sqrt();
883            if len > 1e-10 {
884                chunk[0] /= len;
885                chunk[1] /= len;
886                chunk[2] /= len;
887            } else {
888                chunk[0] = 0.0;
889                chunk[1] = 1.0;
890                chunk[2] = 0.0;
891            }
892        }
893
894        self.geo_dirty = false;
895        self.geo_generation
896    }
897
898    /// Byte offset of the flat positions buffer inside WASM linear memory
899    /// (`f32` array of [`Self::positions_len`] elements).
900    pub fn positions_ptr(&self) -> u32 {
901        self.geo_positions.as_ptr() as usize as u32
902    }
903
904    /// Number of `f32` elements in the positions buffer (`3 * n_verts`).
905    pub fn positions_len(&self) -> u32 {
906        self.geo_positions.len() as u32
907    }
908
909    /// Byte offset of the flat normals buffer inside WASM linear memory.
910    pub fn normals_ptr(&self) -> u32 {
911        self.geo_normals.as_ptr() as usize as u32
912    }
913
914    /// Number of `f32` elements in the normals buffer (`3 * n_verts`).
915    pub fn normals_len(&self) -> u32 {
916        self.geo_normals.len() as u32
917    }
918
919    /// Byte offset of the flat UV buffer inside WASM linear memory.
920    pub fn uvs_ptr(&self) -> u32 {
921        self.geo_uvs.as_ptr() as usize as u32
922    }
923
924    /// Number of `f32` elements in the UV buffer (`2 * n_verts`).
925    pub fn uvs_len(&self) -> u32 {
926        self.geo_uvs.len() as u32
927    }
928
929    /// Byte offset of the triangle index buffer inside WASM linear memory
930    /// (`u32` array of [`Self::indices_len`] elements).
931    pub fn indices_ptr(&self) -> u32 {
932        self.geo_indices.as_ptr() as usize as u32
933    }
934
935    /// Number of `u32` elements in the index buffer.
936    pub fn indices_len(&self) -> u32 {
937        self.geo_indices.len() as u32
938    }
939
940    /// Current mesh generation (bumps when the persistent buffers move).
941    pub fn mesh_generation(&self) -> u32 {
942        self.geo_generation
943    }
944
945    /// Set a strict-mode allowlist on the engine policy.
946    ///
947    /// After calling this, only targets whose names appear in `names` will be loaded
948    /// (the policy is switched to [`PolicyProfile::Strict`]).
949    pub fn set_allowlist(&mut self, names: &[&str]) {
950        let allowlist: Vec<String> = names.iter().map(|s| s.to_string()).collect();
951        let policy = Policy::with_allowlist(PolicyProfile::Strict, allowlist);
952        self.engine.set_policy(policy);
953    }
954
955    /// Set all target weights to 0 (both engine targets and JSON-loaded targets).
956    pub fn reset_all_weights(&mut self) {
957        let mut p = self.params.clone();
958        // Reset extra params (which drive engine target weights)
959        for v in p.extra.values_mut() {
960            *v = 0.0;
961        }
962        p.height = 0.5;
963        p.weight = 0.5;
964        p.muscle = 0.5;
965        p.age = 0.5;
966        // Reset JSON target weights
967        for entry in self.json_targets.values_mut() {
968            entry.1 = 0.0;
969        }
970        self.commit_params(p);
971    }
972
973    /// Look up a `BodyPreset` by name (case-insensitive) and apply it.
974    /// Returns `true` if the preset was found and applied, `false` otherwise.
975    pub fn apply_preset_by_name(&mut self, name: &str) -> bool {
976        use oxihuman_morph::presets::BodyPreset;
977        if BodyPreset::from_name(name).is_some() {
978            self.set_params_from_preset(name);
979            true
980        } else {
981            false
982        }
983    }
984
985    /// Advance the physics simulation by `dt` seconds.
986    ///
987    /// - Clamps `dt` to at most 1/30 s to prevent large instability.
988    /// - Steps the cloth simulation (gravity is built into [`ClothSim::step`]).
989    /// - Applies the current wind field to cloth particles.
990    /// - Lazily generates body proxies from `last_mesh` when not yet available.
991    pub fn step_physics(&mut self, dt: f32) {
992        let dt = dt.clamp(0.0, 1.0 / 30.0);
993        self.sim_time += dt;
994
995        // Lazily build body proxies from the last mesh.
996        if self.body_proxies.is_none() {
997            if let Some(ref mesh) = self.last_mesh {
998                self.body_proxies = oxihuman_physics::generate_proxies(mesh);
999            }
1000        }
1001
1002        if let Some(ref mut sim) = self.cloth_sim {
1003            // Apply wind forces before the Verlet step.
1004            if let Some(ref cfg) = self.wind_config {
1005                let wind_field = WindField::new(cfg.clone());
1006                oxihuman_physics::apply_wind_to_cloth(sim, &wind_field, self.sim_time, dt);
1007            }
1008            // Verlet integration with gravity already in ClothSim.
1009            sim.step(dt, 4);
1010        }
1011    }
1012
1013    /// Return current cloth simulation state as JSON.
1014    ///
1015    /// Format: `{"cloth_positions":[[x,y,z], ...]}`.
1016    /// Returns an empty array when no cloth is initialised.
1017    pub fn get_cloth_state(&self) -> String {
1018        match self.cloth_sim {
1019            None => r#"{"cloth_positions":[]}"#.to_string(),
1020            Some(ref sim) => {
1021                let positions = sim.positions();
1022                let mut out = String::with_capacity(positions.len() * 30 + 32);
1023                out.push_str("{\"cloth_positions\":[");
1024                for (i, p) in positions.iter().enumerate() {
1025                    if i > 0 {
1026                        out.push(',');
1027                    }
1028                    out.push_str(&format!("[{},{},{}]", p[0], p[1], p[2]));
1029                }
1030                out.push_str("]}");
1031                out
1032            }
1033        }
1034    }
1035
1036    /// Return physics proxy data as JSON.
1037    ///
1038    /// Wraps [`oxihuman_physics::proxies_to_json`] output under a `"proxies"` key
1039    /// to preserve backward compatibility with callers expecting that key.
1040    /// Falls back to `{"proxies":[]}` when no proxies are available.
1041    pub fn get_physics_proxy_json(&self) -> String {
1042        match self.body_proxies {
1043            None => r#"{"proxies":[]}"#.to_string(),
1044            Some(ref proxies) => {
1045                let inner = oxihuman_physics::proxies_to_json(proxies);
1046                format!("{{\"proxies\":{inner}}}")
1047            }
1048        }
1049    }
1050
1051    /// Set the wind vector for physics simulation.
1052    ///
1053    /// Normalises the vector internally and stores a [`WindConfig`].
1054    /// Passing a zero vector disables wind.
1055    pub fn set_wind(&mut self, x: f32, y: f32, z: f32) {
1056        let speed = (x * x + y * y + z * z).sqrt();
1057        if speed < 1e-6 {
1058            self.wind_config = None;
1059            return;
1060        }
1061        self.wind_config = Some(WindConfig {
1062            base_direction: [x / speed, y / speed, z / speed],
1063            base_speed: speed,
1064            turbulence: 0.3,
1065            gust_frequency: 0.5,
1066            vortex_strength: 0.2,
1067            seed: 42,
1068        });
1069    }
1070
1071    /// Initialise a cloth simulation from the last built mesh.
1072    ///
1073    /// Does nothing when no mesh has been built yet.
1074    /// `stiffness` is forwarded directly to all springs (0 = limp, 1 = rigid).
1075    pub fn init_cloth(&mut self, stiffness: f32) {
1076        if let Some(ref mesh) = self.last_mesh {
1077            let positions: Vec<[f32; 3]> = mesh.positions.clone();
1078            let indices: Vec<u32> = mesh.indices.clone();
1079            self.cloth_sim = Some(ClothSim::from_mesh(&positions, &indices, stiffness));
1080        }
1081    }
1082
1083    /// Number of vertices in the current base mesh.
1084    pub fn get_vertex_count(&self) -> u32 {
1085        self.engine.vertex_count() as u32
1086    }
1087
1088    /// Number of indices in the current base mesh.
1089    pub fn get_index_count(&self) -> u32 {
1090        if let Some(ref m) = self.last_mesh {
1091            return m.indices.len() as u32;
1092        }
1093        // Fall back: build and cache
1094        0
1095    }
1096}
1097
1098// ── Physics unit tests ─────────────────────────────────────────────────────────
1099
1100#[cfg(test)]
1101mod tests {
1102    use super::*;
1103
1104    const SIMPLE_OBJ: &[u8] = b"\
1105v 0.0 0.0 0.0\n\
1106v 1.0 0.0 0.0\n\
1107v 0.0 1.0 0.0\n\
1108vt 0.0 0.0\n\
1109vt 1.0 0.0\n\
1110vt 0.0 1.0\n\
1111vn 0.0 0.0 1.0\n\
1112f 1/1/1 2/2/1 3/3/1\n";
1113
1114    #[test]
1115    fn test_set_wind_stores_config() {
1116        let mut engine = WasmEngine::new_from_obj_bytes(SIMPLE_OBJ).expect("should succeed");
1117        engine.set_wind(1.0, 0.0, 0.0);
1118        assert!(engine.wind_config.is_some());
1119    }
1120
1121    #[test]
1122    fn test_set_wind_zero_clears() {
1123        let mut engine = WasmEngine::new_from_obj_bytes(SIMPLE_OBJ).expect("should succeed");
1124        engine.set_wind(1.0, 0.0, 0.0);
1125        engine.set_wind(0.0, 0.0, 0.0);
1126        assert!(engine.wind_config.is_none());
1127    }
1128
1129    #[test]
1130    fn test_set_wind_normalises_direction() {
1131        let mut engine = WasmEngine::new_from_obj_bytes(SIMPLE_OBJ).expect("should succeed");
1132        engine.set_wind(3.0, 0.0, 4.0);
1133        let cfg = engine
1134            .wind_config
1135            .as_ref()
1136            .expect("wind_config must be Some");
1137        // Direction should be normalised: magnitude == 1
1138        let mag = (cfg.base_direction[0].powi(2)
1139            + cfg.base_direction[1].powi(2)
1140            + cfg.base_direction[2].powi(2))
1141        .sqrt();
1142        assert!(
1143            (mag - 1.0).abs() < 1e-5,
1144            "direction magnitude should be 1, got {mag}"
1145        );
1146        // Base speed should be the original magnitude: sqrt(9+16) = 5
1147        assert!(
1148            (cfg.base_speed - 5.0).abs() < 1e-5,
1149            "base_speed should be 5, got {}",
1150            cfg.base_speed
1151        );
1152    }
1153
1154    #[test]
1155    fn test_step_physics_no_cloth_no_op() {
1156        let mut engine = WasmEngine::new_from_obj_bytes(SIMPLE_OBJ).expect("should succeed");
1157        // Must not panic
1158        engine.step_physics(1.0 / 60.0);
1159    }
1160
1161    #[test]
1162    fn test_step_physics_advances_sim_time() {
1163        let mut engine = WasmEngine::new_from_obj_bytes(SIMPLE_OBJ).expect("should succeed");
1164        engine.step_physics(0.1);
1165        assert!(engine.sim_time > 0.0, "sim_time should advance");
1166    }
1167
1168    #[test]
1169    fn test_cloth_state_empty_before_init() {
1170        let engine = WasmEngine::new_from_obj_bytes(SIMPLE_OBJ).expect("should succeed");
1171        assert!(engine.get_cloth_state().contains("cloth_positions"));
1172    }
1173
1174    #[test]
1175    fn test_cloth_state_empty_json_before_init() {
1176        let engine = WasmEngine::new_from_obj_bytes(SIMPLE_OBJ).expect("should succeed");
1177        let v: serde_json::Value =
1178            serde_json::from_str(&engine.get_cloth_state()).expect("must be valid JSON");
1179        let arr = v["cloth_positions"].as_array().expect("must be array");
1180        assert!(arr.is_empty(), "no cloth sim yet, array should be empty");
1181    }
1182
1183    #[test]
1184    fn test_init_cloth_requires_built_mesh() {
1185        // init_cloth silently does nothing when last_mesh is None.
1186        let mut engine = WasmEngine::new_from_obj_bytes(SIMPLE_OBJ).expect("should succeed");
1187        engine.init_cloth(0.5);
1188        assert!(
1189            engine.cloth_sim.is_none(),
1190            "cloth_sim should remain None without a built mesh"
1191        );
1192    }
1193
1194    #[test]
1195    fn test_init_cloth_then_step() {
1196        let mut engine = WasmEngine::new_from_obj_bytes(SIMPLE_OBJ).expect("should succeed");
1197        // Build a mesh first so init_cloth has something to work from.
1198        let _ = engine.build_mesh_bytes();
1199        engine.init_cloth(0.5);
1200        assert!(
1201            engine.cloth_sim.is_some(),
1202            "cloth_sim should be Some after init_cloth"
1203        );
1204
1205        // Step 5 frames — must not panic.
1206        for _ in 0..5 {
1207            engine.step_physics(1.0 / 60.0);
1208        }
1209
1210        // Cloth state should now have positions.
1211        let v: serde_json::Value =
1212            serde_json::from_str(&engine.get_cloth_state()).expect("must be valid JSON");
1213        let arr = v["cloth_positions"].as_array().expect("must be array");
1214        assert!(
1215            !arr.is_empty(),
1216            "cloth_positions should be non-empty after init"
1217        );
1218    }
1219
1220    #[test]
1221    fn test_init_cloth_with_wind_no_panic() {
1222        let mut engine = WasmEngine::new_from_obj_bytes(SIMPLE_OBJ).expect("should succeed");
1223        let _ = engine.build_mesh_bytes();
1224        engine.init_cloth(0.8);
1225        engine.set_wind(1.0, 0.0, 0.5);
1226        for _ in 0..10 {
1227            engine.step_physics(1.0 / 60.0);
1228        }
1229        // All particle positions must be finite.
1230        if let Some(ref sim) = engine.cloth_sim {
1231            for p in sim.positions() {
1232                assert!(
1233                    p[0].is_finite() && p[1].is_finite() && p[2].is_finite(),
1234                    "particle position is not finite: {p:?}"
1235                );
1236            }
1237        }
1238    }
1239
1240    #[test]
1241    fn test_step_physics_dt_clamp() {
1242        let mut engine = WasmEngine::new_from_obj_bytes(SIMPLE_OBJ).expect("should succeed");
1243        let _ = engine.build_mesh_bytes();
1244        engine.init_cloth(0.5);
1245        // Very large dt should be clamped and not cause a NaN cascade.
1246        engine.step_physics(100.0);
1247        if let Some(ref sim) = engine.cloth_sim {
1248            for p in sim.positions() {
1249                assert!(
1250                    p[0].is_finite() && p[1].is_finite() && p[2].is_finite(),
1251                    "particle position is not finite after large dt: {p:?}"
1252                );
1253            }
1254        }
1255    }
1256}