Skip to main content

animsmith_report/
lib.rs

1//! [`render`] turns [`animsmith_core::MetricGrids`],
2//! [`animsmith_core::ResolvedRoles`], and a slice of
3//! [`animsmith_core::Finding`] values into a self-contained HTML report.
4//! The viewer is driven by the same [`animsmith_core::PoseGrid`] samples
5//! the checks judged.
6//!
7//! The returned HTML is self-contained: CSS, JavaScript, findings, charts,
8//! and sampled pose data are embedded in the string. There is no runtime
9//! CDN dependency and no JavaScript-side resampling of the clip.
10//!
11//! # Quick start
12//!
13//! ```no_run
14//! fn write_report(
15//!     doc: &animsmith_core::Document,
16//!     roles: &animsmith_core::ResolvedRoles,
17//!     findings: &[animsmith_core::Finding],
18//! ) -> std::io::Result<()> {
19//!     let grids = animsmith_core::MetricGrids::new(doc);
20//!     let html = animsmith_report::render(&grids, roles, findings, None);
21//!     std::fs::write("report.html", html)
22//! }
23//! ```
24//!
25//! # Build and API status
26//!
27//! The library crate has no public feature flags and supports the workspace
28//! MSRV, Rust 1.88. Its Rust API is pre-1.0; see `animsmith-core`'s crate-level
29//! API status for the shared stability boundary.
30//!
31//! See the GitHub [embedding guide] for composing this crate with checks and
32//! the [pipeline scenario guide] for CI and outsourced-acceptance reporting
33//! workflows.
34//!
35//! [embedding guide]: https://github.com/mmannerm/animsmith/blob/main/docs/embedding.md
36//! [pipeline scenario guide]: https://github.com/mmannerm/animsmith/blob/main/docs/pipeline-scenarios.md
37//!
38#![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
50/// Escape untrusted text (clip/bone names, paths from the linted
51/// asset) for interpolation into HTML markup and attributes.
52fn esc(text: &str) -> String {
53    text.replace('&', "&amp;")
54        .replace('<', "&lt;")
55        .replace('>', "&gt;")
56        .replace('"', "&quot;")
57}
58
59/// Render report HTML from shared metric pose grids.
60///
61/// `clip_filter` restricts the report to one clip name when present. The
62/// function performs no filesystem I/O and cannot report write errors;
63/// callers choose where to store or serve the returned self-contained HTML
64/// string.
65pub 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    // A `</script>`-bearing string inside the JSON would terminate the
151    // data block early; escaping `<` inside JSON strings is lossless.
152    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
181/// SVG metric charts for one clip: gait signal (L/R foot heights and
182/// their difference) and the top-down root path. Rust-rendered; a JS
183/// playhead line is moved across them in sync with the 3D view.
184fn 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}