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(img) => match doc.assets.get(img.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(img) if img.asset() == 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                self.check_animated(&format!("{base}/text/tracking"), &text.tracking, finite_f64);
373                self.check_animated(&format!("{base}/text/leading"), &text.leading, finite_f64);
374            }
375            NodeKind::Layer(props) => {
376                if !props.time_stretch.is_finite() || props.time_stretch <= 0.0 {
377                    self.err(
378                        format!("{base}/layer/time_stretch"),
379                        "time stretch must be positive and finite",
380                    );
381                }
382                if props.out_frame <= props.in_frame {
383                    self.warn(
384                        format!("{base}/layer/range"),
385                        "layer out frame must be after in frame",
386                    );
387                }
388            }
389            NodeKind::Mask(mask) => {
390                self.validate_shape_animations(id, &mask.shape);
391                if shape_kind_is_empty(&mask.shape) {
392                    self.warn(format!("{base}/mask"), "mask has no geometry");
393                }
394            }
395            NodeKind::Image(img) => {
396                self.check_animated(&format!("{base}/image/tint"), img.tint(), finite_color);
397                let c = img.crop();
398                if !c.x.is_finite() || !c.y.is_finite() || !c.z.is_finite() || !c.w.is_finite() {
399                    self.err(format!("{base}/image/crop"), "crop is not finite");
400                } else {
401                    if !(0.0..=1.0).contains(&c.x)
402                        || !(0.0..=1.0).contains(&c.y)
403                        || c.z <= 0.0
404                        || c.w <= 0.0
405                        || c.z > 1.0
406                        || c.w > 1.0
407                    {
408                        self.err(
409                            format!("{base}/image/crop"),
410                            "crop must be x,y in [0,1] and w,h in (0,1]",
411                        );
412                    }
413                    if c.x + c.z > 1.0 + 1e-9 || c.y + c.w > 1.0 + 1e-9 {
414                        self.err(
415                            format!("{base}/image/crop"),
416                            "crop rect must be inside [0,1] image bounds (x+w<=1, y+h<=1)",
417                        );
418                    }
419                }
420            }
421            NodeKind::Group | NodeKind::Precomp { .. } => {}
422        }
423    }
424
425    fn validate_shape_animations(&mut self, id: NodeId, shape: &ShapeKind) {
426        let base = format!("node/{id:?}/shape");
427        match shape {
428            ShapeKind::Path(path) => {
429                self.check_animated(&format!("{base}/path"), path, finite_path);
430            }
431            ShapeKind::Rect { pos, size, rounded } => {
432                self.check_animated(&format!("{base}/pos"), pos, finite_vec2);
433                self.check_animated(&format!("{base}/size"), size, finite_vec2);
434                self.check_animated(&format!("{base}/rounded"), rounded, finite_f64);
435            }
436            ShapeKind::Ellipse { pos, size } => {
437                self.check_animated(&format!("{base}/pos"), pos, finite_vec2);
438                self.check_animated(&format!("{base}/size"), size, finite_vec2);
439            }
440            ShapeKind::Star {
441                pos,
442                points,
443                inner_r,
444                outer_r,
445                roundness,
446                ..
447            } => {
448                self.check_animated(&format!("{base}/pos"), pos, finite_vec2);
449                self.check_animated(&format!("{base}/points"), points, finite_f64);
450                self.check_animated(&format!("{base}/inner_r"), inner_r, finite_f64);
451                self.check_animated(&format!("{base}/outer_r"), outer_r, finite_f64);
452                self.check_animated(&format!("{base}/roundness"), roundness, finite_f64);
453            }
454            ShapeKind::Polygon {
455                pos,
456                points,
457                outer_r,
458                roundness,
459            } => {
460                self.check_animated(&format!("{base}/pos"), pos, finite_vec2);
461                self.check_animated(&format!("{base}/points"), points, finite_f64);
462                self.check_animated(&format!("{base}/outer_r"), outer_r, finite_f64);
463                self.check_animated(&format!("{base}/roundness"), roundness, finite_f64);
464            }
465            ShapeKind::CompoundPath(compound) => {
466                for (i, contour) in compound.contours.iter().enumerate() {
467                    self.check_animated(&format!("{base}/contour/{i}"), contour, finite_path);
468                }
469            }
470        }
471    }
472
473    fn validate_style_animations(&mut self, id: NodeId, style: &StyleKind) {
474        let base = format!("node/{id:?}/style");
475        match style {
476            StyleKind::Fill { paint, .. } => {
477                self.validate_paint(&format!("{base}/paint"), paint);
478            }
479            StyleKind::Stroke {
480                paint, width, dash, ..
481            } => {
482                self.validate_paint(&format!("{base}/paint"), paint);
483                self.check_animated(&format!("{base}/width"), width, finite_f64);
484                if let Some(dash) = dash {
485                    for (i, d) in dash.dashes.iter().enumerate() {
486                        self.check_animated(&format!("{base}/dash/{i}"), d, finite_f64);
487                    }
488                    self.check_animated(&format!("{base}/dash/offset"), &dash.offset, finite_f64);
489                }
490            }
491        }
492    }
493
494    fn validate_paint(&mut self, path: &str, paint: &StylePaint) {
495        match paint {
496            StylePaint::Solid { color } => self.check_animated(path, color, finite_color),
497            StylePaint::Gradient(gradient) => {
498                self.check_animated(&format!("{path}/start"), &gradient.start, finite_vec2);
499                self.check_animated(&format!("{path}/end"), &gradient.end, finite_vec2);
500                self.check_animated(&format!("{path}/stops"), &gradient.stops, finite_stops);
501            }
502        }
503    }
504
505    fn validate_modifier_animations(&mut self, id: NodeId, modifier: &ModifierKind) {
506        let base = format!("node/{id:?}/modifier");
507        match modifier {
508            ModifierKind::TrimPath {
509                start, end, offset, ..
510            } => {
511                self.check_animated(&format!("{base}/start"), start, finite_f64);
512                self.check_animated(&format!("{base}/end"), end, finite_f64);
513                self.check_animated(&format!("{base}/offset"), offset, finite_f64);
514            }
515            ModifierKind::Repeater {
516                copies,
517                offset,
518                transform,
519                start_opacity,
520                end_opacity,
521            } => {
522                self.check_animated(&format!("{base}/copies"), copies, finite_f64);
523                self.check_animated(&format!("{base}/offset"), offset, finite_f64);
524                self.check_animated(&format!("{base}/start_opacity"), start_opacity, finite_f64);
525                self.check_animated(&format!("{base}/end_opacity"), end_opacity, finite_f64);
526                self.check_transform(&format!("{base}/transform"), transform);
527            }
528            ModifierKind::RoundCorners { radius } => {
529                self.check_animated(&format!("{base}/radius"), radius, finite_f64);
530            }
531            ModifierKind::OffsetPath { amount } => {
532                self.check_animated(&format!("{base}/amount"), amount, finite_f64);
533            }
534            ModifierKind::ZigZag {
535                amplitude,
536                frequency,
537                ..
538            } => {
539                self.check_animated(&format!("{base}/amplitude"), amplitude, finite_f64);
540                self.check_animated(&format!("{base}/frequency"), frequency, finite_f64);
541            }
542            ModifierKind::PuckerBloat { amount } => {
543                self.check_animated(&format!("{base}/amount"), amount, finite_f64);
544            }
545        }
546    }
547
548    fn check_transform(&mut self, path: &str, transform: &AnimatedTransform) {
549        self.check_animated(&format!("{path}/anchor"), &transform.anchor, finite_vec2);
550        self.check_animated(
551            &format!("{path}/position"),
552            &transform.position,
553            finite_vec2,
554        );
555        self.check_animated(&format!("{path}/scale"), &transform.scale, finite_vec2);
556        self.check_animated(
557            &format!("{path}/rotation"),
558            &transform.rotation,
559            finite_angle,
560        );
561        self.check_animated(&format!("{path}/skew"), &transform.skew, finite_f64);
562        self.check_animated(
563            &format!("{path}/skew_axis"),
564            &transform.skew_axis,
565            finite_f64,
566        );
567    }
568
569    fn check_animated<T>(
570        &mut self,
571        path: &str,
572        animated: &Animated<T>,
573        check_value: impl Fn(&T) -> bool,
574    ) {
575        if !check_value(&animated.base) {
576            self.err(format!("{path}/base"), "value is not finite");
577        }
578        let mut prev: Option<Frame> = None;
579        for (i, key) in animated.keyframes.iter().enumerate() {
580            if let Some(p) = prev
581                && key.frame <= p
582            {
583                self.err(
584                    format!("{path}/key/{i}"),
585                    format!(
586                        "keyframes not strictly increasing (duplicate or out of order at frame {})",
587                        key.frame.0
588                    ),
589                );
590            }
591            if !check_value(&key.value) {
592                self.err(format!("{path}/key/{i}"), "keyframe value is not finite");
593            }
594            if !key.ease_out.x.is_finite()
595                || !key.ease_out.y.is_finite()
596                || !key.ease_in.x.is_finite()
597                || !key.ease_in.y.is_finite()
598            {
599                self.err(
600                    format!("{path}/key/{i}/easing"),
601                    "easing handle is not finite",
602                );
603            }
604            prev = Some(key.frame);
605        }
606    }
607
608    /// Style/modifier scoping mirrors group evaluation: a style paints every
609    /// shape path accumulated in its group, and a modifier only affects shapes
610    /// seen before it. Warn when either would be a no-op.
611    fn validate_scope(&mut self) {
612        let doc = &self.file.document;
613        let mut visited = HashSet::new();
614        for (comp_id, comp) in &doc.compositions {
615            self.scope_group(
616                comp.children.to_vec(),
617                format!("composition/{comp_id:?}"),
618                &mut visited,
619            );
620        }
621    }
622
623    fn scope_group(&mut self, children: Vec<NodeId>, path: String, visited: &mut HashSet<NodeId>) {
624        let doc = &self.file.document;
625        let mut has_shape = false;
626
627        for (index, &id) in children.iter().enumerate() {
628            let Some(node) = doc.nodes.get(id) else {
629                continue;
630            };
631            match &node.kind {
632                NodeKind::Shape(_) | NodeKind::Text(_) => has_shape = true,
633                NodeKind::Modifier(_) if !has_shape => {
634                    self.warn(
635                        format!("{path}/children/{index}"),
636                        "modifier appears before any shape in scope and will have no effect",
637                    );
638                }
639                _ => {}
640            }
641        }
642
643        if !has_shape {
644            for (index, &id) in children.iter().enumerate() {
645                let Some(node) = doc.nodes.get(id) else {
646                    continue;
647                };
648                if matches!(node.kind, NodeKind::Style(_)) {
649                    self.warn(
650                        format!("{path}/children/{index}"),
651                        "style node is not paired with any shape in scope",
652                    );
653                }
654            }
655        }
656
657        for &id in &children {
658            let Some(node) = doc.nodes.get(id) else {
659                continue;
660            };
661            if matches!(node.kind, NodeKind::Group | NodeKind::Layer(_)) && visited.insert(id) {
662                self.scope_group(
663                    node.children.clone(),
664                    format!("{path}/node/{id:?}"),
665                    visited,
666                );
667            }
668        }
669    }
670
671    fn validate_precomps(&mut self) {
672        let doc = &self.file.document;
673
674        for (id, node) in &doc.nodes {
675            if let NodeKind::Precomp { comp, time_map } = &node.kind {
676                if !doc.compositions.contains_key(*comp) {
677                    self.err(
678                        format!("node/{id:?}/precomp"),
679                        "referenced composition does not exist",
680                    );
681                }
682                if !time_map.stretch.is_finite() || time_map.stretch.abs() < 1e-6 {
683                    self.err(
684                        format!("node/{id:?}/precomp/stretch"),
685                        "invalid time stretch",
686                    );
687                }
688            }
689        }
690
691        let mut on_stack = HashSet::new();
692        let mut visited = HashSet::new();
693        for comp in doc.compositions.keys() {
694            self.walk_precomp(comp, &mut on_stack, &mut visited);
695        }
696    }
697
698    fn walk_precomp(
699        &mut self,
700        comp: CompId,
701        on_stack: &mut HashSet<CompId>,
702        visited: &mut HashSet<CompId>,
703    ) {
704        if on_stack.contains(&comp) {
705            self.err(
706                format!("precomp/{comp:?}"),
707                "composition is reachable from itself through precomps (cycle)",
708            );
709            return;
710        }
711        if !visited.insert(comp) {
712            return;
713        }
714        on_stack.insert(comp);
715        if let Some(c) = self.file.document.compositions.get(comp) {
716            let mut stack: Vec<NodeId> = c.children.clone();
717            let mut seen_nodes = HashSet::new();
718            while let Some(nid) = stack.pop() {
719                if !seen_nodes.insert(nid) {
720                    continue;
721                }
722                let Some(node) = self.file.document.nodes.get(nid) else {
723                    continue;
724                };
725                if let NodeKind::Precomp { comp: target, .. } = &node.kind {
726                    self.walk_precomp(*target, on_stack, visited);
727                }
728                if matches!(node.kind, NodeKind::Group | NodeKind::Layer(_)) {
729                    stack.extend(node.children.iter().copied());
730                }
731            }
732        }
733        on_stack.remove(&comp);
734    }
735
736    fn validate_clips(&mut self) {
737        let doc = &self.file.document;
738
739        let mut seen = HashSet::new();
740        for (i, &id) in self.file.clip_order.iter().enumerate() {
741            if !self.file.clips.contains_key(id) {
742                self.err(format!("clips/order/{i}"), "clip id does not exist");
743            }
744            if !seen.insert(id) {
745                self.err(
746                    format!("clips/order/{i}"),
747                    "duplicate clip id in clip_order",
748                );
749            }
750        }
751
752        for (clip_id, clip) in &self.file.clips {
753            if clip.range.1 <= clip.range.0 {
754                self.err(format!("clip/{clip_id:?}/range"), "invalid clip range");
755            }
756
757            for (track_index, track) in clip.tracks.iter().enumerate() {
758                let track_path = format!("clip/{clip_id:?}/track/{track_index}");
759                let prop = match doc.nodes.get(track.node) {
760                    Some(node) => match node.prop_ref(&track.prop) {
761                        Some(prop) => prop,
762                        None => {
763                            self.err(
764                                format!("{track_path}/prop"),
765                                "track references missing or incompatible property",
766                            );
767                            continue;
768                        }
769                    },
770                    None => {
771                        self.err(
772                            format!("{track_path}/node"),
773                            "track references missing node",
774                        );
775                        continue;
776                    }
777                };
778
779                let mut prev: Option<Frame> = None;
780                for (key_index, key) in track.keys.iter().enumerate() {
781                    if let Some(p) = prev
782                        && key.frame <= p
783                    {
784                        self.err(
785                            format!("{track_path}/key/{key_index}"),
786                            "clip keyframes not strictly increasing (duplicate or out of order)",
787                        );
788                    }
789                    if !key_value_matches_prop(&key.value, &prop) {
790                        self.err(
791                            format!("{track_path}/key/{key_index}/value"),
792                            "keyframe value type does not match property",
793                        );
794                    }
795                    prev = Some(key.frame);
796                }
797            }
798        }
799    }
800
801    fn validate_machines(&mut self) {
802        let doc = &self.file.document;
803
804        if let Some(start) = self.file.start_machine {
805            if !self.file.machines.contains_key(start) {
806                self.err("start_machine", "start machine does not exist");
807            }
808            if !self.file.machine_order.contains(&start) {
809                self.warn(
810                    "start_machine",
811                    "start machine exists but is detached from machine_order",
812                );
813            }
814        }
815
816        let mut seen = HashSet::new();
817        for (i, &id) in self.file.machine_order.iter().enumerate() {
818            if !self.file.machines.contains_key(id) {
819                self.err(format!("machines/order/{i}"), "machine id does not exist");
820            }
821            if !seen.insert(id) {
822                self.err(
823                    format!("machines/order/{i}"),
824                    "duplicate machine id in machine_order",
825                );
826            }
827        }
828
829        for (machine_id, machine) in &self.file.machines {
830            for (layer_index, layer) in machine.layers.iter().enumerate() {
831                if layer.states.is_empty() {
832                    self.err(
833                        format!("machine/{machine_id:?}/layer/{layer_index}"),
834                        "layer has no states",
835                    );
836                    continue;
837                }
838
839                if layer.entry >= layer.states.len() {
840                    self.err(
841                        format!("machine/{machine_id:?}/layer/{layer_index}/entry"),
842                        "entry state index is out of range",
843                    );
844                }
845
846                for (state_index, state) in layer.states.iter().enumerate() {
847                    match &state.kind {
848                        StateKind::Clip { clip, speed, .. } => {
849                            if !self.file.clips.contains_key(*clip) {
850                                self.err(
851                                    format!("machine/{machine_id:?}/layer/{layer_index}/state/{state_index}/clip"),
852                                    "state references missing clip",
853                                );
854                            }
855                            if !speed.is_finite() || *speed < 0.0 {
856                                self.err(
857                                    format!("machine/{machine_id:?}/layer/{layer_index}/state/{state_index}/speed"),
858                                    "clip state speed must be non-negative and finite",
859                                );
860                            }
861                        }
862                        StateKind::Blend1D { input, children } => {
863                            let base = format!(
864                                "machine/{machine_id:?}/layer/{layer_index}/state/{state_index}/blend"
865                            );
866                            match machine.inputs.get(*input) {
867                                Some(input_def) => {
868                                    if !matches!(input_def.kind, InputKind::Number { .. }) {
869                                        self.err(
870                                            format!("{base}/input"),
871                                            "Blend1D input must be a number input",
872                                        );
873                                    }
874                                }
875                                None => self.err(
876                                    format!("{base}/input"),
877                                    "Blend1D input index is out of range",
878                                ),
879                            }
880                            if children.is_empty() {
881                                self.err(format!("{base}/children"), "Blend1D has no children");
882                            }
883                            let mut prev: Option<f64> = None;
884                            for (child_index, child) in children.iter().enumerate() {
885                                if !self.file.clips.contains_key(child.clip) {
886                                    self.err(
887                                        format!("{base}/child/{child_index}"),
888                                        "blend child references missing clip",
889                                    );
890                                }
891                                if !child.threshold.is_finite() {
892                                    self.err(
893                                        format!("{base}/child/{child_index}/threshold"),
894                                        "blend threshold must be finite",
895                                    );
896                                }
897                                if let Some(p) = prev
898                                    && child.threshold <= p
899                                {
900                                    self.warn(
901                                        format!("{base}/child/{child_index}/threshold"),
902                                        "blend thresholds are not strictly increasing",
903                                    );
904                                }
905                                prev = Some(child.threshold);
906                            }
907                        }
908                        StateKind::Empty => {}
909                    }
910
911                    self.validate_transitions(
912                        machine_id,
913                        machine,
914                        layer_index,
915                        Some(state_index),
916                        &state.transitions,
917                    );
918                }
919
920                self.validate_transitions(
921                    machine_id,
922                    machine,
923                    layer_index,
924                    None,
925                    &layer.any_transitions,
926                );
927            }
928
929            for (listener_index, listener) in machine.listeners.iter().enumerate() {
930                if !doc.nodes.contains_key(listener.node) {
931                    self.err(
932                        format!("machine/{machine_id:?}/listener/{listener_index}/node"),
933                        "listener references missing node",
934                    );
935                }
936
937                let input = listener_action_input(&listener.action);
938                let base = format!("machine/{machine_id:?}/listener/{listener_index}");
939                match machine.inputs.get(input) {
940                    Some(input_def) => {
941                        if !listener_matches_input(&listener.action, input_def.kind) {
942                            self.err(
943                                format!("{base}/input"),
944                                "listener action type does not match input type",
945                            );
946                        }
947                    }
948                    None => self.err(format!("{base}/input"), "listener references missing input"),
949                }
950            }
951        }
952    }
953
954    #[allow(clippy::too_many_arguments)]
955    fn validate_transitions(
956        &mut self,
957        machine_id: MachineId,
958        machine: &Machine,
959        layer_index: usize,
960        state_index: Option<usize>,
961        transitions: &[Transition],
962    ) {
963        let Some(layer) = machine.layers.get(layer_index) else {
964            return;
965        };
966
967        for (transition_index, transition) in transitions.iter().enumerate() {
968            let base = match state_index {
969                Some(s) => format!(
970                    "machine/{machine_id:?}/layer/{layer_index}/state/{s}/transition/{transition_index}"
971                ),
972                None => format!(
973                    "machine/{machine_id:?}/layer/{layer_index}/any_transition/{transition_index}"
974                ),
975            };
976
977            if transition.to >= layer.states.len() {
978                self.err(&base, "transition target state is out of range");
979            }
980            if !transition.duration.is_finite() || transition.duration < 0.0 {
981                self.err(&base, "transition duration must be non-negative and finite");
982            }
983            if let Some(exit_time) = transition.exit_time
984                && (!exit_time.is_finite() || !(0.0..=1.0).contains(&exit_time))
985            {
986                self.err(&base, "transition exit_time must be in [0, 1]");
987            }
988
989            for (condition_index, condition) in transition.conditions.iter().enumerate() {
990                let input = condition_input(condition);
991                let condition_path = format!("{base}/condition/{condition_index}");
992                match machine.inputs.get(input) {
993                    Some(input_def) => {
994                        if !condition_matches_input(condition, input_def.kind) {
995                            self.err(&condition_path, "condition type does not match input type");
996                        }
997                    }
998                    None => self.err(&condition_path, "condition references missing input"),
999                }
1000            }
1001        }
1002    }
1003
1004    fn validate_export_readiness(&mut self) {
1005        let doc = &self.file.document;
1006
1007        // Nodes attached directly to a composition are Lottie layers; anything
1008        // nested inside a group/layer is a shape item and only some kinds make
1009        // it through that path.
1010        let direct: HashSet<NodeId> = doc
1011            .compositions
1012            .values()
1013            .flat_map(|c| c.children.iter().copied())
1014            .collect();
1015
1016        for (id, node) in &doc.nodes {
1017            match &node.kind {
1018                NodeKind::Text(_) => {
1019                    self.warn(
1020                        format!("node/{id:?}/text"),
1021                        "Lottie export bakes text to vector outlines",
1022                    );
1023                }
1024                NodeKind::Mask(_) => {
1025                    self.warn(
1026                        format!("node/{id:?}/mask"),
1027                        "Lottie mask export is best-effort and may differ from Renamite clip-stack semantics",
1028                    );
1029                }
1030                NodeKind::Image(img) => {
1031                    if doc.image_asset(img.asset()).is_none() {
1032                        self.err(
1033                            format!("node/{id:?}/image"),
1034                            "image layer references missing image asset",
1035                        );
1036                    }
1037                    if !direct.contains(&id) {
1038                        self.warn(
1039                            format!("node/{id:?}/image"),
1040                            "nested image layer is skipped by Lottie export",
1041                        );
1042                    }
1043                }
1044                NodeKind::Precomp { .. } if !direct.contains(&id) => {
1045                    self.warn(
1046                        format!("node/{id:?}/precomp"),
1047                        "nested precomp is skipped by Lottie export",
1048                    );
1049                }
1050                _ => {}
1051            }
1052        }
1053    }
1054}
1055
1056fn condition_input(condition: &Condition) -> usize {
1057    match condition {
1058        Condition::BoolIs { input, .. }
1059        | Condition::NumberCmp { input, .. }
1060        | Condition::Triggered { input } => *input,
1061    }
1062}
1063
1064fn condition_matches_input(condition: &Condition, input: InputKind) -> bool {
1065    matches!(
1066        (condition, input),
1067        (Condition::BoolIs { .. }, InputKind::Bool { .. })
1068            | (Condition::NumberCmp { .. }, InputKind::Number { .. })
1069            | (Condition::Triggered { .. }, InputKind::Trigger)
1070    )
1071}
1072
1073fn listener_action_input(action: &ListenerAction) -> usize {
1074    match action {
1075        ListenerAction::SetBool { input, .. }
1076        | ListenerAction::ToggleBool { input }
1077        | ListenerAction::SetNumber { input, .. }
1078        | ListenerAction::FireTrigger { input } => *input,
1079    }
1080}
1081
1082fn listener_matches_input(action: &ListenerAction, input: InputKind) -> bool {
1083    matches!(
1084        (action, input),
1085        (ListenerAction::SetBool { .. }, InputKind::Bool { .. })
1086            | (ListenerAction::ToggleBool { .. }, InputKind::Bool { .. })
1087            | (ListenerAction::SetNumber { .. }, InputKind::Number { .. })
1088            | (ListenerAction::FireTrigger { .. }, InputKind::Trigger)
1089    )
1090}
1091
1092fn key_value_matches_prop(value: &Value, prop: &PropRef) -> bool {
1093    matches!(
1094        (value, prop),
1095        (Value::F64(_), PropRef::F64(_))
1096            | (Value::DVec2(_), PropRef::Vec2(_))
1097            | (Value::Angle(_), PropRef::Angle(_))
1098            | (Value::Color(_), PropRef::Color(_))
1099            | (Value::Path(_), PropRef::Path(_))
1100            | (Value::Stops(_), PropRef::Stops(_))
1101    )
1102}
1103
1104fn finite_f64(value: &f64) -> bool {
1105    value.is_finite()
1106}
1107
1108fn finite_vec2(value: &DVec2) -> bool {
1109    value.is_finite()
1110}
1111
1112fn finite_angle(value: &Angle) -> bool {
1113    value.0.is_finite()
1114}
1115
1116fn finite_color(color: &Color) -> bool {
1117    color.r.is_finite() && color.g.is_finite() && color.b.is_finite() && color.a.is_finite()
1118}
1119
1120fn finite_stops(stops: &GradientStops) -> bool {
1121    stops
1122        .0
1123        .iter()
1124        .all(|s| s.offset.is_finite() && finite_color(&s.color))
1125}
1126
1127fn finite_path(path: &VectorPath) -> bool {
1128    path.anchors
1129        .iter()
1130        .all(|a| a.pos.is_finite() && a.tan_in.is_finite() && a.tan_out.is_finite())
1131}
1132
1133/// Base-value heuristic for "this shape has no geometry": empty path, zero-size
1134/// rect/ellipse, or non-positive star/polygon radius.
1135fn shape_kind_is_empty(shape: &ShapeKind) -> bool {
1136    match shape {
1137        ShapeKind::Path(path) => path.base.anchors.is_empty(),
1138        ShapeKind::CompoundPath(compound) => compound.contours.is_empty(),
1139        ShapeKind::Rect { size, .. } | ShapeKind::Ellipse { size, .. } => {
1140            size.base.x == 0.0 && size.base.y == 0.0
1141        }
1142        ShapeKind::Star { outer_r, .. } | ShapeKind::Polygon { outer_r, .. } => outer_r.base <= 0.0,
1143    }
1144}
1145
1146#[cfg(test)]
1147mod tests {
1148    use super::*;
1149    use renamite_animation::{EasingHandle, Interpolation, Keyframe};
1150    use renamite_model::{AssetId, Parent, TextAlign, TextNode};
1151
1152    fn file(name: &str) -> RenFile {
1153        RenFile::new(Document::empty(), name)
1154    }
1155
1156    #[test]
1157    fn empty_project_is_valid() {
1158        let report = validate(&file("empty"));
1159        assert_eq!(report.error_count(), 0);
1160        assert_eq!(report.warning_count(), 0);
1161    }
1162
1163    #[test]
1164    fn missing_image_asset_is_error() {
1165        let mut file = file("bad");
1166        let fake = AssetId::from(slotmap::KeyData::from_ffi(42));
1167        let node = file.document.create_node(Node::new(
1168            "img",
1169            NodeKind::Image(renamite_model::ImageNode::new(fake)),
1170        ));
1171        file.document
1172            .attach(node, Parent::Comp(file.document.main), 0)
1173            .unwrap();
1174
1175        let report = validate(&file);
1176        assert!(report.has_errors());
1177        assert!(
1178            report
1179                .diagnostics
1180                .iter()
1181                .any(|d| d.message.contains("image asset"))
1182        );
1183    }
1184
1185    #[test]
1186    fn missing_font_family_is_warning() {
1187        let mut file = file("font");
1188        let node = file.document.create_node(Node::new(
1189            "t",
1190            NodeKind::Text(TextNode {
1191                text: String::new(),
1192                size: Animated::new(48.0),
1193                align: TextAlign::Left,
1194                font: Some("Missing".into()),
1195                tracking: Animated::new(0.0),
1196                leading: Animated::new(0.0),
1197            }),
1198        ));
1199        file.document
1200            .attach(node, Parent::Comp(file.document.main), 0)
1201            .unwrap();
1202
1203        let report = validate(&file);
1204        assert_eq!(report.error_count(), 0);
1205        assert!(report.warning_count() > 0);
1206        assert!(
1207            report
1208                .diagnostics
1209                .iter()
1210                .any(|d| d.message.contains("font family"))
1211        );
1212    }
1213
1214    #[test]
1215    fn duplicate_animated_keyframes_are_error() {
1216        use renamite_model::NodeKind;
1217        let mut file = file("keys");
1218        let mut node = Node::new("n", NodeKind::Group);
1219        node.opacity = Animated {
1220            base: 1.0,
1221            keyframes: vec![
1222                Keyframe {
1223                    frame: Frame(0),
1224                    value: 1.0,
1225                    interpolation: Interpolation::Linear,
1226                    ease_out: EasingHandle::LINEAR_OUT,
1227                    ease_in: EasingHandle::LINEAR_IN,
1228                },
1229                Keyframe {
1230                    frame: Frame(0),
1231                    value: 0.5,
1232                    interpolation: Interpolation::Linear,
1233                    ease_out: EasingHandle::LINEAR_OUT,
1234                    ease_in: EasingHandle::LINEAR_IN,
1235                },
1236            ],
1237        };
1238        let id = file.document.create_node(node);
1239        file.document
1240            .attach(id, Parent::Comp(file.document.main), 0)
1241            .unwrap();
1242
1243        let report = validate(&file);
1244        assert!(report.has_errors());
1245        assert!(
1246            report
1247                .diagnostics
1248                .iter()
1249                .any(|d| d.message.contains("strictly increasing"))
1250        );
1251    }
1252
1253    #[test]
1254    fn machine_bad_transition_is_error() {
1255        use renamite_machine::{Machine, MachineLayer, State};
1256        let mut file = file("machine");
1257        file.machines.insert(Machine {
1258            name: "m".into(),
1259            inputs: vec![],
1260            layers: vec![MachineLayer {
1261                name: "base".into(),
1262                entry: 0,
1263                any_transitions: vec![],
1264                states: vec![State {
1265                    name: "s".into(),
1266                    kind: StateKind::Empty,
1267                    transitions: vec![Transition {
1268                        to: 99,
1269                        duration: 0.0,
1270                        exit_time: None,
1271                        conditions: vec![],
1272                    }],
1273                    graph_pos: None,
1274                }],
1275            }],
1276            listeners: vec![],
1277        });
1278
1279        let report = validate(&file);
1280        assert!(report.has_errors());
1281        assert!(
1282            report
1283                .diagnostics
1284                .iter()
1285                .any(|d| d.path.contains("transition"))
1286        );
1287    }
1288
1289    #[test]
1290    fn empty_blend1d_children_are_error() {
1291        use renamite_machine::{Machine, MachineLayer, State};
1292        let mut file = file("blend");
1293        file.machines.insert(Machine {
1294            name: "m".into(),
1295            inputs: vec![renamite_machine::InputDef {
1296                name: "n".into(),
1297                kind: InputKind::Number { default: 0.0 },
1298            }],
1299            layers: vec![MachineLayer {
1300                name: "base".into(),
1301                entry: 0,
1302                any_transitions: vec![],
1303                states: vec![State {
1304                    name: "s".into(),
1305                    kind: StateKind::Blend1D {
1306                        input: 0,
1307                        children: vec![],
1308                    },
1309                    transitions: vec![],
1310                    graph_pos: None,
1311                }],
1312            }],
1313            listeners: vec![],
1314        });
1315
1316        let report = validate(&file);
1317        assert!(report.has_errors());
1318        assert!(
1319            report
1320                .diagnostics
1321                .iter()
1322                .any(|d| d.message.contains("Blend1D has no children"))
1323        );
1324    }
1325
1326    #[test]
1327    fn style_without_shape_in_scope_warns() {
1328        use renamite_model::{FillRule, StylePaint};
1329        let mut file = file("style");
1330        let fill = file.document.create_node(Node::new(
1331            "Fill",
1332            NodeKind::Style(StyleKind::Fill {
1333                paint: StylePaint::solid(Color::WHITE),
1334                rule: FillRule::NonZero,
1335            }),
1336        ));
1337        file.document
1338            .attach(fill, Parent::Comp(file.document.main), 0)
1339            .unwrap();
1340
1341        let report = validate(&file);
1342        assert_eq!(report.error_count(), 0);
1343        assert!(
1344            report
1345                .diagnostics
1346                .iter()
1347                .any(|d| d.message.contains("not paired with any shape"))
1348        );
1349    }
1350
1351    #[test]
1352    fn precomp_cycle_is_error() {
1353        let mut file = file("cycle");
1354        let comp_a = file.document.main;
1355        let comp_b = file
1356            .document
1357            .compositions
1358            .insert(renamite_model::Composition {
1359                name: "B".into(),
1360                size: (512, 512),
1361                rate: renamite_animation::FrameRate { num: 60, den: 1 },
1362                range: (Frame(0), Frame(60)),
1363                children: vec![],
1364            });
1365        let node_a = file.document.create_node(Node::new(
1366            "to B",
1367            NodeKind::Precomp {
1368                comp: comp_b,
1369                time_map: renamite_model::TimeMap {
1370                    offset: Frame(0),
1371                    stretch: 1.0,
1372                },
1373            },
1374        ));
1375        file.document
1376            .attach(node_a, Parent::Comp(comp_a), 0)
1377            .unwrap();
1378        let node_b = file.document.create_node(Node::new(
1379            "to A",
1380            NodeKind::Precomp {
1381                comp: comp_a,
1382                time_map: renamite_model::TimeMap {
1383                    offset: Frame(0),
1384                    stretch: 1.0,
1385                },
1386            },
1387        ));
1388        file.document
1389            .attach(node_b, Parent::Comp(comp_b), 0)
1390            .unwrap();
1391
1392        let report = validate(&file);
1393        assert!(report.has_errors());
1394        assert!(
1395            report
1396                .diagnostics
1397                .iter()
1398                .any(|d| d.message.contains("cycle"))
1399        );
1400    }
1401
1402    #[test]
1403    fn clip_track_missing_node_is_error() {
1404        use renamite_machine::Clip;
1405        let mut file = file("clip");
1406        let missing = NodeId::from(slotmap::KeyData::from_ffi(7));
1407        file.clips.insert(Clip {
1408            name: "c".into(),
1409            range: (Frame(0), Frame(10)),
1410            tracks: vec![renamite_machine::Track {
1411                node: missing,
1412                prop: renamite_model::PropPath::new("opacity"),
1413                keys: vec![],
1414            }],
1415            events: vec![],
1416        });
1417
1418        let report = validate(&file);
1419        assert!(report.has_errors());
1420        assert!(
1421            report
1422                .diagnostics
1423                .iter()
1424                .any(|d| d.message.contains("missing node"))
1425        );
1426    }
1427}