Skip to main content

motion_core/
validation.rs

1//! Backend-independent scene diagnostics and bounded structural traversal.
2
3use std::fmt;
4
5/// A machine-readable reason for scene preflight failure.
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7#[non_exhaustive]
8pub enum ValidationErrorKind {
9    /// A property is nonfinite, out of range, or otherwise invalid.
10    InvalidValue,
11    /// The scene exceeds its node or nesting-depth allowance.
12    StructuralLimit,
13    /// A required rendering capability is unavailable.
14    Unsupported,
15}
16
17/// A property-level diagnostic, independent of fonts, GPU initialization and I/O.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct ValidationError {
20    /// Category suitable for programmatic handling.
21    pub kind: ValidationErrorKind,
22    /// Structural path, such as `layers[2].mask.group.layers[0]`.
23    pub path: String,
24    /// The invalid property or exhausted allowance.
25    pub field: String,
26    /// The specific constraint that failed.
27    pub message: String,
28    /// The requested frame index, when supplied by composition preflight or rendering.
29    pub frame: Option<u32>,
30}
31
32impl ValidationError {
33    /// Creates a property diagnostic without binding it to an output frame.
34    pub fn new(
35        kind: ValidationErrorKind,
36        path: impl Into<String>,
37        field: impl Into<String>,
38        message: impl Into<String>,
39    ) -> Self {
40        Self {
41            kind,
42            path: path.into(),
43            field: field.into(),
44            message: message.into(),
45            frame: None,
46        }
47    }
48
49    /// Creates an invalid-value diagnostic for a rejected property.
50    pub fn invalid(
51        path: impl Into<String>,
52        field: impl Into<String>,
53        message: impl Into<String>,
54    ) -> Self {
55        Self::new(ValidationErrorKind::InvalidValue, path, field, message)
56    }
57
58    /// Creates a structural, work or storage allowance diagnostic.
59    pub fn limit(
60        path: impl Into<String>,
61        field: impl Into<String>,
62        message: impl Into<String>,
63    ) -> Self {
64        Self::new(ValidationErrorKind::StructuralLimit, path, field, message)
65    }
66
67    /// Creates a diagnostic for input that is valid but not supported.
68    pub fn unsupported(
69        path: impl Into<String>,
70        field: impl Into<String>,
71        message: impl Into<String>,
72    ) -> Self {
73        Self::new(ValidationErrorKind::Unsupported, path, field, message)
74    }
75}
76
77impl fmt::Display for ValidationError {
78    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
79        if let Some(frame) = self.frame {
80            write!(formatter, "frame {frame}: ")?;
81        }
82        write!(formatter, "{}.{}: {}", self.path, self.field, self.message)
83    }
84}
85
86impl std::error::Error for ValidationError {}
87
88/// Scene structure allowances, separate from pixel and kernel-work budgets.
89///
90/// Defaults allow 100,000 layers and 64 levels. All content branches and masks
91/// count, including invisible layers and inactive transition endpoints. The root
92/// layers have depth one. Custom limits must be positive, with at most 1,000,000
93/// nodes and 128 levels, so preflight itself always has bounded recursion.
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub struct ValidationLimits {
96    /// Maximum total layer count across all branches.
97    pub max_nodes: usize,
98    /// Maximum layer nesting depth, including masks.
99    pub max_depth: usize,
100}
101
102impl Default for ValidationLimits {
103    fn default() -> Self {
104        Self {
105            max_nodes: 100_000,
106            max_depth: 64,
107        }
108    }
109}
110
111/// Structural requirements measured without initializing a renderer.
112#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
113pub struct SceneStats {
114    /// Number of logical layers, including masks, all temporal samples and the
115    /// wrapper/primitive charges of retained particle batches.
116    pub nodes: usize,
117    /// Deepest layer, or zero for an empty scene.
118    pub max_depth: usize,
119}
120
121/// A bounded structural traversal shared by compilation and preparation.
122pub struct ValidationState {
123    limits: ValidationLimits,
124    /// Cumulative node/depth statistics for the currently validated structure.
125    pub stats: SceneStats,
126}
127
128impl ValidationState {
129    /// Validates allowances and starts an empty traversal.
130    pub fn new(limits: ValidationLimits) -> Result<Self, ValidationError> {
131        if limits.max_nodes == 0
132            || limits.max_nodes > 1_000_000
133            || limits.max_depth == 0
134            || limits.max_depth > 128
135        {
136            return Err(ValidationError::new(
137                ValidationErrorKind::InvalidValue,
138                "scene",
139                "limits",
140                "Require 1..=1000000 nodes and 1..=128 levels",
141            ));
142        }
143        Ok(Self {
144            limits,
145            stats: SceneStats::default(),
146        })
147    }
148
149    /// Charges one structural node before descending into its children.
150    pub fn enter(&mut self, path: &str, depth: usize) -> Result<(), ValidationError> {
151        self.enter_with(|| path.to_owned(), depth)
152    }
153
154    /// Like [`Self::enter`], but formats the path only when a limit is exceeded.
155    pub fn enter_with(
156        &mut self,
157        path: impl FnOnce() -> String,
158        depth: usize,
159    ) -> Result<(), ValidationError> {
160        if depth > self.limits.max_depth || self.stats.nodes >= self.limits.max_nodes {
161            let (field, allowed) = if depth > self.limits.max_depth {
162                ("max_depth", self.limits.max_depth)
163            } else {
164                ("max_nodes", self.limits.max_nodes)
165            };
166            return Err(ValidationError::limit(
167                path(),
168                field,
169                format!("Scene exceeds {field}={allowed}"),
170            ));
171        }
172        self.stats.nodes += 1;
173        self.stats.max_depth = self.stats.max_depth.max(depth);
174        Ok(())
175    }
176}