Skip to main content

ogeom_algo/
place.rs

1//! Placing and duplicating shapes.
2//!
3//! # A rigid move copies nothing
4//!
5//! [`transformed`] returns the *same* topology at a different
6//! [`Location`]. No node is created, no curve is
7//! re-evaluated, and the result compares equal to the original under
8//! [`Shape::is_partner`], which is how "these thousand bolts are the same
9//! bolt" stays a fact the model knows rather than one an application has to
10//! remember (`docs/DATA_MODEL.md` §2, §3).
11//!
12//! That is only sound because a location is a rigid motion with a uniform
13//! scale. Such a motion carries a line to a line and a circle to a circle, so
14//! the geometry underneath still describes the moved shape. An affine transform
15//! that shears or scales unevenly does not: it carries a circle to an ellipse,
16//! and no amount of placement makes a circle record that. The type system says
17//! so: [`transformed`] takes a [`Transform`], which is a similarity by
18//! construction, and a general affine transform is a different type it will not
19//! accept. Applying one means rebuilding the geometry, which is what
20//! `general_transformed_shape` does.
21//!
22//! # A copy is for editing, not for moving
23//!
24//! [`copied`] duplicates the topology so the two can diverge. It shares the
25//! *geometry*: curves and surfaces are immutable values in an arena, so two
26//! shapes naming one circle can never disagree about it, and copying it would
27//! only make the model larger. What a copy buys is independent topology:
28//! tolerances, representations and children that one shape can change without
29//! the other seeing it.
30
31use ogeom_core::FastMap;
32
33use ogeom_core::{OgeomResult, ogeom_bail};
34use ogeom_math::Transform;
35use ogeom_topo::{Location, Model, NodeData, Shape, ShapeType, TShapeId, explore_unique};
36
37use crate::history::{Built, History};
38
39/// Roles this module assigns.
40pub mod roles {
41    use ogeom_core::Role;
42
43    /// An entity that is a copy of another.
44    pub const COPY: Role = Role::op_defined(30);
45}
46
47/// Move a shape by a rigid motion, sharing everything.
48///
49/// Cheap and exact: the result names the same topology nodes and the same
50/// geometry, at a new placement.
51///
52/// The history records the shape and every sub-shape below it as modified
53/// into its occurrence in the moved shape: the same node under the new
54/// placement. A placement is part of a sub-shape's identity, so without the
55/// record a face of the input would trace to itself, which is no face of
56/// the result. The records cost one walk of the input's unique sub-shapes;
57/// no node is made.
58///
59/// There is no way to pass something that is *not* a placement. [`Transform`]
60/// is a similarity by construction (rigid motion with a uniform scale), and a
61/// shear or a non-uniform scale is a
62/// [`GeneralTransform`](ogeom_math::GeneralTransform), which this does not accept.
63/// That is deliberate: such a transform carries a circle to an ellipse, and
64/// recording it as a placement would leave every circle in the shape claiming
65/// to be a circle while sitting on an ellipse. The type refuses it, so no
66/// runtime check has to.
67///
68/// # Errors
69///
70/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if the shape does not
71/// resolve in this model.
72pub fn transformed(model: &mut Model, shape: &Shape, transform: Transform) -> OgeomResult<Built> {
73    if model.node(shape).is_none() {
74        ogeom_bail!(Dangling, "shape refers to a node not in this model");
75    }
76    model.begin_operation();
77    let datum = model.add_datum(transform);
78    let by = Location::of(datum);
79    let moved = shape.moved(&by);
80
81    let mut history = History::new();
82    history.modify(shape, moved.clone());
83    for kind in [
84        ShapeType::CompSolid,
85        ShapeType::Solid,
86        ShapeType::Shell,
87        ShapeType::Face,
88        ShapeType::Wire,
89        ShapeType::Edge,
90        ShapeType::Vertex,
91    ] {
92        for part in explore_unique(model, shape, kind)? {
93            if !part.is_same(shape) {
94                let image = part.moved(&by);
95                history.modify(&part, image);
96            }
97        }
98    }
99    Ok(Built::new(moved, history))
100}
101
102/// Duplicate a shape's topology so the two can be edited apart.
103///
104/// Geometry is shared, not duplicated; see the module docs.
105///
106/// # Errors
107///
108/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if the shape does not
109/// resolve in this model.
110pub fn copied(model: &mut Model, shape: &Shape) -> OgeomResult<Built> {
111    if model.node(shape).is_none() {
112        ogeom_bail!(Dangling, "shape refers to a node not in this model");
113    }
114    model.begin_operation();
115
116    let mut done: FastMap<TShapeId, Shape> = FastMap::default();
117    let mut history = History::new();
118    let bare = duplicate(model, shape, &mut done, &mut history)?;
119    // The root's own placement and orientation, applied here and nowhere else.
120    let root = bare.moved(shape.location()).composed(shape.orientation());
121    Ok(Built::new(root, history))
122}
123
124/// Copy one node and everything below it, memoized. Returns the *bare* copy
125/// (the new node at identity, forward), never the occurrence it was reached by.
126///
127/// The memo is not an optimization. A shared edge appears under two faces, and
128/// copying it twice would give the copy two edges where the original had one;
129/// the shell would then be open along every shared boundary, and nothing about
130/// the geometry would say why.
131///
132/// The recursion is keyed by *node*, and each level copies its children from
133/// what the node **stores**, not from what [`Model::children_of`] returns.
134/// That accessor composes the parent's placement and orientation onto each
135/// child, which is right for traversal and wrong for copying: storing the
136/// composed occurrence and then returning it composed again applies the
137/// parent's transform twice. A doubled placement merely puts the copy in the
138/// wrong place, but a doubled orientation takes a wire apart: reversing a
139/// wire is *reverse the order and flip every sense*, and `children_of` carries
140/// only the sense half, so the second application flips the senses back while
141/// the order stays put and consecutive edges stop meeting. A parent's
142/// `(location, orientation)` is applied exactly once: by whoever stores the
143/// occurrence, or by [`copied`] at the root.
144fn duplicate(
145    model: &mut Model,
146    shape: &Shape,
147    done: &mut FastMap<TShapeId, Shape>,
148    history: &mut History,
149) -> OgeomResult<Shape> {
150    if let Some(existing) = done.get(&shape.node()) {
151        return Ok(existing.clone());
152    }
153
154    let Some(node) = model.node(shape) else {
155        ogeom_bail!(Dangling, "shape refers to a node not in this model");
156    };
157    let kind = node.kind();
158    let data = node.data().clone();
159    let stored = node.children().to_vec();
160
161    // Children first: a node is built from the shapes below it, so they have to
162    // exist before it does.
163    let mut children = Vec::new();
164    for raw in &stored {
165        // History and provenance are keyed by where the child stands in the
166        // world, which is the occurrence `children_of` would have handed back.
167        let world = raw.moved(shape.location()).composed(shape.orientation());
168        let bare = duplicate(model, &world, done, history)?;
169        // The copied child stands where the original's stored entry said, with
170        // that entry's own placement and sense carried over verbatim.
171        // `Shape::new` rather than `moved`/`composed` because composition
172        // absorbs `Internal` and `External`, and a copy must not quietly
173        // convert them into a plain reversal.
174        children.push(Shape::new(
175            bare.node(),
176            raw.location().clone(),
177            raw.orientation(),
178        ));
179    }
180
181    let fresh = match (kind, data) {
182        (ShapeType::Vertex, NodeData::Vertex(v)) => model.add_vertex(v),
183        (ShapeType::Edge, NodeData::Edge(e)) => model.add_edge(*e, &children)?,
184        (ShapeType::Wire, _) => model.add_wire(&children)?,
185        (ShapeType::Face, NodeData::Face(f)) => model.add_face(*f, &children)?,
186        (ShapeType::Shell, _) => model.add_shell(&children)?,
187        (ShapeType::Solid, _) => model.add_solid(&children)?,
188        (ShapeType::CompSolid, _) => model.add_compsolid(&children)?,
189        (ShapeType::Compound, _) => model.add_compound(&children)?,
190        (other, _) => ogeom_bail!(
191            Construction,
192            "a {other:?} node does not hold the data its kind requires, so it \
193             cannot be copied"
194        ),
195    };
196
197    // The copy names what it came from, so a reference into the original still
198    // resolves after the copy is edited.
199    let bare = Shape::of(fresh.node());
200    model.set_derived(&bare, std::slice::from_ref(shape), roles::COPY)?;
201    history.modify(shape, bare.clone());
202    done.insert(shape.node(), bare.clone());
203
204    Ok(bare)
205}
206
207#[cfg(test)]
208#[allow(clippy::unwrap_used, clippy::expect_used)]
209mod tests {
210    use super::*;
211    use crate::check::check;
212    use crate::mass::volume_properties;
213    use crate::{make_box, make_cylinder};
214    use approx::assert_relative_eq;
215    use ogeom_core::Tolerances;
216    use ogeom_math::{Axis, Direction, Frame, Point, Vector};
217    use ogeom_mesh::Deflection;
218
219    const T: Tolerances = Tolerances::millimetres();
220
221    fn deflection() -> Deflection {
222        Deflection {
223            chord: 0.01,
224            ..Deflection::default()
225        }
226    }
227
228    #[test]
229    fn a_rigid_move_creates_no_topology_at_all() {
230        // The whole point. A thousand identical bolts should cost one bolt and
231        // a thousand placements, not a thousand bolts.
232        let mut model = Model::new();
233        let solid = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), T)
234            .unwrap()
235            .shape;
236        let before = model.node_count();
237
238        let moved = transformed(&mut model, &solid, Transform::translation(Vector::X * 10.0))
239            .unwrap()
240            .shape;
241
242        assert_eq!(model.node_count(), before, "a placement copied something");
243        assert!(moved.is_partner(&solid), "the same topology, elsewhere");
244        assert!(!moved.is_same(&solid), "but at a different placement");
245    }
246
247    /// Each face, edge and vertex of a box, moved, mirrored or scaled,
248    /// traces to exactly one sub-shape of the result of its kind, the one
249    /// with its role, and reads as affected.
250    #[test]
251    fn every_sub_shape_traces_to_its_occurrence_in_the_moved_shape() {
252        let mut model = Model::new();
253        let cube = make_box(&mut model, Frame::WORLD, (10.0, 10.0, 10.0), T)
254            .unwrap()
255            .shape;
256        for transform in [
257            Transform::translation(Vector::new(100.0, 0.0, 0.0)),
258            Transform::plane_mirror(Point::ORIGIN, Direction::X),
259            Transform::scaling(Point::ORIGIN, 2.0, T).unwrap(),
260        ] {
261            let moved = transformed(&mut model, &cube, transform).unwrap();
262            assert!(moved.history.trace(&cube)[0].is_same(&moved.shape));
263            for (kind, count) in [
264                (ShapeType::Face, 6),
265                (ShapeType::Edge, 12),
266                (ShapeType::Vertex, 8),
267            ] {
268                let result = explore_unique(&model, &moved.shape, kind).unwrap();
269                let parts = explore_unique(&model, &cube, kind).unwrap();
270                assert_eq!(parts.len(), count);
271                for part in &parts {
272                    assert!(moved.history.is_affected(part));
273                    let found: Vec<&Shape> = result
274                        .iter()
275                        .filter(|r| moved.history.trace(part).iter().any(|t| t.is_same(r)))
276                        .collect();
277                    assert_eq!(found.len(), 1, "{kind:?}");
278                    assert_eq!(found[0].node(), part.node());
279                    assert_eq!(model.provenance_of(found[0]), model.provenance_of(part));
280                }
281            }
282        }
283    }
284
285    #[test]
286    fn a_moved_shape_measures_the_same_and_sits_elsewhere() {
287        let mut model = Model::new();
288        let solid = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), T)
289            .unwrap()
290            .shape;
291        let offset = Vector::new(10.0, -20.0, 30.0);
292        let moved = transformed(&mut model, &solid, Transform::translation(offset))
293            .unwrap()
294            .shape;
295
296        let here = volume_properties(&model, &solid, deflection(), T).unwrap();
297        let there = volume_properties(&model, &moved, deflection(), T).unwrap();
298        assert_relative_eq!(here.mass, there.mass, epsilon = 1e-9);
299        assert!(there.centre.distance(here.centre + offset) < 1e-9);
300
301        assert!(check(&model, &moved, T).unwrap().is_valid());
302    }
303
304    #[test]
305    fn a_rotation_is_a_placement_and_a_reflection_still_is() {
306        let mut model = Model::new();
307        let solid = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), T)
308            .unwrap()
309            .shape;
310        for transform in [
311            Transform::rotation(Axis::Z, 0.7),
312            Transform::scaling(Point::ORIGIN, 2.0, T).unwrap(),
313            Transform::plane_mirror(Point::ORIGIN, Direction::Z),
314        ] {
315            assert!(
316                transformed(&mut model, &solid, transform).is_ok(),
317                "a rigid or uniformly scaled motion should be a placement"
318            );
319        }
320    }
321
322    #[test]
323    fn a_uniform_scale_scales_the_volume_by_its_cube() {
324        let mut model = Model::new();
325        let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
326            .unwrap()
327            .shape;
328        let bigger = transformed(
329            &mut model,
330            &solid,
331            Transform::scaling(Point::ORIGIN, 3.0, T).unwrap(),
332        )
333        .unwrap()
334        .shape;
335        let props = volume_properties(&model, &bigger, deflection(), T).unwrap();
336        assert_relative_eq!(props.mass, 27.0, epsilon = 1e-9);
337    }
338
339    #[test]
340    fn a_copy_has_its_own_topology_and_the_same_geometry() {
341        let mut model = Model::new();
342        let solid = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T)
343            .unwrap()
344            .shape;
345        let (curves, pcurves, surfaces) = model.geometry().counts();
346
347        let copy = copied(&mut model, &solid).unwrap().shape;
348        assert!(!copy.is_partner(&solid), "a copy is not the same topology");
349        assert_eq!(
350            model.geometry().counts(),
351            (curves, pcurves, surfaces),
352            "geometry is immutable and shared; copying it buys nothing"
353        );
354
355        // And it is a whole, valid solid, not a shell of loose faces.
356        assert!(check(&model, &copy, T).unwrap().is_valid());
357        let props = volume_properties(&model, &copy, deflection(), T).unwrap();
358        assert_relative_eq!(props.mass, 8.0, epsilon = 1e-9);
359    }
360
361    #[test]
362    fn a_copy_keeps_shared_edges_shared() {
363        // The memo is what makes this true. Copying each edge once per face
364        // would give the copy twice the edges, and the shell would be open
365        // along every one of them with nothing in the geometry to say so.
366        let mut model = Model::new();
367        let solid = make_cylinder(&mut model, Frame::WORLD, 2.0, 3.0, T)
368            .unwrap()
369            .shape;
370        let copy = copied(&mut model, &solid).unwrap().shape;
371
372        for kind in [
373            ShapeType::Face,
374            ShapeType::Edge,
375            ShapeType::Vertex,
376            ShapeType::Wire,
377        ] {
378            assert_eq!(
379                explore_unique(&model, &copy, kind).unwrap().len(),
380                explore_unique(&model, &solid, kind).unwrap().len(),
381                "the copy has a different number of {kind:?}"
382            );
383        }
384        assert!(check(&model, &copy, T).unwrap().is_valid());
385    }
386
387    #[test]
388    fn a_copy_of_a_prism_with_instanced_caps_is_still_a_closed_solid() {
389        // A box is the easy case: every occurrence it stores is forward and at
390        // identity, so copying it cannot tell whether a parent's placement is
391        // being applied once or twice. A prism can: its near cap is the
392        // profile *reversed* and its far cap is that same node at a
393        // displacement, and a wire is where a doubled orientation shows,
394        // because reversing one reverses the walk as well as each edge.
395        let mut model = Model::new();
396        let solid = prism_of_a_square(&mut model);
397
398        let copy = copied(&mut model, &solid).unwrap().shape;
399
400        let diagnosis = check(&model, &copy, T).unwrap();
401        assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
402        let props = volume_properties(&model, &copy, deflection(), T).unwrap();
403        assert_relative_eq!(props.mass, 500.0, epsilon = 1e-9);
404
405        // And the instancing survived: one cap node used twice, not two.
406        for kind in [
407            ShapeType::Face,
408            ShapeType::Edge,
409            ShapeType::Vertex,
410            ShapeType::Wire,
411        ] {
412            assert_eq!(
413                explore_unique(&model, &copy, kind).unwrap().len(),
414                explore_unique(&model, &solid, kind).unwrap().len(),
415                "the copy has a different number of {kind:?}"
416            );
417        }
418    }
419
420    #[test]
421    fn a_copy_of_a_prism_bakes_cleanly() {
422        // The bake walks every wire in traversal order and rebuilds it, so it
423        // is the shortest path to the question "do this copy's edges still
424        // meet?", and it is the path a mirrored operand takes into the
425        // boolean.
426        let mut model = Model::new();
427        let solid = prism_of_a_square(&mut model);
428        let copy = copied(&mut model, &solid).unwrap().shape;
429
430        let baked = crate::convert::baked_shape(&mut model, &copy, T)
431            .unwrap()
432            .shape;
433
434        let diagnosis = check(&model, &baked, T).unwrap();
435        assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
436        let props = volume_properties(&model, &baked, deflection(), T).unwrap();
437        assert_relative_eq!(props.mass, 500.0, epsilon = 1e-6);
438    }
439
440    /// A 10×10×5 prism built the way a pad is: a profile face, swept.
441    fn prism_of_a_square(model: &mut Model) -> Shape {
442        use crate::build::{make_face_with_pcurves, make_polygon};
443        use crate::sweep::make_prism;
444        use ogeom_geom::{PlaneSurface, SurfaceGeometry};
445        use ogeom_math::Plane;
446
447        let pts = [
448            Point::new(0.0, 0.0, 0.0),
449            Point::new(10.0, 0.0, 0.0),
450            Point::new(10.0, 10.0, 0.0),
451            Point::new(0.0, 10.0, 0.0),
452        ];
453        let wire = make_polygon(model, &pts, true, T).unwrap().shape;
454        let surface = PlaneSurface::over(Plane::XY, (-1.0, 11.0), (-1.0, 11.0)).unwrap();
455        let edges = model.children_of(&wire).unwrap();
456        let face = make_face_with_pcurves(model, SurfaceGeometry::Plane(surface), &[edges], T)
457            .unwrap()
458            .shape;
459        make_prism(model, &face, Vector::new(0.0, 0.0, 5.0), T)
460            .unwrap()
461            .shape
462    }
463
464    #[test]
465    fn a_copy_names_what_it_came_from() {
466        let mut model = Model::new();
467        let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
468            .unwrap()
469            .shape;
470        let built = copied(&mut model, &solid).unwrap();
471
472        assert_eq!(
473            model
474                .provenance_of(&built.shape)
475                .and_then(ogeom_core::Provenance::role),
476            Some(roles::COPY)
477        );
478        // And the history says the original became it, so a reference into the
479        // original still resolves.
480        assert_eq!(
481            built.history.modified(&solid),
482            std::slice::from_ref(&built.shape)
483        );
484        assert!(!built.history.is_deleted(&solid));
485    }
486
487    #[test]
488    fn placing_or_copying_a_stranger_is_an_error() {
489        let mut other = Model::new();
490        for _ in 0..4 {
491            other.add_vertex(ogeom_topo::VertexData::new(Point::ORIGIN));
492        }
493        let beyond = other.add_vertex(ogeom_topo::VertexData::new(Point::ORIGIN));
494
495        let mut empty = Model::new();
496        assert!(transformed(&mut empty, &beyond, Transform::IDENTITY).is_err());
497        assert!(copied(&mut empty, &beyond).is_err());
498    }
499}