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        let direct: HashSet<NodeId> = doc
1008            .compositions
1009            .values()
1010            .flat_map(|c| c.children.iter().copied())
1011            .collect();
1012        let mut image_exportable = direct.clone();
1013        for id in &direct {
1014            if let Some(node) = doc.nodes.get(*id)
1015                && matches!(node.kind, NodeKind::Group | NodeKind::Layer(_))
1016            {
1017                image_exportable.extend(node.children.iter().copied());
1018            }
1019        }
1020
1021        for (id, node) in &doc.nodes {
1022            match &node.kind {
1023                NodeKind::Text(text) => {
1024                    self.warn(
1025                        format!("node/{id:?}/text"),
1026                        "Lottie export bakes text to vector outlines",
1027                    );
1028                    if !text.size.keyframes.is_empty() {
1029                        self.warn(
1030                            format!("node/{id:?}/text"),
1031                            "animated `text.size` bakes to its base value on Lottie export",
1032                        );
1033                    }
1034                    if !text.tracking.keyframes.is_empty()
1035                        || !text.leading.keyframes.is_empty()
1036                    {
1037                        self.warn(
1038                            format!("node/{id:?}/text"),
1039                            "animated `text.tracking`/`text.leading` bake to base on Lottie export",
1040                        );
1041                    }
1042                }
1043                NodeKind::Mask(_) => {
1044                    self.warn(
1045                        format!("node/{id:?}/mask"),
1046                        "Lottie mask export is best-effort and may differ from Renamite clip-stack semantics",
1047                    );
1048                }
1049                NodeKind::Image(img) => {
1050                    if doc.image_asset(img.asset()).is_none() {
1051                        self.err(
1052                            format!("node/{id:?}/image"),
1053                            "image layer references missing image asset",
1054                        );
1055                    }
1056                    if !image_exportable.contains(&id) {
1057                        self.warn(
1058                            format!("node/{id:?}/image"),
1059                            "deeply nested image layer is skipped by Lottie export (hoist to a top-level Layer/Group child)",
1060                        );
1061                    }
1062                    if img.tint().base != Color::WHITE || !img.tint().keyframes.is_empty() {
1063                        self.warn(
1064                            format!("node/{id:?}/image"),
1065                            "image tint is dropped by Lottie/SVG export",
1066                        );
1067                    }
1068                    let crop = img.crop();
1069                    if (crop.x.abs() > 1e-9
1070                        || crop.y.abs() > 1e-9
1071                        || (crop.z - 1.0).abs() > 1e-6
1072                        || (crop.w - 1.0).abs() > 1e-6)
1073                    {
1074                        self.warn(
1075                            format!("node/{id:?}/image"),
1076                            "image crop is approximated by GPU/SVG/Lottie sinks (full texture fitted into cropped rect)",
1077                        );
1078                    }
1079                }
1080                NodeKind::Precomp { .. } if !direct.contains(&id) => {
1081                    self.warn(
1082                        format!("node/{id:?}/precomp"),
1083                        "nested precomp is skipped by Lottie export (hoist to a top-level child)",
1084                    );
1085                }
1086                _ => {}
1087            }
1088        }
1089    }
1090}
1091
1092fn condition_input(condition: &Condition) -> usize {
1093    match condition {
1094        Condition::BoolIs { input, .. }
1095        | Condition::NumberCmp { input, .. }
1096        | Condition::Triggered { input } => *input,
1097    }
1098}
1099
1100fn condition_matches_input(condition: &Condition, input: InputKind) -> bool {
1101    matches!(
1102        (condition, input),
1103        (Condition::BoolIs { .. }, InputKind::Bool { .. })
1104            | (Condition::NumberCmp { .. }, InputKind::Number { .. })
1105            | (Condition::Triggered { .. }, InputKind::Trigger)
1106    )
1107}
1108
1109fn listener_action_input(action: &ListenerAction) -> usize {
1110    match action {
1111        ListenerAction::SetBool { input, .. }
1112        | ListenerAction::ToggleBool { input }
1113        | ListenerAction::SetNumber { input, .. }
1114        | ListenerAction::FireTrigger { input } => *input,
1115    }
1116}
1117
1118fn listener_matches_input(action: &ListenerAction, input: InputKind) -> bool {
1119    matches!(
1120        (action, input),
1121        (ListenerAction::SetBool { .. }, InputKind::Bool { .. })
1122            | (ListenerAction::ToggleBool { .. }, InputKind::Bool { .. })
1123            | (ListenerAction::SetNumber { .. }, InputKind::Number { .. })
1124            | (ListenerAction::FireTrigger { .. }, InputKind::Trigger)
1125    )
1126}
1127
1128fn key_value_matches_prop(value: &Value, prop: &PropRef) -> bool {
1129    matches!(
1130        (value, prop),
1131        (Value::F64(_), PropRef::F64(_))
1132            | (Value::DVec2(_), PropRef::Vec2(_))
1133            | (Value::Angle(_), PropRef::Angle(_))
1134            | (Value::Color(_), PropRef::Color(_))
1135            | (Value::Path(_), PropRef::Path(_))
1136            | (Value::Stops(_), PropRef::Stops(_))
1137    )
1138}
1139
1140fn finite_f64(value: &f64) -> bool {
1141    value.is_finite()
1142}
1143
1144fn finite_vec2(value: &DVec2) -> bool {
1145    value.is_finite()
1146}
1147
1148fn finite_angle(value: &Angle) -> bool {
1149    value.0.is_finite()
1150}
1151
1152fn finite_color(color: &Color) -> bool {
1153    color.r.is_finite() && color.g.is_finite() && color.b.is_finite() && color.a.is_finite()
1154}
1155
1156fn finite_stops(stops: &GradientStops) -> bool {
1157    stops
1158        .0
1159        .iter()
1160        .all(|s| s.offset.is_finite() && finite_color(&s.color))
1161}
1162
1163fn finite_path(path: &VectorPath) -> bool {
1164    path.anchors
1165        .iter()
1166        .all(|a| a.pos.is_finite() && a.tan_in.is_finite() && a.tan_out.is_finite())
1167}
1168
1169/// Base-value heuristic for "this shape has no geometry": empty path, zero-size
1170/// rect/ellipse, or non-positive star/polygon radius.
1171fn shape_kind_is_empty(shape: &ShapeKind) -> bool {
1172    match shape {
1173        ShapeKind::Path(path) => path.base.anchors.is_empty(),
1174        ShapeKind::CompoundPath(compound) => compound.contours.is_empty(),
1175        ShapeKind::Rect { size, .. } | ShapeKind::Ellipse { size, .. } => {
1176            size.base.x == 0.0 || size.base.y == 0.0
1177        }
1178        ShapeKind::Star { outer_r, .. } | ShapeKind::Polygon { outer_r, .. } => outer_r.base <= 0.0,
1179    }
1180}