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