lib3mf_core/validation/
semantic.rs

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