Skip to main content

axiolid_model/
graph.rs

1//! Immutable geometry DAG and append-only builder.
2
3use core::fmt;
4
5use crate::{id::GraphId, BuiltInNode, GeometryNode, NodeId};
6
7/// Invalid graph construction.
8#[non_exhaustive]
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum GraphError {
11    /// A node handle belongs to a different graph builder.
12    ForeignReference { reference: NodeId },
13    /// A node referenced itself or a later/not-yet-inserted node.
14    NonPriorReference { node: NodeId, reference: NodeId },
15    /// A reference resolves locally but points to the wrong node family.
16    InvalidReferenceType {
17        /// Existing node whose family is invalid for this edge.
18        reference: NodeId,
19        /// Human-readable family accepted by the edge.
20        expected: &'static str,
21        /// Human-readable family of the referenced node.
22        actual: &'static str,
23    },
24    /// A requested root does not exist.
25    UnknownRoot { root: NodeId, node_count: usize },
26    /// A master representation names a side the relation does not have.
27    ContradictoryMaster {
28        /// What the contradiction is, in the caller's terms.
29        detail: &'static str,
30    },
31    /// A station or a list of stations is malformed (#241): a non-finite
32    /// or negative distance, a non-finite offset, too few stations,
33    /// distances that do not increase, or inconsistent tags.
34    InvalidStation {
35        /// What is wrong, in the caller's terms.
36        detail: &'static str,
37    },
38}
39
40impl fmt::Display for GraphError {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        match self {
43            Self::ForeignReference { reference } => {
44                write!(f, "{reference} belongs to another geometry graph")
45            }
46            Self::NonPriorReference { node, reference } => {
47                write!(f, "{node} references non-prior {reference}")
48            }
49            Self::InvalidReferenceType {
50                reference,
51                expected,
52                actual,
53            } => write!(f, "{reference} has node type {actual}; expected {expected}"),
54            Self::ContradictoryMaster { detail } => {
55                write!(f, "contradictory surface-curve master: {detail}")
56            }
57            Self::InvalidStation { detail } => write!(f, "invalid station: {detail}"),
58            Self::UnknownRoot { root, node_count } => {
59                write!(f, "root {root} exceeds graph size {node_count}")
60            }
61        }
62    }
63}
64
65impl std::error::Error for GraphError {}
66
67/// Immutable acyclic geometry graph with one or more roots.
68#[derive(Debug, Clone, PartialEq)]
69pub struct GeometryGraph {
70    owner: GraphId,
71    nodes: Vec<GeometryNode>,
72    roots: Vec<NodeId>,
73}
74
75impl Default for GeometryGraph {
76    fn default() -> Self {
77        Self {
78            owner: GraphId::fresh(),
79            nodes: Vec::new(),
80            roots: Vec::new(),
81        }
82    }
83}
84
85impl GeometryGraph {
86    /// Number of nodes.
87    pub fn len(&self) -> usize {
88        self.nodes.len()
89    }
90
91    /// Whether the graph has no nodes.
92    pub fn is_empty(&self) -> bool {
93        self.nodes.is_empty()
94    }
95
96    /// Read a node by typed handle. A handle owned by another graph returns `None`.
97    pub fn get(&self, id: NodeId) -> Option<&GeometryNode> {
98        if !id.belongs_to(self.owner) {
99            return None;
100        }
101        self.nodes.get(id.index())
102    }
103
104    /// Output roots in caller-specified order.
105    pub fn roots(&self) -> &[NodeId] {
106        &self.roots
107    }
108
109    /// All nodes in stable topological insertion order.
110    pub fn iter(&self) -> impl ExactSizeIterator<Item = (NodeId, &GeometryNode)> {
111        self.nodes
112            .iter()
113            .enumerate()
114            .map(|(index, node)| (NodeId::from_index(self.owner, index), node))
115    }
116}
117
118/// Append-only builder that makes cycles and dangling references unrepresentable.
119#[derive(Debug)]
120pub struct GeometryGraphBuilder {
121    owner: Option<GraphId>,
122    nodes: Vec<GeometryNode>,
123}
124
125impl Default for GeometryGraphBuilder {
126    fn default() -> Self {
127        Self::new()
128    }
129}
130
131impl GeometryGraphBuilder {
132    /// Create an empty builder.
133    pub const fn new() -> Self {
134        Self {
135            owner: None,
136            nodes: Vec::new(),
137        }
138    }
139
140    /// Insert a node. Every reference must be to an earlier node.
141    pub fn push(&mut self, node: GeometryNode) -> Result<NodeId, GraphError> {
142        let owner = *self.owner.get_or_insert_with(GraphId::fresh);
143        let id = NodeId::from_index(owner, self.nodes.len());
144        let references = node.references();
145        if let Some(&reference) = references
146            .iter()
147            .find(|reference| !reference.belongs_to(owner))
148        {
149            return Err(GraphError::ForeignReference { reference });
150        }
151        if let Some(&reference) = references
152            .iter()
153            .find(|reference| reference.index() >= id.index())
154        {
155            return Err(GraphError::NonPriorReference {
156                node: id,
157                reference,
158            });
159        }
160        crate::validation::validate_reference_types(&node, &self.nodes)?;
161        self.nodes.push(node);
162        Ok(id)
163    }
164
165    /// Insert one canonical representation without spelling its enum variant.
166    ///
167    /// The accepted set is deliberately sealed; adapters must translate custom
168    /// values into a built-in neutral representation before graph construction.
169    pub fn push_value<T>(&mut self, value: T) -> Result<NodeId, GraphError>
170    where
171        T: BuiltInNode,
172    {
173        self.push(value.into())
174    }
175
176    /// Freeze the graph after validating roots.
177    pub fn finish(self, roots: Vec<NodeId>) -> Result<GeometryGraph, GraphError> {
178        let owner = self.owner.unwrap_or_else(GraphId::fresh);
179        if let Some(&reference) = roots.iter().find(|root| !root.belongs_to(owner)) {
180            return Err(GraphError::ForeignReference { reference });
181        }
182        if let Some(&root) = roots.iter().find(|root| root.index() >= self.nodes.len()) {
183            return Err(GraphError::UnknownRoot {
184                root,
185                node_count: self.nodes.len(),
186            });
187        }
188        Ok(GeometryGraph {
189            owner,
190            nodes: self.nodes,
191            roots,
192        })
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use axiolid_core::Vec3;
199
200    use super::*;
201    use crate::Instance;
202
203    const EMPTY_BUILDER: GeometryGraphBuilder = GeometryGraphBuilder::new();
204
205    #[test]
206    fn const_constructor_remains_source_compatible() {
207        assert!(EMPTY_BUILDER.finish(Vec::new()).unwrap().is_empty());
208    }
209
210    #[test]
211    fn insertion_order_is_topological_order() {
212        let mut builder = GeometryGraphBuilder::new();
213        let source = builder.push(GeometryNode::Point3(Vec3::ZERO)).unwrap();
214        let instance = builder
215            .push(GeometryNode::Instance(Instance {
216                source,
217                transform: axiolid_core::Transform3::IDENTITY,
218            }))
219            .unwrap();
220        let graph = builder.finish(vec![instance]).unwrap();
221        assert_eq!(graph.len(), 2);
222        assert_eq!(graph.roots(), &[instance]);
223    }
224
225    #[test]
226    fn sealed_built_in_values_have_an_ergonomic_builder_path() {
227        let mut builder = GeometryGraphBuilder::new();
228        let sphere = builder
229            .push_value(axiolid_primitive::Primitive::Sphere { radius: 1.0 })
230            .unwrap();
231        let graph = builder.finish(vec![sphere]).unwrap();
232        assert!(matches!(
233            graph.get(sphere),
234            Some(GeometryNode::Primitive(
235                axiolid_primitive::Primitive::Sphere { radius: 1.0 }
236            ))
237        ));
238    }
239
240    #[test]
241    fn handles_from_another_builder_cannot_alias_local_nodes() {
242        let mut foreign_builder = GeometryGraphBuilder::new();
243        let foreign = foreign_builder
244            .push(GeometryNode::Point3(Vec3::ZERO))
245            .unwrap();
246
247        let mut builder = GeometryGraphBuilder::new();
248        let local = builder.push(GeometryNode::Point3(Vec3::ZERO)).unwrap();
249        let error = builder
250            .push(GeometryNode::Instance(Instance {
251                source: foreign,
252                transform: axiolid_core::Transform3::IDENTITY,
253            }))
254            .unwrap_err();
255        assert!(matches!(
256            error,
257            GraphError::ForeignReference { reference } if reference == foreign
258        ));
259        let error = builder.finish(vec![foreign]).unwrap_err();
260        assert!(matches!(
261            error,
262            GraphError::ForeignReference { reference } if reference == foreign
263        ));
264
265        let graph = foreign_builder.finish(vec![foreign]).unwrap();
266        assert!(graph.get(local).is_none());
267    }
268
269    #[test]
270    fn semantic_reference_types_are_validated_before_insertion() {
271        let mut builder = GeometryGraphBuilder::new();
272        let point = builder.push(GeometryNode::Point3(Vec3::ZERO)).unwrap();
273        let error = builder
274            .push(GeometryNode::SolidOperation(
275                crate::SolidOperation::Extrusion {
276                    profile: point,
277                    direction: Vec3::Z,
278                    depth: 1.0,
279                },
280            ))
281            .unwrap_err();
282        assert!(matches!(
283            error,
284            GraphError::InvalidReferenceType {
285                reference,
286                expected: "profile",
287                actual: "point3",
288            } if reference == point
289        ));
290    }
291
292    #[test]
293    fn instance_nodes_preserve_their_source_reference_family() {
294        let mut builder = GeometryGraphBuilder::new();
295        let point = builder.push(GeometryNode::Point3(Vec3::ZERO)).unwrap();
296        let instance = builder
297            .push(GeometryNode::Instance(Instance {
298                source: point,
299                transform: axiolid_core::Transform3::IDENTITY,
300            }))
301            .unwrap();
302
303        let error = builder
304            .push(GeometryNode::SolidOperation(
305                crate::SolidOperation::Boolean {
306                    left: instance,
307                    right: instance,
308                    operator: axiolid_core::BooleanOperator::Union,
309                },
310            ))
311            .unwrap_err();
312        let GraphError::InvalidReferenceType {
313            reference,
314            expected,
315            actual,
316        } = error
317        else {
318            panic!("unexpected graph error: {error:?}");
319        };
320        assert_eq!(reference, instance);
321        assert_eq!(expected, "solid");
322        assert_eq!(actual, "instance");
323
324        let solid = builder
325            .push(GeometryNode::Primitive(
326                axiolid_primitive::Primitive::Sphere { radius: 1.0 },
327            ))
328            .unwrap();
329        let solid_instance = builder
330            .push(GeometryNode::Instance(Instance {
331                source: solid,
332                transform: axiolid_core::Transform3::IDENTITY,
333            }))
334            .unwrap();
335        assert!(builder
336            .push(GeometryNode::SolidOperation(
337                crate::SolidOperation::Boolean {
338                    left: solid_instance,
339                    right: solid_instance,
340                    operator: axiolid_core::BooleanOperator::Union,
341                },
342            ))
343            .is_ok());
344    }
345
346    #[test]
347    fn surface_curve_requires_a_three_dimensional_basis() {
348        let mut builder = GeometryGraphBuilder::new();
349        let curve_2d = builder
350            .push(GeometryNode::Curve2(axiolid_curve::Curve2::Line(
351                axiolid_curve::Line2 {
352                    origin: axiolid_core::Vec2::ZERO,
353                    direction: axiolid_core::Vec2::X,
354                },
355            )))
356            .unwrap();
357        let plane = builder
358            .push(GeometryNode::Surface(axiolid_surface::Surface::Plane(
359                axiolid_surface::Plane {
360                    frame: axiolid_core::Frame3 {
361                        origin: Vec3::ZERO,
362                        x: Vec3::X,
363                        y: Vec3::Y,
364                        z: Vec3::Z,
365                    },
366                },
367            )))
368            .unwrap();
369        let error = builder
370            .push(GeometryNode::CurveRelation(
371                crate::CurveRelation::SurfaceCurve {
372                    curve_3d: curve_2d,
373                    sides: crate::SurfaceSides::one(plane, curve_2d),
374                    master: crate::MasterRepresentation::Curve3d,
375                },
376            ))
377            .unwrap_err();
378        assert!(matches!(
379            error,
380            GraphError::InvalidReferenceType {
381                reference,
382                expected: "curve3",
383                actual: "curve2",
384            } if reference == curve_2d
385        ));
386
387        let curve_3d = builder
388            .push(GeometryNode::Curve3(axiolid_curve::Curve3::Line(
389                axiolid_curve::Line3 {
390                    origin: Vec3::ZERO,
391                    direction: Vec3::X,
392                },
393            )))
394            .unwrap();
395        let trimmed_3d = builder
396            .push(GeometryNode::CurveRelation(crate::CurveRelation::Trimmed {
397                basis: curve_3d,
398                start: Vec::new(),
399                end: Vec::new(),
400                sense_agreement: true,
401                preference: crate::TrimmingPreference::Unspecified,
402            }))
403            .unwrap();
404        assert!(builder
405            .push(GeometryNode::CurveRelation(
406                crate::CurveRelation::SurfaceCurve {
407                    curve_3d: trimmed_3d,
408                    sides: crate::SurfaceSides::one(plane, curve_2d),
409                    master: crate::MasterRepresentation::Curve3d,
410                },
411            ))
412            .is_ok());
413    }
414
415    #[test]
416    fn parameter_curve_requires_a_two_dimensional_reference() {
417        let mut builder = GeometryGraphBuilder::new();
418        let surface = builder
419            .push(GeometryNode::Surface(axiolid_surface::Surface::Plane(
420                axiolid_surface::Plane {
421                    frame: axiolid_core::Frame3 {
422                        origin: Vec3::ZERO,
423                        x: Vec3::X,
424                        y: Vec3::Y,
425                        z: Vec3::Z,
426                    },
427                },
428            )))
429            .unwrap();
430        let curve_3d = builder
431            .push(GeometryNode::Curve3(axiolid_curve::Curve3::Line(
432                axiolid_curve::Line3 {
433                    origin: Vec3::ZERO,
434                    direction: Vec3::X,
435                },
436            )))
437            .unwrap();
438        let error = builder
439            .push(GeometryNode::CurveRelation(
440                crate::CurveRelation::ParameterCurve {
441                    basis_surface: surface,
442                    reference_curve: curve_3d,
443                },
444            ))
445            .unwrap_err();
446        assert!(matches!(
447            error,
448            GraphError::InvalidReferenceType {
449                reference,
450                expected: "curve2",
451                actual: "curve3",
452            } if reference == curve_3d
453        ));
454    }
455}