1use 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
21pub(crate) type JsonDelta = (u32, f32, f32, f32);
23
24pub(crate) type JsonTargetMap = std::collections::HashMap<String, (Vec<JsonDelta>, f32)>;
26
27type TargetWeightFn = Box<dyn Fn(&ParamState) -> f32 + Send + Sync>;
29
30pub const MODEL_UNIT_CM: f32 = oxihuman_export::core_pack::MODEL_UNIT_MM / 10.0;
36
37pub 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
50pub 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#[derive(Clone, Copy, PartialEq, Eq)]
62enum MacroLevel {
63 Min,
64 Avg,
65 Max,
66}
67
68fn 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
85fn 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
96fn 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
110fn 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 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 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 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 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
208fn 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 if let Some(wf) = macro_blend_weight_fn(name) {
231 return wf;
232 }
233
234 let cat = match TargetCategory::from_str(category) {
235 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#[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#[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
275pub struct WasmEngine {
277 pub(crate) engine: HumanEngine,
278 pub(crate) params: ParamState,
279 pub(crate) last_mesh: Option<MeshBuffers>,
280 pub(crate) target_names: Vec<String>,
282 pub(crate) json_targets: JsonTargetMap,
284 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 pub(crate) particle_sys: Option<ParticleSystem>,
293 pub(crate) wind_config: Option<WindConfig>,
295 pub(crate) cloth_sim: Option<ClothSim>,
296 pub(crate) body_proxies: Option<BodyProxies>,
297 pub(crate) sim_time: f32,
299 pub(crate) geo_positions: Vec<f32>,
303 pub(crate) geo_normals: Vec<f32>,
305 pub(crate) geo_uvs: Vec<f32>,
307 pub(crate) geo_indices: Vec<u32>,
309 pub(crate) geo_generation: u32,
312 pub(crate) geo_dirty: bool,
314 pub(crate) age_floor_years: Option<f32>,
319 pub(crate) measurer_topology: Option<oxihuman_morph::measurements::MeasurerTopology>,
325}
326
327impl WasmEngine {
328 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 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 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 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 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 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 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 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 let params = self.params.clone();
483 self.commit_params(params);
484 Ok(loaded)
485 }
486
487 pub fn age_floor_years(&self) -> Option<f32> {
489 self.age_floor_years
490 }
491
492 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 if self.engine.target_count() > before {
503 self.target_names.push(name.to_string());
504 }
505 self.last_mesh = None; self.geo_dirty = true;
507 Ok(())
508 }
509
510 pub fn load_zip_pack_bytes(&mut self, zip_bytes: &[u8]) -> Result<usize> {
524 let entries = scan_zip_local_entries(zip_bytes)?;
525
526 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 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 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 pub fn list_loaded_targets(&self) -> String {
571 let count = self.engine.target_count();
572 if self.target_names.len() == count {
573 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 format!("{{\"count\":{count}}}")
583 }
584 }
585
586 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 pub fn set_height(&mut self, v: f32) {
615 self._update_param(|p| p.height = v);
616 }
617 pub fn set_weight(&mut self, v: f32) {
619 self._update_param(|p| p.weight = v);
620 }
621 pub fn set_muscle(&mut self, v: f32) {
623 self._update_param(|p| p.muscle = v);
624 }
625 pub fn set_age(&mut self, v: f32) {
627 self._update_param(|p| p.age = v);
628 }
629
630 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 pub(crate) fn commit_params(&mut self, mut p: ParamState) {
644 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 pub fn reset_params(&mut self) {
673 self.commit_params(ParamState::default());
674 }
675
676 pub fn target_count(&self) -> usize {
678 self.engine.target_count()
679 }
680
681 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 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 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 for p in &mesh.positions {
726 for &c in p {
727 out.extend_from_slice(&c.to_le_bytes());
728 }
729 }
730 for n in &mesh.normals {
732 for &c in n {
733 out.extend_from_slice(&c.to_le_bytes());
734 }
735 }
736 for uv in &mesh.uvs {
738 for &c in uv {
739 out.extend_from_slice(&c.to_le_bytes());
740 }
741 }
742 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 pub fn vertex_count(&self) -> usize {
753 self.engine.vertex_count()
754 }
755
756 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 pub fn has_cached_mesh(&self) -> bool {
768 self.last_mesh.is_some()
769 }
770
771 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 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 self.measurer_topology = None;
811 }
812
813 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 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 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 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 pub fn positions_ptr(&self) -> u32 {
901 self.geo_positions.as_ptr() as usize as u32
902 }
903
904 pub fn positions_len(&self) -> u32 {
906 self.geo_positions.len() as u32
907 }
908
909 pub fn normals_ptr(&self) -> u32 {
911 self.geo_normals.as_ptr() as usize as u32
912 }
913
914 pub fn normals_len(&self) -> u32 {
916 self.geo_normals.len() as u32
917 }
918
919 pub fn uvs_ptr(&self) -> u32 {
921 self.geo_uvs.as_ptr() as usize as u32
922 }
923
924 pub fn uvs_len(&self) -> u32 {
926 self.geo_uvs.len() as u32
927 }
928
929 pub fn indices_ptr(&self) -> u32 {
932 self.geo_indices.as_ptr() as usize as u32
933 }
934
935 pub fn indices_len(&self) -> u32 {
937 self.geo_indices.len() as u32
938 }
939
940 pub fn mesh_generation(&self) -> u32 {
942 self.geo_generation
943 }
944
945 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 pub fn reset_all_weights(&mut self) {
957 let mut p = self.params.clone();
958 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 for entry in self.json_targets.values_mut() {
968 entry.1 = 0.0;
969 }
970 self.commit_params(p);
971 }
972
973 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 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 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 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 sim.step(dt, 4);
1010 }
1011 }
1012
1013 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 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 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 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 pub fn get_vertex_count(&self) -> u32 {
1085 self.engine.vertex_count() as u32
1086 }
1087
1088 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 0
1095 }
1096}
1097
1098#[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 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 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 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 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 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 for _ in 0..5 {
1207 engine.step_physics(1.0 / 60.0);
1208 }
1209
1210 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 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 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}