Skip to main content

ogeom_heal/
reshape.rs

1//! The reshape framework: record substitutions, apply them over a shape in
2//! one pass.
3//!
4//! Healing operations want to say "this edge becomes that one, this face
5//! goes away" and have the change ripple upward (every wire holding the
6//! edge rebuilt, every face holding the wire, up to the solid) without
7//! each fix reimplementing the traversal. A [`Reshape`] collects the
8//! requests; [`Reshape::apply`] rebuilds bottom-up, sharing rebuilt nodes
9//! so a substituted edge is one new node however many faces reach it, and
10//! reports what became of every input through the ordinary history.
11
12use std::collections::HashMap;
13
14use ogeom_algo::{Built, History};
15use ogeom_core::{OgeomResult, ogeom_bail};
16use ogeom_topo::{Model, NodeData, Orientation, Shape, ShapeType, TShapeId};
17
18/// A batch of substitutions, applied in one rebuild.
19#[derive(Debug, Default)]
20pub struct Reshape {
21    /// `None` removes the node; `Some` replaces it.
22    requests: HashMap<TShapeId, Option<Shape>>,
23    /// Nodes cut into pieces, in their forward order.
24    splits: HashMap<TShapeId, Vec<Shape>>,
25}
26
27impl Reshape {
28    /// An empty batch.
29    #[must_use]
30    pub fn new() -> Self {
31        Self::default()
32    }
33
34    /// Replace every occurrence of `old` with `new`.
35    pub fn replace(&mut self, old: &Shape, new: Shape) {
36        self.requests.insert(old.node(), Some(new));
37    }
38
39    /// Replace every occurrence of `old` with `pieces`, in order: an edge
40    /// cut in a wire, a face cut in a shell. A reversed occurrence takes
41    /// the pieces reversed, in reverse order.
42    pub fn split(&mut self, old: &Shape, pieces: Vec<Shape>) {
43        self.splits.insert(old.node(), pieces);
44    }
45
46    /// Remove every occurrence of `old`.
47    pub fn remove(&mut self, old: &Shape) {
48        self.requests.insert(old.node(), None);
49    }
50
51    /// How many replacements are staged.
52    #[must_use]
53    pub fn len(&self) -> usize {
54        self.requests.len() + self.splits.len()
55    }
56
57    /// Whether anything is requested.
58    #[must_use]
59    pub fn is_empty(&self) -> bool {
60        self.requests.is_empty() && self.splits.is_empty()
61    }
62
63    /// Apply the batch over `shape`, rebuilding what the substitutions
64    /// touch and sharing everything they do not.
65    ///
66    /// # Errors
67    ///
68    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
69    /// a rebuilt container ends up empty where the model forbids it, or a
70    /// substitution's kind does not fit its slot.
71    pub fn apply(&self, model: &mut Model, shape: &Shape) -> OgeomResult<Built> {
72        let mut memo: HashMap<TShapeId, Option<Shape>> = HashMap::new();
73        let mut history = History::new();
74        let Some(result) = self.rebuilt(model, shape, &mut memo, &mut history)? else {
75            ogeom_bail!(Construction, "the reshape removed the shape itself");
76        };
77        for (old, request) in &self.requests {
78            let stand_in = Shape::of(*old);
79            match request {
80                Some(new) => history.modify(&stand_in, new.clone()),
81                None => history.delete(&stand_in),
82            }
83        }
84        for (old, pieces) in &self.splits {
85            let stand_in = Shape::of(*old);
86            for piece in pieces {
87                history.modify(&stand_in, piece.clone());
88            }
89        }
90        Ok(Built::new(result, history))
91    }
92
93    /// The rebuilt occurrence of `shape`, `None` if it is removed.
94    fn rebuilt(
95        &self,
96        model: &mut Model,
97        shape: &Shape,
98        memo: &mut HashMap<TShapeId, Option<Shape>>,
99        history: &mut History,
100    ) -> OgeomResult<Option<Shape>> {
101        // A direct request wins, orientation carried from the occurrence.
102        if let Some(request) = self.requests.get(&shape.node()) {
103            return Ok(request.as_ref().map(|new| {
104                if shape.orientation() == Orientation::Reversed {
105                    new.reversed()
106                } else {
107                    new.clone()
108                }
109            }));
110        }
111        if let Some(held) = memo.get(&shape.node()) {
112            return Ok(held.as_ref().map(|new| {
113                if shape.orientation() == Orientation::Reversed {
114                    new.reversed()
115                } else {
116                    new.clone()
117                }
118            }));
119        }
120
121        // Rebuild children; if none changed, the node itself is shared.
122        // Read through the forward occurrence: `children_of` composes the
123        // occurrence's orientation into every child, and the rebuilt node is
124        // oriented as the occurrence once, below. Read through a reversed
125        // occurrence, a reversed edge rebuilt around a substituted vertex
126        // came out with its start and end swapped and was then reversed
127        // again, and the wire it sat in walked it against its neighbours.
128        let forward = if shape.orientation() == Orientation::Reversed {
129            shape.reversed()
130        } else {
131            shape.clone()
132        };
133        let children = model.children_of(&forward)?;
134        let mut rebuilt_children = Vec::with_capacity(children.len());
135        let mut changed = false;
136        for child in &children {
137            if let Some(pieces) = self.splits.get(&child.node()) {
138                changed = true;
139                if child.orientation() == Orientation::Reversed {
140                    rebuilt_children.extend(pieces.iter().rev().map(Shape::reversed));
141                } else {
142                    rebuilt_children.extend(pieces.iter().cloned());
143                }
144                continue;
145            }
146            match self.rebuilt(model, child, memo, history)? {
147                Some(new) => {
148                    if new.node() != child.node() {
149                        changed = true;
150                    }
151                    rebuilt_children.push(new);
152                }
153                None => changed = true,
154            }
155        }
156        if !changed {
157            memo.insert(shape.node(), Some(forward));
158            return Ok(Some(shape.clone()));
159        }
160
161        let kind = model.kind_of(shape)?;
162        let data = {
163            let Some(node) = model.node(shape) else {
164                ogeom_bail!(Construction, "shape is not in this model");
165            };
166            node.data().clone()
167        };
168        let fresh = match (kind, data) {
169            (_, NodeData::Face(face)) => {
170                if rebuilt_children.is_empty() {
171                    memo.insert(shape.node(), None);
172                    return Ok(None);
173                }
174                model.add_face(*face, &rebuilt_children)?
175            }
176            (_, NodeData::Edge(edge)) => {
177                if rebuilt_children.is_empty() {
178                    memo.insert(shape.node(), None);
179                    return Ok(None);
180                }
181                model.add_edge(*edge, &rebuilt_children)?
182            }
183            (ShapeType::Wire, NodeData::Container) => {
184                if rebuilt_children.is_empty() {
185                    memo.insert(shape.node(), None);
186                    return Ok(None);
187                }
188                model.add_wire(&rebuilt_children)?
189            }
190            (ShapeType::Shell, NodeData::Container) => {
191                if rebuilt_children.is_empty() {
192                    memo.insert(shape.node(), None);
193                    return Ok(None);
194                }
195                model.add_shell(&rebuilt_children)?
196            }
197            (ShapeType::Solid, NodeData::Container) => {
198                if rebuilt_children.is_empty() {
199                    memo.insert(shape.node(), None);
200                    return Ok(None);
201                }
202                model.add_solid(&rebuilt_children)?
203            }
204            (ShapeType::Compound, NodeData::Container) => model.add_compound(&rebuilt_children)?,
205            (other, _) => {
206                ogeom_bail!(
207                    Construction,
208                    "a {other:?} cannot be rebuilt around substituted children"
209                );
210            }
211        };
212        let oriented = if shape.orientation() == Orientation::Reversed {
213            fresh.reversed()
214        } else {
215            fresh.clone()
216        };
217        history.modify(shape, fresh.clone());
218        memo.insert(shape.node(), Some(fresh));
219        Ok(Some(oriented))
220    }
221}
222
223#[cfg(test)]
224#[allow(clippy::unwrap_used, clippy::expect_used)]
225mod tests {
226    use super::*;
227    use ogeom_core::Tolerances;
228    use ogeom_math::{Frame, Point};
229    use ogeom_topo::explore_unique;
230
231    const T: Tolerances = Tolerances::millimetres();
232
233    #[test]
234    fn a_substituted_vertex_ripples_to_the_top_and_shares_the_rest() {
235        let mut model = Model::new();
236        let solid = ogeom_algo::make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T)
237            .unwrap()
238            .shape;
239        let vertices = explore_unique(&model, &solid, ShapeType::Vertex).unwrap();
240        let corner = vertices
241            .iter()
242            .find(|v| {
243                model
244                    .node(v)
245                    .and_then(|n| n.data().as_vertex())
246                    .is_some_and(|data| data.point.distance(Point::ORIGIN) < 1e-9)
247            })
248            .expect("the origin corner")
249            .clone();
250        let moved = ogeom_algo::make_vertex(&mut model, Point::new(0.1, 0.0, 0.0)).shape;
251
252        let mut reshape = Reshape::new();
253        reshape.replace(&corner, moved.clone());
254        let rebuilt = reshape.apply(&mut model, &solid).unwrap();
255
256        // The new solid holds the new vertex and none of the old one.
257        let after = explore_unique(&model, &rebuilt.shape, ShapeType::Vertex).unwrap();
258        assert!(after.iter().any(|v| v.node() == moved.node()));
259        assert!(after.iter().all(|v| v.node() != corner.node()));
260        // Only the three faces at that corner rebuilt; the rest shared.
261        let before_faces = explore_unique(&model, &solid, ShapeType::Face).unwrap();
262        let after_faces = explore_unique(&model, &rebuilt.shape, ShapeType::Face).unwrap();
263        let shared = after_faces
264            .iter()
265            .filter(|f| before_faces.iter().any(|b| b.node() == f.node()))
266            .count();
267        assert_eq!(shared, 3, "the untouched half of the box is the same nodes");
268        // History names the substitution and the ripple.
269        assert!(!rebuilt.history.modified(&solid).is_empty());
270    }
271}