Skip to main content

ogeom_doc/
structure.rs

1//! Product structure: parts, assemblies, instances with placements.
2//!
3//! A [`Document`] owns a [`Model`] and a table of products over it. A *part*
4//! is a product with a shape; an *assembly* is a product with instances, each
5//! naming a product and carrying a placement. Instancing leans directly on
6//! the location chain (`docs/DATA_MODEL.md` ยง2): every instance's placement
7//! is a datum in the model's own store, an occurrence of a part is the part's
8//! shape *moved* by the chain of placements above it, and ten thousand
9//! identical fasteners are ten thousand chains over one node.
10//!
11//! Appearance and naming ride alongside: a colour or a name attaches to a
12//! product or to a topology node (a whole part or one face of it), and
13//! resolution walks from the most specific to the least.
14
15use ogeom_core::{OgeomResult, ogeom_bail};
16use ogeom_math::Transform;
17use ogeom_topo::{Location, Model, Shape, TShapeId};
18use std::collections::HashMap;
19
20/// A product in a document: a part or an assembly.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub struct ProductId(u32);
23
24impl ProductId {
25    /// The id's position in the document's own product order: the index a
26    /// file format writes and rebinds by re-adding in order.
27    #[must_use]
28    pub const fn index(self) -> u32 {
29        self.0
30    }
31}
32
33/// An RGBA colour, each channel in `[0, 1]`.
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct Colour {
36    /// Red.
37    pub r: f64,
38    /// Green.
39    pub g: f64,
40    /// Blue.
41    pub b: f64,
42    /// Opacity: 1 is opaque.
43    pub a: f64,
44}
45
46impl Colour {
47    /// An opaque colour from red, green and blue.
48    #[must_use]
49    pub const fn rgb(r: f64, g: f64, b: f64) -> Self {
50        Self { r, g, b, a: 1.0 }
51    }
52}
53
54/// One use of a product inside an assembly: which product, where, and what
55/// this particular occurrence is called.
56#[derive(Debug, Clone)]
57pub struct Instance {
58    /// The product this instance places.
59    pub product: ProductId,
60    /// The placement, as a location over the document's own datum store.
61    pub location: Location,
62    /// The occurrence's own name: "bolt-3", not the product's "bolt".
63    pub name: Option<String>,
64}
65
66/// What a product is: geometry, or uses of other products.
67#[derive(Debug, Clone)]
68pub enum ProductKind {
69    /// A part: a product that carries a shape.
70    Part {
71        /// The part's geometry, a shape in the document's model.
72        shape: Shape,
73    },
74    /// An assembly: a product made of placed uses of other products.
75    Assembly {
76        /// The assembly's instances, in authoring order.
77        children: Vec<Instance>,
78    },
79}
80
81/// One product: a name, an optional colour, and what it is.
82#[derive(Debug, Clone)]
83pub struct Product {
84    /// The product's name.
85    pub name: String,
86    /// The product's own colour, inherited by anything in it that has none.
87    pub colour: Option<Colour>,
88    /// Part or assembly.
89    pub kind: ProductKind,
90}
91
92/// A placed part: the flattening of an assembly tree into shapes.
93#[derive(Debug, Clone)]
94pub struct Occurrence {
95    /// The part this occurrence places.
96    pub part: ProductId,
97    /// The part's shape, moved by every placement above it.
98    pub shape: Shape,
99    /// The path of names from the root to here, `/`-separated, instance
100    /// names where they exist and product names where they do not.
101    pub path: String,
102}
103
104/// A model with product structure, appearance and names over it.
105///
106/// The model stays reachable (construction, booleans and measurement all
107/// operate on it directly), and the document adds what a model alone does
108/// not say: which shapes are products, how they assemble, what they are
109/// called and what colour they are.
110#[derive(Debug, Default)]
111pub struct Document {
112    model: Model,
113    products: Vec<Product>,
114    colours: HashMap<TShapeId, Colour>,
115    names: HashMap<TShapeId, String>,
116    pmi: crate::pmi::Pmi,
117    properties: HashMap<TShapeId, Vec<crate::attributes::Property>>,
118    materials: Vec<crate::attributes::Material>,
119    material_of: HashMap<TShapeId, crate::attributes::MaterialId>,
120    layers: Vec<crate::attributes::Layer>,
121    on_layer: HashMap<TShapeId, Vec<crate::attributes::LayerId>>,
122    validation: HashMap<TShapeId, crate::attributes::ValidationProperties>,
123    textures: Vec<crate::attributes::Texture>,
124    texture_of: HashMap<TShapeId, crate::attributes::TextureId>,
125    views: Vec<crate::view::View>,
126    notes: Vec<crate::view::Note>,
127    /// Document states an undo can return to, oldest first, and how far
128    /// back through them the caller currently stands.
129    history: Vec<State>,
130    /// How many of `history`'s tail have been undone: the redo depth.
131    undone: usize,
132}
133
134/// Everything a document holds *about* a model, which is everything an undo
135/// can restore.
136///
137/// The model itself is not in here, and that is the design rather than an
138/// omission. Geometry arenas are append-only (a boolean's result does not
139/// erase its inputs, it stands beside them), so undoing an operation means
140/// putting back what the document *said*, not unmaking what the model
141/// holds. The nodes the undone operation built stay where they are,
142/// unreferenced, which is what a garbage-collected arena is for.
143#[derive(Debug, Clone, Default)]
144struct State {
145    products: Vec<Product>,
146    colours: HashMap<TShapeId, Colour>,
147    names: HashMap<TShapeId, String>,
148    pmi: crate::pmi::Pmi,
149    properties: HashMap<TShapeId, Vec<crate::attributes::Property>>,
150    materials: Vec<crate::attributes::Material>,
151    material_of: HashMap<TShapeId, crate::attributes::MaterialId>,
152    layers: Vec<crate::attributes::Layer>,
153    on_layer: HashMap<TShapeId, Vec<crate::attributes::LayerId>>,
154    validation: HashMap<TShapeId, crate::attributes::ValidationProperties>,
155    textures: Vec<crate::attributes::Texture>,
156    texture_of: HashMap<TShapeId, crate::attributes::TextureId>,
157    views: Vec<crate::view::View>,
158    notes: Vec<crate::view::Note>,
159}
160
161impl Document {
162    /// An empty document over an empty model.
163    #[must_use]
164    pub fn new() -> Self {
165        Self::default()
166    }
167
168    /// A document over an existing model.
169    #[must_use]
170    pub fn over(model: Model) -> Self {
171        Self {
172            model,
173            ..Self::default()
174        }
175    }
176
177    /// The model under the document.
178    #[must_use]
179    pub const fn model(&self) -> &Model {
180        &self.model
181    }
182
183    /// The model, for construction and modification.
184    pub const fn model_mut(&mut self) -> &mut Model {
185        &mut self.model
186    }
187
188    /// Add a part: a named product carrying a shape.
189    pub fn add_part(&mut self, name: impl Into<String>, shape: Shape) -> ProductId {
190        self.push(Product {
191            name: name.into(),
192            colour: None,
193            kind: ProductKind::Part { shape },
194        })
195    }
196
197    /// Add an empty assembly.
198    pub fn add_assembly(&mut self, name: impl Into<String>) -> ProductId {
199        self.push(Product {
200            name: name.into(),
201            colour: None,
202            kind: ProductKind::Assembly {
203                children: Vec::new(),
204            },
205        })
206    }
207
208    fn push(&mut self, product: Product) -> ProductId {
209        self.products.push(product);
210        #[allow(clippy::cast_possible_truncation)]
211        ProductId(self.products.len() as u32 - 1)
212    }
213
214    /// Place `product` inside `assembly` at `at`.
215    ///
216    /// The transform becomes a datum in the model's own store, so the
217    /// instance's placement is structural (comparable by identity, shared by
218    /// every traversal) rather than a matrix to be compared with an epsilon.
219    ///
220    /// # Errors
221    ///
222    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
223    /// `assembly` is not an assembly, or placing `product` there would make a
224    /// product contain itself;
225    /// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if either id
226    /// is not in this document.
227    pub fn add_instance(
228        &mut self,
229        assembly: ProductId,
230        product: ProductId,
231        at: Transform,
232        name: Option<String>,
233    ) -> OgeomResult<()> {
234        if self.get(product).is_none() {
235            ogeom_bail!(Dangling, "the product to place is not in this document");
236        }
237        if self.contains_product(product, assembly) {
238            ogeom_bail!(
239                Construction,
240                "placing this product here would make it contain itself"
241            );
242        }
243        let location = if at == Transform::IDENTITY {
244            Location::identity()
245        } else {
246            Location::of(self.model.add_datum(at))
247        };
248        let Some(entry) = self.products.get_mut(assembly.0 as usize) else {
249            ogeom_bail!(Dangling, "the assembly is not in this document");
250        };
251        let ProductKind::Assembly { children } = &mut entry.kind else {
252            ogeom_bail!(Construction, "instances go inside assemblies, not parts");
253        };
254        children.push(Instance {
255            product,
256            location,
257            name,
258        });
259        Ok(())
260    }
261
262    /// Place `product` inside `assembly` at an already-resolved location.
263    ///
264    /// The persistence path: a file carries the instance's location chain
265    /// verbatim, and re-minting a datum for it would renumber what the file
266    /// preserved. Checks are as [`Document::add_instance`].
267    ///
268    /// # Errors
269    ///
270    /// As [`Document::add_instance`].
271    pub fn add_instance_at(
272        &mut self,
273        assembly: ProductId,
274        product: ProductId,
275        location: Location,
276        name: Option<String>,
277    ) -> OgeomResult<()> {
278        if self.get(product).is_none() {
279            ogeom_bail!(Dangling, "the product to place is not in this document");
280        }
281        if self.contains_product(product, assembly) {
282            ogeom_bail!(
283                Construction,
284                "placing this product here would make it contain itself"
285            );
286        }
287        let Some(entry) = self.products.get_mut(assembly.0 as usize) else {
288            ogeom_bail!(Dangling, "the assembly is not in this document");
289        };
290        let ProductKind::Assembly { children } = &mut entry.kind else {
291            ogeom_bail!(Construction, "instances go inside assemblies, not parts");
292        };
293        children.push(Instance {
294            product,
295            location,
296            name,
297        });
298        Ok(())
299    }
300
301    /// Whether the tree under `haystack` reaches `needle`.
302    fn contains_product(&self, haystack: ProductId, needle: ProductId) -> bool {
303        if haystack == needle {
304            return true;
305        }
306        match self.get(haystack).map(|p| &p.kind) {
307            Some(ProductKind::Assembly { children }) => children
308                .iter()
309                .any(|i| self.contains_product(i.product, needle)),
310            _ => false,
311        }
312    }
313
314    /// The product behind an id.
315    #[must_use]
316    pub fn get(&self, id: ProductId) -> Option<&Product> {
317        self.products.get(id.0 as usize)
318    }
319
320    /// Every product, in the order added.
321    pub fn products(&self) -> impl Iterator<Item = (ProductId, &Product)> {
322        self.products
323            .iter()
324            .enumerate()
325            .map(|(i, p)| (ProductId(u32::try_from(i).unwrap_or(u32::MAX)), p))
326    }
327
328    /// The products no instance places: the top of the tree.
329    #[must_use]
330    pub fn roots(&self) -> Vec<ProductId> {
331        let mut placed = vec![false; self.products.len()];
332        for product in &self.products {
333            if let ProductKind::Assembly { children } = &product.kind {
334                for instance in children {
335                    placed[instance.product.0 as usize] = true;
336                }
337            }
338        }
339        placed
340            .iter()
341            .enumerate()
342            .filter(|&(_, &used)| !used)
343            .map(|(i, _)| ProductId(u32::try_from(i).unwrap_or(u32::MAX)))
344            .collect()
345    }
346
347    /// Every placed part under `product`, shapes moved into world space.
348    ///
349    /// The flattening every consumer of an assembly wants: two instances of
350    /// one part come back as two occurrences whose shapes share a topology
351    /// node and differ only in their location chains.
352    ///
353    /// # Errors
354    ///
355    /// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if `product`
356    /// is not in this document.
357    pub fn occurrences_of(&self, product: ProductId) -> OgeomResult<Vec<Occurrence>> {
358        let Some(root) = self.get(product) else {
359            ogeom_bail!(Dangling, "the product is not in this document");
360        };
361        let mut out = Vec::new();
362        self.flatten(
363            product,
364            root,
365            &Location::identity(),
366            &root.name.clone(),
367            &mut out,
368        );
369        Ok(out)
370    }
371
372    fn flatten(
373        &self,
374        id: ProductId,
375        product: &Product,
376        above: &Location,
377        path: &str,
378        out: &mut Vec<Occurrence>,
379    ) {
380        match &product.kind {
381            ProductKind::Part { shape } => out.push(Occurrence {
382                part: id,
383                shape: shape.moved(above),
384                path: path.to_string(),
385            }),
386            ProductKind::Assembly { children } => {
387                for instance in children {
388                    let Some(child) = self.get(instance.product) else {
389                        continue;
390                    };
391                    let below = above.then(&instance.location);
392                    let step = instance.name.as_deref().unwrap_or(&child.name);
393                    let path = format!("{path}/{step}");
394                    self.flatten(instance.product, child, &below, &path, out);
395                }
396            }
397        }
398    }
399
400    /// Colour a product: the fallback for everything in it.
401    ///
402    /// # Errors
403    ///
404    /// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if `product`
405    /// is not in this document.
406    pub fn set_product_colour(&mut self, product: ProductId, colour: Colour) -> OgeomResult<()> {
407        let Some(entry) = self.products.get_mut(product.0 as usize) else {
408            ogeom_bail!(Dangling, "the product is not in this document");
409        };
410        entry.colour = Some(colour);
411        Ok(())
412    }
413
414    /// Add a texture the document can lay on shapes.
415    pub fn add_texture(
416        &mut self,
417        texture: crate::attributes::Texture,
418    ) -> crate::attributes::TextureId {
419        self.textures.push(texture);
420        crate::attributes::TextureId(self.textures.len() - 1)
421    }
422
423    /// Lay a texture on a shape, replacing whatever was on it.
424    pub fn set_texture(&mut self, shape: &Shape, texture: crate::attributes::TextureId) {
425        self.texture_of.insert(shape.node(), texture);
426    }
427
428    /// The texture on a shape, if it has one.
429    #[must_use]
430    pub fn texture_of(&self, shape: &Shape) -> Option<&crate::attributes::Texture> {
431        let id = self.texture_of.get(&shape.node())?;
432        self.textures.get(id.0)
433    }
434
435    /// Every texture the document holds, in the order they were added.
436    #[must_use]
437    pub fn textures(&self) -> &[crate::attributes::Texture] {
438        &self.textures
439    }
440
441    /// Mark the document's current state as one an undo can return to.
442    ///
443    /// Call it *before* the change a caller might want back. Anything
444    /// undone and not redone is dropped at the next checkpoint, which is
445    /// the usual rule: a new branch replaces the abandoned one.
446    pub fn checkpoint(&mut self) {
447        if self.undone > 0 {
448            let keep = self.history.len() - self.undone;
449            self.history.truncate(keep);
450            self.undone = 0;
451        }
452        let state = self.state();
453        self.history.push(state);
454    }
455
456    /// Step back to the last checkpoint. `false` when there is none.
457    pub fn undo(&mut self) -> bool {
458        if self.history.len() <= self.undone {
459            return false;
460        }
461        // The state being left is kept in its place, so redo has somewhere
462        // to go.
463        let index = self.history.len() - self.undone - 1;
464        let current = self.state();
465        let restored = core::mem::replace(&mut self.history[index], current);
466        self.apply(restored);
467        self.undone += 1;
468        true
469    }
470
471    /// Step forward again. `false` when nothing was undone.
472    pub fn redo(&mut self) -> bool {
473        if self.undone == 0 {
474            return false;
475        }
476        let index = self.history.len() - self.undone;
477        let current = self.state();
478        let restored = core::mem::replace(&mut self.history[index], current);
479        self.apply(restored);
480        self.undone -= 1;
481        true
482    }
483
484    /// How many steps back are available, and how many forward.
485    #[must_use]
486    pub fn undo_depth(&self) -> (usize, usize) {
487        (self.history.len() - self.undone, self.undone)
488    }
489
490    /// The position of a product in write order: how the native format
491    /// refers to one across a save.
492    #[must_use]
493    pub fn product_index(&self, id: ProductId) -> usize {
494        id.index() as usize
495    }
496
497    /// Add a saved view; its index is how STEP and the native format refer
498    /// to it.
499    pub fn add_view(&mut self, view: crate::view::View) -> usize {
500        self.views.push(view);
501        self.views.len() - 1
502    }
503
504    /// The saved views, in order.
505    #[must_use]
506    pub fn views(&self) -> &[crate::view::View] {
507        &self.views
508    }
509
510    /// Add a note.
511    pub fn add_note(&mut self, note: crate::view::Note) -> usize {
512        self.notes.push(note);
513        self.notes.len() - 1
514    }
515
516    /// The notes, in order.
517    #[must_use]
518    pub fn notes(&self) -> &[crate::view::Note] {
519        &self.notes
520    }
521
522    fn state(&self) -> State {
523        State {
524            products: self.products.clone(),
525            colours: self.colours.clone(),
526            names: self.names.clone(),
527            pmi: self.pmi.clone(),
528            properties: self.properties.clone(),
529            materials: self.materials.clone(),
530            material_of: self.material_of.clone(),
531            layers: self.layers.clone(),
532            on_layer: self.on_layer.clone(),
533            validation: self.validation.clone(),
534            textures: self.textures.clone(),
535            texture_of: self.texture_of.clone(),
536            views: self.views.clone(),
537            notes: self.notes.clone(),
538        }
539    }
540
541    /// Put a state back.
542    fn apply(&mut self, state: State) {
543        self.products = state.products;
544        self.colours = state.colours;
545        self.names = state.names;
546        self.pmi = state.pmi;
547        self.properties = state.properties;
548        self.materials = state.materials;
549        self.material_of = state.material_of;
550        self.layers = state.layers;
551        self.on_layer = state.on_layer;
552        self.validation = state.validation;
553        self.textures = state.textures;
554        self.texture_of = state.texture_of;
555        self.views = state.views;
556        self.notes = state.notes;
557    }
558
559    /// Colour a shape: a whole part's shape or one sub-shape of it.
560    ///
561    /// Keyed by the topology node, so every occurrence of an instanced shape
562    /// shows the colour: the colour belongs to the entity, not to one
563    /// placement of it.
564    pub fn set_colour(&mut self, shape: &Shape, colour: Colour) {
565        self.colours.insert(shape.node(), colour);
566    }
567
568    /// The colour set directly on a shape's node, if any.
569    #[must_use]
570    pub fn colour_of(&self, shape: &Shape) -> Option<Colour> {
571        self.colours.get(&shape.node()).copied()
572    }
573
574    /// The colour a sub-shape of a part actually shows.
575    ///
576    /// Most specific wins: the sub-shape's own colour, else the colour of the
577    /// part's whole shape, else the product's, else nothing.
578    #[must_use]
579    pub fn resolved_colour(&self, part: ProductId, sub: &Shape) -> Option<Colour> {
580        if let Some(own) = self.colour_of(sub) {
581            return Some(own);
582        }
583        let product = self.get(part)?;
584        if let ProductKind::Part { shape } = &product.kind
585            && let Some(whole) = self.colour_of(shape)
586        {
587            return Some(whole);
588        }
589        product.colour
590    }
591
592    /// Name a shape's node: a face someone will want to find again.
593    pub fn set_name(&mut self, shape: &Shape, name: impl Into<String>) {
594        self.names.insert(shape.node(), name.into());
595    }
596
597    /// The name set on a shape's node, if any.
598    #[must_use]
599    pub fn name_of(&self, shape: &Shape) -> Option<&str> {
600        self.names.get(&shape.node()).map(String::as_str)
601    }
602
603    /// Every node-attached colour, for a writer to carry out.
604    pub fn colours(&self) -> impl Iterator<Item = (TShapeId, Colour)> + '_ {
605        self.colours.iter().map(|(&node, &colour)| (node, colour))
606    }
607
608    /// Every node-attached name, for a writer to carry out.
609    pub fn names(&self) -> impl Iterator<Item = (TShapeId, &str)> {
610        self.names.iter().map(|(&node, name)| (node, name.as_str()))
611    }
612
613    /// Replace a part's shape: the modification step of an edit.
614    ///
615    /// The old shape's node-attached colours, names and PMI stay where they
616    /// are: entities that survived the modification keep their annotations,
617    /// entities that did not simply no longer resolve.
618    ///
619    /// # Errors
620    ///
621    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
622    /// `product` is not a part;
623    /// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if it is not
624    /// in this document.
625    pub fn replace_part_shape(&mut self, product: ProductId, shape: Shape) -> OgeomResult<()> {
626        let Some(entry) = self.products.get_mut(product.0 as usize) else {
627            ogeom_bail!(Dangling, "the product is not in this document");
628        };
629        let ProductKind::Part { shape: slot } = &mut entry.kind else {
630            ogeom_bail!(Construction, "only a part carries a shape to replace");
631        };
632        *slot = shape;
633        Ok(())
634    }
635
636    /// The document's semantic PMI.
637    #[must_use]
638    pub const fn pmi(&self) -> &crate::pmi::Pmi {
639        &self.pmi
640    }
641
642    /// The PMI, for annotating.
643    pub const fn pmi_mut(&mut self) -> &mut crate::pmi::Pmi {
644        &mut self.pmi
645    }
646
647    // --- attributes ---------------------------------------------------------
648
649    /// Pin a user-defined property to a shape. Properties accumulate; a
650    /// repeated name replaces the earlier value.
651    pub fn set_property(&mut self, shape: &Shape, property: crate::attributes::Property) {
652        let list = self.properties.entry(shape.node()).or_default();
653        if let Some(held) = list.iter_mut().find(|p| p.name == property.name) {
654            *held = property;
655        } else {
656            list.push(property);
657        }
658    }
659
660    /// The properties pinned to a shape.
661    #[must_use]
662    pub fn properties_of(&self, shape: &Shape) -> &[crate::attributes::Property] {
663        self.properties
664            .get(&shape.node())
665            .map_or(&[], Vec::as_slice)
666    }
667
668    /// Every shape with properties, for persistence.
669    pub fn properties(&self) -> impl Iterator<Item = (TShapeId, &[crate::attributes::Property])> {
670        self.properties.iter().map(|(k, v)| (*k, v.as_slice()))
671    }
672
673    /// Add a material to the document's list.
674    pub fn add_material(
675        &mut self,
676        material: crate::attributes::Material,
677    ) -> crate::attributes::MaterialId {
678        self.materials.push(material);
679        crate::attributes::MaterialId(self.materials.len() - 1)
680    }
681
682    /// A material by id.
683    #[must_use]
684    pub fn material(
685        &self,
686        id: crate::attributes::MaterialId,
687    ) -> Option<&crate::attributes::Material> {
688        self.materials.get(id.0)
689    }
690
691    /// The materials, in id order.
692    #[must_use]
693    pub fn materials(&self) -> &[crate::attributes::Material] {
694        &self.materials
695    }
696
697    /// The id at a list position, for rebinding persisted references.
698    #[must_use]
699    pub fn material_id(&self, index: usize) -> Option<crate::attributes::MaterialId> {
700        (index < self.materials.len()).then_some(crate::attributes::MaterialId(index))
701    }
702
703    /// Assign a shape its material.
704    pub fn assign_material(&mut self, shape: &Shape, id: crate::attributes::MaterialId) {
705        self.material_of.insert(shape.node(), id);
706    }
707
708    /// The material a shape is assigned, if any.
709    #[must_use]
710    pub fn material_of(&self, shape: &Shape) -> Option<crate::attributes::MaterialId> {
711        self.material_of.get(&shape.node()).copied()
712    }
713
714    /// Every material assignment, for persistence.
715    pub fn material_assignments(
716        &self,
717    ) -> impl Iterator<Item = (TShapeId, crate::attributes::MaterialId)> + '_ {
718        self.material_of.iter().map(|(k, v)| (*k, *v))
719    }
720
721    /// Add a layer, visible by default.
722    pub fn add_layer(&mut self, name: impl Into<String>) -> crate::attributes::LayerId {
723        self.layers.push(crate::attributes::Layer {
724            name: name.into(),
725            visible: true,
726        });
727        crate::attributes::LayerId(self.layers.len() - 1)
728    }
729
730    /// A layer by id.
731    #[must_use]
732    pub fn layer(&self, id: crate::attributes::LayerId) -> Option<&crate::attributes::Layer> {
733        self.layers.get(id.0)
734    }
735
736    /// The layers, in id order.
737    #[must_use]
738    pub fn layers(&self) -> &[crate::attributes::Layer] {
739        &self.layers
740    }
741
742    /// The id at a list position, for rebinding persisted references.
743    #[must_use]
744    pub fn layer_id(&self, index: usize) -> Option<crate::attributes::LayerId> {
745        (index < self.layers.len()).then_some(crate::attributes::LayerId(index))
746    }
747
748    /// Show or hide a layer.
749    pub fn set_layer_visible(&mut self, id: crate::attributes::LayerId, visible: bool) {
750        if let Some(layer) = self.layers.get_mut(id.0) {
751            layer.visible = visible;
752        }
753    }
754
755    /// Put a shape on a layer. A shape may sit on several.
756    pub fn place_on_layer(&mut self, shape: &Shape, layer: crate::attributes::LayerId) {
757        let list = self.on_layer.entry(shape.node()).or_default();
758        if !list.contains(&layer) {
759            list.push(layer);
760        }
761    }
762
763    /// The layers a shape sits on.
764    #[must_use]
765    pub fn layers_of(&self, shape: &Shape) -> &[crate::attributes::LayerId] {
766        self.on_layer.get(&shape.node()).map_or(&[], Vec::as_slice)
767    }
768
769    /// Every layer membership, for persistence.
770    pub fn layer_memberships(
771        &self,
772    ) -> impl Iterator<Item = (TShapeId, &[crate::attributes::LayerId])> {
773        self.on_layer.iter().map(|(k, v)| (*k, v.as_slice()))
774    }
775
776    /// Record validation values for a shape.
777    pub fn set_validation(
778        &mut self,
779        shape: &Shape,
780        values: crate::attributes::ValidationProperties,
781    ) {
782        self.validation.insert(shape.node(), values);
783    }
784
785    /// The recorded validation values for a shape.
786    #[must_use]
787    pub fn validation_of(&self, shape: &Shape) -> Option<crate::attributes::ValidationProperties> {
788        self.validation.get(&shape.node()).copied()
789    }
790
791    /// Every validation record, for persistence.
792    pub fn validations(
793        &self,
794    ) -> impl Iterator<Item = (TShapeId, crate::attributes::ValidationProperties)> + '_ {
795        self.validation.iter().map(|(k, v)| (*k, *v))
796    }
797}
798
799#[cfg(test)]
800#[allow(clippy::unwrap_used, clippy::expect_used)]
801mod tests {
802    use super::*;
803    use ogeom_core::Tolerances;
804    use ogeom_math::{Frame, Point, Vector};
805    use ogeom_topo::{Filter, ShapeType, explore};
806
807    const T: Tolerances = Tolerances::millimetres();
808
809    fn box_part(document: &mut Document, name: &str, size: f64) -> (ProductId, Shape) {
810        let shape = ogeom_algo::make_box(document.model_mut(), Frame::WORLD, (size, size, size), T)
811            .unwrap()
812            .shape;
813        (document.add_part(name, shape.clone()), shape)
814    }
815
816    #[test]
817    fn two_instances_of_one_part_share_the_node_and_differ_in_placement() {
818        let mut document = Document::new();
819        let (bolt, shape) = box_part(&mut document, "bolt", 1.0);
820        let assembly = document.add_assembly("plate");
821        document
822            .add_instance(assembly, bolt, Transform::IDENTITY, Some("bolt-1".into()))
823            .unwrap();
824        document
825            .add_instance(
826                assembly,
827                bolt,
828                Transform::translation(Vector::new(10.0, 0.0, 0.0)),
829                Some("bolt-2".into()),
830            )
831            .unwrap();
832
833        let occurrences = document.occurrences_of(assembly).unwrap();
834        assert_eq!(occurrences.len(), 2);
835        assert_eq!(occurrences[0].path, "plate/bolt-1");
836        assert_eq!(occurrences[1].path, "plate/bolt-2");
837        // One node, two placements: the instancing claim itself.
838        assert_eq!(occurrences[0].shape.node(), shape.node());
839        assert_eq!(occurrences[1].shape.node(), shape.node());
840        let at = |o: &Occurrence| {
841            o.shape
842                .transform(document.model().datums())
843                .unwrap()
844                .apply(Point::new(0.0, 0.0, 0.0))
845        };
846        assert!(at(&occurrences[0]).is_equal(Point::new(0.0, 0.0, 0.0), T));
847        assert!(at(&occurrences[1]).is_equal(Point::new(10.0, 0.0, 0.0), T));
848    }
849
850    #[test]
851    fn nested_placements_compose_outer_then_inner() {
852        let mut document = Document::new();
853        let (part, _) = box_part(&mut document, "washer", 1.0);
854        let sub = document.add_assembly("stack");
855        let top = document.add_assembly("machine");
856        document
857            .add_instance(
858                sub,
859                part,
860                Transform::translation(Vector::new(0.0, 5.0, 0.0)),
861                None,
862            )
863            .unwrap();
864        document
865            .add_instance(
866                top,
867                sub,
868                Transform::translation(Vector::new(100.0, 0.0, 0.0)),
869                None,
870            )
871            .unwrap();
872
873        let occurrences = document.occurrences_of(top).unwrap();
874        assert_eq!(occurrences.len(), 1);
875        assert_eq!(occurrences[0].path, "machine/stack/washer");
876        let world = occurrences[0]
877            .shape
878            .transform(document.model().datums())
879            .unwrap()
880            .apply(Point::new(0.0, 0.0, 0.0));
881        assert!(world.is_equal(Point::new(100.0, 5.0, 0.0), T));
882    }
883
884    #[test]
885    fn a_product_cannot_contain_itself() {
886        let mut document = Document::new();
887        let a = document.add_assembly("a");
888        let b = document.add_assembly("b");
889        document
890            .add_instance(a, b, Transform::IDENTITY, None)
891            .unwrap();
892        let err = document.add_instance(b, a, Transform::IDENTITY, None);
893        assert!(err.is_err(), "a cycle must be refused");
894        let direct = document.add_instance(a, a, Transform::IDENTITY, None);
895        assert!(direct.is_err(), "self-containment must be refused");
896    }
897
898    #[test]
899    fn instances_go_only_inside_assemblies() {
900        let mut document = Document::new();
901        let (part, _) = box_part(&mut document, "block", 1.0);
902        let (other, _) = box_part(&mut document, "pin", 1.0);
903        assert!(
904            document
905                .add_instance(part, other, Transform::IDENTITY, None)
906                .is_err()
907        );
908    }
909
910    #[test]
911    fn roots_are_the_products_nothing_places() {
912        let mut document = Document::new();
913        let (part, _) = box_part(&mut document, "gear", 1.0);
914        let assembly = document.add_assembly("gearbox");
915        document
916            .add_instance(assembly, part, Transform::IDENTITY, None)
917            .unwrap();
918        assert_eq!(document.roots(), vec![assembly]);
919    }
920
921    #[test]
922    fn colour_resolution_walks_sub_shape_then_shape_then_product() {
923        let mut document = Document::new();
924        let (part, shape) = box_part(&mut document, "housing", 2.0);
925        let face = explore(document.model(), &shape, Filter::OfType(ShapeType::Face))
926            .unwrap()
927            .remove(0);
928
929        let red = Colour::rgb(1.0, 0.0, 0.0);
930        let green = Colour::rgb(0.0, 1.0, 0.0);
931        let blue = Colour::rgb(0.0, 0.0, 1.0);
932
933        assert_eq!(document.resolved_colour(part, &face), None);
934        document.set_product_colour(part, blue).unwrap();
935        assert_eq!(document.resolved_colour(part, &face), Some(blue));
936        document.set_colour(&shape, green);
937        assert_eq!(document.resolved_colour(part, &face), Some(green));
938        document.set_colour(&face, red);
939        assert_eq!(document.resolved_colour(part, &face), Some(red));
940        // The whole shape keeps its own colour under the face's override.
941        assert_eq!(document.colour_of(&shape), Some(green));
942    }
943
944    #[test]
945    fn names_attach_to_nodes_and_survive_occurrences() {
946        let mut document = Document::new();
947        let (_, shape) = box_part(&mut document, "bracket", 1.0);
948        let face = explore(document.model(), &shape, Filter::OfType(ShapeType::Face))
949            .unwrap()
950            .remove(0);
951        document.set_name(&face, "mounting-face");
952        assert_eq!(document.name_of(&face), Some("mounting-face"));
953        // The same node reached through a moved occurrence still answers.
954        let moved = face.moved(&Location::identity());
955        assert_eq!(document.name_of(&moved), Some("mounting-face"));
956    }
957}