1#[derive(Debug, Clone)]
13pub struct WebMaterial {
14 pub name: String,
15 pub base_color: [f32; 4],
16 pub metallic: f32,
17 pub roughness: f32,
18 pub emissive: [f32; 3],
19 pub alpha_mode: String, pub double_sided: bool,
21}
22
23#[derive(Debug, Clone)]
25pub struct WebLodLevel {
26 pub level: u32,
27 pub triangle_count: usize,
28 pub positions: Vec<[f32; 3]>,
29 pub normals: Vec<[f32; 3]>,
30 pub uvs: Vec<[f32; 2]>,
31 pub indices: Vec<u32>,
32 pub screen_size_threshold: f32,
33}
34
35#[derive(Debug, Clone)]
37pub struct WebExportOptions {
38 pub include_normals: bool,
39 pub include_uvs: bool,
40 pub include_colors: bool,
41 pub quantize_positions: bool,
42 pub interleave_buffers: bool,
43 pub include_lod: bool,
44 pub max_lod_levels: usize,
45}
46
47impl Default for WebExportOptions {
48 fn default() -> Self {
49 WebExportOptions {
50 include_normals: true,
51 include_uvs: true,
52 include_colors: false,
53 quantize_positions: false,
54 interleave_buffers: false,
55 include_lod: false,
56 max_lod_levels: 4,
57 }
58 }
59}
60
61#[derive(Debug, Clone)]
63pub struct WebMesh {
64 pub name: String,
65 pub positions: Vec<[f32; 3]>,
66 pub normals: Vec<[f32; 3]>,
67 pub uvs: Vec<[f32; 2]>,
68 pub indices: Vec<u32>,
69 pub material: Option<WebMaterial>,
70 pub lod_levels: Vec<WebLodLevel>,
71 pub bounding_box: ([f32; 3], [f32; 3]),
72 pub vertex_count: usize,
73 pub triangle_count: usize,
74}
75
76#[allow(dead_code)]
81pub fn new_web_mesh(name: &str, positions: Vec<[f32; 3]>, indices: Vec<u32>) -> WebMesh {
82 let triangle_count = indices.len() / 3;
83 let vertex_count = positions.len();
84 let bb = compute_web_mesh_bounds_raw(&positions);
85 WebMesh {
86 name: name.to_string(),
87 normals: Vec::new(),
88 uvs: Vec::new(),
89 indices,
90 material: None,
91 lod_levels: Vec::new(),
92 bounding_box: bb,
93 vertex_count,
94 triangle_count,
95 positions,
96 }
97}
98
99#[allow(dead_code)]
101pub fn web_mesh_to_json(mesh: &WebMesh, opts: &WebExportOptions) -> String {
102 let mut parts: Vec<String> = Vec::new();
103 parts.push(format!("\"name\":\"{}\"", esc(&mesh.name)));
104 parts.push(format!("\"vertex_count\":{}", mesh.vertex_count));
105 parts.push(format!("\"triangle_count\":{}", mesh.triangle_count));
106
107 let pos_strs: Vec<String> = mesh
109 .positions
110 .iter()
111 .map(|p| format!("[{},{},{}]", p[0], p[1], p[2]))
112 .collect();
113 parts.push(format!("\"positions\":[{}]", pos_strs.join(",")));
114
115 if opts.include_normals && !mesh.normals.is_empty() {
117 let nrm_strs: Vec<String> = mesh
118 .normals
119 .iter()
120 .map(|n| format!("[{},{},{}]", n[0], n[1], n[2]))
121 .collect();
122 parts.push(format!("\"normals\":[{}]", nrm_strs.join(",")));
123 }
124
125 if opts.include_uvs && !mesh.uvs.is_empty() {
127 let uv_strs: Vec<String> = mesh
128 .uvs
129 .iter()
130 .map(|u| format!("[{},{}]", u[0], u[1]))
131 .collect();
132 parts.push(format!("\"uvs\":[{}]", uv_strs.join(",")));
133 }
134
135 let idx_strs: Vec<String> = mesh.indices.iter().map(|i| i.to_string()).collect();
137 parts.push(format!("\"indices\":[{}]", idx_strs.join(",")));
138
139 let (mn, mx) = &mesh.bounding_box;
141 parts.push(format!(
142 "\"bounding_box\":{{\"min\":[{},{},{}],\"max\":[{},{},{}]}}",
143 mn[0], mn[1], mn[2], mx[0], mx[1], mx[2]
144 ));
145
146 if let Some(ref mat) = mesh.material {
148 parts.push(format!(
149 "\"material\":{{\"name\":\"{}\",\"base_color\":[{},{},{},{}],\
150 \"metallic\":{},\"roughness\":{},\"emissive\":[{},{},{}],\
151 \"alpha_mode\":\"{}\",\"double_sided\":{}}}",
152 esc(&mat.name),
153 mat.base_color[0],
154 mat.base_color[1],
155 mat.base_color[2],
156 mat.base_color[3],
157 mat.metallic,
158 mat.roughness,
159 mat.emissive[0],
160 mat.emissive[1],
161 mat.emissive[2],
162 esc(&mat.alpha_mode),
163 mat.double_sided,
164 ));
165 }
166
167 if opts.include_lod && !mesh.lod_levels.is_empty() {
169 let lod_strs: Vec<String> = mesh
170 .lod_levels
171 .iter()
172 .map(|l| {
173 let p: Vec<String> = l
174 .positions
175 .iter()
176 .map(|p| format!("[{},{},{}]", p[0], p[1], p[2]))
177 .collect();
178 let idx: Vec<String> = l.indices.iter().map(|i| i.to_string()).collect();
179 format!(
180 "{{\"level\":{},\"triangle_count\":{},\
181 \"screen_size_threshold\":{},\
182 \"positions\":[{}],\"indices\":[{}]}}",
183 l.level,
184 l.triangle_count,
185 l.screen_size_threshold,
186 p.join(","),
187 idx.join(","),
188 )
189 })
190 .collect();
191 parts.push(format!("\"lod_levels\":[{}]", lod_strs.join(",")));
192 }
193
194 format!("{{{}}}", parts.join(","))
195}
196
197#[allow(dead_code)]
200pub fn web_mesh_from_json(json: &str) -> Option<WebMesh> {
201 let name = extract_str(json, "name").unwrap_or_default();
203 let vertex_count = extract_usize(json, "vertex_count").unwrap_or(0);
204 let triangle_count = extract_usize(json, "triangle_count").unwrap_or(0);
205
206 let positions = extract_f32_3_array(json, "positions").unwrap_or_default();
208 let indices = extract_u32_array(json, "indices").unwrap_or_default();
209
210 let bb = compute_web_mesh_bounds_raw(&positions);
211 Some(WebMesh {
212 name,
213 positions,
214 normals: Vec::new(),
215 uvs: Vec::new(),
216 indices,
217 material: None,
218 lod_levels: Vec::new(),
219 bounding_box: bb,
220 vertex_count,
221 triangle_count,
222 })
223}
224
225#[allow(dead_code)]
227pub fn add_lod_level(mesh: &mut WebMesh, level: WebLodLevel) {
228 mesh.lod_levels.push(level);
229}
230
231#[allow(dead_code)]
234pub fn generate_lod_levels(mesh: &WebMesh, levels: &[f32]) -> Vec<WebLodLevel> {
235 levels
236 .iter()
237 .enumerate()
238 .map(|(i, &threshold)| {
239 let keep_ratio = threshold.clamp(0.0, 1.0);
241 let target_tris = ((mesh.triangle_count as f32) * keep_ratio) as usize;
242 let keep_tris = target_tris.max(1);
243 let max_idx = (keep_tris * 3).min(mesh.indices.len());
244 let safe_idx = (max_idx / 3) * 3;
246 let dec_indices: Vec<u32> = mesh.indices[..safe_idx].to_vec();
247 let tri_count = dec_indices.len() / 3;
248 WebLodLevel {
249 level: i as u32,
250 triangle_count: tri_count,
251 positions: mesh.positions.clone(),
252 normals: mesh.normals.clone(),
253 uvs: mesh.uvs.clone(),
254 indices: dec_indices,
255 screen_size_threshold: threshold,
256 }
257 })
258 .collect()
259}
260
261#[allow(dead_code)]
264pub fn quantize_web_mesh_positions(mesh: &WebMesh) -> Vec<u16> {
265 if mesh.positions.is_empty() {
266 return Vec::new();
267 }
268 let (mn, mx) = compute_web_mesh_bounds_raw(&mesh.positions);
269 let range = [
270 (mx[0] - mn[0]).max(1e-9),
271 (mx[1] - mn[1]).max(1e-9),
272 (mx[2] - mn[2]).max(1e-9),
273 ];
274 mesh.positions
275 .iter()
276 .flat_map(|p| {
277 [
278 (((p[0] - mn[0]) / range[0]) * 65535.0).clamp(0.0, 65535.0) as u16,
279 (((p[1] - mn[1]) / range[1]) * 65535.0).clamp(0.0, 65535.0) as u16,
280 (((p[2] - mn[2]) / range[2]) * 65535.0).clamp(0.0, 65535.0) as u16,
281 ]
282 })
283 .collect()
284}
285
286#[allow(dead_code)]
288pub fn estimate_web_size_bytes(mesh: &WebMesh, opts: &WebExportOptions) -> usize {
289 let bytes_per_float = if opts.quantize_positions { 2 } else { 4 };
290 let mut total = mesh.positions.len() * 3 * bytes_per_float;
291 if opts.include_normals {
292 total += mesh.normals.len() * 3 * 4;
293 }
294 if opts.include_uvs {
295 total += mesh.uvs.len() * 2 * 4;
296 }
297 let idx_bytes = if mesh.vertex_count <= 65535 { 2 } else { 4 };
299 total += mesh.indices.len() * idx_bytes;
300 if opts.include_lod {
301 for lod in &mesh.lod_levels {
302 total += lod.positions.len() * 3 * bytes_per_float;
303 total += lod.indices.len() * idx_bytes;
304 }
305 }
306 total
307}
308
309#[allow(dead_code)]
311pub fn validate_web_mesh(mesh: &WebMesh) -> Vec<String> {
312 let mut issues = Vec::new();
313 if mesh.positions.is_empty() {
314 issues.push("mesh has no positions".to_string());
315 }
316 if mesh.indices.is_empty() {
317 issues.push("mesh has no indices".to_string());
318 }
319 if !mesh.indices.len().is_multiple_of(3) {
320 issues.push(format!(
321 "index count {} is not a multiple of 3",
322 mesh.indices.len()
323 ));
324 }
325 let n = mesh.positions.len() as u32;
326 let oob: usize = mesh.indices.iter().filter(|&&i| i >= n).count();
327 if oob > 0 {
328 issues.push(format!("{} out-of-bounds indices", oob));
329 }
330 if !mesh.normals.is_empty() && mesh.normals.len() != mesh.positions.len() {
331 issues.push(format!(
332 "normal count {} != position count {}",
333 mesh.normals.len(),
334 mesh.positions.len()
335 ));
336 }
337 if !mesh.uvs.is_empty() && mesh.uvs.len() != mesh.positions.len() {
338 issues.push(format!(
339 "uv count {} != position count {}",
340 mesh.uvs.len(),
341 mesh.positions.len()
342 ));
343 }
344 issues
345}
346
347#[allow(dead_code)]
349pub fn compute_web_mesh_bounds(mesh: &WebMesh) -> ([f32; 3], [f32; 3]) {
350 compute_web_mesh_bounds_raw(&mesh.positions)
351}
352
353#[allow(dead_code)]
355pub fn web_export_batch(meshes: &[WebMesh], opts: &WebExportOptions) -> String {
356 let strs: Vec<String> = meshes.iter().map(|m| web_mesh_to_json(m, opts)).collect();
357 format!("[{}]", strs.join(","))
358}
359
360fn compute_web_mesh_bounds_raw(positions: &[[f32; 3]]) -> ([f32; 3], [f32; 3]) {
363 if positions.is_empty() {
364 return ([0.0; 3], [0.0; 3]);
365 }
366 let mut mn = positions[0];
367 let mut mx = positions[0];
368 for p in positions {
369 for i in 0..3 {
370 if p[i] < mn[i] {
371 mn[i] = p[i];
372 }
373 if p[i] > mx[i] {
374 mx[i] = p[i];
375 }
376 }
377 }
378 (mn, mx)
379}
380
381fn esc(s: &str) -> String {
383 s.replace('\\', "\\\\").replace('"', "\\\"")
384}
385
386fn extract_str(json: &str, key: &str) -> Option<String> {
388 let needle = format!("\"{}\":", key);
389 let start = json.find(&needle)? + needle.len();
390 let rest = json[start..].trim_start();
391 if !rest.starts_with('"') {
392 return None;
393 }
394 let inner = &rest[1..];
395 let end = inner.find('"')?;
396 Some(inner[..end].to_string())
397}
398
399fn extract_usize(json: &str, key: &str) -> Option<usize> {
401 let needle = format!("\"{}\":", key);
402 let start = json.find(&needle)? + needle.len();
403 let rest = json[start..].trim_start();
404 let end = rest
405 .find(|c: char| !c.is_ascii_digit())
406 .unwrap_or(rest.len());
407 rest[..end].parse().ok()
408}
409
410fn extract_f32_3_array(json: &str, key: &str) -> Option<Vec<[f32; 3]>> {
412 let needle = format!("\"{}\":", key);
413 let start = json.find(&needle)? + needle.len();
414 let rest = &json[start..];
415 let arr_start = rest.find('[')? + 1;
416 let arr_end = find_matching_bracket(&rest[arr_start..])?;
417 let inner = &rest[arr_start..arr_start + arr_end];
418 let mut result = Vec::new();
419 let mut pos = 0;
420 while pos < inner.len() {
421 let sub = &inner[pos..];
422 let open = match sub.find('[') {
423 Some(i) => i,
424 None => break,
425 };
426 let sub2 = &sub[open + 1..];
427 let close = match sub2.find(']') {
428 Some(i) => i,
429 None => break,
430 };
431 let nums_str = &sub2[..close];
432 let nums: Vec<f32> = nums_str
433 .split(',')
434 .filter_map(|s| s.trim().parse().ok())
435 .collect();
436 if nums.len() == 3 {
437 result.push([nums[0], nums[1], nums[2]]);
438 }
439 pos += open + 1 + close + 1;
440 }
441 Some(result)
442}
443
444fn extract_u32_array(json: &str, key: &str) -> Option<Vec<u32>> {
446 let needle = format!("\"{}\":", key);
447 let start = json.find(&needle)? + needle.len();
448 let rest = &json[start..];
449 let arr_start = rest.find('[')? + 1;
450 let arr_end = find_matching_bracket(&rest[arr_start..])?;
451 let inner = &rest[arr_start..arr_start + arr_end];
452 let result: Vec<u32> = inner
453 .split(',')
454 .filter_map(|s| s.trim().parse().ok())
455 .collect();
456 Some(result)
457}
458
459fn find_matching_bracket(s: &str) -> Option<usize> {
461 let mut depth = 1i32;
462 for (i, c) in s.char_indices() {
463 match c {
464 '[' => depth += 1,
465 ']' => {
466 depth -= 1;
467 if depth == 0 {
468 return Some(i);
469 }
470 }
471 _ => {}
472 }
473 }
474 None
475}
476
477#[allow(dead_code)]
481#[derive(Debug, Clone)]
482pub struct WebExportConfig {
483 pub output_dir: String,
484 pub base_url: String,
485 pub pretty_json: bool,
486 pub include_html_stub: bool,
487}
488
489#[allow(dead_code)]
491#[derive(Debug, Clone)]
492pub struct WebAssetEntry {
493 pub name: String,
494 pub mime_type: String,
495 pub size_bytes: u64,
496}
497
498#[allow(dead_code)]
500#[derive(Debug, Clone)]
501pub struct WebManifest {
502 pub base_url: String,
503 pub assets: Vec<WebAssetEntry>,
504}
505
506#[allow(dead_code)]
508#[derive(Debug, Clone)]
509pub struct WebBundle {
510 pub config: WebExportConfig,
511 pub assets: Vec<WebAssetEntry>,
512}
513
514#[allow(dead_code)]
516pub fn default_web_config() -> WebExportConfig {
517 WebExportConfig {
518 output_dir: "./web_export".to_string(),
519 base_url: "/assets/".to_string(),
520 pretty_json: false,
521 include_html_stub: true,
522 }
523}
524
525#[allow(dead_code)]
527pub fn new_web_bundle(cfg: &WebExportConfig) -> WebBundle {
528 WebBundle {
529 config: cfg.clone(),
530 assets: Vec::new(),
531 }
532}
533
534#[allow(dead_code)]
536pub fn web_bundle_add_asset(bundle: &mut WebBundle, name: &str, mime_type: &str, size_bytes: u64) {
537 bundle.assets.push(WebAssetEntry {
538 name: name.to_string(),
539 mime_type: mime_type.to_string(),
540 size_bytes,
541 });
542}
543
544#[allow(dead_code)]
546pub fn web_bundle_to_manifest(bundle: &WebBundle) -> WebManifest {
547 WebManifest {
548 base_url: bundle.config.base_url.clone(),
549 assets: bundle.assets.clone(),
550 }
551}
552
553#[allow(dead_code)]
555pub fn manifest_to_json(manifest: &WebManifest) -> String {
556 let entries: Vec<String> = manifest
557 .assets
558 .iter()
559 .map(|a| {
560 format!(
561 "{{\"name\":\"{}\",\"mime_type\":\"{}\",\"size_bytes\":{}}}",
562 esc(&a.name),
563 esc(&a.mime_type),
564 a.size_bytes
565 )
566 })
567 .collect();
568 format!(
569 "{{\"base_url\":\"{}\",\"assets\":[{}]}}",
570 esc(&manifest.base_url),
571 entries.join(",")
572 )
573}
574
575#[allow(dead_code)]
577pub fn web_bundle_asset_count(bundle: &WebBundle) -> usize {
578 bundle.assets.len()
579}
580
581#[allow(dead_code)]
583pub fn web_bundle_total_size(bundle: &WebBundle) -> u64 {
584 bundle.assets.iter().map(|a| a.size_bytes).sum()
585}
586
587#[allow(dead_code)]
589pub fn web_export_html_stub(bundle: &WebBundle) -> String {
590 let scripts: String = bundle
591 .assets
592 .iter()
593 .filter(|a| a.mime_type.contains("javascript"))
594 .map(|a| {
595 format!(
596 " <script src=\"{}{}\"></script>\n",
597 bundle.config.base_url, a.name
598 )
599 })
600 .collect();
601 let links: String = bundle
602 .assets
603 .iter()
604 .filter(|a| a.mime_type.contains("css"))
605 .map(|a| {
606 format!(
607 " <link rel=\"stylesheet\" href=\"{}{}\"/>\n",
608 bundle.config.base_url, a.name
609 )
610 })
611 .collect();
612 format!(
613 "<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\"/>\n{}</head>\n<body>\n{}</body>\n</html>",
614 links, scripts
615 )
616}
617
618#[allow(dead_code)]
620pub fn web_manifest_find_asset<'a>(
621 manifest: &'a WebManifest,
622 name: &str,
623) -> Option<&'a WebAssetEntry> {
624 manifest.assets.iter().find(|a| a.name == name)
625}
626
627#[allow(dead_code)]
629pub fn web_bundle_clear(bundle: &mut WebBundle) {
630 bundle.assets.clear();
631}
632
633#[cfg(test)]
636mod tests {
637 use super::*;
638
639 fn tri_mesh() -> WebMesh {
640 new_web_mesh(
641 "test_tri",
642 vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
643 vec![0, 1, 2],
644 )
645 }
646
647 #[test]
648 fn new_web_mesh_basic() {
649 let m = tri_mesh();
650 assert_eq!(m.name, "test_tri");
651 assert_eq!(m.vertex_count, 3);
652 assert_eq!(m.triangle_count, 1);
653 assert_eq!(m.positions.len(), 3);
654 assert_eq!(m.indices.len(), 3);
655 }
656
657 #[test]
658 fn new_web_mesh_bounding_box() {
659 let m = tri_mesh();
660 let (mn, mx) = m.bounding_box;
661 assert!((mn[0] - 0.0).abs() < 1e-6);
662 assert!((mx[0] - 1.0).abs() < 1e-6);
663 assert!((mx[1] - 1.0).abs() < 1e-6);
664 }
665
666 #[test]
667 fn web_mesh_to_json_contains_name() {
668 let m = tri_mesh();
669 let opts = WebExportOptions::default();
670 let json = web_mesh_to_json(&m, &opts);
671 assert!(json.contains("\"name\":\"test_tri\""));
672 }
673
674 #[test]
675 fn web_mesh_to_json_contains_vertex_count() {
676 let m = tri_mesh();
677 let opts = WebExportOptions::default();
678 let json = web_mesh_to_json(&m, &opts);
679 assert!(json.contains("\"vertex_count\":3"));
680 }
681
682 #[test]
683 fn web_mesh_to_json_contains_positions() {
684 let m = tri_mesh();
685 let opts = WebExportOptions::default();
686 let json = web_mesh_to_json(&m, &opts);
687 assert!(json.contains("\"positions\":["));
688 }
689
690 #[test]
691 fn web_mesh_to_json_contains_indices() {
692 let m = tri_mesh();
693 let opts = WebExportOptions::default();
694 let json = web_mesh_to_json(&m, &opts);
695 assert!(json.contains("\"indices\":[0,1,2]"));
696 }
697
698 #[test]
699 fn web_mesh_from_json_roundtrip() {
700 let m = tri_mesh();
701 let opts = WebExportOptions::default();
702 let json = web_mesh_to_json(&m, &opts);
703 let m2 = web_mesh_from_json(&json).expect("roundtrip should succeed");
704 assert_eq!(m2.name, m.name);
705 assert_eq!(m2.vertex_count, m.vertex_count);
706 assert_eq!(m2.triangle_count, m.triangle_count);
707 }
708
709 #[test]
710 fn add_lod_level_increments_count() {
711 let mut m = tri_mesh();
712 assert_eq!(m.lod_levels.len(), 0);
713 let lod = WebLodLevel {
714 level: 1,
715 triangle_count: 0,
716 positions: Vec::new(),
717 normals: Vec::new(),
718 uvs: Vec::new(),
719 indices: Vec::new(),
720 screen_size_threshold: 0.5,
721 };
722 add_lod_level(&mut m, lod);
723 assert_eq!(m.lod_levels.len(), 1);
724 }
725
726 #[test]
727 fn generate_lod_levels_count() {
728 let m = new_web_mesh(
729 "quad",
730 vec![
731 [0.0, 0.0, 0.0],
732 [1.0, 0.0, 0.0],
733 [0.0, 1.0, 0.0],
734 [1.0, 1.0, 0.0],
735 ],
736 vec![0, 1, 2, 1, 3, 2],
737 );
738 let lods = generate_lod_levels(&m, &[0.5, 0.25]);
739 assert_eq!(lods.len(), 2);
740 assert_eq!(lods[0].screen_size_threshold, 0.5);
741 assert_eq!(lods[1].screen_size_threshold, 0.25);
742 }
743
744 #[test]
745 fn quantize_web_mesh_positions_count() {
746 let m = tri_mesh();
747 let q = quantize_web_mesh_positions(&m);
748 assert_eq!(q.len(), 9); }
750
751 #[test]
752 fn quantize_web_mesh_positions_range() {
753 let m = tri_mesh();
754 let q = quantize_web_mesh_positions(&m);
755 for &v in &q {
757 let _ = v; }
759 assert!(q.contains(&65535));
761 }
762
763 #[test]
764 fn estimate_web_size_bytes_nonzero() {
765 let m = tri_mesh();
766 let opts = WebExportOptions::default();
767 let sz = estimate_web_size_bytes(&m, &opts);
768 assert!(sz > 0);
769 }
770
771 #[test]
772 fn validate_web_mesh_valid() {
773 let m = tri_mesh();
774 let issues = validate_web_mesh(&m);
775 assert!(
776 issues.is_empty(),
777 "valid mesh should have no issues: {:?}",
778 issues
779 );
780 }
781
782 #[test]
783 fn validate_web_mesh_bad_index() {
784 let mut m = tri_mesh();
785 m.indices.push(999);
786 let issues = validate_web_mesh(&m);
787 assert!(!issues.is_empty());
788 }
789
790 #[test]
791 fn compute_web_mesh_bounds_empty() {
792 let m = new_web_mesh("empty", Vec::new(), Vec::new());
793 let (mn, mx) = compute_web_mesh_bounds(&m);
794 assert_eq!(mn, [0.0; 3]);
795 assert_eq!(mx, [0.0; 3]);
796 }
797
798 #[test]
799 fn web_export_batch_returns_array() {
800 let m1 = tri_mesh();
801 let m2 = tri_mesh();
802 let opts = WebExportOptions::default();
803 let json = web_export_batch(&[m1, m2], &opts);
804 assert!(json.starts_with('['));
805 assert!(json.ends_with(']'));
806 }
807
808 #[test]
809 fn web_mesh_to_json_includes_lod_when_requested() {
810 let m = new_web_mesh(
811 "lod_test",
812 vec![
813 [0.0, 0.0, 0.0],
814 [1.0, 0.0, 0.0],
815 [0.0, 1.0, 0.0],
816 [1.0, 1.0, 0.0],
817 ],
818 vec![0, 1, 2, 1, 3, 2],
819 );
820 let opts = WebExportOptions {
821 include_lod: true,
822 ..WebExportOptions::default()
823 };
824
825 let lods = generate_lod_levels(&m, &[0.5]);
826 let mut m2 = m;
827 for l in lods {
828 add_lod_level(&mut m2, l);
829 }
830 let json = web_mesh_to_json(&m2, &opts);
831 assert!(json.contains("\"lod_levels\":["));
832 }
833
834 #[test]
835 fn web_mesh_material_roundtrip_in_json() {
836 let mut m = tri_mesh();
837 m.material = Some(WebMaterial {
838 name: "skin".to_string(),
839 base_color: [1.0, 0.8, 0.7, 1.0],
840 metallic: 0.0,
841 roughness: 0.9,
842 emissive: [0.0, 0.0, 0.0],
843 alpha_mode: "OPAQUE".to_string(),
844 double_sided: true,
845 });
846 let opts = WebExportOptions::default();
847 let json = web_mesh_to_json(&m, &opts);
848 assert!(json.contains("\"material\":"));
849 assert!(json.contains("\"alpha_mode\":\"OPAQUE\""));
850 }
851
852 #[test]
855 fn test_default_web_config() {
856 let cfg = default_web_config();
857 assert!(!cfg.output_dir.is_empty());
858 assert!(!cfg.base_url.is_empty());
859 }
860
861 #[test]
862 fn test_new_web_bundle_empty() {
863 let cfg = default_web_config();
864 let bundle = new_web_bundle(&cfg);
865 assert_eq!(web_bundle_asset_count(&bundle), 0);
866 }
867
868 #[test]
869 fn test_web_bundle_add_asset() {
870 let cfg = default_web_config();
871 let mut bundle = new_web_bundle(&cfg);
872 web_bundle_add_asset(&mut bundle, "model.glb", "model/gltf-binary", 1024);
873 assert_eq!(web_bundle_asset_count(&bundle), 1);
874 }
875
876 #[test]
877 fn test_web_bundle_total_size() {
878 let cfg = default_web_config();
879 let mut bundle = new_web_bundle(&cfg);
880 web_bundle_add_asset(&mut bundle, "a.glb", "model/gltf-binary", 500);
881 web_bundle_add_asset(&mut bundle, "b.json", "application/json", 300);
882 assert_eq!(web_bundle_total_size(&bundle), 800);
883 }
884
885 #[test]
886 fn test_web_bundle_to_manifest() {
887 let cfg = default_web_config();
888 let mut bundle = new_web_bundle(&cfg);
889 web_bundle_add_asset(&mut bundle, "model.glb", "model/gltf-binary", 2048);
890 let manifest = web_bundle_to_manifest(&bundle);
891 assert_eq!(manifest.assets.len(), 1);
892 assert_eq!(manifest.assets[0].name, "model.glb");
893 }
894
895 #[test]
896 fn test_manifest_to_json_contains_asset() {
897 let cfg = default_web_config();
898 let mut bundle = new_web_bundle(&cfg);
899 web_bundle_add_asset(&mut bundle, "mesh.glb", "model/gltf-binary", 999);
900 let manifest = web_bundle_to_manifest(&bundle);
901 let json = manifest_to_json(&manifest);
902 assert!(json.contains("mesh.glb"));
903 assert!(json.contains("999"));
904 }
905
906 #[test]
907 fn test_web_manifest_find_asset_found() {
908 let cfg = default_web_config();
909 let mut bundle = new_web_bundle(&cfg);
910 web_bundle_add_asset(&mut bundle, "scene.json", "application/json", 128);
911 let manifest = web_bundle_to_manifest(&bundle);
912 let found = web_manifest_find_asset(&manifest, "scene.json");
913 assert!(found.is_some());
914 assert_eq!(found.expect("should succeed").size_bytes, 128);
915 }
916
917 #[test]
918 fn test_web_manifest_find_asset_not_found() {
919 let cfg = default_web_config();
920 let bundle = new_web_bundle(&cfg);
921 let manifest = web_bundle_to_manifest(&bundle);
922 assert!(web_manifest_find_asset(&manifest, "nonexistent").is_none());
923 }
924
925 #[test]
926 fn test_web_export_html_stub_contains_doctype() {
927 let cfg = default_web_config();
928 let bundle = new_web_bundle(&cfg);
929 let html = web_export_html_stub(&bundle);
930 assert!(html.contains("<!DOCTYPE html>"));
931 }
932
933 #[test]
934 fn test_web_bundle_clear() {
935 let cfg = default_web_config();
936 let mut bundle = new_web_bundle(&cfg);
937 web_bundle_add_asset(&mut bundle, "x.glb", "model/gltf-binary", 10);
938 web_bundle_clear(&mut bundle);
939 assert_eq!(web_bundle_asset_count(&bundle), 0);
940 }
941}