1#![warn(missing_docs)]
39
40use animsmith_core::finding::Finding;
41use animsmith_core::metrics::MetricGrids;
42use animsmith_core::profile::{ResolvedRoles, Role};
43use animsmith_core::sample::PoseGrid;
44use base64::Engine as _;
45use serde_json::{Value, json};
46
47const VIEWER_JS: &str = include_str!("../assets/viewer.js");
48const VIEWER_CSS: &str = include_str!("../assets/viewer.css");
49
50fn esc(text: &str) -> String {
53 text.replace('&', "&")
54 .replace('<', "<")
55 .replace('>', ">")
56 .replace('"', """)
57}
58
59pub fn render(
66 grids: &MetricGrids<'_>,
67 roles: &ResolvedRoles,
68 findings: &[Finding],
69 clip_filter: Option<&str>,
70) -> String {
71 let doc = grids.document();
72 let bones: Vec<Value> = doc
73 .skeleton
74 .bones
75 .iter()
76 .map(|b| json!({ "name": b.name, "parent": b.parent.map(|p| p as i64).unwrap_or(-1) }))
77 .collect();
78
79 let trail_roles = [
80 (Role::Root, "root"),
81 (Role::Hips, "hips"),
82 (Role::LeftFoot, "left_foot"),
83 (Role::RightFoot, "right_foot"),
84 ];
85
86 let mut clips_json: Vec<Value> = Vec::new();
87 let mut charts_html = String::new();
88 for (clip_index, clip) in doc.clips.iter().enumerate() {
89 if clip_filter.is_some_and(|f| f != clip.name) {
90 continue;
91 }
92 let Some(grid) = grids.grid(clip_index) else {
93 continue;
94 };
95 let frames = grid.frame_count();
96 let nb = doc.skeleton.bones.len();
97 let mut positions = Vec::with_capacity(frames * nb * 3 * 4);
98 for f in 0..frames {
99 for b in 0..nb {
100 let p = grid.model_position(f, b);
101 positions.extend_from_slice(&p.x.to_le_bytes());
102 positions.extend_from_slice(&p.y.to_le_bytes());
103 positions.extend_from_slice(&p.z.to_le_bytes());
104 }
105 }
106 let trails: Value = trail_roles
107 .iter()
108 .filter_map(|&(role, name)| roles.get(role).map(|id| (name.to_string(), json!(id))))
109 .collect::<serde_json::Map<_, _>>()
110 .into();
111 clips_json.push(json!({
112 "name": clip.name,
113 "duration": clip.duration_s,
114 "frames": frames,
115 "positions": base64::engine::general_purpose::STANDARD.encode(&positions),
116 "trails": trails,
117 }));
118 charts_html.push_str(&clip_charts(&clip.name, grid.as_ref(), roles));
119 }
120
121 let findings_json: Vec<Value> = findings
122 .iter()
123 .filter(|f| clip_filter.is_none() || f.clip.as_deref() == clip_filter || f.clip.is_none())
124 .map(|f| {
125 json!({
126 "check": f.check_id,
127 "severity": f.severity.to_string(),
128 "clip": f.clip,
129 "bone": f.bone,
130 "time": f.time_s,
131 "message": f.message,
132 })
133 })
134 .collect();
135
136 let data = json!({
137 "file": doc.source.path,
138 "profile": roles.profile,
139 "bones": bones,
140 "clips": clips_json,
141 "findings": findings_json,
142 });
143
144 let title = esc(doc
145 .source
146 .path
147 .as_deref()
148 .and_then(|p| p.rsplit(['/', '\\']).next())
149 .unwrap_or("animsmith report"));
150 let data = data.to_string().replace('<', "\\u003c");
153
154 format!(
155 "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\
156 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
157 <title>animsmith — {title}</title>\n<style>{VIEWER_CSS}</style>\n</head>\n<body>\n\
158 <header><h1>animsmith report</h1><span id=\"file\"></span></header>\n\
159 <main>\n\
160 <section id=\"viewer-panel\">\n\
161 <div id=\"controls\">\n\
162 <select id=\"clip-select\"></select>\n\
163 <button id=\"play\">▶</button>\n\
164 <input type=\"range\" id=\"scrub\" min=\"0\" value=\"0\" step=\"1\">\n\
165 <span id=\"time\"></span>\n\
166 </div>\n\
167 <canvas id=\"gl\"></canvas>\n\
168 <p class=\"hint\">drag to orbit · wheel to zoom · frames shown are exactly the \
169 grid the checks judged</p>\n\
170 </section>\n\
171 <section id=\"side\">\n\
172 <h2>Findings</h2>\n<ul id=\"findings\"></ul>\n\
173 <h2>Charts</h2>\n<div id=\"charts\">{charts_html}</div>\n\
174 </section>\n\
175 </main>\n\
176 <script type=\"application/json\" id=\"report-data\">{data}</script>\n\
177 <script>{VIEWER_JS}</script>\n</body>\n</html>\n"
178 )
179}
180
181fn clip_charts(clip_name: &str, grid: &PoseGrid, roles: &ResolvedRoles) -> String {
185 let mut out = String::new();
186 let frames = grid.frame_count();
187 let hips = roles.get(Role::Hips);
188 let left = roles.get(Role::LeftFoot);
189 let right = roles.get(Role::RightFoot);
190
191 if let (Some(hips), Some(left), Some(right)) = (hips, left, right) {
192 let rel_y = |f: usize, b: usize| {
193 (grid.model_position(f, b).y - grid.model_position(f, hips).y) as f64
194 };
195 let l: Vec<f64> = (0..frames).map(|f| rel_y(f, left)).collect();
196 let r: Vec<f64> = (0..frames).map(|f| rel_y(f, right)).collect();
197 let d: Vec<f64> = l.iter().zip(&r).map(|(a, b)| a - b).collect();
198 out.push_str(&line_chart(
199 clip_name,
200 "gait",
201 "foot height rel hips (m) — L blue · R orange · L−R grey",
202 &[("#7aa2f7", &l), ("#e0af68", &r), ("#9099b2", &d)],
203 ));
204 }
205
206 let root = roles.get(Role::Root).or(hips);
207 if let Some(root) = root {
208 let xs: Vec<f64> = (0..frames)
209 .map(|f| grid.model_position(f, root).x as f64)
210 .collect();
211 let zs: Vec<f64> = (0..frames)
212 .map(|f| grid.model_position(f, root).z as f64)
213 .collect();
214 out.push_str(&path_chart(clip_name, "root path (top-down, m)", &xs, &zs));
215 }
216 out
217}
218
219const W: f64 = 360.0;
220const H: f64 = 120.0;
221const PAD: f64 = 8.0;
222
223fn line_chart(clip: &str, kind: &str, label: &str, series: &[(&str, &Vec<f64>)]) -> String {
224 let clip = &esc(clip);
225 let all: Vec<f64> = series.iter().flat_map(|(_, v)| v.iter().copied()).collect();
226 if all.is_empty() {
227 return String::new();
228 }
229 let min = all.iter().copied().fold(f64::MAX, f64::min);
230 let max = all.iter().copied().fold(f64::MIN, f64::max);
231 let span = (max - min).max(1e-6);
232 let n = series[0].1.len().max(2);
233 let x = |i: usize| PAD + (W - 2.0 * PAD) * i as f64 / (n - 1) as f64;
234 let y = |v: f64| H - PAD - (H - 2.0 * PAD) * (v - min) / span;
235 let mut paths = String::new();
236 for (color, values) in series {
237 let d: Vec<String> = values
238 .iter()
239 .enumerate()
240 .map(|(i, &v)| format!("{}{:.1},{:.1}", if i == 0 { "M" } else { "L" }, x(i), y(v)))
241 .collect();
242 paths.push_str(&format!(
243 "<path d=\"{}\" fill=\"none\" stroke=\"{color}\" stroke-width=\"1.5\"/>",
244 d.join("")
245 ));
246 }
247 format!(
248 "<figure class=\"chart\" data-clip=\"{clip}\" data-kind=\"{kind}\" data-pad=\"{PAD}\" \
249 data-plotw=\"{}\"><figcaption>{clip} — {label}</figcaption>\
250 <svg viewBox=\"0 0 {W} {H}\" width=\"100%\">{paths}\
251 <line class=\"playhead\" x1=\"{PAD}\" x2=\"{PAD}\" y1=\"0\" y2=\"{H}\"/></svg></figure>",
252 W - 2.0 * PAD
253 )
254}
255
256fn path_chart(clip: &str, label: &str, xs: &[f64], zs: &[f64]) -> String {
257 let clip = &esc(clip);
258 if xs.is_empty() {
259 return String::new();
260 }
261 let (min_x, max_x) = (
262 xs.iter().copied().fold(f64::MAX, f64::min),
263 xs.iter().copied().fold(f64::MIN, f64::max),
264 );
265 let (min_z, max_z) = (
266 zs.iter().copied().fold(f64::MAX, f64::min),
267 zs.iter().copied().fold(f64::MIN, f64::max),
268 );
269 let span = (max_x - min_x).max(max_z - min_z).max(1e-3);
270 let x = |v: f64| PAD + (W - 2.0 * PAD) * (v - min_x) / span;
271 let y = |v: f64| H - PAD - (H - 2.0 * PAD) * (v - min_z) / span;
272 let d: Vec<String> = xs
273 .iter()
274 .zip(zs)
275 .enumerate()
276 .map(|(i, (&px, &pz))| {
277 format!(
278 "{}{:.1},{:.1}",
279 if i == 0 { "M" } else { "L" },
280 x(px),
281 y(pz)
282 )
283 })
284 .collect();
285 format!(
286 "<figure class=\"chart\" data-clip=\"{clip}\" data-kind=\"rootpath\">\
287 <figcaption>{clip} — {label}</figcaption>\
288 <svg viewBox=\"0 0 {W} {H}\" width=\"100%\">\
289 <path d=\"{}\" fill=\"none\" stroke=\"#9ece6a\" stroke-width=\"1.5\"/>\
290 <circle class=\"pathdot\" r=\"3\" cx=\"{:.1}\" cy=\"{:.1}\"/></svg>\
291 <template class=\"pathpoints\">{}</template></figure>",
292 d.join(""),
293 x(xs[0]),
294 y(zs[0]),
295 xs.iter()
296 .zip(zs)
297 .map(|(&px, &pz)| format!("{:.1},{:.1}", x(px), y(pz)))
298 .collect::<Vec<_>>()
299 .join(";")
300 )
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use animsmith_core::glam::Vec3;
307 use animsmith_core::model::{
308 Bone, Clip, Document, Interpolation, Property, Skeleton, SourceInfo, Track, TrackValues,
309 Transform,
310 };
311 use animsmith_core::profile::Role;
312 use animsmith_core::{CheckCtx, Config};
313
314 fn report_document() -> Document {
315 Document {
316 skeleton: Skeleton {
317 bones: vec![Bone {
318 name: "root".into(),
319 parent: None,
320 rest: Transform::IDENTITY,
321 inverse_bind: None,
322 }],
323 },
324 clips: vec![Clip {
325 name: "walk".into(),
326 duration_s: 1.0,
327 tracks: vec![Track {
328 bone: 0,
329 property: Property::Translation,
330 interpolation: Interpolation::Linear,
331 times: vec![0.0, 0.5, 1.0],
332 values: TrackValues::Vec3s(vec![
333 Vec3::ZERO,
334 Vec3::new(1.0, 0.0, 0.0),
335 Vec3::new(2.0, 0.0, 0.0),
336 ]),
337 }],
338 }],
339 source: SourceInfo {
340 path: Some("walk.glb".into()),
341 format: Some("gltf".into()),
342 },
343 ..Document::default()
344 }
345 }
346
347 fn report_data(html: &str) -> Value {
348 let marker = r#"<script type="application/json" id="report-data">"#;
349 let (_, tail) = html.split_once(marker).expect("report data marker");
350 let (raw, _) = tail.split_once("</script>").expect("report data close");
351 serde_json::from_str(raw).expect("report data is JSON")
352 }
353
354 #[test]
355 fn shared_grid_render_embeds_clip_data() {
356 let doc = report_document();
357 let roles = ResolvedRoles::from_names(&doc.skeleton, [(Role::Root, "root".to_string())]);
358 let config = Config::default();
359 let findings = Vec::new();
360
361 let fresh = render(&MetricGrids::new(&doc), &roles, &findings, None);
362 let grids = MetricGrids::new(&doc);
363 let ctx = CheckCtx::new(&grids, &roles, &config);
364 assert!(ctx.grid(0).is_some());
365 let shared = render(&grids, &roles, &findings, None);
366
367 assert_eq!(fresh, shared);
368 assert!(shared.contains(r#"data-kind="rootpath""#));
369
370 let data = report_data(&shared);
371 assert_eq!(data["file"], "walk.glb");
372 assert_eq!(data["clips"][0]["name"], "walk");
373 assert_eq!(data["clips"][0]["frames"], 3);
374 assert_eq!(data["clips"][0]["trails"]["root"], 0);
375
376 let positions = data["clips"][0]["positions"]
377 .as_str()
378 .expect("encoded positions");
379 let bytes = base64::engine::general_purpose::STANDARD
380 .decode(positions)
381 .expect("positions decode");
382 assert_eq!(bytes.len(), 3 * 3 * std::mem::size_of::<f32>());
383 }
384}