Skip to main content

ogeom_heal/
reanchor.rs

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