Skip to main content

ogeom_heal/
reanchor.rs

1//! Re-anchoring the rings of a seamless periodic face.
2//!
3//! The first healing operation an imported file demanded by name. A STEP
4//! face on a periodic surface may arrive bounded by two closed rings and no
5//! seam; where the rings' vertices share an angle, the reader synthesises
6//! the seam itself, but where they sit at *different* angles no seam can
7//! join them: the vertices are load-bearing, and one of them is in the
8//! wrong place. Moving it is surgery: the ring's closed edge is rebuilt
9//! anchored at the aligned angle, and every face that shares the edge (the
10//! neighbour on the other side of the ring) is rebuilt to use the new one,
11//! or the shell tears exactly where the file was trying to close it.
12
13use ogeom_algo::{
14    Built, History, is_shell_closed, make_edge_between, make_face_on, make_shell, make_solid,
15    make_vertex, make_wire,
16};
17use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
18use ogeom_geom::Curve2d as _;
19use ogeom_geom::Curve3d as _;
20use ogeom_geom::Surface as _;
21use ogeom_geom::{Curve, PlanarCurve, SurfaceGeometry};
22use ogeom_math::Point;
23use ogeom_topo::{
24    EdgeRepr, Filter, Location, Model, NodeData, Shape, ShapeType, TShapeId, explore,
25    explore_unique,
26};
27use std::collections::HashMap;
28
29/// A face bounded by two closed rings on a periodic surface, and what the
30/// repair needs to know about it.
31struct Broken {
32    face: Shape,
33    surface_id: ogeom_topo::SurfaceId,
34    surface: SurfaceGeometry,
35    /// Each ring: its single closed edge, that edge's vertex, curve and
36    /// range.
37    rings: Vec<(Shape, Shape, Curve, (f64, f64))>,
38}
39
40/// A coaxial chain of broken faces, healed with one shared half-plane.
41struct Chain {
42    point: Point,
43    dir: ogeom_math::Vector,
44    members: Vec<usize>,
45}
46
47/// Re-anchor misaligned ring vertices so every periodic face can carry a seam.
48///
49/// Faces already whole pass through untouched; a shape with nothing to heal
50/// comes back as itself with an empty history.
51///
52/// # Errors
53///
54/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the shape is
55/// not a solid, or a broken face's structure resists the repair, in which
56/// case nothing is modified.
57pub fn reanchor_periodic_rings(
58    model: &mut Model,
59    shape: &Shape,
60    tol: Tolerances,
61) -> OgeomResult<(Built, usize)> {
62    if model.kind_of(shape)? != ShapeType::Solid {
63        ogeom_bail!(Construction, "ring re-anchoring heals solids");
64    }
65
66    // --- find the broken faces ---------------------------------------------
67    let mut revolution: Vec<Broken> = Vec::new();
68    let mut any_broken = false;
69    for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
70        let Some(node) = model.node(&face) else {
71            ogeom_bail!(Dangling, "face is not in this model");
72        };
73        let NodeData::Face(data) = node.data() else {
74            ogeom_bail!(Construction, "face node holds no face data");
75        };
76        let Some(surface) = model.geometry().surface(data.surface) else {
77            ogeom_bail!(Dangling, "face refers to a surface not in this model");
78        };
79        if !surface.is_periodic_u() {
80            continue;
81        }
82        let surface = surface.clone();
83        let surface_id = data.surface;
84        let wires = explore(model, &face, Filter::OfType(ShapeType::Wire))?;
85
86        // The face's ring edges: its closed non-seam boundaries, whether it
87        // arrived broken (two single-ring wires, no seam) or already seamed
88        // (one wire in which the seam edge appears twice).
89        let mut ring_edges: Vec<Shape> = Vec::new();
90        let mut seen_twice: Vec<TShapeId> = Vec::new();
91        for wire in &wires {
92            let edges = explore(model, wire, Filter::OfType(ShapeType::Edge))?;
93            for edge in &edges {
94                if edges.iter().filter(|e| e.node() == edge.node()).count() == 2 {
95                    if !seen_twice.contains(&edge.node()) {
96                        seen_twice.push(edge.node());
97                    }
98                    continue;
99                }
100                let Some((a, b)) = ogeom_algo::edge_vertices(model, edge)? else {
101                    continue;
102                };
103                if a.is_same(&b) && !ring_edges.iter().any(|r| r.node() == edge.node()) {
104                    ring_edges.push(edge.clone());
105                }
106            }
107        }
108        if ring_edges.len() != 2 {
109            continue;
110        }
111        let broken_here = wires.len() == 2 && seen_twice.is_empty();
112        any_broken |= broken_here;
113        let mut rings = Vec::new();
114        for edge in ring_edges {
115            let Some((a, _)) = ogeom_algo::edge_vertices(model, &edge)? else {
116                continue;
117            };
118            let Some(edge_node) = model.node(&edge) else {
119                continue;
120            };
121            let Some(edge_data) = edge_node.data().as_edge() else {
122                continue;
123            };
124            let Some(EdgeRepr::Curve3d { curve, range, .. }) = edge_data.curve3d() else {
125                continue;
126            };
127            let Some(geometry) = model.geometry().curve(*curve) else {
128                continue;
129            };
130            rings.push((edge, a, geometry.clone(), *range));
131        }
132        if rings.len() != 2 {
133            continue;
134        }
135        revolution.push(Broken {
136            face,
137            surface_id,
138            surface,
139            rings,
140        });
141    }
142    if !any_broken {
143        return Ok((Built::new(shape.clone(), History::new()), 0));
144    }
145    let broken = revolution;
146
147    // --- one half-plane per coaxial chain ------------------------------------
148    //
149    // Re-anchoring one ring per face is not enough: rings are shared, and a
150    // neighbouring face's seam ties to the vertex being moved. For a coaxial
151    // chain (which a fillet stack is) a single half-plane through the axis
152    // meets every ring exactly once, and anchoring them all there satisfies
153    // every face's equal-angle constraint at one stroke. A real part carries
154    // several such chains on several axes (every bored hole is its own),
155    // so the broken faces are grouped by axis first and each group healed
156    // with its own half-plane; a group whose rings resist (a non-circle
157    // ring, a stray non-coaxial circle) is left alone rather than damning
158    // the rest of the part.
159    let mut history = History::new();
160    let axis_of = |s: &SurfaceGeometry| -> Option<(Point, ogeom_math::Vector)> {
161        match s {
162            SurfaceGeometry::Cylinder(c) => {
163                let f = c.cylinder().frame();
164                Some((f.origin(), f.z().vector()))
165            }
166            SurfaceGeometry::Cone(c) => {
167                let f = c.cone().frame();
168                Some((f.origin(), f.z().vector()))
169            }
170            SurfaceGeometry::Sphere(c) => {
171                let f = c.sphere().frame();
172                Some((f.origin(), f.z().vector()))
173            }
174            SurfaceGeometry::Torus(c) => {
175                let f = c.torus().frame();
176                Some((f.origin(), f.z().vector()))
177            }
178            _ => None,
179        }
180    };
181    let mut chains: Vec<Chain> = Vec::new();
182    for (i, b) in broken.iter().enumerate() {
183        let Some((point, dir)) = axis_of(&b.surface) else {
184            continue;
185        };
186        let joined = chains.iter_mut().find(|c| {
187            c.dir.cross(dir).magnitude() < 1e-6
188                && (point - c.point).cross(c.dir).magnitude() < tol.confusion() * 1e3
189        });
190        match joined {
191            Some(c) => c.members.push(i),
192            None => chains.push(Chain {
193                point,
194                dir,
195                members: vec![i],
196            }),
197        }
198    }
199
200    let mut substitution: HashMap<TShapeId, Shape> = HashMap::new();
201    let mut healable: Vec<usize> = Vec::new();
202    for chain in &chains {
203        let anchored = anchor_chain(model, &broken, chain, tol);
204        match anchored {
205            Ok(subs) => {
206                for (node, edge, old_edge, old_vertex) in subs {
207                    history.modify(&old_edge, edge.clone());
208                    history.delete(&old_vertex);
209                    substitution.insert(node, edge);
210                }
211                healable.extend_from_slice(&chain.members);
212            }
213            Err(_) => {
214                // This chain resists; its faces stay as imported.
215            }
216        }
217    }
218    if healable.is_empty() {
219        return Ok((Built::new(shape.clone(), History::new()), 0));
220    }
221
222    // --- rebuild every face that touches a substituted edge ------------------
223    let mut face_map: HashMap<TShapeId, Shape> = HashMap::new();
224    for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
225        // Every revolution face is rebuilt, moved rings or not: an
226        // already-aligned band may still carry import-time pcurves whose
227        // chart windows disagree, and the rebuild is what makes its
228        // annotation coherent. Plain faces rebuild only when an edge they
229        // use actually changed.
230        let uses_any = explore_unique(model, &face, ShapeType::Edge)?
231            .iter()
232            .any(|e| substitution.contains_key(&e.node()));
233        let is_revolution = healable.iter().any(|&i| broken[i].face.is_same(&face));
234        if !uses_any && !is_revolution {
235            continue;
236        }
237        let rebuilt = if let Some(b) = healable
238            .iter()
239            .map(|&i| &broken[i])
240            .find(|b| b.face.is_same(&face))
241        {
242            rebuild_broken_face(
243                model,
244                b.surface_id,
245                &b.surface,
246                &b.rings,
247                &substitution,
248                tol,
249            )?
250        } else {
251            rebuild_plain_face(model, &face, &substitution, tol)?
252        };
253        let rebuilt = if face.orientation() == ogeom_topo::Orientation::Reversed {
254            rebuilt.reversed()
255        } else {
256            rebuilt
257        };
258        history.modify(&face, rebuilt.clone());
259        face_map.insert(face.node(), rebuilt);
260    }
261
262    // --- rebuild shells and the solid ----------------------------------------
263    let mut shells = Vec::new();
264    for shell in explore_unique(model, shape, ShapeType::Shell)? {
265        let faces: Vec<Shape> = explore(model, &shell, Filter::OfType(ShapeType::Face))?
266            .into_iter()
267            .map(|f| {
268                face_map.get(&f.node()).map_or(f.clone(), |n| {
269                    if f.orientation() == ogeom_topo::Orientation::Reversed {
270                        n.reversed()
271                    } else {
272                        n.clone()
273                    }
274                })
275            })
276            .collect();
277        let rebuilt = make_shell(model, &faces)?.shape;
278        if !is_shell_closed(model, &rebuilt)? {
279            ogeom_bail!(
280                Construction,
281                "re-anchoring left the shell open; the shape resists this \
282                 repair"
283            );
284        }
285        history.modify(&shell, rebuilt.clone());
286        shells.push(rebuilt);
287    }
288    let solid = make_solid(model, &shells)?.shape;
289    history.modify(shape, solid.clone());
290    let moved = substitution.len();
291    Ok((Built::new(solid, history), moved))
292}
293
294/// Anchor every ring of one coaxial chain at the chain's own half-plane.
295///
296/// Returns the substitutions to apply (`(old node, new edge, old edge, old
297/// vertex)`) or an error if any ring in the chain resists, in which case
298/// nothing has been decided and the chain is left as imported. New vertices
299/// and edges may have been added to the model, but nothing references them.
300#[allow(clippy::type_complexity)]
301fn anchor_chain(
302    model: &mut Model,
303    broken: &[Broken],
304    chain: &Chain,
305    tol: Tolerances,
306) -> OgeomResult<Vec<(TShapeId, Shape, Shape, Shape)>> {
307    let (axis_point, axis_dir) = (chain.point, chain.dir);
308    let radial = {
309        let (_, vertex, curve, _) = &broken[chain.members[0]].rings[0];
310        let Curve::Circle(c) = curve else {
311            ogeom_bail!(
312                Construction,
313                "a ring to re-anchor is not a circle; the repair does not \
314                 know its parameterization"
315            );
316        };
317        let circle = c.circle();
318        let Some(node) = model.node(vertex) else {
319            ogeom_bail!(Dangling, "vertex is not in this model");
320        };
321        let Some(data) = node.data().as_vertex() else {
322            ogeom_bail!(Construction, "vertex node holds no vertex data");
323        };
324        let r = data.point - circle.centre();
325        let radial = r - axis_dir * r.dot(axis_dir);
326        if radial.magnitude() <= tol.confusion() {
327            ogeom_bail!(Construction, "an anchor vertex sits on the axis");
328        }
329        radial / radial.magnitude()
330    };
331
332    let mut out = Vec::new();
333    let mut done: Vec<TShapeId> = Vec::new();
334    for &i in &chain.members {
335        for (old_edge, old_vertex, curve, _) in &broken[i].rings {
336            if done.contains(&old_edge.node()) {
337                continue;
338            }
339            done.push(old_edge.node());
340            let Curve::Circle(c) = curve else {
341                ogeom_bail!(
342                    Construction,
343                    "a ring to re-anchor is not a circle; the repair does \
344                     not know its parameterization"
345                );
346            };
347            let circle = c.circle();
348            // Coaxial or nothing: the half-plane trick needs one axis.
349            if circle.frame().z().vector().cross(axis_dir).magnitude() > 1e-6
350                || (circle.centre() - axis_point).cross(axis_dir).magnitude()
351                    > tol.confusion() * 1e3
352            {
353                ogeom_bail!(
354                    Construction,
355                    "the rings are not coaxial; the half-plane repair does \
356                     not apply"
357                );
358            }
359            let target = circle.centre() + radial * circle.radius();
360            let current = {
361                let Some(node) = model.node(old_vertex) else {
362                    ogeom_bail!(Dangling, "vertex is not in this model");
363                };
364                let Some(data) = node.data().as_vertex() else {
365                    ogeom_bail!(Construction, "vertex node holds no vertex data");
366                };
367                data.point
368            };
369            if current.distance(target) <= tol.confusion() * 1e2 {
370                continue;
371            }
372            let Some(t_star) = circle_parameter(curve, target) else {
373                ogeom_bail!(Construction, "an anchor point fell off its circle");
374            };
375            let period = {
376                let (lo, hi) = curve.domain();
377                hi - lo
378            };
379            let vertex = make_vertex(model, target).shape;
380            let rebuilt = make_edge_between(
381                model,
382                curve.clone(),
383                (t_star, t_star + period),
384                &vertex,
385                &vertex,
386                tol,
387            )?
388            .shape;
389            out.push((
390                old_edge.node(),
391                rebuilt,
392                old_edge.clone(),
393                old_vertex.clone(),
394            ));
395        }
396    }
397    Ok(out)
398}
399
400/// The circle parameter of a point on a circle curve.
401fn circle_parameter(curve: &Curve, p: Point) -> Option<f64> {
402    let Curve::Circle(c) = curve else {
403        return None;
404    };
405    let local = c.circle().frame().to_local(p);
406    Some(local.y.atan2(local.x).rem_euclid(core::f64::consts::TAU))
407}
408
409/// Rebuild a healed face: both rings now share an angle, so the seam the
410/// reader could not synthesise can exist. The band construction itself is
411/// ogeom-algo's `make_revolution_band`: one authority for reader and healer.
412#[allow(clippy::type_complexity)]
413fn rebuild_broken_face(
414    model: &mut Model,
415    _old_surface_id: ogeom_topo::SurfaceId,
416    surface: &SurfaceGeometry,
417    rings: &[(Shape, Shape, Curve, (f64, f64))],
418    substitution: &HashMap<TShapeId, Shape>,
419    tol: Tolerances,
420) -> OgeomResult<Shape> {
421    let resolved: Vec<Shape> = rings
422        .iter()
423        .map(|(e, ..)| substitution.get(&e.node()).unwrap_or(e).clone())
424        .collect();
425    ogeom_algo::make_revolution_band(model, surface, &resolved[0], &resolved[1], tol)
426}
427
428/// Rebuild an ordinary face around a substituted edge, pcurves recomputed.
429fn rebuild_plain_face(
430    model: &mut Model,
431    face: &Shape,
432    substitution: &HashMap<TShapeId, Shape>,
433    tol: Tolerances,
434) -> OgeomResult<Shape> {
435    let (surface_id, surface) = {
436        let Some(node) = model.node(face) else {
437            ogeom_bail!(Dangling, "face is not in this model");
438        };
439        let NodeData::Face(data) = node.data() else {
440            ogeom_bail!(Construction, "face node holds no face data");
441        };
442        let Some(surface) = model.geometry().surface(data.surface) else {
443            ogeom_bail!(Dangling, "face refers to a surface not in this model");
444        };
445        (data.surface, surface.clone())
446    };
447    let mut wires = Vec::new();
448    let mut replaced = Vec::new();
449    for wire in model.ordered_children_of(face)? {
450        let mut edges = Vec::new();
451        for edge in model.ordered_children_of(&wire)? {
452            match substitution.get(&edge.node()) {
453                Some(new_edge) => {
454                    let placed = if edge.orientation() == ogeom_topo::Orientation::Reversed {
455                        new_edge.reversed()
456                    } else {
457                        new_edge.clone()
458                    };
459                    replaced.push(new_edge.clone());
460                    edges.push(placed);
461                }
462                None => edges.push(edge),
463            }
464        }
465        wires.push(make_wire(model, &edges, tol)?.shape);
466    }
467    attach_face_pcurves(model, &surface, surface_id, &replaced, tol)?;
468    Ok(make_face_on(model, surface_id, &wires, tol)?.shape)
469}
470
471/// Attach this face's pcurves to freshly rebuilt edges, where the projection
472/// has a closed form.
473fn attach_face_pcurves(
474    model: &mut Model,
475    surface: &SurfaceGeometry,
476    surface_id: ogeom_topo::SurfaceId,
477    edges: &[Shape],
478    tol: Tolerances,
479) -> OgeomResult<()> {
480    for edge in edges {
481        let (curve, range, already) = {
482            let Some(node) = model.node(edge) else {
483                ogeom_bail!(Dangling, "edge is not in this model");
484            };
485            let Some(data) = node.data().as_edge() else {
486                ogeom_bail!(Construction, "edge node holds no edge data");
487            };
488            let already = data
489                .representations
490                .iter()
491                .any(|r| matches!(r, EdgeRepr::PCurve { surface: s, .. } if *s == surface_id));
492            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
493                continue;
494            };
495            let Some(geometry) = model.geometry().curve(*curve) else {
496                ogeom_bail!(Dangling, "curve is not in this model");
497            };
498            (geometry.clone(), *range, already)
499        };
500        if already {
501            continue;
502        }
503        let Some(pcurve) = ogeom_intersect::exact_pcurve_over(&curve, range, surface, tol) else {
504            continue;
505        };
506        // A line pcurve's stated domain must cover the edge's range, which
507        // for a wrapped circle runs past one period.
508        let pcurve = if let PlanarCurve::Line(l) = &pcurve {
509            let (lo, hi) = (l.domain().0.min(range.0), l.domain().1.max(range.1));
510            ogeom_geom::Line2d::over(l.axis(), lo, hi)
511                .map(Into::into)
512                .unwrap_or(pcurve)
513        } else {
514            pcurve
515        };
516        ogeom_algo::attach_pcurve(model, edge, pcurve, surface_id, Location::identity(), range)?;
517    }
518    Ok(())
519}