Skip to main content

oxihuman_export/
web_export.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! WebGL/browser-optimized JSON mesh export.
5//!
6//! Provides browser-ready export with material/shader hints, LOD embedding,
7//! and compressed indices.
8
9// ── Types ─────────────────────────────────────────────────────────────────────
10
11/// PBR material definition for web export.
12#[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, // "OPAQUE", "BLEND", "MASK"
20    pub double_sided: bool,
21}
22
23/// A single LOD level with its own geometry.
24#[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/// Options controlling what is included in the web export.
36#[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/// A mesh ready for browser/WebGL consumption.
62#[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// ── Public API ────────────────────────────────────────────────────────────────
77
78/// Create a new `WebMesh` from positions and indices.
79/// Normals and UVs are left empty; bounding box is computed automatically.
80#[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/// Serialize a `WebMesh` to a JSON string honouring the export options.
100#[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    // positions
108    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    // normals
116    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    // uvs
126    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    // indices
136    let idx_strs: Vec<String> = mesh.indices.iter().map(|i| i.to_string()).collect();
137    parts.push(format!("\"indices\":[{}]", idx_strs.join(",")));
138
139    // bounding_box
140    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    // material
147    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    // LOD
168    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/// Deserialize a `WebMesh` from JSON produced by `web_mesh_to_json`.
198/// Returns `None` on parse failure.
199#[allow(dead_code)]
200pub fn web_mesh_from_json(json: &str) -> Option<WebMesh> {
201    // Minimal hand-rolled parser: extract "name" and "vertex_count".
202    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    // For a stub round-trip, we only need positions + indices.
207    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/// Append a pre-built LOD level to the mesh.
226#[allow(dead_code)]
227pub fn add_lod_level(mesh: &mut WebMesh, level: WebLodLevel) {
228    mesh.lod_levels.push(level);
229}
230
231/// Generate LOD levels for a mesh by decimating at each screen-size threshold.
232/// `levels` contains `screen_size_threshold` values (e.g. [0.5, 0.25, 0.1]).
233#[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            // Decimate by removing every other triangle pair naively.
240            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            // Round down to multiple of 3
245            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/// Quantize mesh positions to 16-bit unsigned integers.
262/// Returns a flat list: [x0_u16, y0_u16, z0_u16, x1_u16, ...] encoded as individual u16 values.
263#[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/// Estimate the byte size of the exported mesh given the options.
287#[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    // indices: u32 or u16 depending on size
298    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/// Validate a `WebMesh` and return a list of issue descriptions.
310#[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/// Compute the axis-aligned bounding box of a `WebMesh`.
348#[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/// Export a batch of meshes as a JSON array.
354#[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
360// ── Private helpers ───────────────────────────────────────────────────────────
361
362fn 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
381/// Escape special JSON chars in a string.
382fn esc(s: &str) -> String {
383    s.replace('\\', "\\\\").replace('"', "\\\"")
384}
385
386/// Extract a quoted string field from JSON by key.
387fn 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
399/// Extract a usize field from JSON by key.
400fn 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
410/// Minimal extraction of [[f32;3]] arrays from JSON text.
411fn 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
444/// Extract a flat [u32] array from JSON text.
445fn 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
459/// Find the offset of the closing `]` that matches an opening `[` (already consumed).
460fn 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// ── Web Bundle / Manifest API ─────────────────────────────────────────────────
478
479/// Configuration for the web bundle export.
480#[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/// A single asset entry in a web bundle.
490#[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/// Manifest listing all assets in a web bundle.
499#[allow(dead_code)]
500#[derive(Debug, Clone)]
501pub struct WebManifest {
502    pub base_url: String,
503    pub assets: Vec<WebAssetEntry>,
504}
505
506/// A collection of assets ready for web export.
507#[allow(dead_code)]
508#[derive(Debug, Clone)]
509pub struct WebBundle {
510    pub config: WebExportConfig,
511    pub assets: Vec<WebAssetEntry>,
512}
513
514/// Return a default `WebExportConfig`.
515#[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/// Create a new `WebBundle` from a configuration.
526#[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/// Add an asset entry to the bundle.
535#[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/// Convert a `WebBundle` into a `WebManifest`.
545#[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/// Serialize a `WebManifest` to a JSON string.
554#[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/// Return the number of assets in a bundle.
576#[allow(dead_code)]
577pub fn web_bundle_asset_count(bundle: &WebBundle) -> usize {
578    bundle.assets.len()
579}
580
581/// Return the total byte size of all assets in the bundle.
582#[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/// Generate a minimal HTML stub that references all bundle assets.
588#[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/// Find a named asset in a manifest.  Returns `None` if not present.
619#[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/// Clear all assets from a bundle.
628#[allow(dead_code)]
629pub fn web_bundle_clear(bundle: &mut WebBundle) {
630    bundle.assets.clear();
631}
632
633// ── Tests ─────────────────────────────────────────────────────────────────────
634
635#[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); // 3 verts × 3 coords
749    }
750
751    #[test]
752    fn quantize_web_mesh_positions_range() {
753        let m = tri_mesh();
754        let q = quantize_web_mesh_positions(&m);
755        // All values must be within u16 range
756        for &v in &q {
757            let _ = v; // just check it compiled to u16
758        }
759        // The max component should be 65535
760        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    // ── Web Bundle / Manifest API tests ───────────────────────────────────────
853
854    #[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}