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