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::FastMap;
16use ogeom_core::{OgeomResult, ogeom_bail};
17use ogeom_math::Transform;
18use ogeom_topo::{Location, Model, Shape, TShapeId};
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: FastMap<TShapeId, Colour>,
115    names: FastMap<TShapeId, String>,
116    pmi: crate::pmi::Pmi,
117    properties: FastMap<TShapeId, Vec<crate::attributes::Property>>,
118    materials: Vec<crate::attributes::Material>,
119    material_of: FastMap<TShapeId, crate::attributes::MaterialId>,
120    layers: Vec<crate::attributes::Layer>,
121    on_layer: FastMap<TShapeId, Vec<crate::attributes::LayerId>>,
122    validation: FastMap<TShapeId, crate::attributes::ValidationProperties>,
123    textures: Vec<crate::attributes::Texture>,
124    texture_of: FastMap<TShapeId, crate::attributes::TextureId>,
125    views: Vec<crate::view::View>,
126    notes: Vec<crate::view::Note>,
127    construction: Vec<crate::construction::Construction>,
128    /// Document states an undo can return to, oldest first, and how far
129    /// back through them the caller currently stands.
130    history: Vec<State>,
131    /// How many of `history`'s tail have been undone: the redo depth.
132    undone: usize,
133    /// How many checkpoints are kept, oldest dropped first; `None` for
134    /// [`Document::DEFAULT_UNDO_LIMIT`].
135    undo_limit: Option<usize>,
136}
137
138/// Everything a document holds *about* a model, which is everything an undo
139/// can restore.
140///
141/// The model itself is not in here, and that is the design rather than an
142/// omission. Geometry arenas are append-only (a boolean's result does not
143/// erase its inputs, it stands beside them), so undoing an operation means
144/// putting back what the document *said*, not unmaking what the model
145/// holds. The nodes the undone operation built stay where they are,
146/// unreferenced, which is what a garbage-collected arena is for.
147#[derive(Debug, Clone, Default)]
148struct State {
149    products: Vec<Product>,
150    colours: FastMap<TShapeId, Colour>,
151    names: FastMap<TShapeId, String>,
152    pmi: crate::pmi::Pmi,
153    properties: FastMap<TShapeId, Vec<crate::attributes::Property>>,
154    materials: Vec<crate::attributes::Material>,
155    material_of: FastMap<TShapeId, crate::attributes::MaterialId>,
156    layers: Vec<crate::attributes::Layer>,
157    on_layer: FastMap<TShapeId, Vec<crate::attributes::LayerId>>,
158    validation: FastMap<TShapeId, crate::attributes::ValidationProperties>,
159    textures: Vec<crate::attributes::Texture>,
160    texture_of: FastMap<TShapeId, crate::attributes::TextureId>,
161    views: Vec<crate::view::View>,
162    notes: Vec<crate::view::Note>,
163    construction: Vec<crate::construction::Construction>,
164}
165
166impl Document {
167    /// An empty document over an empty model.
168    #[must_use]
169    pub fn new() -> Self {
170        Self::default()
171    }
172
173    /// A document over an existing model.
174    #[must_use]
175    pub fn over(model: Model) -> Self {
176        Self {
177            model,
178            ..Self::default()
179        }
180    }
181
182    /// The model under the document.
183    #[must_use]
184    pub const fn model(&self) -> &Model {
185        &self.model
186    }
187
188    /// The model, for construction and modification.
189    pub const fn model_mut(&mut self) -> &mut Model {
190        &mut self.model
191    }
192
193    /// Add a part: a named product carrying a shape.
194    pub fn add_part(&mut self, name: impl Into<String>, shape: Shape) -> ProductId {
195        self.push(Product {
196            name: name.into(),
197            colour: None,
198            kind: ProductKind::Part { shape },
199        })
200    }
201
202    /// Add an empty assembly.
203    pub fn add_assembly(&mut self, name: impl Into<String>) -> ProductId {
204        self.push(Product {
205            name: name.into(),
206            colour: None,
207            kind: ProductKind::Assembly {
208                children: Vec::new(),
209            },
210        })
211    }
212
213    fn push(&mut self, product: Product) -> ProductId {
214        self.products.push(product);
215        #[allow(clippy::cast_possible_truncation)]
216        ProductId(self.products.len() as u32 - 1)
217    }
218
219    /// Place `product` inside `assembly` at `at`.
220    ///
221    /// The transform becomes a datum in the model's own store, so the
222    /// instance's placement is structural (comparable by identity, shared by
223    /// every traversal) rather than a matrix to be compared with an epsilon.
224    ///
225    /// # Errors
226    ///
227    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
228    /// `assembly` is not an assembly, or placing `product` there would make a
229    /// product contain itself;
230    /// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if either id
231    /// is not in this document.
232    pub fn add_instance(
233        &mut self,
234        assembly: ProductId,
235        product: ProductId,
236        at: Transform,
237        name: Option<String>,
238    ) -> OgeomResult<()> {
239        if self.get(product).is_none() {
240            ogeom_bail!(Dangling, "the product to place is not in this document");
241        }
242        if self.contains_product(product, assembly) {
243            ogeom_bail!(
244                Construction,
245                "placing this product here would make it contain itself"
246            );
247        }
248        let location = if at == Transform::IDENTITY {
249            Location::identity()
250        } else {
251            Location::of(self.model.add_datum(at))
252        };
253        let Some(entry) = self.products.get_mut(assembly.0 as usize) else {
254            ogeom_bail!(Dangling, "the assembly is not in this document");
255        };
256        let ProductKind::Assembly { children } = &mut entry.kind else {
257            ogeom_bail!(Construction, "instances go inside assemblies, not parts");
258        };
259        children.push(Instance {
260            product,
261            location,
262            name,
263        });
264        Ok(())
265    }
266
267    /// Place `product` inside `assembly` at an already-resolved location.
268    ///
269    /// The persistence path: a file carries the instance's location chain
270    /// verbatim, and re-minting a datum for it would renumber what the file
271    /// preserved. Checks are as [`Document::add_instance`].
272    ///
273    /// # Errors
274    ///
275    /// As [`Document::add_instance`].
276    pub fn add_instance_at(
277        &mut self,
278        assembly: ProductId,
279        product: ProductId,
280        location: Location,
281        name: Option<String>,
282    ) -> OgeomResult<()> {
283        if self.get(product).is_none() {
284            ogeom_bail!(Dangling, "the product to place is not in this document");
285        }
286        if self.contains_product(product, assembly) {
287            ogeom_bail!(
288                Construction,
289                "placing this product here would make it contain itself"
290            );
291        }
292        let Some(entry) = self.products.get_mut(assembly.0 as usize) else {
293            ogeom_bail!(Dangling, "the assembly is not in this document");
294        };
295        let ProductKind::Assembly { children } = &mut entry.kind else {
296            ogeom_bail!(Construction, "instances go inside assemblies, not parts");
297        };
298        children.push(Instance {
299            product,
300            location,
301            name,
302        });
303        Ok(())
304    }
305
306    /// Whether the tree under `haystack` reaches `needle`.
307    ///
308    /// Each product is looked into once, however many instances reach it: a
309    /// sub-assembly shared at every level would otherwise be walked once per
310    /// path to it, and the paths double with every level.
311    fn contains_product(&self, haystack: ProductId, needle: ProductId) -> bool {
312        let mut seen = ogeom_core::FastSet::default();
313        let mut stack = vec![haystack];
314        while let Some(product) = stack.pop() {
315            if product == needle {
316                return true;
317            }
318            if !seen.insert(product) {
319                continue;
320            }
321            if let Some(ProductKind::Assembly { children }) = self.get(product).map(|p| &p.kind) {
322                stack.extend(children.iter().map(|i| i.product));
323            }
324        }
325        false
326    }
327
328    /// The product behind an id.
329    #[must_use]
330    pub fn get(&self, id: ProductId) -> Option<&Product> {
331        self.products.get(id.0 as usize)
332    }
333
334    /// Every product, in the order added.
335    pub fn products(&self) -> impl Iterator<Item = (ProductId, &Product)> {
336        self.products
337            .iter()
338            .enumerate()
339            .map(|(i, p)| (ProductId(u32::try_from(i).unwrap_or(u32::MAX)), p))
340    }
341
342    /// The products no instance places: the top of the tree.
343    #[must_use]
344    pub fn roots(&self) -> Vec<ProductId> {
345        let mut placed = vec![false; self.products.len()];
346        for product in &self.products {
347            if let ProductKind::Assembly { children } = &product.kind {
348                for instance in children {
349                    placed[instance.product.0 as usize] = true;
350                }
351            }
352        }
353        placed
354            .iter()
355            .enumerate()
356            .filter(|&(_, &used)| !used)
357            .map(|(i, _)| ProductId(u32::try_from(i).unwrap_or(u32::MAX)))
358            .collect()
359    }
360
361    /// Every placed part under `product`, shapes moved into world space.
362    ///
363    /// The flattening every consumer of an assembly wants: two instances of
364    /// one part come back as two occurrences whose shapes share a topology
365    /// node and differ only in their location chains.
366    ///
367    /// An occurrence is a view of the part, not an operation on it, so there
368    /// is no history to record: its shape names the part's own nodes. A
369    /// sub-shape `s` of the part's shape stands in an occurrence as
370    /// `s.moved(&by)`, where `by` is the placement above the part,
371    /// `occurrence.shape.location().then(&part_shape.location().inverted())`.
372    ///
373    /// # Errors
374    ///
375    /// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if `product`
376    /// is not in this document.
377    pub fn occurrences_of(&self, product: ProductId) -> OgeomResult<Vec<Occurrence>> {
378        let Some(root) = self.get(product) else {
379            ogeom_bail!(Dangling, "the product is not in this document");
380        };
381        let mut out = Vec::new();
382        self.flatten(
383            product,
384            root,
385            &Location::identity(),
386            &root.name.clone(),
387            &mut out,
388        );
389        Ok(out)
390    }
391
392    fn flatten(
393        &self,
394        id: ProductId,
395        product: &Product,
396        above: &Location,
397        path: &str,
398        out: &mut Vec<Occurrence>,
399    ) {
400        match &product.kind {
401            ProductKind::Part { shape } => out.push(Occurrence {
402                part: id,
403                shape: shape.moved(above),
404                path: path.to_string(),
405            }),
406            ProductKind::Assembly { children } => {
407                for instance in children {
408                    let Some(child) = self.get(instance.product) else {
409                        continue;
410                    };
411                    let below = above.then(&instance.location);
412                    let step = instance.name.as_deref().unwrap_or(&child.name);
413                    let path = format!("{path}/{step}");
414                    self.flatten(instance.product, child, &below, &path, out);
415                }
416            }
417        }
418    }
419
420    /// Colour a product: the fallback for everything in it.
421    ///
422    /// # Errors
423    ///
424    /// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if `product`
425    /// is not in this document.
426    pub fn set_product_colour(&mut self, product: ProductId, colour: Colour) -> OgeomResult<()> {
427        let Some(entry) = self.products.get_mut(product.0 as usize) else {
428            ogeom_bail!(Dangling, "the product is not in this document");
429        };
430        entry.colour = Some(colour);
431        Ok(())
432    }
433
434    /// Add a texture the document can lay on shapes.
435    pub fn add_texture(
436        &mut self,
437        texture: crate::attributes::Texture,
438    ) -> crate::attributes::TextureId {
439        self.textures.push(texture);
440        crate::attributes::TextureId(self.textures.len() - 1)
441    }
442
443    /// Lay a texture on a shape, replacing whatever was on it.
444    pub fn set_texture(&mut self, shape: &Shape, texture: crate::attributes::TextureId) {
445        self.texture_of.insert(shape.node(), texture);
446    }
447
448    /// The texture on a shape, if it has one.
449    #[must_use]
450    pub fn texture_of(&self, shape: &Shape) -> Option<&crate::attributes::Texture> {
451        let id = self.texture_of.get(&shape.node())?;
452        self.textures.get(id.0)
453    }
454
455    /// Every texture the document holds, in the order they were added.
456    #[must_use]
457    pub fn textures(&self) -> &[crate::attributes::Texture] {
458        &self.textures
459    }
460
461    /// Mark the document's current state as one an undo can return to.
462    ///
463    /// Call it *before* the change a caller might want back. Anything
464    /// undone and not redone is dropped at the next checkpoint, which is
465    /// the usual rule: a new branch replaces the abandoned one.
466    pub fn checkpoint(&mut self) {
467        if self.undone > 0 {
468            let keep = self.history.len() - self.undone;
469            self.history.truncate(keep);
470            self.undone = 0;
471        }
472        let state = self.state();
473        self.history.push(state);
474        self.keep_to_limit();
475    }
476
477    /// How many checkpoints a document keeps unless told otherwise. Each is
478    /// a copy of everything the document says about its model, and a long
479    /// session checkpointing every edit would otherwise hold them all.
480    pub const DEFAULT_UNDO_LIMIT: usize = 256;
481
482    /// Keep at most `limit` checkpoints (at least one), dropping the oldest.
483    pub fn set_undo_limit(&mut self, limit: usize) {
484        self.undo_limit = Some(limit.max(1));
485        self.keep_to_limit();
486    }
487
488    fn keep_to_limit(&mut self) {
489        let limit = self.undo_limit.unwrap_or(Self::DEFAULT_UNDO_LIMIT);
490        // Only checkpoints behind the caller can go: an undone tail is the
491        // redo the caller may still want.
492        let behind = self.history.len() - self.undone;
493        if behind > limit {
494            self.history.drain(..behind - limit);
495        }
496    }
497
498    /// Step back to the last checkpoint. `false` when there is none.
499    pub fn undo(&mut self) -> bool {
500        if self.history.len() <= self.undone {
501            return false;
502        }
503        // The state being left is kept in its place, so redo has somewhere
504        // to go.
505        let index = self.history.len() - self.undone - 1;
506        let current = self.state();
507        let restored = core::mem::replace(&mut self.history[index], current);
508        self.apply(restored);
509        self.undone += 1;
510        true
511    }
512
513    /// Step forward again. `false` when nothing was undone.
514    pub fn redo(&mut self) -> bool {
515        if self.undone == 0 {
516            return false;
517        }
518        let index = self.history.len() - self.undone;
519        let current = self.state();
520        let restored = core::mem::replace(&mut self.history[index], current);
521        self.apply(restored);
522        self.undone -= 1;
523        true
524    }
525
526    /// How many steps back are available, and how many forward.
527    #[must_use]
528    pub fn undo_depth(&self) -> (usize, usize) {
529        (self.history.len() - self.undone, self.undone)
530    }
531
532    /// The position of a product in write order: how the native format
533    /// refers to one across a save.
534    #[must_use]
535    pub fn product_index(&self, id: ProductId) -> usize {
536        id.index() as usize
537    }
538
539    /// Add a saved view; its index is how STEP and the native format refer
540    /// to it.
541    pub fn add_view(&mut self, view: crate::view::View) -> usize {
542        self.views.push(view);
543        self.views.len() - 1
544    }
545
546    /// The saved views, in order.
547    #[must_use]
548    pub fn views(&self) -> &[crate::view::View] {
549        &self.views
550    }
551
552    /// Add a note.
553    pub fn add_note(&mut self, note: crate::view::Note) -> usize {
554        self.notes.push(note);
555        self.notes.len() - 1
556    }
557
558    /// The notes, in order.
559    #[must_use]
560    pub fn notes(&self) -> &[crate::view::Note] {
561        &self.notes
562    }
563
564    /// Add a construction item to `product`; its index is its place in
565    /// [`Document::construction`].
566    ///
567    /// # Errors
568    ///
569    /// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if `product`
570    /// is not in this document;
571    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a
572    /// coordinate is not finite, or a curve's range or a surface's window is
573    /// empty or not finite.
574    pub fn add_construction(
575        &mut self,
576        product: ProductId,
577        name: impl Into<String>,
578        geometry: crate::construction::ConstructionGeometry,
579    ) -> OgeomResult<usize> {
580        use crate::construction::ConstructionGeometry as G;
581        if self.get(product).is_none() {
582            ogeom_bail!(Dangling, "the product is not in this document");
583        }
584        let stretch = |(lo, hi): (f64, f64)| lo.is_finite() && hi.is_finite() && lo < hi;
585        let finite = |p: ogeom_math::Point| p.x.is_finite() && p.y.is_finite() && p.z.is_finite();
586        let sound = match &geometry {
587            G::Point(p) => finite(*p),
588            G::Axis(frame) => finite(frame.origin()),
589            G::Curve { range, .. } => stretch(*range),
590            G::Surface { u, v, .. } => stretch(*u) && stretch(*v),
591        };
592        if !sound {
593            ogeom_bail!(
594                Construction,
595                "a construction item's coordinates, range or window are not finite and increasing"
596            );
597        }
598        self.construction.push(crate::construction::Construction {
599            name: name.into(),
600            geometry,
601            product,
602        });
603        Ok(self.construction.len() - 1)
604    }
605
606    /// The construction items, in order.
607    #[must_use]
608    pub fn construction(&self) -> &[crate::construction::Construction] {
609        &self.construction
610    }
611
612    /// The construction items `product` carries, in order.
613    pub fn construction_of(
614        &self,
615        product: ProductId,
616    ) -> impl Iterator<Item = &crate::construction::Construction> {
617        self.construction
618            .iter()
619            .filter(move |item| item.product == product)
620    }
621
622    fn state(&self) -> State {
623        State {
624            products: self.products.clone(),
625            colours: self.colours.clone(),
626            names: self.names.clone(),
627            pmi: self.pmi.clone(),
628            properties: self.properties.clone(),
629            materials: self.materials.clone(),
630            material_of: self.material_of.clone(),
631            layers: self.layers.clone(),
632            on_layer: self.on_layer.clone(),
633            validation: self.validation.clone(),
634            textures: self.textures.clone(),
635            texture_of: self.texture_of.clone(),
636            views: self.views.clone(),
637            notes: self.notes.clone(),
638            construction: self.construction.clone(),
639        }
640    }
641
642    /// Put a state back.
643    fn apply(&mut self, state: State) {
644        self.products = state.products;
645        self.colours = state.colours;
646        self.names = state.names;
647        self.pmi = state.pmi;
648        self.properties = state.properties;
649        self.materials = state.materials;
650        self.material_of = state.material_of;
651        self.layers = state.layers;
652        self.on_layer = state.on_layer;
653        self.validation = state.validation;
654        self.textures = state.textures;
655        self.texture_of = state.texture_of;
656        self.views = state.views;
657        self.notes = state.notes;
658        self.construction = state.construction;
659    }
660
661    /// Colour a shape: a whole part's shape or one sub-shape of it.
662    ///
663    /// Keyed by the topology node, so every occurrence of an instanced shape
664    /// shows the colour: the colour belongs to the entity, not to one
665    /// placement of it.
666    pub fn set_colour(&mut self, shape: &Shape, colour: Colour) {
667        self.colours.insert(shape.node(), colour);
668    }
669
670    /// The colour set directly on a shape's node, if any.
671    #[must_use]
672    pub fn colour_of(&self, shape: &Shape) -> Option<Colour> {
673        self.colours.get(&shape.node()).copied()
674    }
675
676    /// The colour a sub-shape of a part actually shows.
677    ///
678    /// Most specific wins: the sub-shape's own colour, else the colour of the
679    /// part's whole shape, else the product's, else nothing.
680    #[must_use]
681    pub fn resolved_colour(&self, part: ProductId, sub: &Shape) -> Option<Colour> {
682        if let Some(own) = self.colour_of(sub) {
683            return Some(own);
684        }
685        let product = self.get(part)?;
686        if let ProductKind::Part { shape } = &product.kind
687            && let Some(whole) = self.colour_of(shape)
688        {
689            return Some(whole);
690        }
691        product.colour
692    }
693
694    /// Name a shape's node: a face someone will want to find again.
695    pub fn set_name(&mut self, shape: &Shape, name: impl Into<String>) {
696        self.names.insert(shape.node(), name.into());
697    }
698
699    /// The name set on a shape's node, if any.
700    #[must_use]
701    pub fn name_of(&self, shape: &Shape) -> Option<&str> {
702        self.names.get(&shape.node()).map(String::as_str)
703    }
704
705    /// Every node-attached colour, for a writer to carry out.
706    pub fn colours(&self) -> impl Iterator<Item = (TShapeId, Colour)> + '_ {
707        self.colours.iter().map(|(&node, &colour)| (node, colour))
708    }
709
710    /// Every node-attached name, for a writer to carry out.
711    pub fn names(&self) -> impl Iterator<Item = (TShapeId, &str)> {
712        self.names.iter().map(|(&node, name)| (node, name.as_str()))
713    }
714
715    /// Replace a part's shape: the modification step of an edit.
716    ///
717    /// The old shape's node-attached colours, names and PMI stay where they
718    /// are: entities that survived the modification keep their annotations,
719    /// entities that did not simply no longer resolve.
720    ///
721    /// # Errors
722    ///
723    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
724    /// `product` is not a part;
725    /// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if it is not
726    /// in this document.
727    pub fn replace_part_shape(&mut self, product: ProductId, shape: Shape) -> OgeomResult<()> {
728        let Some(entry) = self.products.get_mut(product.0 as usize) else {
729            ogeom_bail!(Dangling, "the product is not in this document");
730        };
731        let ProductKind::Part { shape: slot } = &mut entry.kind else {
732            ogeom_bail!(Construction, "only a part carries a shape to replace");
733        };
734        *slot = shape;
735        Ok(())
736    }
737
738    /// The document's semantic PMI.
739    #[must_use]
740    pub const fn pmi(&self) -> &crate::pmi::Pmi {
741        &self.pmi
742    }
743
744    /// The PMI, for annotating.
745    pub const fn pmi_mut(&mut self) -> &mut crate::pmi::Pmi {
746        &mut self.pmi
747    }
748
749    // --- attributes ---------------------------------------------------------
750
751    /// Pin a user-defined property to a shape. Properties accumulate; a
752    /// repeated name replaces the earlier value.
753    pub fn set_property(&mut self, shape: &Shape, property: crate::attributes::Property) {
754        let list = self.properties.entry(shape.node()).or_default();
755        if let Some(held) = list.iter_mut().find(|p| p.name == property.name) {
756            *held = property;
757        } else {
758            list.push(property);
759        }
760    }
761
762    /// The properties pinned to a shape.
763    #[must_use]
764    pub fn properties_of(&self, shape: &Shape) -> &[crate::attributes::Property] {
765        self.properties
766            .get(&shape.node())
767            .map_or(&[], Vec::as_slice)
768    }
769
770    /// Every shape with properties, for persistence.
771    pub fn properties(&self) -> impl Iterator<Item = (TShapeId, &[crate::attributes::Property])> {
772        self.properties.iter().map(|(k, v)| (*k, v.as_slice()))
773    }
774
775    /// Add a material to the document's list.
776    pub fn add_material(
777        &mut self,
778        material: crate::attributes::Material,
779    ) -> crate::attributes::MaterialId {
780        self.materials.push(material);
781        crate::attributes::MaterialId(self.materials.len() - 1)
782    }
783
784    /// A material by id.
785    #[must_use]
786    pub fn material(
787        &self,
788        id: crate::attributes::MaterialId,
789    ) -> Option<&crate::attributes::Material> {
790        self.materials.get(id.0)
791    }
792
793    /// The materials, in id order.
794    #[must_use]
795    pub fn materials(&self) -> &[crate::attributes::Material] {
796        &self.materials
797    }
798
799    /// The id at a list position, for rebinding persisted references.
800    #[must_use]
801    pub fn material_id(&self, index: usize) -> Option<crate::attributes::MaterialId> {
802        (index < self.materials.len()).then_some(crate::attributes::MaterialId(index))
803    }
804
805    /// Assign a shape its material.
806    pub fn assign_material(&mut self, shape: &Shape, id: crate::attributes::MaterialId) {
807        self.material_of.insert(shape.node(), id);
808    }
809
810    /// The material a shape is assigned, if any.
811    #[must_use]
812    pub fn material_of(&self, shape: &Shape) -> Option<crate::attributes::MaterialId> {
813        self.material_of.get(&shape.node()).copied()
814    }
815
816    /// Every material assignment, for persistence.
817    pub fn material_assignments(
818        &self,
819    ) -> impl Iterator<Item = (TShapeId, crate::attributes::MaterialId)> + '_ {
820        self.material_of.iter().map(|(k, v)| (*k, *v))
821    }
822
823    /// Add a layer, visible by default.
824    pub fn add_layer(&mut self, name: impl Into<String>) -> crate::attributes::LayerId {
825        self.layers.push(crate::attributes::Layer {
826            name: name.into(),
827            visible: true,
828        });
829        crate::attributes::LayerId(self.layers.len() - 1)
830    }
831
832    /// A layer by id.
833    #[must_use]
834    pub fn layer(&self, id: crate::attributes::LayerId) -> Option<&crate::attributes::Layer> {
835        self.layers.get(id.0)
836    }
837
838    /// The layers, in id order.
839    #[must_use]
840    pub fn layers(&self) -> &[crate::attributes::Layer] {
841        &self.layers
842    }
843
844    /// The id at a list position, for rebinding persisted references.
845    #[must_use]
846    pub fn layer_id(&self, index: usize) -> Option<crate::attributes::LayerId> {
847        (index < self.layers.len()).then_some(crate::attributes::LayerId(index))
848    }
849
850    /// Show or hide a layer.
851    pub fn set_layer_visible(&mut self, id: crate::attributes::LayerId, visible: bool) {
852        if let Some(layer) = self.layers.get_mut(id.0) {
853            layer.visible = visible;
854        }
855    }
856
857    /// Put a shape on a layer. A shape may sit on several.
858    pub fn place_on_layer(&mut self, shape: &Shape, layer: crate::attributes::LayerId) {
859        let list = self.on_layer.entry(shape.node()).or_default();
860        if !list.contains(&layer) {
861            list.push(layer);
862        }
863    }
864
865    /// The layers a shape sits on.
866    #[must_use]
867    pub fn layers_of(&self, shape: &Shape) -> &[crate::attributes::LayerId] {
868        self.on_layer.get(&shape.node()).map_or(&[], Vec::as_slice)
869    }
870
871    /// Every layer membership, for persistence.
872    pub fn layer_memberships(
873        &self,
874    ) -> impl Iterator<Item = (TShapeId, &[crate::attributes::LayerId])> {
875        self.on_layer.iter().map(|(k, v)| (*k, v.as_slice()))
876    }
877
878    /// Record validation values for a shape.
879    pub fn set_validation(
880        &mut self,
881        shape: &Shape,
882        values: crate::attributes::ValidationProperties,
883    ) {
884        self.validation.insert(shape.node(), values);
885    }
886
887    /// The recorded validation values for a shape.
888    #[must_use]
889    pub fn validation_of(&self, shape: &Shape) -> Option<crate::attributes::ValidationProperties> {
890        self.validation.get(&shape.node()).copied()
891    }
892
893    /// Every validation record, for persistence.
894    pub fn validations(
895        &self,
896    ) -> impl Iterator<Item = (TShapeId, crate::attributes::ValidationProperties)> + '_ {
897        self.validation.iter().map(|(k, v)| (*k, *v))
898    }
899}
900
901#[cfg(test)]
902#[allow(clippy::unwrap_used, clippy::expect_used)]
903mod tests {
904    use super::*;
905    use ogeom_core::Tolerances;
906    use ogeom_math::{Frame, Point, Vector};
907    use ogeom_topo::{Filter, ShapeType, explore};
908
909    const T: Tolerances = Tolerances::millimetres();
910
911    fn box_part(document: &mut Document, name: &str, size: f64) -> (ProductId, Shape) {
912        let shape = ogeom_algo::make_box(document.model_mut(), Frame::WORLD, (size, size, size), T)
913            .unwrap()
914            .shape;
915        (document.add_part(name, shape.clone()), shape)
916    }
917
918    /// A long session keeps only its most recent checkpoints: the limit
919    /// holds, the newest states are the ones kept, and undo still reaches
920    /// back as far as the limit allows.
921    #[test]
922    fn undo_keeps_only_its_most_recent_checkpoints() {
923        let mut doc = Document::new();
924        doc.set_undo_limit(3);
925        for k in 0..10 {
926            doc.checkpoint();
927            doc.names
928                .insert(ogeom_topo::TShapeId::from_parts(k, 0), format!("state {k}"));
929        }
930        assert_eq!(doc.undo_depth(), (3, 0));
931        let mut steps = 0;
932        while doc.undo() {
933            steps += 1;
934        }
935        assert_eq!(steps, 3);
936    }
937
938    #[test]
939    fn two_instances_of_one_part_share_the_node_and_differ_in_placement() {
940        let mut document = Document::new();
941        let (bolt, shape) = box_part(&mut document, "bolt", 1.0);
942        let assembly = document.add_assembly("plate");
943        document
944            .add_instance(assembly, bolt, Transform::IDENTITY, Some("bolt-1".into()))
945            .unwrap();
946        document
947            .add_instance(
948                assembly,
949                bolt,
950                Transform::translation(Vector::new(10.0, 0.0, 0.0)),
951                Some("bolt-2".into()),
952            )
953            .unwrap();
954
955        let occurrences = document.occurrences_of(assembly).unwrap();
956        assert_eq!(occurrences.len(), 2);
957        assert_eq!(occurrences[0].path, "plate/bolt-1");
958        assert_eq!(occurrences[1].path, "plate/bolt-2");
959        // One node, two placements: the instancing claim itself.
960        assert_eq!(occurrences[0].shape.node(), shape.node());
961        assert_eq!(occurrences[1].shape.node(), shape.node());
962        let at = |o: &Occurrence| {
963            o.shape
964                .transform(document.model().datums())
965                .unwrap()
966                .apply(Point::new(0.0, 0.0, 0.0))
967        };
968        assert!(at(&occurrences[0]).is_equal(Point::new(0.0, 0.0, 0.0), T));
969        assert!(at(&occurrences[1]).is_equal(Point::new(10.0, 0.0, 0.0), T));
970    }
971
972    #[test]
973    fn nested_placements_compose_outer_then_inner() {
974        let mut document = Document::new();
975        let (part, _) = box_part(&mut document, "washer", 1.0);
976        let sub = document.add_assembly("stack");
977        let top = document.add_assembly("machine");
978        document
979            .add_instance(
980                sub,
981                part,
982                Transform::translation(Vector::new(0.0, 5.0, 0.0)),
983                None,
984            )
985            .unwrap();
986        document
987            .add_instance(
988                top,
989                sub,
990                Transform::translation(Vector::new(100.0, 0.0, 0.0)),
991                None,
992            )
993            .unwrap();
994
995        let occurrences = document.occurrences_of(top).unwrap();
996        assert_eq!(occurrences.len(), 1);
997        assert_eq!(occurrences[0].path, "machine/stack/washer");
998        let world = occurrences[0]
999            .shape
1000            .transform(document.model().datums())
1001            .unwrap()
1002            .apply(Point::new(0.0, 0.0, 0.0));
1003        assert!(world.is_equal(Point::new(100.0, 5.0, 0.0), T));
1004    }
1005
1006    /// A part whose shape carries its own placement, instanced twice under
1007    /// nested assemblies: each face, edge and vertex of the part, moved by
1008    /// the placement above it as `occurrences_of` states, is exactly one
1009    /// sub-shape of its kind in each occurrence.
1010    #[test]
1011    fn a_part_sub_shape_moved_by_the_placement_above_is_in_each_occurrence() {
1012        let mut document = Document::new();
1013        let (_, shape) = box_part(&mut document, "washer", 1.0);
1014        let shape = document
1015            .model_mut()
1016            .placed(&shape, Transform::translation(Vector::new(0.0, 0.0, 3.0)));
1017        let part = document.add_part("lifted", shape.clone());
1018        let sub = document.add_assembly("stack");
1019        let top = document.add_assembly("machine");
1020        for y in [0.0, 5.0] {
1021            document
1022                .add_instance(
1023                    sub,
1024                    part,
1025                    Transform::translation(Vector::new(0.0, y, 0.0)),
1026                    None,
1027                )
1028                .unwrap();
1029        }
1030        document
1031            .add_instance(
1032                top,
1033                sub,
1034                Transform::translation(Vector::new(100.0, 0.0, 0.0)),
1035                None,
1036            )
1037            .unwrap();
1038
1039        let occurrences = document.occurrences_of(top).unwrap();
1040        assert_eq!(occurrences.len(), 2);
1041        let model = document.model();
1042        for occurrence in &occurrences {
1043            let by = occurrence
1044                .shape
1045                .location()
1046                .then(&shape.location().inverted());
1047            for kind in [ShapeType::Face, ShapeType::Edge, ShapeType::Vertex] {
1048                let there = ogeom_topo::explore_unique(model, &occurrence.shape, kind).unwrap();
1049                for s in ogeom_topo::explore_unique(model, &shape, kind).unwrap() {
1050                    let moved = s.moved(&by);
1051                    assert_eq!(there.iter().filter(|t| t.is_same(&moved)).count(), 1);
1052                }
1053            }
1054        }
1055    }
1056
1057    #[test]
1058    fn a_product_cannot_contain_itself() {
1059        let mut document = Document::new();
1060        let a = document.add_assembly("a");
1061        let b = document.add_assembly("b");
1062        document
1063            .add_instance(a, b, Transform::IDENTITY, None)
1064            .unwrap();
1065        let err = document.add_instance(b, a, Transform::IDENTITY, None);
1066        assert!(err.is_err(), "a cycle must be refused");
1067        let direct = document.add_instance(a, a, Transform::IDENTITY, None);
1068        assert!(direct.is_err(), "self-containment must be refused");
1069    }
1070
1071    #[test]
1072    fn instances_go_only_inside_assemblies() {
1073        let mut document = Document::new();
1074        let (part, _) = box_part(&mut document, "block", 1.0);
1075        let (other, _) = box_part(&mut document, "pin", 1.0);
1076        assert!(
1077            document
1078                .add_instance(part, other, Transform::IDENTITY, None)
1079                .is_err()
1080        );
1081    }
1082
1083    #[test]
1084    fn roots_are_the_products_nothing_places() {
1085        let mut document = Document::new();
1086        let (part, _) = box_part(&mut document, "gear", 1.0);
1087        let assembly = document.add_assembly("gearbox");
1088        document
1089            .add_instance(assembly, part, Transform::IDENTITY, None)
1090            .unwrap();
1091        assert_eq!(document.roots(), vec![assembly]);
1092    }
1093
1094    #[test]
1095    fn colour_resolution_walks_sub_shape_then_shape_then_product() {
1096        let mut document = Document::new();
1097        let (part, shape) = box_part(&mut document, "housing", 2.0);
1098        let face = explore(document.model(), &shape, Filter::OfType(ShapeType::Face))
1099            .unwrap()
1100            .remove(0);
1101
1102        let red = Colour::rgb(1.0, 0.0, 0.0);
1103        let green = Colour::rgb(0.0, 1.0, 0.0);
1104        let blue = Colour::rgb(0.0, 0.0, 1.0);
1105
1106        assert_eq!(document.resolved_colour(part, &face), None);
1107        document.set_product_colour(part, blue).unwrap();
1108        assert_eq!(document.resolved_colour(part, &face), Some(blue));
1109        document.set_colour(&shape, green);
1110        assert_eq!(document.resolved_colour(part, &face), Some(green));
1111        document.set_colour(&face, red);
1112        assert_eq!(document.resolved_colour(part, &face), Some(red));
1113        // The whole shape keeps its own colour under the face's override.
1114        assert_eq!(document.colour_of(&shape), Some(green));
1115    }
1116
1117    #[test]
1118    fn names_attach_to_nodes_and_survive_occurrences() {
1119        let mut document = Document::new();
1120        let (_, shape) = box_part(&mut document, "bracket", 1.0);
1121        let face = explore(document.model(), &shape, Filter::OfType(ShapeType::Face))
1122            .unwrap()
1123            .remove(0);
1124        document.set_name(&face, "mounting-face");
1125        assert_eq!(document.name_of(&face), Some("mounting-face"));
1126        // The same node reached through a moved occurrence still answers.
1127        let moved = face.moved(&Location::identity());
1128        assert_eq!(document.name_of(&moved), Some("mounting-face"));
1129    }
1130}