konjure_sdk/
validation.rs1use 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;
11pub 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}