Skip to main content

oxihuman_export/
report_html.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4#![allow(dead_code)]
5
6use oxihuman_mesh::MeshBuffers;
7use std::collections::HashMap;
8
9// ---------------------------------------------------------------------------
10// Data structures
11// ---------------------------------------------------------------------------
12
13pub struct MeshReportData {
14    pub name: String,
15    pub vertex_count: usize,
16    pub face_count: usize,
17    pub has_normals: bool,
18    pub has_uvs: bool,
19    pub has_colors: bool,
20    pub bounding_box_min: [f32; 3],
21    pub bounding_box_max: [f32; 3],
22    pub file_size_bytes: Option<u64>,
23    pub format: String,
24}
25
26pub struct PipelineReportData {
27    pub title: String,
28    pub timestamp: String,
29    pub version: String,
30    pub meshes: Vec<MeshReportData>,
31    pub parameters: HashMap<String, f32>,
32    pub export_paths: Vec<String>,
33    pub warnings: Vec<String>,
34    pub errors: Vec<String>,
35    pub duration_ms: u64,
36}
37
38impl PipelineReportData {
39    pub fn new(title: impl Into<String>) -> Self {
40        PipelineReportData {
41            title: title.into(),
42            timestamp: String::new(),
43            version: String::from("0.1.0"),
44            meshes: Vec::new(),
45            parameters: HashMap::new(),
46            export_paths: Vec::new(),
47            warnings: Vec::new(),
48            errors: Vec::new(),
49            duration_ms: 0,
50        }
51    }
52
53    pub fn add_mesh(&mut self, mesh: MeshReportData) {
54        self.meshes.push(mesh);
55    }
56
57    pub fn add_param(&mut self, key: impl Into<String>, value: f32) {
58        self.parameters.insert(key.into(), value);
59    }
60
61    pub fn add_export_path(&mut self, path: impl Into<String>) {
62        self.export_paths.push(path.into());
63    }
64
65    pub fn add_warning(&mut self, msg: impl Into<String>) {
66        self.warnings.push(msg.into());
67    }
68
69    pub fn add_error(&mut self, msg: impl Into<String>) {
70        self.errors.push(msg.into());
71    }
72
73    pub fn total_vertices(&self) -> usize {
74        self.meshes.iter().map(|m| m.vertex_count).sum()
75    }
76
77    pub fn total_faces(&self) -> usize {
78        self.meshes.iter().map(|m| m.face_count).sum()
79    }
80}
81
82// ---------------------------------------------------------------------------
83// HTML helpers
84// ---------------------------------------------------------------------------
85
86/// Escape HTML special characters.
87pub fn html_escape(s: &str) -> String {
88    let mut out = String::with_capacity(s.len());
89    for ch in s.chars() {
90        match ch {
91            '&' => out.push_str("&amp;"),
92            '<' => out.push_str("&lt;"),
93            '>' => out.push_str("&gt;"),
94            '"' => out.push_str("&quot;"),
95            c => out.push(c),
96        }
97    }
98    out
99}
100
101fn bool_badge(v: bool) -> &'static str {
102    if v {
103        "Yes"
104    } else {
105        "No"
106    }
107}
108
109fn fmt_opt_u64(v: Option<u64>) -> String {
110    match v {
111        Some(n) => format!("{n}"),
112        None => String::from("—"),
113    }
114}
115
116fn fmt_f32x3(v: [f32; 3]) -> String {
117    format!("[{:.3}, {:.3}, {:.3}]", v[0], v[1], v[2])
118}
119
120// ---------------------------------------------------------------------------
121// Inline CSS
122// ---------------------------------------------------------------------------
123
124const INLINE_CSS: &str = r#"
125  body { font-family: 'Segoe UI', Arial, sans-serif; margin: 24px; background: #f5f5f5; color: #222; }
126  h1   { color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 8px; }
127  h2   { color: #34495e; margin-top: 32px; }
128  table { border-collapse: collapse; width: 100%; margin-top: 12px; background: #fff; }
129  th { background: #3498db; color: #fff; padding: 8px 12px; text-align: left; }
130  td { padding: 7px 12px; border-bottom: 1px solid #dde; }
131  tr:nth-child(even) td { background: #f0f4ff; }
132  .summary-grid { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 12px; }
133  .summary-card { background: #fff; border: 1px solid #cce; border-radius: 6px;
134                  padding: 14px 20px; min-width: 160px; }
135  .summary-card .label { font-size: 0.8em; color: #666; }
136  .summary-card .value { font-size: 1.6em; font-weight: bold; color: #2c3e50; }
137  .warn-list li { background: #fffbe6; border-left: 4px solid #f1c40f;
138                  padding: 6px 10px; margin: 4px 0; list-style: none; }
139  .err-list  li { background: #fdecea; border-left: 4px solid #e74c3c;
140                  padding: 6px 10px; margin: 4px 0; list-style: none; }
141  .path-list li { background: #eafaf1; border-left: 4px solid #2ecc71;
142                  padding: 6px 10px; margin: 4px 0; list-style: none; font-family: monospace; }
143  footer { margin-top: 40px; font-size: 0.8em; color: #999; border-top: 1px solid #ddd; padding-top: 8px; }
144"#;
145
146// ---------------------------------------------------------------------------
147// Core report generation
148// ---------------------------------------------------------------------------
149
150/// Generate a full self-contained HTML5 report string.
151pub fn generate_html_report(data: &PipelineReportData) -> String {
152    let mut html = String::with_capacity(8192);
153
154    // DOCTYPE + head
155    html.push_str("<!DOCTYPE html>\n");
156    html.push_str("<html lang=\"en\">\n<head>\n");
157    html.push_str("<meta charset=\"UTF-8\">\n");
158    html.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n");
159    html.push_str(&format!("<title>{}</title>\n", html_escape(&data.title)));
160    html.push_str("<style>");
161    html.push_str(INLINE_CSS);
162    html.push_str("</style>\n</head>\n<body>\n");
163
164    // Header
165    html.push_str(&format!("<h1>{}</h1>\n", html_escape(&data.title)));
166    html.push_str(&format!(
167        "<p><strong>Timestamp:</strong> {}&nbsp;&nbsp;<strong>Version:</strong> {}&nbsp;&nbsp;<strong>Duration:</strong> {} ms</p>\n",
168        html_escape(&data.timestamp),
169        html_escape(&data.version),
170        data.duration_ms
171    ));
172
173    // Summary cards
174    html.push_str("<h2>Summary</h2>\n<div class=\"summary-grid\">\n");
175    html.push_str(&summary_card("Meshes", &data.meshes.len().to_string()));
176    html.push_str(&summary_card(
177        "Total Vertices",
178        &data.total_vertices().to_string(),
179    ));
180    html.push_str(&summary_card(
181        "Total Faces",
182        &data.total_faces().to_string(),
183    ));
184    html.push_str(&summary_card(
185        "Parameters",
186        &data.parameters.len().to_string(),
187    ));
188    html.push_str(&summary_card(
189        "Exports",
190        &data.export_paths.len().to_string(),
191    ));
192    html.push_str(&summary_card("Warnings", &data.warnings.len().to_string()));
193    html.push_str(&summary_card("Errors", &data.errors.len().to_string()));
194    html.push_str("</div>\n");
195
196    // Export paths
197    if !data.export_paths.is_empty() {
198        html.push_str("<h2>Export Paths</h2>\n<ul class=\"path-list\">\n");
199        for p in &data.export_paths {
200            html.push_str(&format!("<li>{}</li>\n", html_escape(p)));
201        }
202        html.push_str("</ul>\n");
203    }
204
205    // Mesh table
206    html.push_str("<h2>Meshes</h2>\n");
207    if data.meshes.is_empty() {
208        html.push_str("<p><em>No meshes.</em></p>\n");
209    } else {
210        html.push_str("<table>\n<thead><tr>");
211        for col in &[
212            "Name",
213            "Format",
214            "Vertices",
215            "Faces",
216            "Normals",
217            "UVs",
218            "Colors",
219            "BBox Min",
220            "BBox Max",
221            "File Size",
222        ] {
223            html.push_str(&format!("<th>{col}</th>"));
224        }
225        html.push_str("</tr></thead>\n<tbody>\n");
226        for m in &data.meshes {
227            html.push_str("<tr>");
228            html.push_str(&td(&html_escape(&m.name)));
229            html.push_str(&td(&html_escape(&m.format)));
230            html.push_str(&td(&m.vertex_count.to_string()));
231            html.push_str(&td(&m.face_count.to_string()));
232            html.push_str(&td(bool_badge(m.has_normals)));
233            html.push_str(&td(bool_badge(m.has_uvs)));
234            html.push_str(&td(bool_badge(m.has_colors)));
235            html.push_str(&td(&fmt_f32x3(m.bounding_box_min)));
236            html.push_str(&td(&fmt_f32x3(m.bounding_box_max)));
237            html.push_str(&td(&fmt_opt_u64(m.file_size_bytes)));
238            html.push_str("</tr>\n");
239        }
240        html.push_str("</tbody>\n</table>\n");
241    }
242
243    // Parameters table
244    html.push_str("<h2>Parameters</h2>\n");
245    if data.parameters.is_empty() {
246        html.push_str("<p><em>No parameters.</em></p>\n");
247    } else {
248        html.push_str("<table>\n<thead><tr><th>Key</th><th>Value</th></tr></thead>\n<tbody>\n");
249        let mut sorted_keys: Vec<&String> = data.parameters.keys().collect();
250        sorted_keys.sort();
251        for k in sorted_keys {
252            let v = data.parameters[k];
253            html.push_str(&format!(
254                "<tr>{}{}</tr>\n",
255                td(&html_escape(k)),
256                td(&format!("{v:.6}"))
257            ));
258        }
259        html.push_str("</tbody>\n</table>\n");
260    }
261
262    // Warnings
263    if !data.warnings.is_empty() {
264        html.push_str("<h2>Warnings</h2>\n<ul class=\"warn-list\">\n");
265        for w in &data.warnings {
266            html.push_str(&format!("<li>{}</li>\n", html_escape(w)));
267        }
268        html.push_str("</ul>\n");
269    }
270
271    // Errors
272    if !data.errors.is_empty() {
273        html.push_str("<h2>Errors</h2>\n<ul class=\"err-list\">\n");
274        for e in &data.errors {
275            html.push_str(&format!("<li>{}</li>\n", html_escape(e)));
276        }
277        html.push_str("</ul>\n");
278    }
279
280    // Footer
281    html.push_str("<footer>Generated by OxiHuman Export Pipeline</footer>\n");
282    html.push_str("</body>\n</html>\n");
283
284    html
285}
286
287fn summary_card(label: &str, value: &str) -> String {
288    format!(
289        "<div class=\"summary-card\"><div class=\"label\">{label}</div><div class=\"value\">{value}</div></div>\n"
290    )
291}
292
293fn td(content: &str) -> String {
294    format!("<td>{content}</td>")
295}
296
297// ---------------------------------------------------------------------------
298// File export
299// ---------------------------------------------------------------------------
300
301/// Export HTML report to a file.
302pub fn export_html_report(data: &PipelineReportData, path: &std::path::Path) -> anyhow::Result<()> {
303    let html = generate_html_report(data);
304    std::fs::write(path, html)?;
305    Ok(())
306}
307
308// ---------------------------------------------------------------------------
309// Mesh summary table
310// ---------------------------------------------------------------------------
311
312/// Generate a minimal mesh summary HTML table for a single mesh.
313pub fn mesh_summary_html(mesh: &MeshReportData) -> String {
314    let mut html = String::with_capacity(512);
315    html.push_str("<table>\n<thead><tr><th>Property</th><th>Value</th></tr></thead>\n<tbody>\n");
316    let rows: &[(&str, String)] = &[
317        ("Name", html_escape(&mesh.name)),
318        ("Format", html_escape(&mesh.format)),
319        ("Vertices", mesh.vertex_count.to_string()),
320        ("Faces", mesh.face_count.to_string()),
321        ("Normals", bool_badge(mesh.has_normals).to_string()),
322        ("UVs", bool_badge(mesh.has_uvs).to_string()),
323        ("Colors", bool_badge(mesh.has_colors).to_string()),
324        ("BBox Min", fmt_f32x3(mesh.bounding_box_min)),
325        ("BBox Max", fmt_f32x3(mesh.bounding_box_max)),
326        ("File Size", fmt_opt_u64(mesh.file_size_bytes)),
327    ];
328    for (k, v) in rows {
329        html.push_str(&format!(
330            "<tr><td><strong>{k}</strong></td><td>{v}</td></tr>\n"
331        ));
332    }
333    html.push_str("</tbody>\n</table>\n");
334    html
335}
336
337// ---------------------------------------------------------------------------
338// Auto-populate MeshReportData from MeshBuffers
339// ---------------------------------------------------------------------------
340
341/// Build a `MeshReportData` by reading the fields of a `MeshBuffers`.
342pub fn mesh_report_from_buffers(mesh: &MeshBuffers, name: &str, format: &str) -> MeshReportData {
343    let vertex_count = mesh.positions.len();
344    let face_count = mesh.indices.len() / 3;
345    let has_normals = !mesh.normals.is_empty();
346    let has_uvs = !mesh.uvs.is_empty();
347    let has_colors = mesh.colors.is_some();
348
349    // Bounding box
350    let mut bb_min = [f32::INFINITY; 3];
351    let mut bb_max = [f32::NEG_INFINITY; 3];
352    for pos in &mesh.positions {
353        for i in 0..3 {
354            if pos[i] < bb_min[i] {
355                bb_min[i] = pos[i];
356            }
357            if pos[i] > bb_max[i] {
358                bb_max[i] = pos[i];
359            }
360        }
361    }
362    // Handle empty mesh
363    if mesh.positions.is_empty() {
364        bb_min = [0.0; 3];
365        bb_max = [0.0; 3];
366    }
367
368    MeshReportData {
369        name: name.to_string(),
370        vertex_count,
371        face_count,
372        has_normals,
373        has_uvs,
374        has_colors,
375        bounding_box_min: bb_min,
376        bounding_box_max: bb_max,
377        file_size_bytes: None,
378        format: format.to_string(),
379    }
380}
381
382// ---------------------------------------------------------------------------
383// Tests
384// ---------------------------------------------------------------------------
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use oxihuman_mesh::MeshBuffers;
390    use oxihuman_morph::engine::MeshBuffers as MB;
391
392    fn simple_mesh_buffers() -> MeshBuffers {
393        MeshBuffers::from_morph(MB {
394            positions: vec![[-1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
395            normals: vec![[0.0, 0.0, 1.0]; 3],
396            uvs: vec![[0.0, 0.0], [1.0, 0.0], [0.5, 1.0]],
397            indices: vec![0, 1, 2],
398            has_suit: false,
399        })
400    }
401
402    fn simple_mesh_report_data() -> MeshReportData {
403        MeshReportData {
404            name: String::from("body"),
405            vertex_count: 100,
406            face_count: 80,
407            has_normals: true,
408            has_uvs: true,
409            has_colors: false,
410            bounding_box_min: [-1.0, -1.0, -1.0],
411            bounding_box_max: [1.0, 1.0, 1.0],
412            file_size_bytes: Some(4096),
413            format: String::from("GLB"),
414        }
415    }
416
417    fn simple_pipeline() -> PipelineReportData {
418        let mut p = PipelineReportData::new("Test Pipeline");
419        p.timestamp = String::from("2026-02-22T00:00:00Z");
420        p.version = String::from("1.2.3");
421        p.duration_ms = 42;
422        p.add_mesh(simple_mesh_report_data());
423        p.add_param("height", 1.75);
424        p.add_param("weight", 70.0);
425        p.add_export_path("/tmp/output/body.glb");
426        p.add_warning("normals may be inverted");
427        p.add_error("texture missing");
428        p
429    }
430
431    // --- html_escape tests ---
432
433    #[test]
434    fn test_html_escape_basic() {
435        assert_eq!(html_escape("hello"), "hello");
436        assert_eq!(html_escape(""), "");
437    }
438
439    #[test]
440    fn test_html_escape_special_chars() {
441        assert_eq!(html_escape("&"), "&amp;");
442        assert_eq!(html_escape("<tag>"), "&lt;tag&gt;");
443        assert_eq!(html_escape("say \"hi\""), "say &quot;hi&quot;");
444        assert_eq!(
445            html_escape("<a href=\"foo\">bar & baz</a>"),
446            "&lt;a href=&quot;foo&quot;&gt;bar &amp; baz&lt;/a&gt;"
447        );
448    }
449
450    // --- PipelineReportData construction ---
451
452    #[test]
453    fn test_pipeline_report_new() {
454        let p = PipelineReportData::new("My Report");
455        assert_eq!(p.title, "My Report");
456        assert!(p.meshes.is_empty());
457        assert!(p.parameters.is_empty());
458        assert!(p.export_paths.is_empty());
459        assert!(p.warnings.is_empty());
460        assert!(p.errors.is_empty());
461        assert_eq!(p.duration_ms, 0);
462    }
463
464    #[test]
465    fn test_pipeline_report_add_mesh() {
466        let mut p = PipelineReportData::new("R");
467        assert_eq!(p.meshes.len(), 0);
468        p.add_mesh(simple_mesh_report_data());
469        assert_eq!(p.meshes.len(), 1);
470        assert_eq!(p.meshes[0].name, "body");
471    }
472
473    #[test]
474    fn test_pipeline_report_totals() {
475        let mut p = PipelineReportData::new("R");
476        p.add_mesh(MeshReportData {
477            vertex_count: 300,
478            face_count: 200,
479            ..simple_mesh_report_data()
480        });
481        p.add_mesh(MeshReportData {
482            name: String::from("head"),
483            vertex_count: 150,
484            face_count: 100,
485            ..simple_mesh_report_data()
486        });
487        assert_eq!(p.total_vertices(), 450);
488        assert_eq!(p.total_faces(), 300);
489    }
490
491    // --- generate_html_report content checks ---
492
493    #[test]
494    fn test_generate_html_has_doctype() {
495        let p = simple_pipeline();
496        let html = generate_html_report(&p);
497        assert!(html.starts_with("<!DOCTYPE html>"), "Missing DOCTYPE");
498    }
499
500    #[test]
501    fn test_generate_html_has_title() {
502        let p = simple_pipeline();
503        let html = generate_html_report(&p);
504        assert!(html.contains("<title>Test Pipeline</title>"));
505        assert!(html.contains("<h1>Test Pipeline</h1>"));
506    }
507
508    #[test]
509    fn test_generate_html_has_mesh_table() {
510        let p = simple_pipeline();
511        let html = generate_html_report(&p);
512        assert!(html.contains("<h2>Meshes</h2>"));
513        assert!(html.contains("body"));
514        assert!(html.contains("GLB"));
515        assert!(html.contains("100")); // vertex count
516        assert!(html.contains("80")); // face count
517    }
518
519    #[test]
520    fn test_generate_html_has_params() {
521        let p = simple_pipeline();
522        let html = generate_html_report(&p);
523        assert!(html.contains("<h2>Parameters</h2>"));
524        assert!(html.contains("height"));
525        assert!(html.contains("weight"));
526    }
527
528    #[test]
529    fn test_generate_html_warnings() {
530        let p = simple_pipeline();
531        let html = generate_html_report(&p);
532        assert!(html.contains("<h2>Warnings</h2>"));
533        assert!(html.contains("normals may be inverted"));
534        assert!(html.contains("<h2>Errors</h2>"));
535        assert!(html.contains("texture missing"));
536    }
537
538    // --- mesh_summary_html ---
539
540    #[test]
541    fn test_mesh_summary_html() {
542        let m = simple_mesh_report_data();
543        let html = mesh_summary_html(&m);
544        assert!(html.contains("<table>"));
545        assert!(html.contains("body"));
546        assert!(html.contains("GLB"));
547        assert!(html.contains("100"));
548        assert!(html.contains("80"));
549        assert!(html.contains("Yes")); // has_normals / has_uvs
550    }
551
552    // --- export_html_report writes file ---
553
554    #[test]
555    fn test_export_html_report() {
556        let p = simple_pipeline();
557        let path = std::path::Path::new("/tmp/oxihuman_test_report.html");
558        export_html_report(&p, path).expect("export failed");
559        let contents = std::fs::read_to_string(path).expect("read failed");
560        assert!(contents.contains("<!DOCTYPE html>"));
561        assert!(contents.contains("Test Pipeline"));
562    }
563
564    // --- mesh_report_from_buffers ---
565
566    #[test]
567    fn test_mesh_report_from_buffers() {
568        let mb = simple_mesh_buffers();
569        let rd = mesh_report_from_buffers(&mb, "tri", "OBJ");
570        assert_eq!(rd.name, "tri");
571        assert_eq!(rd.format, "OBJ");
572        assert_eq!(rd.vertex_count, 3);
573        assert_eq!(rd.face_count, 1);
574        assert!(rd.has_normals);
575        assert!(rd.has_uvs);
576        assert!(!rd.has_colors);
577        // bounding box: x in [-1, 1], y in [0, 1], z in [0, 0]
578        assert!((rd.bounding_box_min[0] - (-1.0)).abs() < 1e-6);
579        assert!((rd.bounding_box_max[0] - 1.0).abs() < 1e-6);
580        assert!((rd.bounding_box_min[1] - 0.0).abs() < 1e-6);
581        assert!((rd.bounding_box_max[1] - 1.0).abs() < 1e-6);
582    }
583}