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;
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;
15
16use crate::buffer::serialize_quantized_to_bytes;
17use crate::pack::scan_zip_local_entries;
18use crate::BUFFER_FORMAT_VERSION;
19
20/// Delta tuple stored for a JSON-loaded morph target: (vertex_id, dx, dy, dz).
21pub(crate) type JsonDelta = (u32, f32, f32, f32);
22
23/// JSON-loaded target map: name -> (deltas, weight).
24pub(crate) type JsonTargetMap = std::collections::HashMap<String, (Vec<JsonDelta>, f32)>;
25
26/// A simple point particle system stored in the engine.
27#[derive(Debug, Clone)]
28pub struct ParticleSystem {
29    pub emit_rate: f32,
30    pub lifetime: f32,
31    pub particles: Vec<Particle>,
32    pub time_accum: f32,
33}
34
35/// A single active particle.
36#[derive(Debug, Clone)]
37pub struct Particle {
38    pub position: [f32; 3],
39    pub velocity: [f32; 3],
40    pub age: f32,
41    pub lifetime: f32,
42}
43
44/// A human body generator that can be driven from WASM (or native Rust).
45pub struct WasmEngine {
46    pub(crate) engine: HumanEngine,
47    pub(crate) params: ParamState,
48    pub(crate) last_mesh: Option<MeshBuffers>,
49    /// Names of currently loaded morph targets (in load order).
50    pub(crate) target_names: Vec<String>,
51    // -- JSON-loaded targets: name -> (deltas, weight) --
52    pub(crate) json_targets: JsonTargetMap,
53    // -- Animation state --
54    pub(crate) anim_frames: Vec<std::collections::HashMap<String, f32>>,
55    pub(crate) anim_current_frame: usize,
56    pub(crate) anim_fps: f32,
57    #[allow(dead_code)]
58    pub(crate) anim_playing: bool,
59    pub(crate) anim_accum: f32,
60    // -- Particle system --
61    pub(crate) particle_sys: Option<ParticleSystem>,
62}
63
64impl WasmEngine {
65    /// Create a new engine from raw OBJ bytes (UTF-8 text).
66    pub fn new_from_obj_bytes(obj_bytes: &[u8]) -> Result<Self> {
67        let src = std::str::from_utf8(obj_bytes)?;
68        let base = parse_obj(src)?;
69        let policy = Policy::new(PolicyProfile::Standard);
70        Ok(WasmEngine {
71            engine: HumanEngine::new(base, policy),
72            params: ParamState::default(),
73            last_mesh: None,
74            target_names: Vec::new(),
75            json_targets: std::collections::HashMap::new(),
76            anim_frames: Vec::new(),
77            anim_current_frame: 0,
78            anim_fps: 24.0,
79            anim_playing: false,
80            anim_accum: 0.0,
81            particle_sys: None,
82        })
83    }
84
85    /// Create with a strict policy (only allowlisted targets accepted).
86    pub fn new_strict(obj_bytes: &[u8]) -> Result<Self> {
87        let src = std::str::from_utf8(obj_bytes)?;
88        let base = parse_obj(src)?;
89        let policy = Policy::new(PolicyProfile::Strict);
90        Ok(WasmEngine {
91            engine: HumanEngine::new(base, policy),
92            params: ParamState::default(),
93            last_mesh: None,
94            target_names: Vec::new(),
95            json_targets: std::collections::HashMap::new(),
96            anim_frames: Vec::new(),
97            anim_current_frame: 0,
98            anim_fps: 24.0,
99            anim_playing: false,
100            anim_accum: 0.0,
101            particle_sys: None,
102        })
103    }
104
105    /// Load a morph target from raw .target file bytes.
106    /// The `name` is used to infer the category and auto-assign a weight function.
107    pub fn load_target_bytes(&mut self, name: &str, target_bytes: &[u8]) -> Result<()> {
108        use oxihuman_core::parser::target::parse_target;
109        let src = std::str::from_utf8(target_bytes)?;
110        let target = parse_target(name, src)?;
111        let before = self.engine.target_count();
112        let weight_fn = auto_weight_fn_for_target(name);
113        self.engine.load_target(target, weight_fn);
114        // Only record the name when the engine actually accepted the target.
115        if self.engine.target_count() > before {
116            self.target_names.push(name.to_string());
117        }
118        self.last_mesh = None; // Invalidate cached mesh
119        Ok(())
120    }
121
122    // -- ZIP pack loader --
123
124    /// Load a ZIP asset pack from raw bytes (in-memory).
125    ///
126    /// The ZIP must contain:
127    /// - One file named `base.obj` (or ending in `.obj`) -- the base mesh.
128    /// - Zero or more files ending in `.target` -- morph targets.
129    ///
130    /// Parses all entries inline by scanning local file headers
131    /// (signature `0x04034B50`, STORE compression only -- no decompression).
132    /// Re-initialises the engine with the new base mesh, then loads all targets.
133    ///
134    /// Returns the number of morph targets loaded.
135    pub fn load_zip_pack_bytes(&mut self, zip_bytes: &[u8]) -> Result<usize> {
136        let entries = scan_zip_local_entries(zip_bytes)?;
137
138        // Find the .obj entry.
139        let obj_entry = entries
140            .iter()
141            .find(|(name, _)| name == "base.obj" || name.ends_with(".obj"))
142            .ok_or_else(|| anyhow::anyhow!("ZIP pack contains no .obj entry"))?;
143
144        // Re-initialise engine with new base mesh.
145        let src = std::str::from_utf8(&obj_entry.1)
146            .map_err(|e| anyhow::anyhow!("base.obj is not valid UTF-8: {e}"))?;
147        let base = parse_obj(src)?;
148        let policy = Policy::new(PolicyProfile::Standard);
149        self.engine = HumanEngine::new(base, policy);
150        self.params = ParamState::default();
151        self.target_names.clear();
152        self.json_targets.clear();
153        self.last_mesh = None;
154
155        // Load all .target entries.
156        let mut loaded = 0usize;
157        for (name, data) in &entries {
158            if name.ends_with(".target") {
159                let stem = name
160                    .strip_suffix(".target")
161                    .unwrap_or(name.as_str())
162                    .rsplit('/')
163                    .next()
164                    .unwrap_or(name.as_str());
165                self.load_target_bytes(stem, data)?;
166                loaded += 1;
167            }
168        }
169
170        Ok(loaded)
171    }
172
173    // -- Target name listing --
174
175    /// Returns a JSON array of target names currently loaded.
176    ///
177    /// Example: `["height","weight","muscle"]`
178    ///
179    /// Falls back to `{"count":<n>}` only when the internal list is somehow
180    /// out of sync with the engine (should never occur in normal use).
181    pub fn list_loaded_targets(&self) -> String {
182        let count = self.engine.target_count();
183        if self.target_names.len() == count {
184            // Produce a JSON array.
185            let items: Vec<String> = self
186                .target_names
187                .iter()
188                .map(|n| format!("\"{}\"", n.replace('\\', "\\\\").replace('"', "\\\"")))
189                .collect();
190            format!("[{}]", items.join(","))
191        } else {
192            // Fallback: engine count differs from our tracking -- return count.
193            format!("{{\"count\":{count}}}")
194        }
195    }
196
197    // -- Quantized mesh export --
198
199    /// Build the morphed mesh, quantize it, and return the QMSH binary bytes.
200    ///
201    /// Binary layout (matches `write_quantized_bin`):
202    /// ```text
203    /// Bytes  0..4   : magic  b"QMSH"
204    /// Bytes  4..8   : version u32 LE  (= 1)
205    /// Bytes  8..12  : vertex_count u32 LE
206    /// Bytes 12..16  : index_count  u32 LE
207    /// Then: 6 f32s (3 x min/max for pos_range) LE
208    /// Then: vertex_count x 6 bytes  (u16x3 positions, LE)
209    /// Then: vertex_count x 3 bytes  (i8x3 normals)
210    /// Then: vertex_count x 4 bytes  (u16x2 uvs, LE)
211    /// Then: index_count  x 4 bytes  (u32 indices, LE)
212    /// Then: 1 byte has_suit flag
213    /// ```
214    pub fn export_quantized_bytes(&mut self) -> Vec<u8> {
215        use oxihuman_export::mesh_quantize::quantize_mesh;
216
217        let morph_buf = self.engine.build_mesh_incremental();
218        let mut mesh = MeshBuffers::from_morph(morph_buf);
219        compute_normals(&mut mesh);
220        apply_suit_flag(&mut mesh);
221
222        self.last_mesh = Some(mesh.clone());
223
224        let q = quantize_mesh(&mesh);
225        serialize_quantized_to_bytes(&q)
226    }
227
228    // -- Param setters --
229
230    /// Set the height parameter [0.0, 1.0].
231    pub fn set_height(&mut self, v: f32) {
232        self._update_param(|p| p.height = v);
233    }
234    /// Set the weight parameter [0.0, 1.0].
235    pub fn set_weight(&mut self, v: f32) {
236        self._update_param(|p| p.weight = v);
237    }
238    /// Set the muscle parameter [0.0, 1.0].
239    pub fn set_muscle(&mut self, v: f32) {
240        self._update_param(|p| p.muscle = v);
241    }
242    /// Set the age parameter [0.0, 1.0].
243    pub fn set_age(&mut self, v: f32) {
244        self._update_param(|p| p.age = v);
245    }
246
247    /// Set an arbitrary named parameter (for extra morph targets).
248    pub fn set_param(&mut self, name: &str, value: f32) {
249        self._update_param(|p| {
250            p.extra.insert(name.to_string(), value);
251        });
252    }
253
254    pub(crate) fn _update_param<F: FnOnce(&mut ParamState)>(&mut self, f: F) {
255        let mut p = self.params.clone();
256        f(&mut p);
257        self.engine.set_params(p.clone());
258        self.params = p;
259        self.last_mesh = None;
260    }
261
262    /// Reset all parameters to their default (mid-point) values and invalidate the mesh cache.
263    pub fn reset_params(&mut self) {
264        let default = ParamState::default();
265        self.engine.set_params(default.clone());
266        self.params = default;
267        self.last_mesh = None;
268    }
269
270    /// Return how many morph targets are currently loaded.
271    pub fn target_count(&self) -> usize {
272        self.engine.target_count()
273    }
274
275    /// Build the morphed mesh and return raw bytes.
276    ///
277    /// Format: `[format_version: u32][n_verts: u32][n_idx: u32]`
278    ///          `[positions: f32 * 3 * n_verts][normals: f32 * 3 * n_verts]`
279    ///          `[uvs: f32 * 2 * n_verts][indices: u32 * n_idx]`
280    pub fn build_mesh_bytes(&mut self) -> Vec<u8> {
281        let morph_buf = self.engine.build_mesh();
282        let mut mesh = MeshBuffers::from_morph(morph_buf);
283        compute_normals(&mut mesh);
284        apply_suit_flag(&mut mesh);
285
286        let n_verts = mesh.positions.len() as u32;
287        let n_idx = mesh.indices.len() as u32;
288
289        let mut out =
290            Vec::with_capacity(12 + (n_verts as usize) * (3 + 3 + 2) * 4 + (n_idx as usize) * 4);
291
292        // Header
293        out.extend_from_slice(&BUFFER_FORMAT_VERSION.to_le_bytes());
294        out.extend_from_slice(&n_verts.to_le_bytes());
295        out.extend_from_slice(&n_idx.to_le_bytes());
296
297        // Positions
298        for p in &mesh.positions {
299            for &c in p {
300                out.extend_from_slice(&c.to_le_bytes());
301            }
302        }
303        // Normals
304        for n in &mesh.normals {
305            for &c in n {
306                out.extend_from_slice(&c.to_le_bytes());
307            }
308        }
309        // UVs
310        for uv in &mesh.uvs {
311            for &c in uv {
312                out.extend_from_slice(&c.to_le_bytes());
313            }
314        }
315        // Indices
316        for &i in &mesh.indices {
317            out.extend_from_slice(&i.to_le_bytes());
318        }
319
320        self.last_mesh = Some(mesh);
321        out
322    }
323
324    /// Number of vertices in the base mesh.
325    pub fn vertex_count(&self) -> usize {
326        self.engine.vertex_count()
327    }
328
329    /// Clear the incremental morph cache and the last-built mesh buffer.
330    ///
331    /// After calling this, the next `build_mesh_bytes()` will perform a full
332    /// rebuild even if params have not changed.
333    pub fn reset_incremental_cache(&mut self) {
334        self.engine.clear_incremental_cache();
335        self.last_mesh = None;
336    }
337
338    /// Returns true if a mesh has been built since the last param change.
339    pub fn has_cached_mesh(&self) -> bool {
340        self.last_mesh.is_some()
341    }
342
343    /// Build the morphed mesh and return a fully-prepared [`MeshBuffers`]
344    /// (normals computed, suit flag applied).
345    ///
346    /// This is the public entry point used by wasm-bindgen wrappers
347    /// and external tests that need a `MeshBuffers` rather than a raw byte buffer.
348    pub fn build_mesh_prepared(&mut self) -> MeshBuffers {
349        let morph_buf = self.engine.build_mesh_incremental();
350        let mut mesh = MeshBuffers::from_morph(morph_buf);
351        compute_normals(&mut mesh);
352        apply_suit_flag(&mut mesh);
353        self.last_mesh = Some(mesh.clone());
354        mesh
355    }
356
357    /// Set a strict-mode allowlist on the engine policy.
358    ///
359    /// After calling this, only targets whose names appear in `names` will be loaded
360    /// (the policy is switched to [`PolicyProfile::Strict`]).
361    pub fn set_allowlist(&mut self, names: &[&str]) {
362        let allowlist: Vec<String> = names.iter().map(|s| s.to_string()).collect();
363        let policy = Policy::with_allowlist(PolicyProfile::Strict, allowlist);
364        self.engine.set_policy(policy);
365    }
366
367    /// Set all target weights to 0 (both engine targets and JSON-loaded targets).
368    pub fn reset_all_weights(&mut self) {
369        // Reset extra params (which drive engine target weights)
370        for v in self.params.extra.values_mut() {
371            *v = 0.0;
372        }
373        self.params.height = 0.5;
374        self.params.weight = 0.5;
375        self.params.muscle = 0.5;
376        self.params.age = 0.5;
377        self.engine.set_params(self.params.clone());
378        // Reset JSON target weights
379        for entry in self.json_targets.values_mut() {
380            entry.1 = 0.0;
381        }
382        self.last_mesh = None;
383    }
384
385    /// Look up a `BodyPreset` by name (case-insensitive) and apply it.
386    /// Returns `true` if the preset was found and applied, `false` otherwise.
387    pub fn apply_preset_by_name(&mut self, name: &str) -> bool {
388        use oxihuman_morph::presets::BodyPreset;
389        if BodyPreset::from_name(name).is_some() {
390            self.set_params_from_preset(name);
391            true
392        } else {
393            false
394        }
395    }
396
397    /// Physics step placeholder.
398    pub fn step_physics(&mut self, _dt: f32) {
399        // placeholder physics integration
400    }
401
402    /// Return placeholder cloth state as JSON.
403    pub fn get_cloth_state(&self) -> String {
404        r#"{"cloth_positions":[]}"#.to_string()
405    }
406
407    /// Return placeholder physics proxy data as JSON.
408    pub fn get_physics_proxy_json(&self) -> String {
409        r#"{"proxies":[]}"#.to_string()
410    }
411
412    /// Set wind vector (stored but not yet simulated in placeholder).
413    pub fn set_wind(&mut self, _x: f32, _y: f32, _z: f32) {
414        // placeholder: wind stored externally when physics is wired up
415    }
416
417    /// Number of vertices in the current base mesh.
418    pub fn get_vertex_count(&self) -> u32 {
419        self.engine.vertex_count() as u32
420    }
421
422    /// Number of indices in the current base mesh.
423    pub fn get_index_count(&self) -> u32 {
424        if let Some(ref m) = self.last_mesh {
425            return m.indices.len() as u32;
426        }
427        // Fall back: build and cache
428        0
429    }
430}