Skip to main content

ranim/cmd/
inspect.rs

1use anyhow::Result;
2use clap::{Subcommand, ValueEnum};
3use ranim::{
4    App, AppExt, SceneConstructor,
5    core::{
6        animation::node::{AnimationInfo, AnimationInfoKind},
7        frame::{EvaluatedFrame, FrameItemMetadata},
8        utils::rate_functions,
9    },
10};
11#[cfg(feature = "items")]
12use ranim::{
13    color::{AlphaColor, Srgb},
14    core::{
15        anchor::Aabb,
16        components::{rgba::Rgba, vpoint::VPointVec},
17        utils::bezier::get_subpath_closed_flag,
18    },
19    glam::{DAffine3, DMat4, DVec3, Mat3},
20    items::{
21        camera::CameraFrame,
22        hierarchy::PlacedLeaves,
23        mesh::{self, render::RenderMeshItem},
24        vitem::{
25            self,
26            render::{RenderVItem, vitem_normal_from_points},
27        },
28    },
29};
30use serde::Serialize;
31
32use super::cli::select_scene;
33
34/// Output format for `ranim inspect` subcommands.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
36pub enum FormatArg {
37    /// Human-readable text output.
38    #[default]
39    Text,
40    /// JSON output.
41    Json,
42}
43
44/// `inspect` subcommands: list scenes, print an animation tree, or dump an
45/// evaluated frame.
46#[derive(Debug, Subcommand)]
47pub enum InspectCommand {
48    /// List available scenes and their output summaries without constructing scenes.
49    Scenes {
50        /// Output format.
51        #[arg(long, value_enum, default_value_t = FormatArg::Text)]
52        format: FormatArg,
53    },
54    /// Build a scene and print its hierarchical animation tree.
55    Tree {
56        /// Scene name. Optional when the project contains exactly one scene.
57        scene: Option<String>,
58        /// Output format.
59        #[arg(long, value_enum, default_value_t = FormatArg::Text)]
60        format: FormatArg,
61    },
62    /// Sample a scene at a time and print the evaluated frame items.
63    Frame {
64        /// Scene name.
65        scene: String,
66        /// Sample time in seconds.
67        #[arg(long)]
68        at: f64,
69        /// Output format.
70        #[arg(long, value_enum, default_value_t = FormatArg::Text)]
71        format: FormatArg,
72        /// Include full geometry data (points, indices, colors, normals).
73        #[arg(long)]
74        verbose: bool,
75    },
76}
77
78/// Run an `inspect` subcommand against the project.
79pub fn inspect_command(app: &App, command: InspectCommand) -> Result<()> {
80    match command {
81        InspectCommand::Scenes { format } => scenes_command(app, format),
82        InspectCommand::Tree { scene, format } => tree_command(app, scene.as_deref(), format),
83        InspectCommand::Frame {
84            scene,
85            at,
86            format,
87            verbose,
88        } => frame_command(app, &scene, at, format, verbose),
89    }
90}
91
92// MARK: Scenes
93
94#[derive(Debug, Serialize)]
95struct ScenesOutput {
96    schema_version: u32,
97    scenes: Vec<SceneDto>,
98}
99
100#[derive(Debug, Serialize)]
101struct SceneDto {
102    name: String,
103    clear_color: String,
104    outputs: Vec<OutputDto>,
105}
106
107#[derive(Debug, Serialize)]
108struct OutputDto {
109    width: u32,
110    height: u32,
111    fps: u32,
112    format: String,
113    dir: String,
114    name_template: String,
115    save_frames: bool,
116}
117
118const DEFAULT_NAME_TEMPLATE: &str = "{name}_{width}x{height}_{fps}";
119
120fn scenes_output(app: &App) -> ScenesOutput {
121    let scenes = app
122        .scenes()
123        .iter()
124        .map(|scene| SceneDto {
125            name: scene.name.clone(),
126            clear_color: scene.config.clear_color.clone(),
127            outputs: scene
128                .outputs
129                .iter()
130                .map(|output| OutputDto {
131                    width: output.width,
132                    height: output.height,
133                    fps: output.fps,
134                    format: output.format.to_string(),
135                    dir: output.dir.clone(),
136                    name_template: output
137                        .name_template
138                        .clone()
139                        .unwrap_or_else(|| DEFAULT_NAME_TEMPLATE.to_string()),
140                    save_frames: output.save_frames,
141                })
142                .collect(),
143        })
144        .collect();
145    ScenesOutput {
146        schema_version: 1,
147        scenes,
148    }
149}
150
151fn scenes_command(app: &App, format: FormatArg) -> Result<()> {
152    let output = scenes_output(app);
153    match format {
154        FormatArg::Json => print_json(&output),
155        FormatArg::Text => {
156            for scene in &output.scenes {
157                println!("scene {}", scene.name);
158                println!("  clear_color: {}", scene.clear_color);
159                println!("  outputs:");
160                if scene.outputs.is_empty() {
161                    println!("    (none)");
162                }
163                for output in &scene.outputs {
164                    println!(
165                        "    - {}x{} @{}fps {} -> {} save_frames={} name_template=\"{}\"",
166                        output.width,
167                        output.height,
168                        output.fps,
169                        output.format,
170                        output.dir,
171                        output.save_frames,
172                        output.name_template
173                    );
174                }
175            }
176            Ok(())
177        }
178    }
179}
180
181// MARK: Tree
182
183#[derive(Debug, Serialize)]
184struct TreeOutput {
185    schema_version: u32,
186    scene: String,
187    total_secs: f64,
188    time_marks: Vec<TimeMarkDto>,
189    tree: Vec<TreeNodeDto>,
190}
191
192#[derive(Debug, Serialize)]
193struct TimeMarkDto {
194    sec: f64,
195    kind: String,
196    label: String,
197}
198
199#[derive(Debug, Serialize)]
200struct TreeNodeDto {
201    path: Vec<usize>,
202    kind: String,
203    anim_name: String,
204    range: [f64; 2],
205    content_duration_secs: f64,
206    rate_func: String,
207    enabled: bool,
208    /// Content step of iterative segments (`1/N` progress per step); absent
209    /// for other nodes.
210    #[serde(skip_serializing_if = "Option::is_none")]
211    sim_step: Option<f64>,
212    children: Vec<TreeNodeDto>,
213}
214
215fn tree_output(
216    scene_name: String,
217    total_secs: f64,
218    time_marks: Vec<TimeMarkDto>,
219    infos: Vec<AnimationInfo>,
220) -> TreeOutput {
221    TreeOutput {
222        schema_version: 1,
223        scene: scene_name,
224        total_secs,
225        time_marks,
226        tree: infos
227            .iter()
228            .enumerate()
229            .map(|(idx, info)| animation_info_to_tree_node(info, vec![idx]))
230            .collect(),
231    }
232}
233
234fn animation_info_to_tree_node(info: &AnimationInfo, path: Vec<usize>) -> TreeNodeDto {
235    TreeNodeDto {
236        path: path.clone(),
237        kind: animation_kind_str(info.kind).to_string(),
238        anim_name: info.anim_name.clone(),
239        range: range_to_array(&info.range),
240        content_duration_secs: info.content_duration_secs,
241        rate_func: rate_func_name(info.rate_func).to_string(),
242        enabled: info.enabled,
243        sim_step: info.sim_step,
244        children: info
245            .children
246            .iter()
247            .enumerate()
248            .map(|(idx, child)| {
249                let mut child_path = path.clone();
250                child_path.push(idx);
251                animation_info_to_tree_node(child, child_path)
252            })
253            .collect(),
254    }
255}
256
257fn range_to_array(range: &std::ops::Range<f64>) -> [f64; 2] {
258    [range.start, range.end]
259}
260
261fn animation_kind_str(kind: AnimationInfoKind) -> &'static str {
262    match kind {
263        AnimationInfoKind::Anim => "anim",
264        AnimationInfoKind::Children => "children",
265        AnimationInfoKind::Audio => "audio",
266    }
267}
268
269fn rate_func_name(rate_func: fn(f64) -> f64) -> &'static str {
270    // Function pointers are opaque in Rust and addresses are not guaranteed to
271    // be comparable across codegen units, so identify the known rate functions
272    // by their values on a small probe set instead.
273    const PROBE_POINTS: [f64; 6] = [0.0, 0.1, 0.25, 0.5, 0.75, 1.0];
274    type KnownRateFunc = (&'static str, fn(f64) -> f64);
275    const KNOWN: &[KnownRateFunc] = &[
276        ("linear", rate_functions::linear),
277        ("smooth", rate_functions::smooth),
278        ("ease_in_quad", rate_functions::ease_in_quad),
279        ("ease_out_quad", rate_functions::ease_out_quad),
280        ("ease_in_out_quad", rate_functions::ease_in_out_quad),
281        ("ease_in_cubic", rate_functions::ease_in_cubic),
282        ("ease_out_cubic", rate_functions::ease_out_cubic),
283        ("ease_in_out_cubic", rate_functions::ease_in_out_cubic),
284    ];
285
286    KNOWN
287        .iter()
288        .find(|(_, known)| {
289            PROBE_POINTS
290                .iter()
291                .all(|&point| (known(point) - rate_func(point)).abs() < 1e-12)
292        })
293        .map(|(name, _)| *name)
294        .unwrap_or("custom")
295}
296
297fn short_anim_name(anim_name: &str) -> &str {
298    let without_generics = anim_name.split('<').next().unwrap_or(anim_name);
299    without_generics
300        .rsplit("::")
301        .next()
302        .unwrap_or(without_generics)
303}
304
305fn tree_command(app: &App, scene_name: Option<&str>, format: FormatArg) -> Result<()> {
306    let scene = select_scene(app, scene_name)?;
307    let sealed = scene.build_scene();
308    let time_marks = sealed
309        .time_marks()
310        .iter()
311        .map(|(sec, mark)| TimeMarkDto {
312            sec: *sec,
313            kind: match mark {
314                ranim::core::TimeMark::Capture(_) => "capture".to_string(),
315            },
316            label: match mark {
317                ranim::core::TimeMark::Capture(label) => label.clone(),
318            },
319        })
320        .collect();
321    let output = tree_output(
322        scene.name.clone(),
323        sealed.total_secs(),
324        time_marks,
325        sealed.get_animation_infos(),
326    );
327    match format {
328        FormatArg::Json => print_json(&output),
329        FormatArg::Text => {
330            println!("scene {}", output.scene);
331            println!("total_secs: {}", output.total_secs);
332            println!("time_marks:");
333            for mark in &output.time_marks {
334                println!("  - {} {} {:?}", mark.sec, mark.kind, mark.label);
335            }
336            println!("tree:");
337            for node in &output.tree {
338                print_tree_node(node, 0);
339            }
340            Ok(())
341        }
342    }
343}
344
345fn print_tree_node(node: &TreeNodeDto, depth: usize) {
346    let path = node
347        .path
348        .iter()
349        .map(|idx| idx.to_string())
350        .collect::<Vec<_>>()
351        .join(", ");
352    let sim_step = node
353        .sim_step
354        .map(|step| format!(" sim_step={step}"))
355        .unwrap_or_default();
356    println!(
357        "{}[{}] {} {} [{}..{}] content={} rate_func={} enabled={}{}",
358        "  ".repeat(depth),
359        path,
360        short_anim_name(&node.anim_name),
361        node.kind,
362        node.range[0],
363        node.range[1],
364        node.content_duration_secs,
365        node.rate_func,
366        node.enabled,
367        sim_step
368    );
369    for child in &node.children {
370        print_tree_node(child, depth + 1);
371    }
372}
373
374// MARK: Frame
375
376#[derive(Debug, Serialize)]
377struct FrameOutput {
378    schema_version: u32,
379    scene: String,
380    at: f64,
381    total_secs: f64,
382    items: Vec<FrameItemDto>,
383}
384
385#[derive(Debug, Serialize)]
386struct FrameItemDto {
387    order: usize,
388    input_type: String,
389    id: [usize; 2],
390    animation_id: usize,
391    part: usize,
392    kind: String,
393    source: SourceDto,
394    data: FrameItemDataDto,
395}
396
397#[derive(Debug, Serialize)]
398struct SourceDto {
399    path: Vec<usize>,
400    kind: String,
401    anim_name: String,
402    range: [f64; 2],
403}
404
405#[derive(Debug, Serialize)]
406#[serde(untagged)]
407enum FrameItemDataDto {
408    #[cfg(feature = "items")]
409    VItem(VItemDataDto),
410    #[cfg(feature = "items")]
411    Mesh(MeshDataDto),
412    #[cfg(feature = "items")]
413    Camera(CameraDataDto),
414    #[cfg(feature = "items")]
415    Group {
416        items: Vec<FrameItemDataDto>,
417    },
418    Opaque {},
419}
420
421#[cfg(feature = "items")]
422#[derive(Debug, Serialize)]
423struct BoundsDto {
424    min: [f64; 3],
425    max: [f64; 3],
426}
427
428#[cfg(feature = "items")]
429#[derive(Debug, Serialize)]
430struct VItemDataDto {
431    point_count: usize,
432    subpath_count: usize,
433    closed: Vec<bool>,
434    fill_colors: Vec<String>,
435    stroke_colors: Vec<String>,
436    stroke_widths: Vec<f32>,
437    normal: [f32; 3],
438    bounds: BoundsDto,
439    #[serde(skip_serializing_if = "Option::is_none")]
440    points: Option<Vec<[f32; 4]>>,
441}
442
443#[cfg(feature = "items")]
444#[derive(Debug, Serialize)]
445struct MeshDataDto {
446    point_count: usize,
447    triangle_count: usize,
448    transform: [f32; 16],
449    bounds: BoundsDto,
450    #[serde(skip_serializing_if = "Option::is_none")]
451    points: Option<Vec<[f32; 3]>>,
452    #[serde(skip_serializing_if = "Option::is_none")]
453    triangle_indices: Option<Vec<u32>>,
454    #[serde(skip_serializing_if = "Option::is_none")]
455    vertex_colors: Option<Vec<String>>,
456    #[serde(skip_serializing_if = "Option::is_none")]
457    vertex_normals: Option<Vec<[f32; 3]>>,
458}
459
460#[cfg(feature = "items")]
461#[derive(Debug, Serialize)]
462struct CameraDataDto {
463    pos: [f64; 3],
464    facing: [f64; 3],
465    up: [f64; 3],
466    near: f64,
467    far: f64,
468    perspective_blend: f64,
469    frame_height: f64,
470    scale: f64,
471    fovy: f64,
472}
473
474fn frame_command(
475    app: &App,
476    scene_name: &str,
477    at: f64,
478    format: FormatArg,
479    verbose: bool,
480) -> Result<()> {
481    let scene = select_scene(app, Some(scene_name))?;
482    let sealed = scene.build_scene();
483    let total_secs = sealed.total_secs();
484    let mut evaluator = sealed.into_evaluator(120.0);
485    let infos = evaluator.animation_infos();
486    let mut frame = EvaluatedFrame::new();
487    evaluator.sample_at(at, &mut frame);
488
489    let items = inspect_frame(&frame, &infos, verbose);
490
491    let output = FrameOutput {
492        schema_version: 2,
493        scene: scene.name.clone(),
494        at,
495        total_secs,
496        items,
497    };
498
499    match format {
500        FormatArg::Json => print_json(&output),
501        FormatArg::Text => {
502            println!(
503                "scene {} @{} total_secs={}",
504                output.scene, output.at, output.total_secs
505            );
506            for item in &output.items {
507                print_frame_item(item);
508            }
509            Ok(())
510        }
511    }
512}
513
514fn inspect_frame(
515    frame: &EvaluatedFrame,
516    infos: &[AnimationInfo],
517    _verbose: bool,
518) -> Vec<FrameItemDto> {
519    let mut data = std::collections::HashMap::<usize, FrameItemDataDto>::new();
520    #[cfg(feature = "items")]
521    {
522        let verbose = _verbose;
523        for item in frame.items::<CameraFrame>() {
524            data.insert(item.order, FrameItemDataDto::from_camera(&item.value));
525        }
526        macro_rules! vector_inputs {
527        ($($input:ty),* $(,)?) => {$({
528            for item in frame.items::<$input>() {
529                let value = RenderVItem::from(vitem::VItem::from(item.value.clone()));
530                data.insert(item.order, inspect_vitem(value, DAffine3::IDENTITY, verbose));
531            }
532        })*};
533    }
534        vector_inputs!(
535            vitem::VItem,
536            vitem::geometry::Arc,
537            vitem::geometry::ArcBetweenPoints,
538            vitem::geometry::Circle,
539            vitem::geometry::Ellipse,
540            vitem::geometry::EllipticArc,
541            vitem::geometry::Line,
542            vitem::geometry::Parallelogram,
543            vitem::geometry::Polygon,
544            vitem::geometry::Rectangle,
545            vitem::geometry::Square,
546        );
547        for item in frame.items::<vitem::geometry::RegularPolygon>() {
548            let value = RenderVItem::from(vitem::VItem::from(vitem::geometry::Polygon::from(
549                item.value.clone(),
550            )));
551            data.insert(
552                item.order,
553                inspect_vitem(value, DAffine3::IDENTITY, verbose),
554            );
555        }
556        for item in frame.items::<vitem::svg::SvgItem>() {
557            let items = item
558                .value
559                .tree()
560                .leaves()
561                .map(|(placement, value)| {
562                    inspect_vitem(RenderVItem::from(value.clone()), placement, verbose)
563                })
564                .collect();
565            data.insert(item.order, FrameItemDataDto::Group { items });
566        }
567        macro_rules! mesh_inputs {
568        ($($input:ty),* $(,)?) => {$({
569            for item in frame.items::<$input>() {
570                let value = RenderMeshItem::from(mesh::MeshItem::from(item.value.clone()));
571                data.insert(item.order, inspect_mesh(value, DAffine3::IDENTITY, verbose));
572            }
573        })*};
574    }
575        mesh_inputs!(mesh::MeshItem, mesh::Sphere, mesh::Surface);
576        #[cfg(feature = "typst")]
577        {
578            macro_rules! text_inputs {
579            ($($input:ty),* $(,)?) => {$({
580                for item in frame.items::<$input>() {
581                    let items = item.value.vitems().iter().cloned()
582                        .map(|value| inspect_vitem(value.into(), DAffine3::IDENTITY, verbose))
583                        .collect();
584                    data.insert(item.order, FrameItemDataDto::Group { items });
585                }
586            })*};
587        }
588            text_inputs!(vitem::text::TextItem, vitem::typst::TypstText);
589        }
590    }
591    let mut items = frame
592        .item_metadata()
593        .map(|metadata| {
594            let item_data = data
595                .remove(&metadata.order)
596                .unwrap_or(FrameItemDataDto::Opaque {});
597            FrameItemDto::from_metadata(metadata, item_data, infos)
598        })
599        .collect::<Vec<_>>();
600    items.sort_by_key(|item| item.order);
601    items
602}
603
604#[cfg(feature = "items")]
605fn inspect_vitem(mut value: RenderVItem, placement: DAffine3, verbose: bool) -> FrameItemDataDto {
606    value.transform = DMat4::from(placement).as_mat4() * value.transform;
607    FrameItemDataDto::from_vitem(&value, verbose)
608}
609
610#[cfg(feature = "items")]
611fn inspect_mesh(mut value: RenderMeshItem, placement: DAffine3, verbose: bool) -> FrameItemDataDto {
612    value.transform = DMat4::from(placement).as_mat4() * value.transform;
613    FrameItemDataDto::from_mesh(&value, verbose)
614}
615
616impl FrameItemDto {
617    fn from_metadata(
618        metadata: FrameItemMetadata<'_>,
619        data: FrameItemDataDto,
620        infos: &[AnimationInfo],
621    ) -> Self {
622        let (animation_id, part) = metadata.id;
623        let kind = match &data {
624            #[cfg(feature = "items")]
625            FrameItemDataDto::VItem(_) => "vitem",
626            #[cfg(feature = "items")]
627            FrameItemDataDto::Mesh(_) => "mesh",
628            #[cfg(feature = "items")]
629            FrameItemDataDto::Camera(_) => "camera",
630            #[cfg(feature = "items")]
631            FrameItemDataDto::Group { .. } => "group",
632            FrameItemDataDto::Opaque {} => "opaque",
633        };
634        Self {
635            order: metadata.order,
636            input_type: metadata.type_name.to_owned(),
637            id: [animation_id, part],
638            animation_id,
639            part,
640            kind: kind.to_string(),
641            source: source_from_infos(infos, animation_id),
642            data,
643        }
644    }
645}
646
647#[cfg(feature = "items")]
648impl FrameItemDataDto {
649    fn from_vitem(item: &RenderVItem, verbose: bool) -> Self {
650        // RenderVItem data lives in the item's local space; the inspect dump
651        // reports world-space values, so apply the stored transform here
652        // (in the same f32 precision used by the renderer).
653        let normal_matrix = Mat3::from_mat4(item.transform).inverse().transpose();
654        let vpoints = VPointVec(
655            item.points
656                .iter()
657                .map(|point| item.transform.transform_point3(point.truncate()).as_dvec3())
658                .collect(),
659        );
660        let subpaths = if vpoints.is_empty() {
661            Vec::new()
662        } else {
663            vpoints.get_subpaths()
664        };
665        let closed = subpaths
666            .iter()
667            .map(|subpath| {
668                get_subpath_closed_flag(subpath)
669                    .map(|(_, closed)| closed)
670                    .unwrap_or(false)
671            })
672            .collect();
673        let normal_local = item
674            .normal
675            .unwrap_or_else(|| vitem_normal_from_points(&item.points));
676        let normal = (normal_matrix * normal_local).normalize_or_zero();
677        let bounds = dvec3_bounds(vpoints.aabb());
678
679        Self::VItem(VItemDataDto {
680            point_count: item.points.len(),
681            subpath_count: subpaths.len(),
682            closed,
683            fill_colors: item.fill_rgbas.iter().map(rgba_to_hex).collect(),
684            stroke_colors: item.stroke_rgbas.iter().map(rgba_to_hex).collect(),
685            stroke_widths: item.stroke_widths.iter().map(|width| width.0).collect(),
686            normal: normal.to_array(),
687            bounds,
688            points: verbose.then(|| {
689                item.points
690                    .iter()
691                    .map(|point| {
692                        let world = item.transform.transform_point3(point.truncate());
693                        [world.x, world.y, world.z, point.w]
694                    })
695                    .collect()
696            }),
697        })
698    }
699
700    fn from_mesh(item: &RenderMeshItem, verbose: bool) -> Self {
701        let transformed_points = item
702            .points
703            .iter()
704            .map(|point| item.transform.transform_point3(*point))
705            .collect::<Vec<_>>();
706        let bounds = vec3_bounds(&transformed_points);
707
708        Self::Mesh(MeshDataDto {
709            point_count: item.points.len(),
710            triangle_count: item.triangle_indices.len() / 3,
711            transform: item.transform.to_cols_array(),
712            bounds,
713            points: verbose.then(|| {
714                transformed_points
715                    .iter()
716                    .map(|point| point.to_array())
717                    .collect()
718            }),
719            triangle_indices: verbose.then(|| item.triangle_indices.clone()),
720            vertex_colors: verbose.then(|| item.vertex_colors.iter().map(rgba_to_hex).collect()),
721            vertex_normals: verbose.then(|| {
722                item.vertex_normals
723                    .iter()
724                    .map(|normal| normal.to_array())
725                    .collect()
726            }),
727        })
728    }
729
730    fn from_camera(item: &CameraFrame) -> Self {
731        Self::Camera(CameraDataDto {
732            pos: dvec3_to_array(item.pos),
733            facing: dvec3_to_array(item.facing),
734            up: dvec3_to_array(item.up),
735            near: item.near,
736            far: item.far,
737            perspective_blend: item.perspective_blend,
738            frame_height: item.frame_height,
739            scale: item.scale,
740            fovy: item.fovy,
741        })
742    }
743}
744
745fn source_from_infos(infos: &[AnimationInfo], animation_id: usize) -> SourceDto {
746    infos
747        .get(animation_id)
748        .map(|info| SourceDto {
749            path: vec![animation_id],
750            kind: animation_kind_str(info.kind).to_string(),
751            anim_name: info.anim_name.clone(),
752            range: range_to_array(&info.range),
753        })
754        .unwrap_or(SourceDto {
755            path: vec![animation_id],
756            kind: "unknown".to_string(),
757            anim_name: "<unknown>".to_string(),
758            range: [0.0, 0.0],
759        })
760}
761
762#[cfg(feature = "items")]
763fn dvec3_bounds(bounds: [DVec3; 2]) -> BoundsDto {
764    BoundsDto {
765        min: dvec3_to_array(bounds[0]),
766        max: dvec3_to_array(bounds[1]),
767    }
768}
769
770#[cfg(feature = "items")]
771fn vec3_bounds(points: &[ranim::glam::Vec3]) -> BoundsDto {
772    if points.is_empty() {
773        return BoundsDto {
774            min: [0.0; 3],
775            max: [0.0; 3],
776        };
777    }
778    let mut min = points[0];
779    let mut max = points[0];
780    for point in &points[1..] {
781        min = min.min(*point);
782        max = max.max(*point);
783    }
784    BoundsDto {
785        min: [min.x as f64, min.y as f64, min.z as f64],
786        max: [max.x as f64, max.y as f64, max.z as f64],
787    }
788}
789
790#[cfg(feature = "items")]
791fn dvec3_to_array(v: DVec3) -> [f64; 3] {
792    [v.x, v.y, v.z]
793}
794
795#[cfg(feature = "items")]
796fn rgba_to_hex(rgba: &Rgba) -> String {
797    let srgb: AlphaColor<Srgb> = (*rgba).into();
798    let [r, g, b, a] = srgb.to_rgba8().to_u8_array();
799    format!("#{r:02x}{g:02x}{b:02x}{a:02x}")
800}
801
802fn print_frame_item(item: &FrameItemDto) {
803    println!(
804        "[{}] id=[{}, {}] {} input_type={} source=[{}] {} {} range=[{}..{}]",
805        item.order,
806        item.id[0],
807        item.id[1],
808        item.kind,
809        item.input_type,
810        item.source
811            .path
812            .iter()
813            .map(|idx| idx.to_string())
814            .collect::<Vec<_>>()
815            .join(", "),
816        item.source.kind,
817        short_anim_name(&item.source.anim_name),
818        item.source.range[0],
819        item.source.range[1],
820    );
821    print_frame_item_data(&item.data);
822}
823
824fn print_frame_item_data(data: &FrameItemDataDto) {
825    match data {
826        #[cfg(feature = "items")]
827        FrameItemDataDto::VItem(data) => {
828            println!(
829                "  point_count={} subpath_count={} closed={:?}",
830                data.point_count, data.subpath_count, data.closed
831            );
832            println!(
833                "  fill={:?} stroke={:?} stroke_widths={:?}",
834                data.fill_colors, data.stroke_colors, data.stroke_widths
835            );
836            println!(
837                "  normal={:?} bounds=min:{:?} max:{:?}",
838                data.normal, data.bounds.min, data.bounds.max
839            );
840            if let Some(points) = &data.points {
841                println!("  points={points:?}");
842            }
843        }
844        #[cfg(feature = "items")]
845        FrameItemDataDto::Mesh(data) => {
846            println!(
847                "  point_count={} triangle_count={} bounds=min:{:?} max:{:?}",
848                data.point_count, data.triangle_count, data.bounds.min, data.bounds.max
849            );
850            if let Some(points) = &data.points {
851                println!("  points={points:?}");
852            }
853            if let Some(triangle_indices) = &data.triangle_indices {
854                println!("  triangle_indices={triangle_indices:?}");
855            }
856            if let Some(vertex_colors) = &data.vertex_colors {
857                println!("  vertex_colors={vertex_colors:?}");
858            }
859            if let Some(vertex_normals) = &data.vertex_normals {
860                println!("  vertex_normals={vertex_normals:?}");
861            }
862        }
863        #[cfg(feature = "items")]
864        FrameItemDataDto::Camera(data) => {
865            println!(
866                "  pos={:?} facing={:?} up={:?} near={} far={} perspective_blend={} frame_height={} scale={} fovy={}",
867                data.pos,
868                data.facing,
869                data.up,
870                data.near,
871                data.far,
872                data.perspective_blend,
873                data.frame_height,
874                data.scale,
875                data.fovy
876            );
877        }
878        #[cfg(feature = "items")]
879        FrameItemDataDto::Group { items } => {
880            println!("  rendered_parts={}", items.len());
881            for item in items {
882                print_frame_item_data(item);
883            }
884        }
885        FrameItemDataDto::Opaque {} => println!("  no built-in inspector for this input type"),
886    }
887}
888
889fn print_json<T: Serialize>(output: &T) -> Result<()> {
890    println!("{}", serde_json::to_string_pretty(output)?);
891    Ok(())
892}
893
894#[cfg(test)]
895mod tests {
896    use ranim::core::{animation::node::AnimationInfo, utils::rate_functions};
897
898    use super::*;
899    #[cfg(feature = "items")]
900    use ranim::glam::{DVec3, Vec4};
901
902    fn sample_animation_info() -> AnimationInfo {
903        AnimationInfo {
904            anim_name: "AnimSequence".to_string(),
905            kind: AnimationInfoKind::Children,
906            range: 0.0..8.0,
907            content_duration_secs: 8.0,
908            rate_func: rate_functions::linear,
909            enabled: true,
910            sim_step: None,
911            children: vec![AnimationInfo {
912                anim_name: "FadeIn<VItem>".to_string(),
913                kind: AnimationInfoKind::Anim,
914                range: 0.0..3.0,
915                content_duration_secs: 3.0,
916                rate_func: rate_functions::smooth,
917                enabled: true,
918                sim_step: None,
919                children: vec![],
920            }],
921        }
922    }
923
924    #[test]
925    fn maps_animation_info_to_tree_dto() {
926        let node = animation_info_to_tree_node(&sample_animation_info(), vec![0]);
927        assert_eq!(node.path, [0]);
928        assert_eq!(node.kind, "children");
929        assert_eq!(node.anim_name, "AnimSequence");
930        assert_eq!(node.range, [0.0, 8.0]);
931        assert_eq!(node.rate_func, "linear");
932        assert!(node.enabled);
933        assert_eq!(node.children.len(), 1);
934        assert_eq!(node.children[0].path, [0, 0]);
935        assert_eq!(node.children[0].kind, "anim");
936        assert_eq!(node.children[0].rate_func, "smooth");
937
938        fn custom(_t: f64) -> f64 {
939            0.0
940        }
941        assert_eq!(rate_func_name(custom as fn(f64) -> f64), "custom");
942    }
943
944    #[test]
945    fn custom_inputs_keep_metadata_without_builtin_inspectors() {
946        struct CustomInput;
947        let mut frame = EvaluatedFrame::new();
948        frame.push((0, 3), CustomInput);
949        let items = inspect_frame(&frame, &[sample_animation_info()], true);
950        assert_eq!(items.len(), 1);
951        assert_eq!(items[0].id, [0, 3]);
952        assert_eq!(items[0].kind, "opaque");
953        assert_eq!(items[0].input_type, std::any::type_name::<CustomInput>());
954        assert_eq!(items[0].source.anim_name, "AnimSequence");
955        assert_eq!(
956            serde_json::to_value(&items[0].data).unwrap(),
957            serde_json::json!({})
958        );
959    }
960
961    #[cfg(feature = "items")]
962    #[test]
963    fn maps_vitem_to_frame_dto() {
964        let vitem = vitem::VItem {
965            vpoints: VPointVec(vec![
966                DVec3::new(-1.0, -1.0, 0.0),
967                DVec3::new(1.0, 0.0, 0.0),
968                DVec3::new(-1.0, -1.0, 0.0),
969            ]),
970            fill_rgbas: vec![Vec4::new(1.0, 1.0, 1.0, 1.0).into()].into(),
971            stroke_rgbas: vec![Vec4::new(0.0, 0.0, 0.0, 1.0).into()].into(),
972            stroke_widths: vec![0.0.into()].into(),
973            normal: None,
974        };
975
976        let mut frame = EvaluatedFrame::new();
977        frame.push((0, 0), vitem);
978        let dto = inspect_frame(&frame, &[sample_animation_info()], false).remove(0);
979
980        assert_eq!(dto.id, [0, 0]);
981        assert_eq!(dto.kind, "vitem");
982        assert_eq!(dto.source.path, [0]);
983        assert_eq!(dto.source.anim_name, "AnimSequence");
984
985        let FrameItemDataDto::VItem(data) = &dto.data else {
986            panic!("expected vitem data");
987        };
988        assert_eq!(data.point_count, 3);
989        assert_eq!(data.subpath_count, 1);
990        assert_eq!(data.closed, [true]);
991        assert_eq!(data.fill_colors, ["#ffffffff"]);
992        assert_eq!(data.stroke_colors, ["#000000ff"]);
993        assert!(data.points.is_none());
994
995        let bounds = dvec3_bounds([DVec3::new(-1.0, -2.0, -3.0), DVec3::new(1.0, 2.0, 3.0)]);
996        assert_eq!(bounds.min, [-1.0, -2.0, -3.0]);
997        assert_eq!(bounds.max, [1.0, 2.0, 3.0]);
998    }
999
1000    #[cfg(feature = "items")]
1001    #[test]
1002    fn typed_inputs_keep_order_and_unknown_payloads() {
1003        struct CustomInput;
1004
1005        let mut frame = EvaluatedFrame::new();
1006        frame.push((0, 0), CustomInput);
1007        frame.push((0, 1), vitem::geometry::Square::new(2.0));
1008        frame.push((0, 2), CameraFrame::default());
1009
1010        let items = inspect_frame(&frame, &[sample_animation_info()], true);
1011        assert_eq!(
1012            items.iter().map(|item| item.order).collect::<Vec<_>>(),
1013            [0, 1, 2]
1014        );
1015        assert_eq!(
1016            items.iter().map(|item| item.id).collect::<Vec<_>>(),
1017            [[0, 0], [0, 1], [0, 2]]
1018        );
1019        assert_eq!(items[0].input_type, std::any::type_name::<CustomInput>());
1020        assert!(matches!(items[0].data, FrameItemDataDto::Opaque {}));
1021        assert_eq!(
1022            items[1].input_type,
1023            std::any::type_name::<vitem::geometry::Square>()
1024        );
1025        let FrameItemDataDto::VItem(data) = &items[1].data else {
1026            panic!("expected projected square geometry");
1027        };
1028        assert_eq!(data.bounds.min, [-1.0, -1.0, 0.0]);
1029        assert_eq!(data.bounds.max, [1.0, 1.0, 0.0]);
1030        assert!(matches!(items[2].data, FrameItemDataDto::Camera(_)));
1031        assert!(
1032            serde_json::to_value(&items[1])
1033                .unwrap()
1034                .get("placement")
1035                .is_none()
1036        );
1037    }
1038
1039    #[cfg(feature = "items")]
1040    #[test]
1041    fn whole_transformed_vectors_keep_their_type_and_identity_as_opaque_inputs() {
1042        use ranim::core::transformed::Transformed;
1043
1044        type Input = Transformed<Vec<vitem::VItem>, DAffine3>;
1045        let mut frame = EvaluatedFrame::new();
1046        frame.push((0, 0), CameraFrame::default());
1047        frame.push(
1048            (4, 9),
1049            Input::new(
1050                vec![vitem::VItem::from_vpoints(Vec::new()); 2],
1051                DAffine3::from_translation(DVec3::new(3.0, 4.0, 0.0)),
1052            ),
1053        );
1054
1055        let items = inspect_frame(&frame, &[sample_animation_info()], true);
1056        assert_eq!(items.len(), 2);
1057        let item = &items[1];
1058        assert_eq!(item.order, 1);
1059        assert_eq!(item.id, [4, 9]);
1060        assert_eq!(item.input_type, std::any::type_name::<Input>());
1061        assert_eq!(item.kind, "opaque");
1062        assert!(matches!(item.data, FrameItemDataDto::Opaque {}));
1063        assert_eq!(frame.items::<Input>()[0].value.inner.len(), 2);
1064        assert!(frame.items::<vitem::VItem>().is_empty());
1065    }
1066}