Skip to main content

lib3mf_core/validation/
semantic.rs

1use crate::model::{Geometry, Model, ResourceId};
2use crate::validation::report::ValidationReport;
3use std::collections::{HashMap, HashSet};
4
5/// Validates semantic correctness of the model (build references, material constraints, metadata).
6pub fn validate_semantic(model: &Model, report: &mut ValidationReport) {
7    // Validate build items
8    validate_build_references(model, report);
9
10    // Validate boolean operation cycles
11    validate_boolean_cycles(model, report);
12
13    // Validate material references and constraints
14    validate_material_constraints(model, report);
15
16    // Validate metadata
17    validate_metadata(model, report);
18
19    // Check Resources
20    for object in model.resources.iter_objects() {
21        // Check PID validity
22        if let Some(pid) = object.pid {
23            // Must exist in base_materials or color_groups or texture_groups
24            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                    // Check indices bounds
39                    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                    // Check PID
53                    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                    // Only validate internal references (components without external path)
63                    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                // Validate base object exists and is valid type
98                if let Some(base_obj) = model.resources.get_object(bs.base_object_id) {
99                    // Base can be Mesh or another BooleanShape (per spec)
100                    match &base_obj.geometry {
101                        Geometry::Mesh(_) | Geometry::BooleanShape(_) => {
102                            // Valid base types
103                        }
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                            // Other extensions (SliceStack, VolumetricStack) - allow per spec extensibility
115                        }
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                // Validate each operation object
128                for (idx, op) in bs.operations.iter().enumerate() {
129                    if let Some(op_obj) = model.resources.get_object(op.object_id) {
130                        // Operation objects MUST be triangle meshes (not Components, not BooleanShape)
131                        match &op_obj.geometry {
132                            Geometry::Mesh(_) => {
133                                // Valid - mesh object
134                            }
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                // Validate base transformation matrix
157                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                // Validate operation transformation matrices
168                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                // Displacement mesh validation will be implemented in displacement.rs
182                // For now, just allow it to pass semantic checks
183            }
184        }
185    }
186}
187
188fn validate_build_references(model: &Model, report: &mut ValidationReport) {
189    for (idx, item) in model.build.items.iter().enumerate() {
190        // Check if referenced object exists
191        if let Some(obj) = model.resources.get_object(item.object_id) {
192            // Check type constraint: Other cannot be in build
193            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            // Existing check: object must exist
204            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
215/// Detects cycles in boolean operation graphs using DFS with recursion stack.
216fn validate_boolean_cycles(model: &Model, report: &mut ValidationReport) {
217    // Build adjacency list: BooleanShape -> referenced objects
218    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    // DFS for cycle detection
229    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            // Only follow edges to other BooleanShape objects (those in the graph)
259            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                    // Back edge found = cycle
266                    return true;
267                }
268            }
269        }
270    }
271
272    rec_stack.remove(&node);
273    false
274}
275
276/// Validates that a transformation matrix contains only finite values.
277fn 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
284/// Validates material reference constraints and property rules.
285fn validate_material_constraints(model: &Model, report: &mut ValidationReport) {
286    // Validate pindex requires pid rule
287    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    // Validate composite materials matid references basematerials
300    for composite in model.resources.iter_composite_materials() {
301        // Check that matid references a basematerials group
302        if let Some(resource) = model
303            .resources
304            .get_base_materials(composite.base_material_id)
305        {
306            // Valid - references basematerials
307            let _ = resource; // Use to avoid unused warning
308        } else {
309            // Check if it references something else (invalid)
310            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                // Already caught by existing PID validation (2001), but add specific error
320                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    // Validate multiproperties reference rules
332    for multi_prop in model.resources.iter_multi_properties() {
333        // Track counts of each resource type referenced
334        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            // Determine what type of resource this pid references
342            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            // Note: pid might reference other types or be non-existent (caught by other validation)
354        }
355
356        // Validate at most one reference to each material type
357        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        // Validate cannot have both basematerials and compositematerials
398        // (compositematerials is a material type, not compatible with basematerials)
399        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        // Validate no references to other multiproperties
410        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
422/// Validates metadata constraints.
423fn validate_metadata(model: &Model, report: &mut ValidationReport) {
424    let mut seen_names = HashSet::new();
425
426    for name in model.metadata.keys() {
427        // Check for empty names
428        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        // Check for duplicate names
436        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}