1#![allow(dead_code)]
5
6use oxihuman_mesh::MeshBuffers;
7use std::collections::HashMap;
8
9pub 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
82pub 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("&"),
92 '<' => out.push_str("<"),
93 '>' => out.push_str(">"),
94 '"' => out.push_str("""),
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
120const 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
146pub fn generate_html_report(data: &PipelineReportData) -> String {
152 let mut html = String::with_capacity(8192);
153
154 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 html.push_str(&format!("<h1>{}</h1>\n", html_escape(&data.title)));
166 html.push_str(&format!(
167 "<p><strong>Timestamp:</strong> {} <strong>Version:</strong> {} <strong>Duration:</strong> {} ms</p>\n",
168 html_escape(&data.timestamp),
169 html_escape(&data.version),
170 data.duration_ms
171 ));
172
173 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 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 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 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 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 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 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
297pub 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
308pub 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
337pub 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 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 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#[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 #[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("&"), "&");
442 assert_eq!(html_escape("<tag>"), "<tag>");
443 assert_eq!(html_escape("say \"hi\""), "say "hi"");
444 assert_eq!(
445 html_escape("<a href=\"foo\">bar & baz</a>"),
446 "<a href="foo">bar & baz</a>"
447 );
448 }
449
450 #[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 #[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")); assert!(html.contains("80")); }
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 #[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")); }
551
552 #[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 #[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 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}