1use crate::model::{Geometry, Model, ResourceId};
2use crate::validation::report::ValidationReport;
3use std::collections::{HashMap, HashSet};
4
5pub fn validate_semantic(model: &Model, report: &mut ValidationReport) {
6 validate_build_references(model, report);
8
9 validate_boolean_cycles(model, report);
11
12 validate_material_constraints(model, report);
14
15 validate_metadata(model, report);
17
18 for object in model.resources.iter_objects() {
20 if let Some(pid) = object.pid {
22 if !model.resources.exists(pid) {
24 report.add_error(
25 2001,
26 format!(
27 "Object {} references non-existent property group {}",
28 object.id.0, pid.0
29 ),
30 );
31 }
32 }
33
34 match &object.geometry {
35 Geometry::Mesh(mesh) => {
36 for (i, tri) in mesh.triangles.iter().enumerate() {
37 if tri.v1 as usize >= mesh.vertices.len()
39 || tri.v2 as usize >= mesh.vertices.len()
40 || tri.v3 as usize >= mesh.vertices.len()
41 {
42 report.add_error(
43 3001,
44 format!(
45 "Triangle {} in Object {} references out-of-bounds vertex",
46 i, object.id.0
47 ),
48 );
49 }
50
51 if let Some(pid) = tri.pid.map(crate::model::ResourceId)
53 && !model.resources.exists(pid)
54 {
55 report.add_error(2002, format!("Triangle {} in Object {} references non-existent property group {}", i, object.id.0, pid.0));
56 }
57 }
58 }
59 Geometry::Components(comps) => {
60 for comp in &comps.components {
61 if comp.path.is_none() && model.resources.get_object(comp.object_id).is_none() {
63 report.add_error(
64 2003,
65 format!(
66 "Component in Object {} references non-existent object {}",
67 object.id.0, comp.object_id.0
68 ),
69 );
70 }
71 }
72 }
73 Geometry::SliceStack(stack_id) => {
74 if model.resources.get_slice_stack(*stack_id).is_none() {
75 report.add_error(
76 2004,
77 format!(
78 "Object {} references non-existent slicestack {}",
79 object.id.0, stack_id.0
80 ),
81 );
82 }
83 }
84 Geometry::VolumetricStack(stack_id) => {
85 if model.resources.get_volumetric_stack(*stack_id).is_none() {
86 report.add_error(
87 2005,
88 format!(
89 "Object {} references non-existent volumetricstack {}",
90 object.id.0, stack_id.0
91 ),
92 );
93 }
94 }
95 Geometry::BooleanShape(bs) => {
96 if let Some(base_obj) = model.resources.get_object(bs.base_object_id) {
98 match &base_obj.geometry {
100 Geometry::Mesh(_) | Geometry::BooleanShape(_) => {
101 }
103 Geometry::Components(_) => {
104 report.add_error(
105 2101,
106 format!(
107 "BooleanShape {} base object {} cannot be Components type",
108 object.id.0, bs.base_object_id.0
109 ),
110 );
111 }
112 _ => {
113 }
115 }
116 } else {
117 report.add_error(
118 2102,
119 format!(
120 "BooleanShape {} references non-existent base object {}",
121 object.id.0, bs.base_object_id.0
122 ),
123 );
124 }
125
126 for (idx, op) in bs.operations.iter().enumerate() {
128 if let Some(op_obj) = model.resources.get_object(op.object_id) {
129 match &op_obj.geometry {
131 Geometry::Mesh(_) => {
132 }
134 _ => {
135 report.add_error(
136 2103,
137 format!(
138 "BooleanShape {} operation {} references non-mesh object {} (type must be mesh)",
139 object.id.0, idx, op.object_id.0
140 ),
141 );
142 }
143 }
144 } else {
145 report.add_error(
146 2104,
147 format!(
148 "BooleanShape {} operation {} references non-existent object {}",
149 object.id.0, idx, op.object_id.0
150 ),
151 );
152 }
153 }
154
155 if !is_transform_valid(&bs.base_transform) {
157 report.add_error(
158 2106,
159 format!(
160 "BooleanShape {} has invalid base transformation matrix (contains NaN or Infinity)",
161 object.id.0
162 ),
163 );
164 }
165
166 for (idx, op) in bs.operations.iter().enumerate() {
168 if !is_transform_valid(&op.transform) {
169 report.add_error(
170 2105,
171 format!(
172 "BooleanShape {} operation {} has invalid transformation matrix (contains NaN or Infinity)",
173 object.id.0, idx
174 ),
175 );
176 }
177 }
178 }
179 Geometry::DisplacementMesh(_mesh) => {
180 }
183 }
184 }
185}
186
187fn validate_build_references(model: &Model, report: &mut ValidationReport) {
188 for (idx, item) in model.build.items.iter().enumerate() {
189 if let Some(obj) = model.resources.get_object(item.object_id) {
191 if !obj.object_type.can_be_in_build() {
193 report.add_error(
194 3010,
195 format!(
196 "Build item {} references object {} with type '{}' which cannot be in build",
197 idx, item.object_id.0, obj.object_type
198 ),
199 );
200 }
201 } else {
202 report.add_error(
204 3002,
205 format!(
206 "Build item {} references non-existent object {}",
207 idx, item.object_id.0
208 ),
209 );
210 }
211 }
212}
213
214fn validate_boolean_cycles(model: &Model, report: &mut ValidationReport) {
216 let mut graph: HashMap<ResourceId, Vec<ResourceId>> = HashMap::new();
218
219 for obj in model.resources.iter_objects() {
220 if let Geometry::BooleanShape(bs) = &obj.geometry {
221 let mut refs = vec![bs.base_object_id];
222 refs.extend(bs.operations.iter().map(|op| op.object_id));
223 graph.insert(obj.id, refs);
224 }
225 }
226
227 let mut visited = HashSet::new();
229 let mut rec_stack = HashSet::new();
230
231 for &start_id in graph.keys() {
232 if !visited.contains(&start_id)
233 && has_cycle_dfs(start_id, &graph, &mut visited, &mut rec_stack)
234 {
235 report.add_error(
236 2100,
237 format!(
238 "Cycle detected in boolean operation graph involving object {}",
239 start_id.0
240 ),
241 );
242 }
243 }
244}
245
246fn has_cycle_dfs(
247 node: ResourceId,
248 graph: &HashMap<ResourceId, Vec<ResourceId>>,
249 visited: &mut HashSet<ResourceId>,
250 rec_stack: &mut HashSet<ResourceId>,
251) -> bool {
252 visited.insert(node);
253 rec_stack.insert(node);
254
255 if let Some(neighbors) = graph.get(&node) {
256 for &neighbor in neighbors {
257 if graph.contains_key(&neighbor) {
259 if !visited.contains(&neighbor) {
260 if has_cycle_dfs(neighbor, graph, visited, rec_stack) {
261 return true;
262 }
263 } else if rec_stack.contains(&neighbor) {
264 return true;
266 }
267 }
268 }
269 }
270
271 rec_stack.remove(&node);
272 false
273}
274
275fn is_transform_valid(mat: &glam::Mat4) -> bool {
277 mat.x_axis.is_finite()
278 && mat.y_axis.is_finite()
279 && mat.z_axis.is_finite()
280 && mat.w_axis.is_finite()
281}
282
283fn validate_material_constraints(model: &Model, report: &mut ValidationReport) {
285 for object in model.resources.iter_objects() {
287 if object.pindex.is_some() && object.pid.is_none() {
288 report.add_error(
289 2010,
290 format!(
291 "Object {} has pindex but no pid (pindex requires pid to be specified)",
292 object.id.0
293 ),
294 );
295 }
296 }
297
298 for composite in model.resources.iter_composite_materials() {
300 if let Some(resource) = model
302 .resources
303 .get_base_materials(composite.base_material_id)
304 {
305 let _ = resource; } else {
308 if model.resources.exists(composite.base_material_id) {
310 report.add_error(
311 2030,
312 format!(
313 "CompositeMaterials {} matid {} must reference basematerials, not another resource type",
314 composite.id.0, composite.base_material_id.0
315 ),
316 );
317 } else {
318 report.add_error(
320 2030,
321 format!(
322 "CompositeMaterials {} matid {} references non-existent basematerials",
323 composite.id.0, composite.base_material_id.0
324 ),
325 );
326 }
327 }
328 }
329
330 for multi_prop in model.resources.iter_multi_properties() {
332 let mut basematerials_count = 0;
334 let mut colorgroup_count = 0;
335 let mut texture2dgroup_count = 0;
336 let mut composite_count = 0;
337 let mut multiproperties_refs = Vec::new();
338
339 for &pid in &multi_prop.pids {
340 if model.resources.get_base_materials(pid).is_some() {
342 basematerials_count += 1;
343 } else if model.resources.get_color_group(pid).is_some() {
344 colorgroup_count += 1;
345 } else if model.resources.get_texture_2d_group(pid).is_some() {
346 texture2dgroup_count += 1;
347 } else if model.resources.get_composite_materials(pid).is_some() {
348 composite_count += 1;
349 } else if model.resources.get_multi_properties(pid).is_some() {
350 multiproperties_refs.push(pid);
351 }
352 }
354
355 if basematerials_count > 1 {
357 report.add_error(
358 2020,
359 format!(
360 "MultiProperties {} references basematerials {} times (maximum 1 allowed)",
361 multi_prop.id.0, basematerials_count
362 ),
363 );
364 }
365
366 if colorgroup_count > 1 {
367 report.add_error(
368 2021,
369 format!(
370 "MultiProperties {} references colorgroup {} times (maximum 1 allowed)",
371 multi_prop.id.0, colorgroup_count
372 ),
373 );
374 }
375
376 if texture2dgroup_count > 1 {
377 report.add_error(
378 2022,
379 format!(
380 "MultiProperties {} references texture2dgroup {} times (maximum 1 allowed)",
381 multi_prop.id.0, texture2dgroup_count
382 ),
383 );
384 }
385
386 if composite_count > 1 {
387 report.add_error(
388 2023,
389 format!(
390 "MultiProperties {} references compositematerials {} times (maximum 1 allowed)",
391 multi_prop.id.0, composite_count
392 ),
393 );
394 }
395
396 if basematerials_count > 0 && composite_count > 0 {
399 report.add_error(
400 2025,
401 format!(
402 "MultiProperties {} references both basematerials and compositematerials (only one material type allowed)",
403 multi_prop.id.0
404 ),
405 );
406 }
407
408 for &ref_id in &multiproperties_refs {
410 report.add_error(
411 2024,
412 format!(
413 "MultiProperties {} references another multiproperties {} (not allowed)",
414 multi_prop.id.0, ref_id.0
415 ),
416 );
417 }
418 }
419}
420
421fn validate_metadata(model: &Model, report: &mut ValidationReport) {
423 let mut seen_names = HashSet::new();
424
425 for name in model.metadata.keys() {
426 if name.is_empty() {
428 report.add_error(
429 2040,
430 "Metadata entry has empty name (name attribute is required)".to_string(),
431 );
432 }
433
434 if !seen_names.insert(name.clone()) {
436 report.add_error(
437 2041,
438 format!(
439 "Metadata name '{}' is duplicated (names must be unique)",
440 name
441 ),
442 );
443 }
444 }
445}