Skip to main content

renamite_validate/
lib.rs

1//! Project validation and diagnostics for renamite.
2//!
3//! Deterministic checks over a [`RenFile`]: document tree integrity,
4//! asset references, animation keyframe hygiene, clip/machine sanity, and
5//! export-readiness warnings. Use [`validate`] to produce a
6//! [`ValidationReport`]; [`ValidationReport::has_errors`] tells you whether the
7//! project is safe to save/render/export.
8
9use glam::DVec2;
10use renamite_animation::{Angle, Animated, AnimatedTransform, Frame};
11use renamite_geometry::VectorPath;
12use renamite_io_ren::RenFile;
13use renamite_machine::{
14    Condition, InputKind, ListenerAction, Machine, MachineId, StateKind, Transition,
15};
16use renamite_model::{
17    Asset, Color, CompId, Document, GradientStops, ModifierKind, Node, NodeId, NodeKind, PropRef,
18    ShapeKind, StyleKind, StylePaint, Value,
19};
20use serde::{Deserialize, Serialize};
21use std::collections::HashSet;
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
24pub enum Severity {
25    Error,
26    Warning,
27    Info,
28}
29
30#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
31pub struct Diagnostic {
32    pub severity: Severity,
33    pub path: String,
34    pub message: String,
35}
36
37impl Diagnostic {
38    pub fn error(path: impl Into<String>, message: impl Into<String>) -> Self {
39        Self {
40            severity: Severity::Error,
41            path: path.into(),
42            message: message.into(),
43        }
44    }
45
46    pub fn warning(path: impl Into<String>, message: impl Into<String>) -> Self {
47        Self {
48            severity: Severity::Warning,
49            path: path.into(),
50            message: message.into(),
51        }
52    }
53
54    pub fn info(path: impl Into<String>, message: impl Into<String>) -> Self {
55        Self {
56            severity: Severity::Info,
57            path: path.into(),
58            message: message.into(),
59        }
60    }
61}
62
63#[derive(Clone, Debug, Default, Serialize, Deserialize)]
64pub struct ValidationReport {
65    pub diagnostics: Vec<Diagnostic>,
66}
67
68impl ValidationReport {
69    pub fn has_errors(&self) -> bool {
70        self.diagnostics
71            .iter()
72            .any(|d| d.severity == Severity::Error)
73    }
74
75    pub fn error_count(&self) -> usize {
76        self.diagnostics
77            .iter()
78            .filter(|d| d.severity == Severity::Error)
79            .count()
80    }
81
82    pub fn warning_count(&self) -> usize {
83        self.diagnostics
84            .iter()
85            .filter(|d| d.severity == Severity::Warning)
86            .count()
87    }
88
89    pub fn push(&mut self, d: Diagnostic) {
90        self.diagnostics.push(d);
91    }
92}
93
94pub fn validate(file: &RenFile) -> ValidationReport {
95    let mut v = Validator {
96        file,
97        report: ValidationReport::default(),
98    };
99    v.run();
100    v.report
101}
102
103struct Validator<'a> {
104    file: &'a RenFile,
105    report: ValidationReport,
106}
107
108impl<'a> Validator<'a> {
109    fn run(&mut self) {
110        self.validate_compositions();
111        self.validate_document_tree();
112        self.validate_assets();
113        self.validate_animations();
114        self.validate_scope();
115        self.validate_precomps();
116        self.validate_clips();
117        self.validate_machines();
118        self.validate_export_readiness();
119    }
120
121    fn err(&mut self, path: impl Into<String>, message: impl Into<String>) {
122        self.report.push(Diagnostic::error(path, message));
123    }
124
125    fn warn(&mut self, path: impl Into<String>, message: impl Into<String>) {
126        self.report.push(Diagnostic::warning(path, message));
127    }
128
129    fn validate_compositions(&mut self) {
130        let doc = &self.file.document;
131
132        if !doc.compositions.contains_key(doc.main) {
133            self.err("document.main", "main composition does not exist");
134        }
135
136        for (id, comp) in &doc.compositions {
137            if comp.rate.num == 0 || comp.rate.den == 0 {
138                self.err(format!("composition/{id:?}/rate"), "invalid frame rate");
139            }
140            if comp.range.1 <= comp.range.0 {
141                self.err(
142                    format!("composition/{id:?}/range"),
143                    "out frame must be after in frame",
144                );
145            }
146            if comp.size.0 == 0 || comp.size.1 == 0 {
147                self.warn(
148                    format!("composition/{id:?}/size"),
149                    "composition size is zero",
150                );
151            }
152            for (index, child) in comp.children.iter().enumerate() {
153                if !doc.nodes.contains_key(*child) {
154                    self.err(
155                        format!("composition/{id:?}/children/{index}"),
156                        "child node does not exist",
157                    );
158                }
159            }
160        }
161    }
162
163    fn validate_document_tree(&mut self) {
164        let doc = &self.file.document;
165        let mut seen = HashSet::new();
166
167        for (comp_id, comp) in &doc.compositions {
168            for &root in &comp.children {
169                self.walk_node_tree(
170                    root,
171                    format!("composition/{comp_id:?}"),
172                    &mut seen,
173                    Vec::new(),
174                );
175            }
176        }
177
178        for id in doc.nodes.keys() {
179            if !seen.contains(&id) {
180                self.warn(
181                    format!("node/{id:?}"),
182                    "detached arena node will be pruned on save",
183                );
184            }
185        }
186    }
187
188    fn walk_node_tree(
189        &mut self,
190        id: NodeId,
191        path: String,
192        seen: &mut HashSet<NodeId>,
193        mut stack: Vec<NodeId>,
194    ) {
195        if stack.contains(&id) {
196            self.err(format!("{path}/node/{id:?}"), "cycle in node tree");
197            return;
198        }
199
200        let Some(node) = self.file.document.nodes.get(id) else {
201            self.err(path, format!("node {id:?} does not exist"));
202            return;
203        };
204
205        seen.insert(id);
206        stack.push(id);
207
208        for (index, &child) in node.children.iter().enumerate() {
209            match self.file.document.nodes.get(child) {
210                Some(child_node) => {
211                    if child_node.parent != Some(id) {
212                        self.err(
213                            format!("node/{id:?}/children/{index}"),
214                            "child parent pointer does not point back to this node",
215                        );
216                    }
217                    self.walk_node_tree(
218                        child,
219                        format!("node/{id:?}/children/{index}"),
220                        seen,
221                        stack.clone(),
222                    );
223                }
224                None => {
225                    self.err(
226                        format!("node/{id:?}/children/{index}"),
227                        "child node does not exist",
228                    );
229                }
230            }
231        }
232    }
233
234    fn validate_assets(&mut self) {
235        let doc = &self.file.document;
236
237        let mut seen = HashSet::new();
238        for (i, &id) in doc.asset_order.iter().enumerate() {
239            if !doc.assets.contains_key(id) {
240                self.err(format!("assets/order/{i}"), "asset id does not exist");
241            }
242            if !seen.insert(id) {
243                self.err(
244                    format!("assets/order/{i}"),
245                    "duplicate asset id in asset_order",
246                );
247            }
248        }
249
250        for id in doc.assets.keys() {
251            if !seen.contains(&id) {
252                self.warn(
253                    format!("asset/{id:?}"),
254                    "asset exists but is not attached in asset_order",
255                );
256            }
257        }
258
259        for (id, node) in &doc.nodes {
260            match &node.kind {
261                NodeKind::Image(asset) => match doc.assets.get(*asset) {
262                    Some(Asset::Image(img)) => {
263                        if img.width == 0 || img.height == 0 {
264                            self.err(
265                                format!("node/{id:?}/image"),
266                                "image dimensions must be nonzero",
267                            );
268                        }
269                        if img.bytes.is_empty() {
270                            self.err(format!("node/{id:?}/image"), "image asset has no bytes");
271                        }
272                    }
273                    Some(_) => self.err(
274                        format!("node/{id:?}/image"),
275                        "referenced asset is not an image",
276                    ),
277                    None => self.err(format!("node/{id:?}/image"), "image asset is missing"),
278                },
279                NodeKind::Text(text) => {
280                    if let Some(family) = &text.font
281                        && family != "default"
282                        && doc.font_asset_for_family(family).is_none()
283                    {
284                        self.warn(
285                            format!("node/{id:?}/text/font"),
286                            format!(
287                                "font family `{family}` not found; bundled default will be used"
288                            ),
289                        );
290                    }
291                }
292                _ => {}
293            }
294        }
295
296        for (id, asset) in &doc.assets {
297            match asset {
298                Asset::Image(img) => {
299                    if img.width == 0 || img.height == 0 {
300                        self.err(format!("asset/{id:?}"), "image dimensions must be nonzero");
301                    }
302                    if img.bytes.is_empty() {
303                        self.warn(format!("asset/{id:?}"), "image asset has empty bytes");
304                    }
305                }
306                Asset::Font(font) => {
307                    if font.bytes.is_empty() {
308                        self.err(format!("asset/{id:?}"), "font has no bytes");
309                    }
310                    if font.family.trim().is_empty() {
311                        self.err(format!("asset/{id:?}"), "font family is empty");
312                    }
313                }
314            }
315        }
316
317        self.validate_asset_usage(doc);
318    }
319
320    fn validate_asset_usage(&mut self, doc: &Document) {
321        for (id, asset) in &doc.assets {
322            match asset {
323                Asset::Image(_) => {
324                    let used = doc
325                        .nodes
326                        .values()
327                        .any(|n| matches!(n.kind, NodeKind::Image(a) if a == id));
328                    if !used {
329                        self.warn(
330                            format!("asset/{id:?}"),
331                            "image asset is not used by any image layer",
332                        );
333                    }
334                }
335                Asset::Font(font) => {
336                    let used = doc.nodes.values().any(|n| {
337                        matches!(&n.kind, NodeKind::Text(t) if t.font.as_deref() == Some(font.family.as_str()))
338                    });
339                    if !used {
340                        self.warn(
341                            format!("asset/{id:?}"),
342                            "font asset is not used by any text node",
343                        );
344                    }
345                }
346            }
347        }
348    }
349
350    fn validate_animations(&mut self) {
351        let doc = &self.file.document;
352        for (id, node) in &doc.nodes {
353            self.validate_node_animations(id, node);
354        }
355    }
356
357    fn validate_node_animations(&mut self, id: NodeId, node: &Node) {
358        let base = format!("node/{id:?}");
359        self.check_transform(&format!("{base}/transform"), &node.transform);
360        self.check_animated(&format!("{base}/opacity"), &node.opacity, finite_f64);
361
362        if node.transform.scale.base == DVec2::ZERO {
363            self.warn(format!("{base}/transform/scale"), "transform scale is zero");
364        }
365
366        match &node.kind {
367            NodeKind::Shape(shape) => self.validate_shape_animations(id, shape),
368            NodeKind::Style(style) => self.validate_style_animations(id, style),
369            NodeKind::Modifier(modifier) => self.validate_modifier_animations(id, modifier),
370            NodeKind::Text(text) => {
371                self.check_animated(&format!("{base}/text/size"), &text.size, finite_f64);
372            }
373            NodeKind::Layer(props) => {
374                if !props.time_stretch.is_finite() || props.time_stretch <= 0.0 {
375                    self.err(
376                        format!("{base}/layer/time_stretch"),
377                        "time stretch must be positive and finite",
378                    );
379                }
380                if props.out_frame <= props.in_frame {
381                    self.warn(
382                        format!("{base}/layer/range"),
383                        "layer out frame must be after in frame",
384                    );
385                }
386            }
387            NodeKind::Mask(mask) => {
388                self.validate_shape_animations(id, &mask.shape);
389                if shape_kind_is_empty(&mask.shape) {
390                    self.warn(format!("{base}/mask"), "mask has no geometry");
391                }
392            }
393            NodeKind::Group | NodeKind::Image(_) | NodeKind::Precomp { .. } => {}
394        }
395    }
396
397    fn validate_shape_animations(&mut self, id: NodeId, shape: &ShapeKind) {
398        let base = format!("node/{id:?}/shape");
399        match shape {
400            ShapeKind::Path(path) => {
401                self.check_animated(&format!("{base}/path"), path, finite_path);
402            }
403            ShapeKind::Rect { pos, size, rounded } => {
404                self.check_animated(&format!("{base}/pos"), pos, finite_vec2);
405                self.check_animated(&format!("{base}/size"), size, finite_vec2);
406                self.check_animated(&format!("{base}/rounded"), rounded, finite_f64);
407            }
408            ShapeKind::Ellipse { pos, size } => {
409                self.check_animated(&format!("{base}/pos"), pos, finite_vec2);
410                self.check_animated(&format!("{base}/size"), size, finite_vec2);
411            }
412            ShapeKind::Star {
413                pos,
414                points,
415                inner_r,
416                outer_r,
417                roundness,
418                ..
419            } => {
420                self.check_animated(&format!("{base}/pos"), pos, finite_vec2);
421                self.check_animated(&format!("{base}/points"), points, finite_f64);
422                self.check_animated(&format!("{base}/inner_r"), inner_r, finite_f64);
423                self.check_animated(&format!("{base}/outer_r"), outer_r, finite_f64);
424                self.check_animated(&format!("{base}/roundness"), roundness, finite_f64);
425            }
426            ShapeKind::Polygon {
427                pos,
428                points,
429                outer_r,
430                roundness,
431            } => {
432                self.check_animated(&format!("{base}/pos"), pos, finite_vec2);
433                self.check_animated(&format!("{base}/points"), points, finite_f64);
434                self.check_animated(&format!("{base}/outer_r"), outer_r, finite_f64);
435                self.check_animated(&format!("{base}/roundness"), roundness, finite_f64);
436            }
437            ShapeKind::CompoundPath(compound) => {
438                for (i, contour) in compound.contours.iter().enumerate() {
439                    self.check_animated(&format!("{base}/contour/{i}"), contour, finite_path);
440                }
441            }
442        }
443    }
444
445    fn validate_style_animations(&mut self, id: NodeId, style: &StyleKind) {
446        let base = format!("node/{id:?}/style");
447        match style {
448            StyleKind::Fill { paint, .. } => {
449                self.validate_paint(&format!("{base}/paint"), paint);
450            }
451            StyleKind::Stroke {
452                paint, width, dash, ..
453            } => {
454                self.validate_paint(&format!("{base}/paint"), paint);
455                self.check_animated(&format!("{base}/width"), width, finite_f64);
456                if let Some(dash) = dash {
457                    for (i, d) in dash.dashes.iter().enumerate() {
458                        self.check_animated(&format!("{base}/dash/{i}"), d, finite_f64);
459                    }
460                    self.check_animated(&format!("{base}/dash/offset"), &dash.offset, finite_f64);
461                }
462            }
463        }
464    }
465
466    fn validate_paint(&mut self, path: &str, paint: &StylePaint) {
467        match paint {
468            StylePaint::Solid { color } => self.check_animated(path, color, finite_color),
469            StylePaint::Gradient(gradient) => {
470                self.check_animated(&format!("{path}/start"), &gradient.start, finite_vec2);
471                self.check_animated(&format!("{path}/end"), &gradient.end, finite_vec2);
472                self.check_animated(&format!("{path}/stops"), &gradient.stops, finite_stops);
473            }
474        }
475    }
476
477    fn validate_modifier_animations(&mut self, id: NodeId, modifier: &ModifierKind) {
478        let base = format!("node/{id:?}/modifier");
479        match modifier {
480            ModifierKind::TrimPath {
481                start, end, offset, ..
482            } => {
483                self.check_animated(&format!("{base}/start"), start, finite_f64);
484                self.check_animated(&format!("{base}/end"), end, finite_f64);
485                self.check_animated(&format!("{base}/offset"), offset, finite_f64);
486            }
487            ModifierKind::Repeater {
488                copies,
489                offset,
490                transform,
491                start_opacity,
492                end_opacity,
493            } => {
494                self.check_animated(&format!("{base}/copies"), copies, finite_f64);
495                self.check_animated(&format!("{base}/offset"), offset, finite_f64);
496                self.check_animated(&format!("{base}/start_opacity"), start_opacity, finite_f64);
497                self.check_animated(&format!("{base}/end_opacity"), end_opacity, finite_f64);
498                self.check_transform(&format!("{base}/transform"), transform);
499            }
500            ModifierKind::RoundCorners { radius } => {
501                self.check_animated(&format!("{base}/radius"), radius, finite_f64);
502            }
503            ModifierKind::OffsetPath { amount } => {
504                self.check_animated(&format!("{base}/amount"), amount, finite_f64);
505            }
506            ModifierKind::ZigZag {
507                amplitude,
508                frequency,
509                ..
510            } => {
511                self.check_animated(&format!("{base}/amplitude"), amplitude, finite_f64);
512                self.check_animated(&format!("{base}/frequency"), frequency, finite_f64);
513            }
514            ModifierKind::PuckerBloat { amount } => {
515                self.check_animated(&format!("{base}/amount"), amount, finite_f64);
516            }
517        }
518    }
519
520    fn check_transform(&mut self, path: &str, transform: &AnimatedTransform) {
521        self.check_animated(&format!("{path}/anchor"), &transform.anchor, finite_vec2);
522        self.check_animated(
523            &format!("{path}/position"),
524            &transform.position,
525            finite_vec2,
526        );
527        self.check_animated(&format!("{path}/scale"), &transform.scale, finite_vec2);
528        self.check_animated(
529            &format!("{path}/rotation"),
530            &transform.rotation,
531            finite_angle,
532        );
533        self.check_animated(&format!("{path}/skew"), &transform.skew, finite_f64);
534        self.check_animated(
535            &format!("{path}/skew_axis"),
536            &transform.skew_axis,
537            finite_f64,
538        );
539    }
540
541    fn check_animated<T>(
542        &mut self,
543        path: &str,
544        animated: &Animated<T>,
545        check_value: impl Fn(&T) -> bool,
546    ) {
547        if !check_value(&animated.base) {
548            self.err(format!("{path}/base"), "value is not finite");
549        }
550        let mut prev: Option<Frame> = None;
551        for (i, key) in animated.keyframes.iter().enumerate() {
552            if let Some(p) = prev
553                && key.frame <= p
554            {
555                self.err(
556                    format!("{path}/key/{i}"),
557                    format!(
558                        "keyframes not strictly increasing (duplicate or out of order at frame {})",
559                        key.frame.0
560                    ),
561                );
562            }
563            if !check_value(&key.value) {
564                self.err(format!("{path}/key/{i}"), "keyframe value is not finite");
565            }
566            if !key.ease_out.x.is_finite()
567                || !key.ease_out.y.is_finite()
568                || !key.ease_in.x.is_finite()
569                || !key.ease_in.y.is_finite()
570            {
571                self.err(
572                    format!("{path}/key/{i}/easing"),
573                    "easing handle is not finite",
574                );
575            }
576            prev = Some(key.frame);
577        }
578    }
579
580    /// Style/modifier scoping mirrors group evaluation: a style paints every
581    /// shape path accumulated in its group, and a modifier only affects shapes
582    /// seen before it. Warn when either would be a no-op.
583    fn validate_scope(&mut self) {
584        let doc = &self.file.document;
585        let mut visited = HashSet::new();
586        for (comp_id, comp) in &doc.compositions {
587            self.scope_group(
588                comp.children.to_vec(),
589                format!("composition/{comp_id:?}"),
590                &mut visited,
591            );
592        }
593    }
594
595    fn scope_group(&mut self, children: Vec<NodeId>, path: String, visited: &mut HashSet<NodeId>) {
596        let doc = &self.file.document;
597        let mut has_shape = false;
598
599        for (index, &id) in children.iter().enumerate() {
600            let Some(node) = doc.nodes.get(id) else {
601                continue;
602            };
603            match &node.kind {
604                NodeKind::Shape(_) | NodeKind::Text(_) => has_shape = true,
605                NodeKind::Modifier(_) if !has_shape => {
606                    self.warn(
607                        format!("{path}/children/{index}"),
608                        "modifier appears before any shape in scope and will have no effect",
609                    );
610                }
611                _ => {}
612            }
613        }
614
615        if !has_shape {
616            for (index, &id) in children.iter().enumerate() {
617                let Some(node) = doc.nodes.get(id) else {
618                    continue;
619                };
620                if matches!(node.kind, NodeKind::Style(_)) {
621                    self.warn(
622                        format!("{path}/children/{index}"),
623                        "style node is not paired with any shape in scope",
624                    );
625                }
626            }
627        }
628
629        for &id in &children {
630            let Some(node) = doc.nodes.get(id) else {
631                continue;
632            };
633            if matches!(node.kind, NodeKind::Group | NodeKind::Layer(_)) && visited.insert(id) {
634                self.scope_group(
635                    node.children.clone(),
636                    format!("{path}/node/{id:?}"),
637                    visited,
638                );
639            }
640        }
641    }
642
643    fn validate_precomps(&mut self) {
644        let doc = &self.file.document;
645
646        for (id, node) in &doc.nodes {
647            if let NodeKind::Precomp { comp, time_map } = &node.kind {
648                if !doc.compositions.contains_key(*comp) {
649                    self.err(
650                        format!("node/{id:?}/precomp"),
651                        "referenced composition does not exist",
652                    );
653                }
654                if !time_map.stretch.is_finite() || time_map.stretch.abs() < 1e-9 {
655                    self.err(
656                        format!("node/{id:?}/precomp/stretch"),
657                        "invalid time stretch",
658                    );
659                }
660            }
661        }
662
663        let mut on_stack = HashSet::new();
664        let mut visited = HashSet::new();
665        for comp in doc.compositions.keys() {
666            self.walk_precomp(comp, &mut on_stack, &mut visited);
667        }
668    }
669
670    fn walk_precomp(
671        &mut self,
672        comp: CompId,
673        on_stack: &mut HashSet<CompId>,
674        visited: &mut HashSet<CompId>,
675    ) {
676        if on_stack.contains(&comp) {
677            self.err(
678                format!("precomp/{comp:?}"),
679                "composition is reachable from itself through precomps (cycle)",
680            );
681            return;
682        }
683        if !visited.insert(comp) {
684            return;
685        }
686        on_stack.insert(comp);
687        if let Some(c) = self.file.document.compositions.get(comp) {
688            for &child in &c.children {
689                if let Some(node) = self.file.document.nodes.get(child)
690                    && let NodeKind::Precomp { comp: target, .. } = &node.kind
691                {
692                    self.walk_precomp(*target, on_stack, visited);
693                }
694            }
695        }
696        on_stack.remove(&comp);
697    }
698
699    fn validate_clips(&mut self) {
700        let doc = &self.file.document;
701
702        let mut seen = HashSet::new();
703        for (i, &id) in self.file.clip_order.iter().enumerate() {
704            if !self.file.clips.contains_key(id) {
705                self.err(format!("clips/order/{i}"), "clip id does not exist");
706            }
707            if !seen.insert(id) {
708                self.err(
709                    format!("clips/order/{i}"),
710                    "duplicate clip id in clip_order",
711                );
712            }
713        }
714
715        for (clip_id, clip) in &self.file.clips {
716            if clip.range.1 <= clip.range.0 {
717                self.err(format!("clip/{clip_id:?}/range"), "invalid clip range");
718            }
719
720            for (track_index, track) in clip.tracks.iter().enumerate() {
721                let track_path = format!("clip/{clip_id:?}/track/{track_index}");
722                let prop = match doc.nodes.get(track.node) {
723                    Some(node) => match node.prop_ref(&track.prop) {
724                        Some(prop) => prop,
725                        None => {
726                            self.err(
727                                format!("{track_path}/prop"),
728                                "track references missing or incompatible property",
729                            );
730                            continue;
731                        }
732                    },
733                    None => {
734                        self.err(
735                            format!("{track_path}/node"),
736                            "track references missing node",
737                        );
738                        continue;
739                    }
740                };
741
742                let mut prev: Option<Frame> = None;
743                for (key_index, key) in track.keys.iter().enumerate() {
744                    if let Some(p) = prev
745                        && key.frame <= p
746                    {
747                        self.err(
748                            format!("{track_path}/key/{key_index}"),
749                            "clip keyframes not strictly increasing (duplicate or out of order)",
750                        );
751                    }
752                    if !key_value_matches_prop(&key.value, &prop) {
753                        self.err(
754                            format!("{track_path}/key/{key_index}/value"),
755                            "keyframe value type does not match property",
756                        );
757                    }
758                    prev = Some(key.frame);
759                }
760            }
761        }
762    }
763
764    fn validate_machines(&mut self) {
765        let doc = &self.file.document;
766
767        if let Some(start) = self.file.start_machine {
768            if !self.file.machines.contains_key(start) {
769                self.err("start_machine", "start machine does not exist");
770            }
771            if !self.file.machine_order.contains(&start) {
772                self.warn(
773                    "start_machine",
774                    "start machine exists but is detached from machine_order",
775                );
776            }
777        }
778
779        let mut seen = HashSet::new();
780        for (i, &id) in self.file.machine_order.iter().enumerate() {
781            if !self.file.machines.contains_key(id) {
782                self.err(format!("machines/order/{i}"), "machine id does not exist");
783            }
784            if !seen.insert(id) {
785                self.err(
786                    format!("machines/order/{i}"),
787                    "duplicate machine id in machine_order",
788                );
789            }
790        }
791
792        for (machine_id, machine) in &self.file.machines {
793            for (layer_index, layer) in machine.layers.iter().enumerate() {
794                if layer.states.is_empty() {
795                    self.err(
796                        format!("machine/{machine_id:?}/layer/{layer_index}"),
797                        "layer has no states",
798                    );
799                    continue;
800                }
801
802                if layer.entry >= layer.states.len() {
803                    self.err(
804                        format!("machine/{machine_id:?}/layer/{layer_index}/entry"),
805                        "entry state index is out of range",
806                    );
807                }
808
809                for (state_index, state) in layer.states.iter().enumerate() {
810                    match &state.kind {
811                        StateKind::Clip { clip, speed, .. } => {
812                            if !self.file.clips.contains_key(*clip) {
813                                self.err(
814                                    format!("machine/{machine_id:?}/layer/{layer_index}/state/{state_index}/clip"),
815                                    "state references missing clip",
816                                );
817                            }
818                            if !speed.is_finite() || *speed < 0.0 {
819                                self.err(
820                                    format!("machine/{machine_id:?}/layer/{layer_index}/state/{state_index}/speed"),
821                                    "clip state speed must be non-negative and finite",
822                                );
823                            }
824                        }
825                        StateKind::Blend1D { input, children } => {
826                            let base = format!(
827                                "machine/{machine_id:?}/layer/{layer_index}/state/{state_index}/blend"
828                            );
829                            match machine.inputs.get(*input) {
830                                Some(input_def) => {
831                                    if !matches!(input_def.kind, InputKind::Number { .. }) {
832                                        self.err(
833                                            format!("{base}/input"),
834                                            "Blend1D input must be a number input",
835                                        );
836                                    }
837                                }
838                                None => self.err(
839                                    format!("{base}/input"),
840                                    "Blend1D input index is out of range",
841                                ),
842                            }
843                            if children.is_empty() {
844                                self.err(format!("{base}/children"), "Blend1D has no children");
845                            }
846                            let mut prev: Option<f64> = None;
847                            for (child_index, child) in children.iter().enumerate() {
848                                if !self.file.clips.contains_key(child.clip) {
849                                    self.err(
850                                        format!("{base}/child/{child_index}"),
851                                        "blend child references missing clip",
852                                    );
853                                }
854                                if !child.threshold.is_finite() {
855                                    self.err(
856                                        format!("{base}/child/{child_index}/threshold"),
857                                        "blend threshold must be finite",
858                                    );
859                                }
860                                if let Some(p) = prev
861                                    && child.threshold <= p
862                                {
863                                    self.warn(
864                                        format!("{base}/child/{child_index}/threshold"),
865                                        "blend thresholds are not strictly increasing",
866                                    );
867                                }
868                                prev = Some(child.threshold);
869                            }
870                        }
871                        StateKind::Empty => {}
872                    }
873
874                    self.validate_transitions(
875                        machine_id,
876                        machine,
877                        layer_index,
878                        Some(state_index),
879                        &state.transitions,
880                    );
881                }
882
883                self.validate_transitions(
884                    machine_id,
885                    machine,
886                    layer_index,
887                    None,
888                    &layer.any_transitions,
889                );
890            }
891
892            for (listener_index, listener) in machine.listeners.iter().enumerate() {
893                if !doc.nodes.contains_key(listener.node) {
894                    self.err(
895                        format!("machine/{machine_id:?}/listener/{listener_index}/node"),
896                        "listener references missing node",
897                    );
898                }
899
900                let input = listener_action_input(&listener.action);
901                let base = format!("machine/{machine_id:?}/listener/{listener_index}");
902                match machine.inputs.get(input) {
903                    Some(input_def) => {
904                        if !listener_matches_input(&listener.action, input_def.kind) {
905                            self.err(
906                                format!("{base}/input"),
907                                "listener action type does not match input type",
908                            );
909                        }
910                    }
911                    None => self.err(format!("{base}/input"), "listener references missing input"),
912                }
913            }
914        }
915    }
916
917    #[allow(clippy::too_many_arguments)]
918    fn validate_transitions(
919        &mut self,
920        machine_id: MachineId,
921        machine: &Machine,
922        layer_index: usize,
923        state_index: Option<usize>,
924        transitions: &[Transition],
925    ) {
926        let Some(layer) = machine.layers.get(layer_index) else {
927            return;
928        };
929
930        for (transition_index, transition) in transitions.iter().enumerate() {
931            let base = match state_index {
932                Some(s) => format!(
933                    "machine/{machine_id:?}/layer/{layer_index}/state/{s}/transition/{transition_index}"
934                ),
935                None => format!(
936                    "machine/{machine_id:?}/layer/{layer_index}/any_transition/{transition_index}"
937                ),
938            };
939
940            if transition.to >= layer.states.len() {
941                self.err(&base, "transition target state is out of range");
942            }
943            if !transition.duration.is_finite() || transition.duration < 0.0 {
944                self.err(&base, "transition duration must be non-negative and finite");
945            }
946            if let Some(exit_time) = transition.exit_time
947                && (!exit_time.is_finite() || !(0.0..=1.0).contains(&exit_time))
948            {
949                self.err(&base, "transition exit_time must be in [0, 1]");
950            }
951
952            for (condition_index, condition) in transition.conditions.iter().enumerate() {
953                let input = condition_input(condition);
954                let condition_path = format!("{base}/condition/{condition_index}");
955                match machine.inputs.get(input) {
956                    Some(input_def) => {
957                        if !condition_matches_input(condition, input_def.kind) {
958                            self.err(&condition_path, "condition type does not match input type");
959                        }
960                    }
961                    None => self.err(&condition_path, "condition references missing input"),
962                }
963            }
964        }
965    }
966
967    fn validate_export_readiness(&mut self) {
968        let doc = &self.file.document;
969
970        // Nodes attached directly to a composition are Lottie layers; anything
971        // nested inside a group/layer is a shape item and only some kinds make
972        // it through that path.
973        let direct: HashSet<NodeId> = doc
974            .compositions
975            .values()
976            .flat_map(|c| c.children.iter().copied())
977            .collect();
978
979        for (id, node) in &doc.nodes {
980            match &node.kind {
981                NodeKind::Text(_) => {
982                    self.warn(
983                        format!("node/{id:?}/text"),
984                        "Lottie export bakes text to vector outlines",
985                    );
986                }
987                NodeKind::Mask(_) => {
988                    self.warn(
989                        format!("node/{id:?}/mask"),
990                        "Lottie mask export is best-effort and may differ from Renamite clip-stack semantics",
991                    );
992                }
993                NodeKind::Image(asset) => {
994                    if doc.image_asset(*asset).is_none() {
995                        self.err(
996                            format!("node/{id:?}/image"),
997                            "image layer references missing image asset",
998                        );
999                    }
1000                    if !direct.contains(&id) {
1001                        self.warn(
1002                            format!("node/{id:?}/image"),
1003                            "nested image layer is skipped by Lottie export",
1004                        );
1005                    }
1006                }
1007                NodeKind::Precomp { .. } if !direct.contains(&id) => {
1008                    self.warn(
1009                        format!("node/{id:?}/precomp"),
1010                        "nested precomp is skipped by Lottie export",
1011                    );
1012                }
1013                _ => {}
1014            }
1015        }
1016    }
1017}
1018
1019fn condition_input(condition: &Condition) -> usize {
1020    match condition {
1021        Condition::BoolIs { input, .. }
1022        | Condition::NumberCmp { input, .. }
1023        | Condition::Triggered { input } => *input,
1024    }
1025}
1026
1027fn condition_matches_input(condition: &Condition, input: InputKind) -> bool {
1028    matches!(
1029        (condition, input),
1030        (Condition::BoolIs { .. }, InputKind::Bool { .. })
1031            | (Condition::NumberCmp { .. }, InputKind::Number { .. })
1032            | (Condition::Triggered { .. }, InputKind::Trigger)
1033    )
1034}
1035
1036fn listener_action_input(action: &ListenerAction) -> usize {
1037    match action {
1038        ListenerAction::SetBool { input, .. }
1039        | ListenerAction::ToggleBool { input }
1040        | ListenerAction::SetNumber { input, .. }
1041        | ListenerAction::FireTrigger { input } => *input,
1042    }
1043}
1044
1045fn listener_matches_input(action: &ListenerAction, input: InputKind) -> bool {
1046    matches!(
1047        (action, input),
1048        (ListenerAction::SetBool { .. }, InputKind::Bool { .. })
1049            | (ListenerAction::ToggleBool { .. }, InputKind::Bool { .. })
1050            | (ListenerAction::SetNumber { .. }, InputKind::Number { .. })
1051            | (ListenerAction::FireTrigger { .. }, InputKind::Trigger)
1052    )
1053}
1054
1055fn key_value_matches_prop(value: &Value, prop: &PropRef) -> bool {
1056    matches!(
1057        (value, prop),
1058        (Value::F64(_), PropRef::F64(_))
1059            | (Value::DVec2(_), PropRef::Vec2(_))
1060            | (Value::Angle(_), PropRef::Angle(_))
1061            | (Value::Color(_), PropRef::Color(_))
1062            | (Value::Path(_), PropRef::Path(_))
1063            | (Value::Stops(_), PropRef::Stops(_))
1064    )
1065}
1066
1067fn finite_f64(value: &f64) -> bool {
1068    value.is_finite()
1069}
1070
1071fn finite_vec2(value: &DVec2) -> bool {
1072    value.is_finite()
1073}
1074
1075fn finite_angle(value: &Angle) -> bool {
1076    value.0.is_finite()
1077}
1078
1079fn finite_color(color: &Color) -> bool {
1080    color.r.is_finite() && color.g.is_finite() && color.b.is_finite() && color.a.is_finite()
1081}
1082
1083fn finite_stops(stops: &GradientStops) -> bool {
1084    stops
1085        .0
1086        .iter()
1087        .all(|s| s.offset.is_finite() && finite_color(&s.color))
1088}
1089
1090fn finite_path(path: &VectorPath) -> bool {
1091    path.anchors
1092        .iter()
1093        .all(|a| a.pos.is_finite() && a.tan_in.is_finite() && a.tan_out.is_finite())
1094}
1095
1096/// Base-value heuristic for "this shape has no geometry": empty path, zero-size
1097/// rect/ellipse, or non-positive star/polygon radius.
1098fn shape_kind_is_empty(shape: &ShapeKind) -> bool {
1099    match shape {
1100        ShapeKind::Path(path) => path.base.anchors.is_empty(),
1101        ShapeKind::CompoundPath(compound) => compound.contours.is_empty(),
1102        ShapeKind::Rect { size, .. } | ShapeKind::Ellipse { size, .. } => {
1103            size.base.x == 0.0 && size.base.y == 0.0
1104        }
1105        ShapeKind::Star { outer_r, .. } | ShapeKind::Polygon { outer_r, .. } => outer_r.base <= 0.0,
1106    }
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111    use super::*;
1112    use renamite_animation::{EasingHandle, Interpolation, Keyframe};
1113    use renamite_model::{AssetId, Parent, TextAlign, TextNode};
1114
1115    fn file(name: &str) -> RenFile {
1116        RenFile::new(Document::empty(), name)
1117    }
1118
1119    #[test]
1120    fn empty_project_is_valid() {
1121        let report = validate(&file("empty"));
1122        assert_eq!(report.error_count(), 0);
1123        assert_eq!(report.warning_count(), 0);
1124    }
1125
1126    #[test]
1127    fn missing_image_asset_is_error() {
1128        let mut file = file("bad");
1129        let fake = AssetId::from(slotmap::KeyData::from_ffi(42));
1130        let node = file
1131            .document
1132            .create_node(Node::new("img", NodeKind::Image(fake)));
1133        file.document
1134            .attach(node, Parent::Comp(file.document.main), 0)
1135            .unwrap();
1136
1137        let report = validate(&file);
1138        assert!(report.has_errors());
1139        assert!(
1140            report
1141                .diagnostics
1142                .iter()
1143                .any(|d| d.message.contains("image asset"))
1144        );
1145    }
1146
1147    #[test]
1148    fn missing_font_family_is_warning() {
1149        let mut file = file("font");
1150        let node = file.document.create_node(Node::new(
1151            "t",
1152            NodeKind::Text(TextNode {
1153                text: String::new(),
1154                size: Animated::new(48.0),
1155                align: TextAlign::Left,
1156                font: Some("Missing".into()),
1157            }),
1158        ));
1159        file.document
1160            .attach(node, Parent::Comp(file.document.main), 0)
1161            .unwrap();
1162
1163        let report = validate(&file);
1164        assert_eq!(report.error_count(), 0);
1165        assert!(report.warning_count() > 0);
1166        assert!(
1167            report
1168                .diagnostics
1169                .iter()
1170                .any(|d| d.message.contains("font family"))
1171        );
1172    }
1173
1174    #[test]
1175    fn duplicate_animated_keyframes_are_error() {
1176        use renamite_model::NodeKind;
1177        let mut file = file("keys");
1178        let mut node = Node::new("n", NodeKind::Group);
1179        node.opacity = Animated {
1180            base: 1.0,
1181            keyframes: vec![
1182                Keyframe {
1183                    frame: Frame(0),
1184                    value: 1.0,
1185                    interpolation: Interpolation::Linear,
1186                    ease_out: EasingHandle::LINEAR_OUT,
1187                    ease_in: EasingHandle::LINEAR_IN,
1188                },
1189                Keyframe {
1190                    frame: Frame(0),
1191                    value: 0.5,
1192                    interpolation: Interpolation::Linear,
1193                    ease_out: EasingHandle::LINEAR_OUT,
1194                    ease_in: EasingHandle::LINEAR_IN,
1195                },
1196            ],
1197        };
1198        let id = file.document.create_node(node);
1199        file.document
1200            .attach(id, Parent::Comp(file.document.main), 0)
1201            .unwrap();
1202
1203        let report = validate(&file);
1204        assert!(report.has_errors());
1205        assert!(
1206            report
1207                .diagnostics
1208                .iter()
1209                .any(|d| d.message.contains("strictly increasing"))
1210        );
1211    }
1212
1213    #[test]
1214    fn machine_bad_transition_is_error() {
1215        use renamite_machine::{Machine, MachineLayer, State};
1216        let mut file = file("machine");
1217        file.machines.insert(Machine {
1218            name: "m".into(),
1219            inputs: vec![],
1220            layers: vec![MachineLayer {
1221                name: "base".into(),
1222                entry: 0,
1223                any_transitions: vec![],
1224                states: vec![State {
1225                    name: "s".into(),
1226                    kind: StateKind::Empty,
1227                    transitions: vec![Transition {
1228                        to: 99,
1229                        duration: 0.0,
1230                        exit_time: None,
1231                        conditions: vec![],
1232                    }],
1233                    graph_pos: None,
1234                }],
1235            }],
1236            listeners: vec![],
1237        });
1238
1239        let report = validate(&file);
1240        assert!(report.has_errors());
1241        assert!(
1242            report
1243                .diagnostics
1244                .iter()
1245                .any(|d| d.path.contains("transition"))
1246        );
1247    }
1248
1249    #[test]
1250    fn empty_blend1d_children_are_error() {
1251        use renamite_machine::{Machine, MachineLayer, State};
1252        let mut file = file("blend");
1253        file.machines.insert(Machine {
1254            name: "m".into(),
1255            inputs: vec![renamite_machine::InputDef {
1256                name: "n".into(),
1257                kind: InputKind::Number { default: 0.0 },
1258            }],
1259            layers: vec![MachineLayer {
1260                name: "base".into(),
1261                entry: 0,
1262                any_transitions: vec![],
1263                states: vec![State {
1264                    name: "s".into(),
1265                    kind: StateKind::Blend1D {
1266                        input: 0,
1267                        children: vec![],
1268                    },
1269                    transitions: vec![],
1270                    graph_pos: None,
1271                }],
1272            }],
1273            listeners: vec![],
1274        });
1275
1276        let report = validate(&file);
1277        assert!(report.has_errors());
1278        assert!(
1279            report
1280                .diagnostics
1281                .iter()
1282                .any(|d| d.message.contains("Blend1D has no children"))
1283        );
1284    }
1285
1286    #[test]
1287    fn style_without_shape_in_scope_warns() {
1288        use renamite_model::{FillRule, StylePaint};
1289        let mut file = file("style");
1290        let fill = file.document.create_node(Node::new(
1291            "Fill",
1292            NodeKind::Style(StyleKind::Fill {
1293                paint: StylePaint::solid(Color::WHITE),
1294                rule: FillRule::NonZero,
1295            }),
1296        ));
1297        file.document
1298            .attach(fill, Parent::Comp(file.document.main), 0)
1299            .unwrap();
1300
1301        let report = validate(&file);
1302        assert_eq!(report.error_count(), 0);
1303        assert!(
1304            report
1305                .diagnostics
1306                .iter()
1307                .any(|d| d.message.contains("not paired with any shape"))
1308        );
1309    }
1310
1311    #[test]
1312    fn precomp_cycle_is_error() {
1313        let mut file = file("cycle");
1314        let comp_a = file.document.main;
1315        let comp_b = file
1316            .document
1317            .compositions
1318            .insert(renamite_model::Composition {
1319                name: "B".into(),
1320                size: (512, 512),
1321                rate: renamite_animation::FrameRate { num: 60, den: 1 },
1322                range: (Frame(0), Frame(60)),
1323                children: vec![],
1324            });
1325        let node_a = file.document.create_node(Node::new(
1326            "to B",
1327            NodeKind::Precomp {
1328                comp: comp_b,
1329                time_map: renamite_model::TimeMap {
1330                    offset: Frame(0),
1331                    stretch: 1.0,
1332                },
1333            },
1334        ));
1335        file.document
1336            .attach(node_a, Parent::Comp(comp_a), 0)
1337            .unwrap();
1338        let node_b = file.document.create_node(Node::new(
1339            "to A",
1340            NodeKind::Precomp {
1341                comp: comp_a,
1342                time_map: renamite_model::TimeMap {
1343                    offset: Frame(0),
1344                    stretch: 1.0,
1345                },
1346            },
1347        ));
1348        file.document
1349            .attach(node_b, Parent::Comp(comp_b), 0)
1350            .unwrap();
1351
1352        let report = validate(&file);
1353        assert!(report.has_errors());
1354        assert!(
1355            report
1356                .diagnostics
1357                .iter()
1358                .any(|d| d.message.contains("cycle"))
1359        );
1360    }
1361
1362    #[test]
1363    fn clip_track_missing_node_is_error() {
1364        use renamite_machine::Clip;
1365        let mut file = file("clip");
1366        let missing = NodeId::from(slotmap::KeyData::from_ffi(7));
1367        file.clips.insert(Clip {
1368            name: "c".into(),
1369            range: (Frame(0), Frame(10)),
1370            tracks: vec![renamite_machine::Track {
1371                node: missing,
1372                prop: renamite_model::PropPath::new("opacity"),
1373                keys: vec![],
1374            }],
1375            events: vec![],
1376        });
1377
1378        let report = validate(&file);
1379        assert!(report.has_errors());
1380        assert!(
1381            report
1382                .diagnostics
1383                .iter()
1384                .any(|d| d.message.contains("missing node"))
1385        );
1386    }
1387}