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