Skip to main content

ogeom_offset/
loft.rs

1//! Solid lofts closed from loft sheets.
2//!
3//! The walls are the loft sheet through the sections ([`make_loft_surface`]):
4//! ruled or smooth, matched by arc length across sections of different edge
5//! counts, and following guide curves where they are given. The sheet
6//! passes through every section exactly, so its two open ends are the
7//! first and last sections; each is closed by a face on its own plane,
8//! bounded by the sheet's own edges there, and walls and caps are sewn
9//! into one closed shell.
10
11use ogeom_algo::{
12    Built, attach_pcurve, edge_vertices, find_plane, is_wire_closed, make_face, make_solid,
13    make_wire, sew,
14};
15use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
16use ogeom_geom::{PlaneSurface, SurfaceGeometry};
17use ogeom_math::Plane;
18use ogeom_topo::{Filter, Location, Model, NodeData, Shape, ShapeType, TShapeId, explore};
19
20use crate::sheet::make_loft_surface;
21
22/// Loft a solid through closed sections, in order, its ends capped by the
23/// first and last sections' planes.
24///
25/// The walls are [`make_loft_surface`] through the sections, open in the
26/// loft direction: with `ruled` straight between each pair of neighbouring
27/// sections, otherwise the smooth skin through them all, and with `guides`
28/// the Gordon surface that also follows each guide (through sections of
29/// several edges, a guide through every corner, one wall per edge between
30/// the guides through its corners). Sections of different
31/// edge counts, lines and arcs and splines mixed, are first split to match
32/// by arc length from each section's own start, every piece keeping its
33/// exact form, and the pieces pair point to point at one fraction of their
34/// exact parameters. A ruled solid between a square and a circle is so
35/// exact: its walls are the degree-one surfaces between the square's sides
36/// and the quarter arcs they pair with, and its caps the two sections'
37/// planes. Aligning the sections' starts is the caller's authorship.
38///
39/// # Errors
40///
41/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a
42/// section is not a closed edge or wire, if the first or last section is
43/// not planar, if the walls and caps do not close into one shell, and as
44/// [`make_loft_surface`] refuses the sections or the guides (fewer than two
45/// sections; with guides, a ruled loft, or sections of several edges with
46/// a corner no guide runs through).
47/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) as
48/// [`make_loft_surface`].
49pub fn make_loft_solid(
50    model: &mut Model,
51    sections: &[Shape],
52    guides: &[Shape],
53    ruled: bool,
54    tol: Tolerances,
55) -> OgeomResult<Built> {
56    for (k, section) in sections.iter().enumerate() {
57        if !is_closed_section(model, section, tol)? {
58            ogeom_bail!(
59                Construction,
60                "a solid loft runs through closed sections; section {k} is not a closed edge or \
61                 wire"
62            );
63        }
64    }
65    let sheet = make_loft_surface(model, sections, false, guides, ruled, tol)?.shape;
66    let mut faces = explore(model, &sheet, Filter::OfType(ShapeType::Face))?;
67    let ends = free_loops(model, &faces)?;
68    if ends.len() != 2 {
69        ogeom_bail!(
70            Construction,
71            "the loft's walls are open along {} loops, not at its two ends",
72            ends.len()
73        );
74    }
75    for ring in &ends {
76        let wire = make_wire(model, ring, tol)?.shape;
77        let Some(plane) = find_plane(model, &wire, tol)? else {
78            ogeom_bail!(
79                Construction,
80                "a solid loft is capped by its end sections' planes; an end section is not planar"
81            );
82        };
83        faces.push(cap(model, plane, ring, wire, tol)?);
84    }
85    let sewn = sew(model, &faces, tol)?;
86    if sewn.shells.len() != 1 || !sewn.free_edges.is_empty() {
87        ogeom_bail!(
88            Construction,
89            "the loft's walls and caps did not close into one shell"
90        );
91    }
92    let mut built = make_solid(model, &sewn.shells)?;
93    for section in sections.iter().chain(guides) {
94        built.history.generate(section, built.shape.clone());
95    }
96    Ok(built)
97}
98
99/// The face on `plane` bounded by `wire`, the ring of `edges`, each edge
100/// given its exact trim in the plane's chart.
101fn cap(
102    model: &mut Model,
103    plane: Plane,
104    edges: &[Shape],
105    wire: Shape,
106    tol: Tolerances,
107) -> OgeomResult<Shape> {
108    let surface: SurfaceGeometry = PlaneSurface::new(plane).into();
109    let face = make_face(model, surface.clone(), &[wire], tol)?.shape;
110    let Some(NodeData::Face(data)) = model.node(&face).map(|n| n.data()) else {
111        ogeom_bail!(Construction, "the cap holds no face data");
112    };
113    let surface_id = data.surface;
114    for edge in edges {
115        let (curve, range) = crate::sweep::spine_curve_of(model, edge)?;
116        let Some(pcurve) = ogeom_intersect::exact_pcurve_over(&curve, range, &surface, tol) else {
117            ogeom_bail!(
118                Construction,
119                "an end section's edge has no exact trim on the section's plane"
120            );
121        };
122        attach_pcurve(model, edge, pcurve, surface_id, Location::identity(), range)?;
123    }
124    Ok(face)
125}
126
127/// Whether `section` is an edge or a wire that closes on itself.
128fn is_closed_section(model: &Model, section: &Shape, tol: Tolerances) -> OgeomResult<bool> {
129    match model.kind_of(section)? {
130        ShapeType::Wire => is_wire_closed(model, section, tol),
131        ShapeType::Edge => match edge_vertices(model, section)? {
132            Some((start, end)) => {
133                Ok(start.is_same(&end) || model.same_position(&start, &end, tol)?)
134            }
135            None => Ok(false),
136        },
137        _ => Ok(false),
138    }
139}
140
141/// The edges walked by an odd number of the faces' rings, chained end to
142/// end into loops. An edge walked twice by one face, its seam, is no
143/// border.
144fn free_loops(model: &Model, faces: &[Shape]) -> OgeomResult<Vec<Vec<Shape>>> {
145    let mut uses: Vec<(TShapeId, usize, Shape)> = Vec::new();
146    for face in faces {
147        for wire in model.children_of(face)? {
148            for edge in model.children_of(&wire)? {
149                if model
150                    .node(&edge)
151                    .and_then(|n| n.data().as_edge())
152                    .is_some_and(|d| d.degenerate)
153                {
154                    continue;
155                }
156                match uses.iter_mut().find(|(id, _, _)| *id == edge.node()) {
157                    Some(entry) => entry.1 += 1,
158                    None => uses.push((edge.node(), 1, edge)),
159                }
160            }
161        }
162    }
163    let mut free: Vec<(Shape, TShapeId, TShapeId)> = Vec::new();
164    for (_, count, edge) in uses {
165        if count % 2 == 0 {
166            continue;
167        }
168        let Some((start, end)) = edge_vertices(model, &edge)? else {
169            ogeom_bail!(Construction, "a loft wall's border edge has no vertices");
170        };
171        free.push((edge, start.node(), end.node()));
172    }
173    let mut loops = Vec::new();
174    while let Some((edge, first, mut at)) = free.pop() {
175        let mut ring = vec![edge];
176        while at != first {
177            let Some(next) = free.iter().position(|(_, a, b)| *a == at || *b == at) else {
178                ogeom_bail!(Construction, "a loft wall's border does not close");
179            };
180            let (edge, a, b) = free.swap_remove(next);
181            at = if a == at { b } else { a };
182            ring.push(edge);
183        }
184        loops.push(ring);
185    }
186    Ok(loops)
187}