Skip to main content

konjure_sdk/
validation.rs

1use crate::geometry::{finite_positive, tessellate};
2use crate::{
3    Action, MAX_ACTIONS_PER_ENTITY, MAX_ANIMATIONS_PER_ENTITY, MAX_CONVERSATION_MESSAGES,
4    MAX_ID_BYTES, MAX_TEXT_BYTES,
5};
6use crate::{
7    Animation, Diagnostic, FORMAT_VERSION, Geometry, MAX_COORDINATE_M, MAX_ENTITIES, MAX_NESTING,
8    MAX_SPIN_DEGREES_PER_SECOND, SceneDocument, Transform,
9};
10use alloc::collections::BTreeMap;
11/// Validates a deserialized or programmatically constructed scene document.
12///
13/// This function borrows the document and performs no I/O or mutation. It
14/// checks format/version, bounded owned data, identifiers, graph topology,
15/// numeric transforms, material values, animations, conversations, and every
16/// tessellatable primitive.
17///
18/// # Errors
19///
20/// Returns a [`Diagnostic`] for any unsupported, malformed, oversized,
21/// non-finite, cyclic, or non-renderable scene value.
22pub fn validate(scene: &SceneDocument) -> Result<(), Diagnostic> {
23    if scene.format != "konjure.scene" {
24        return Err(Diagnostic::plain("unsupported scene format"));
25    }
26    if scene.version != FORMAT_VERSION {
27        return Err(Diagnostic::plain("unsupported scene version"));
28    }
29    if scene.id.0.is_empty() || scene.title.is_empty() {
30        return Err(Diagnostic::plain("scene id and title must not be empty"));
31    }
32    valid_id(&scene.id.0)?;
33    if scene.title.len() > 512 || scene.conversation.len() > MAX_CONVERSATION_MESSAGES {
34        return Err(Diagnostic::plain(
35            "scene title or conversation count exceeds its limit",
36        ));
37    }
38    if scene.entities.len() > MAX_ENTITIES {
39        return Err(Diagnostic::plain("entity limit exceeded"));
40    }
41    let mut ids = BTreeMap::new();
42    for e in &scene.entities {
43        valid_id(&e.id.0)?;
44        if e.actions.len() > MAX_ACTIONS_PER_ENTITY
45            || e.animations.len() > MAX_ANIMATIONS_PER_ENTITY
46        {
47            return Err(Diagnostic::plain(
48                "entity action or animation count exceeds its limit",
49            ));
50        }
51        for (name, action) in &e.actions {
52            valid_id(&name.0)?;
53            if let Action::ProposeEffect { effect } = action {
54                valid_id(effect)?;
55            }
56        }
57        if e.id.0.is_empty() || ids.insert(e.id.0.as_str(), ()).is_some() {
58            return Err(Diagnostic::plain("entity ids must be unique and non-empty"));
59        }
60        valid_transform(e.transform)?;
61        if !e.material.roughness.is_finite()
62            || !(0.0..=1.0).contains(&e.material.roughness)
63            || !e.material.emissive.is_finite()
64            || !(0.0..=100.0).contains(&e.material.emissive)
65        {
66            return Err(Diagnostic::plain(
67                "material roughness must be 0..=1 and emissive must be 0..=100",
68            ));
69        }
70        if !matches!(e.geometry, Geometry::Group | Geometry::Mesh { .. }) {
71            tessellate(&e.geometry)?;
72        }
73        for a in &e.animations {
74            valid_animation(a)?;
75        }
76    }
77    for e in &scene.entities {
78        if let Some(parent) = &e.parent {
79            if parent == &e.id || !ids.contains_key(parent.0.as_str()) {
80                return Err(Diagnostic::plain(
81                    "entity parent must exist and cannot be itself",
82                ));
83            }
84            let mut cursor = parent;
85            let mut hops = 1;
86            while let Some(next) = scene
87                .entities
88                .iter()
89                .find(|x| &x.id == cursor)
90                .and_then(|x| x.parent.as_ref())
91            {
92                hops += 1;
93                if hops > scene.entities.len() {
94                    return Err(Diagnostic::plain("entity parents must not form a cycle"));
95                }
96                if hops >= MAX_NESTING {
97                    return Err(Diagnostic::plain(
98                        "entity parent depth exceeds nesting limit",
99                    ));
100                }
101                cursor = next;
102            }
103        }
104    }
105    for m in &scene.conversation {
106        if m.text.is_empty()
107            || m.text.len() > MAX_TEXT_BYTES
108            || m.scene_references.len() > MAX_ENTITIES
109        {
110            return Err(Diagnostic::plain(
111                "conversation text or references exceed their bounds",
112            ));
113        }
114        for id in &m.scene_references {
115            if !ids.contains_key(id.0.as_str()) {
116                return Err(Diagnostic::plain(
117                    "conversation reference must identify an entity",
118                ));
119            }
120        }
121    }
122    Ok(())
123}
124fn valid_id(id: &str) -> Result<(), Diagnostic> {
125    let valid_start = id
126        .as_bytes()
127        .first()
128        .is_some_and(|byte| byte.is_ascii_alphabetic() || *byte == b'_');
129    if !valid_start
130        || id.len() > MAX_ID_BYTES
131        || !id
132            .bytes()
133            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
134    {
135        return Err(Diagnostic::plain(
136            "identifier must start with a letter or underscore and contain at most 128 ASCII letters, digits, underscores or hyphens",
137        ));
138    }
139    Ok(())
140}
141fn valid_transform(t: Transform) -> Result<(), Diagnostic> {
142    for n in [
143        t.translation.x,
144        t.translation.y,
145        t.translation.z,
146        t.rotation.x,
147        t.rotation.y,
148        t.rotation.z,
149        t.rotation.w,
150        t.scale.x,
151        t.scale.y,
152        t.scale.z,
153    ] {
154        if !n.is_finite() {
155            return Err(Diagnostic::plain("transform values must be finite"));
156        }
157    }
158    if t.translation.x.abs() > MAX_COORDINATE_M
159        || t.translation.y.abs() > MAX_COORDINATE_M
160        || t.translation.z.abs() > MAX_COORDINATE_M
161        || t.scale.x > MAX_COORDINATE_M
162        || t.scale.y > MAX_COORDINATE_M
163        || t.scale.z > MAX_COORDINATE_M
164    {
165        return Err(Diagnostic::plain("transform exceeds coordinate bounds"));
166    }
167    let norm = t.rotation.x * t.rotation.x
168        + t.rotation.y * t.rotation.y
169        + t.rotation.z * t.rotation.z
170        + t.rotation.w * t.rotation.w;
171    if (norm - 1.0).abs() > 1e-9 {
172        return Err(Diagnostic::plain("rotation quaternion must be unit length"));
173    }
174    if t.scale.x < 1e-6 || t.scale.y < 1e-6 || t.scale.z < 1e-6 {
175        return Err(Diagnostic::plain(
176            "transform scale must be at least 0.000001",
177        ));
178    }
179    Ok(())
180}
181fn valid_animation(a: &Animation) -> Result<(), Diagnostic> {
182    match a {
183        Animation::Orbit { radius_m, period_s } => {
184            finite_positive(*radius_m, "orbit radius")?;
185            finite_positive(*period_s, "orbit period")?
186        }
187        Animation::Spin { degrees_per_second } => {
188            if !degrees_per_second.is_finite()
189                || degrees_per_second.abs() > MAX_SPIN_DEGREES_PER_SECOND
190            {
191                return Err(Diagnostic::plain("spin speed must be finite"));
192            }
193        }
194        Animation::Bob {
195            amplitude_m,
196            period_s,
197        } => {
198            finite_positive(*amplitude_m, "bob amplitude")?;
199            finite_positive(*period_s, "bob period")?
200        }
201    }
202    Ok(())
203}