Skip to main content

ogeom_bool/
mesh_region.rs

1//! Cutting a solid by a region of a mesh: a break surface, extended past
2//! the solid and thickened into a slab, cut away so the pieces on one side
3//! of it are kept.
4//!
5//! The region is seen along its average normal, the sum of its triangles'
6//! area vectors. Where every triangle faces that way and the rims meet
7//! nothing but themselves, the region is the graph of a height over its
8//! average plane: the triangles covering a point of the plane number the
9//! times the rims wind about it, each triangle's winding about the point
10//! one inside it and nothing outside, and with every winding one or nothing
11//! each point is covered once at most. The plane is then triangulated
12//! between the rims and a rectangle standing clear of the shape's box, with
13//! the rims' edges as constraints, and the triangles the rims wind about
14//! nothing (outside the region, and in its holes) are lifted to heights
15//! taken linearly between the rims and the average plane, where the
16//! rectangle lies. The sheet so extended is one graph over the rectangle,
17//! so it cannot cross itself, and its only rim is the rectangle's.
18//!
19//! The sheet is thickened by the offset to each side with
20//! [`thicken`](ogeom_mesh::thicken()), which refuses layers that run into
21//! each other, built into a solid with
22//! [`solid_from_mesh`](ogeom_algo::solid_from_mesh) and cut from the shape.
23//! The offset layers' rim vertices stand within
24//! [`OFFSET_FARTHEST`](ogeom_mesh::OFFSET_FARTHEST) times the offset of the
25//! rectangle, which stands off the shape's box by the box's diagonal and
26//! that reach, so the slab's walls meet nothing of the shape: the shape's
27//! faces are cut only by the break and its extension, and each face the
28//! slab misses comes through on its own surface.
29//!
30//! Every piece left lies wholly to one side of the slab. A piece is read by
31//! its vertex farthest from the sheet along the normal: the sheet lies
32//! over every point of the rectangle once, so a line along the normal
33//! through the vertex meets it on one side only.
34
35use ogeom_algo::{Built, History, MeshSolidOptions, solid_from_mesh, tight_bounds};
36use ogeom_core::{Exact, OgeomResult, Predicates, Sign, Tolerances, ogeom_bail, ogeom_err};
37use ogeom_math::{Point, Vector};
38use ogeom_mesh::{Bvh, OFFSET_FARTHEST, Side, Thicken, thicken};
39use ogeom_topo::{Model, Shape, ShapeType, Triangulation, explore_unique};
40use spade::handles::{FixedDirectedEdgeHandle, FixedVertexHandle};
41use spade::{ConstrainedDelaunayTriangulation, Triangulation as _};
42
43/// Cut `shape` by the break surface `region` marks on `mesh`, thickened by
44/// `offset` to each side, and keep the pieces on the `keep` side.
45///
46/// `region` holds one flag per triangle of `mesh`; the flagged triangles
47/// are the break. They must be wound alike (each edge two of them share
48/// run opposite ways in them) and face, as their corners wind
49/// counter-clockwise, one way: seen along their average normal none may
50/// face away or overlap another. [`Side::Above`] keeps the side the break's
51/// faces face, [`Side::Below`] the other.
52///
53/// The break is extended past its rims along its average plane until it is
54/// clear of the shape's box, its holes are closed, and the sheet is
55/// thickened by `offset` to each side into a slab `2 * offset` thick, which
56/// is cut from the shape: the face the kept pieces take along the break
57/// stands `offset` off it, on the kept side. The slab's faces are the
58/// thickened mesh's, merged where coplanar and recognized where they lie on
59/// a cylinder, cone, sphere or torus. The shape's faces keep their
60/// surfaces, those the break does not reach whole. The result is the kept solid, or a compound
61/// of the kept solids where the break leaves several on that side. Its
62/// history is the cut's, with the pieces on the other side deleted.
63///
64/// # Errors
65///
66/// [`OgeomError::Dimension`](ogeom_core::OgeomError::Dimension) if
67/// `region` does not hold one flag per triangle.
68/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
69/// `offset` is not a positive distance, `shape` holds no solid, the region
70/// is empty, a triangle of it names a vertex twice or one that is not
71/// there, two of its triangles run along an edge the same way, its
72/// triangles' areas cancel so it has no average normal, a triangle faces
73/// away from that normal, its rims cross or touch seen along it, or the
74/// region does not separate the shape (no piece lies on one side of it,
75/// the side kept included); and as [`thicken`](ogeom_mesh::thicken()),
76/// [`solid_from_mesh`](ogeom_algo::solid_from_mesh) and [`cut`](crate::cut)
77/// fail.
78/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the
79/// thickened sheet does not build into one closed solid.
80pub fn cut_by_mesh_region(
81    model: &mut Model,
82    shape: &Shape,
83    mesh: &Triangulation,
84    region: &[bool],
85    offset: f64,
86    keep: Side,
87    tol: Tolerances,
88) -> OgeomResult<Built> {
89    if region.len() != mesh.triangles.len() {
90        ogeom_bail!(
91            Dimension,
92            "the region holds {} flags for {} triangles",
93            region.len(),
94            mesh.triangles.len()
95        );
96    }
97    if !(offset.is_finite() && offset > 0.0) {
98        ogeom_bail!(Construction, "an offset of {offset} is not a distance");
99    }
100    if explore_unique(model, shape, ShapeType::Solid)?.is_empty() {
101        ogeom_bail!(Construction, "the shape to cut holds no solid");
102    }
103    let break_mesh = flagged(mesh, region)?;
104    let bounds = tight_bounds(model, shape, tol)?;
105    let (Some(low), Some(high)) = (bounds.low(), bounds.high()) else {
106        ogeom_bail!(Construction, "the shape to cut has no extent");
107    };
108    let corners = [
109        Point::new(low.x, low.y, low.z),
110        Point::new(high.x, low.y, low.z),
111        Point::new(low.x, high.y, low.z),
112        Point::new(high.x, high.y, low.z),
113        Point::new(low.x, low.y, high.z),
114        Point::new(high.x, low.y, high.z),
115        Point::new(low.x, high.y, high.z),
116        Point::new(high.x, high.y, high.z),
117    ];
118    let margin = low.distance(high) + OFFSET_FARTHEST * offset;
119    let sheet = extended(&break_mesh, &corners, margin, tol)?;
120
121    let slab = thicken(&sheet.mesh, 2.0 * offset, Thicken::Both)?;
122    let built = solid_from_mesh(model, &slab.mesh, &MeshSolidOptions::default(), tol)?;
123    if !built.closed || explore_unique(model, &built.shape, ShapeType::Solid)?.len() != 1 {
124        ogeom_bail!(
125            NotDone,
126            "the thickened break did not build into one closed solid"
127        );
128    }
129    let cut = crate::cut(model, shape, &built.shape, tol)?;
130
131    let bvh = Bvh::build(&sheet.mesh)?;
132    let mut kept = Vec::new();
133    let mut dropped = Vec::new();
134    for piece in explore_unique(model, &cut.shape, ShapeType::Solid)? {
135        if side_of(model, &piece, &bvh, sheet.normal)? == keep {
136            kept.push(piece);
137        } else {
138            dropped.push(piece);
139        }
140    }
141    if kept.is_empty() || dropped.is_empty() {
142        ogeom_bail!(
143            Construction,
144            "the region does not separate the shape: {} pieces lie on the side kept \
145             and {} on the other",
146            kept.len(),
147            dropped.len()
148        );
149    }
150
151    let result = if let [one] = kept.as_slice() {
152        one.clone()
153    } else {
154        model.add_compound(&kept)?
155    };
156    let mut step = History::new();
157    for piece in &dropped {
158        for kind in [
159            ShapeType::Solid,
160            ShapeType::Shell,
161            ShapeType::Face,
162            ShapeType::Wire,
163            ShapeType::Edge,
164            ShapeType::Vertex,
165        ] {
166            for sub in explore_unique(model, piece, kind)? {
167                step.delete(&sub);
168            }
169        }
170    }
171    step.modify(&cut.shape, result.clone());
172    Ok(Built::new(result, cut.history.followed_by(&step)))
173}
174
175/// The flagged triangles of `mesh`, their vertices renumbered from nought.
176fn flagged(mesh: &Triangulation, region: &[bool]) -> OgeomResult<Triangulation> {
177    let mut slot = vec![u32::MAX; mesh.positions.len()];
178    let mut out = Triangulation::new();
179    for (t, _) in mesh.triangles.iter().zip(region).filter(|&(_, &f)| f) {
180        let mut corners = [0_u32; 3];
181        for (k, &v) in t.iter().enumerate() {
182            let Some(p) = mesh.positions.get(v as usize) else {
183                ogeom_bail!(
184                    Construction,
185                    "a triangle of the region names vertex {v}, which is not there"
186                );
187            };
188            if !p.is_finite() {
189                ogeom_bail!(Construction, "vertex {v} of the region is not finite");
190            }
191            if slot[v as usize] == u32::MAX {
192                #[allow(
193                    clippy::cast_possible_truncation,
194                    reason = "no more vertices than the mesh's indices name"
195                )]
196                let next = out.positions.len() as u32;
197                slot[v as usize] = next;
198                out.positions.push(*p);
199            }
200            corners[k] = slot[v as usize];
201        }
202        if corners[0] == corners[1] || corners[1] == corners[2] || corners[2] == corners[0] {
203            ogeom_bail!(
204                Construction,
205                "a triangle of the region names a vertex twice"
206            );
207        }
208        out.triangles.push(corners);
209    }
210    if out.triangles.is_empty() {
211        ogeom_bail!(Construction, "the region is empty: no triangle is flagged");
212    }
213    Ok(out)
214}
215
216/// The break extended to a rectangle over its average plane.
217struct Extended {
218    /// The sheet: the break's triangles, then the ones closing its holes and
219    /// reaching to the rectangle, all facing along `normal`.
220    mesh: Triangulation,
221    /// The break's average normal.
222    normal: Vector,
223}
224
225/// The break extended over its average plane to a rectangle holding the
226/// points `clear` and the break, grown by `margin` on each side.
227fn extended(
228    sheet: &Triangulation,
229    clear: &[Point],
230    margin: f64,
231    tol: Tolerances,
232) -> OgeomResult<Extended> {
233    let at = |v: u32| sheet.positions[v as usize];
234
235    // Each directed edge once, or two triangles run along it the same way.
236    let mut edges: ogeom_core::FastMap<(u32, u32), ()> = ogeom_core::FastMap::default();
237    for &[a, b, c] in &sheet.triangles {
238        for (x, y) in [(a, b), (b, c), (c, a)] {
239            if edges.insert((x, y), ()).is_some() {
240                ogeom_bail!(
241                    Construction,
242                    "two triangles of the region run along one edge the same way: \
243                     it is not wound alike or not 2-manifold"
244                );
245            }
246        }
247    }
248    let mut rim: Vec<(u32, u32)> = edges
249        .keys()
250        .filter(|&&(x, y)| !edges.contains_key(&(y, x)))
251        .copied()
252        .collect();
253    rim.sort_unstable();
254
255    // The average normal and plane: the area vectors summed, through the
256    // area-weighted centroid.
257    let mut area = Vector::new(0.0, 0.0, 0.0);
258    let mut moment = Vector::new(0.0, 0.0, 0.0);
259    let mut total = 0.0;
260    for &[a, b, c] in &sheet.triangles {
261        let (pa, pb, pc) = (at(a), at(b), at(c));
262        let twice = (pb - pa).cross(pc - pa);
263        area += twice;
264        let weight = twice.magnitude();
265        total += weight;
266        moment += (pa.to_vector() + pb.to_vector() + pc.to_vector()) * (weight / 3.0);
267    }
268    // A sum of areas that normalizes leaves `total` positive.
269    let Ok(normal) = area.normalized(tol) else {
270        ogeom_bail!(
271            Construction,
272            "the region's triangles' areas cancel: it has no average normal to extend along"
273        );
274    };
275    let origin = Point::from_vector(moment / total);
276    let seed = if normal.x.abs() <= normal.y.abs() && normal.x.abs() <= normal.z.abs() {
277        Vector::new(1.0, 0.0, 0.0)
278    } else if normal.y.abs() <= normal.z.abs() {
279        Vector::new(0.0, 1.0, 0.0)
280    } else {
281        Vector::new(0.0, 0.0, 1.0)
282    };
283    let u = normal.cross(seed).normalized(tol)?;
284    let v = normal.cross(u);
285    let flat = |p: Point| {
286        let d = p - origin;
287        [d.dot(u), d.dot(v)]
288    };
289    let plane: Vec<[f64; 2]> = sheet.positions.iter().map(|&p| flat(p)).collect();
290
291    for &[a, b, c] in &sheet.triangles {
292        if Exact::orient2d(plane[a as usize], plane[b as usize], plane[c as usize])
293            != Sign::Positive
294        {
295            ogeom_bail!(
296                Construction,
297                "a triangle of the region faces away from its average normal: \
298                 the region folds over and cannot be extended without crossing itself"
299            );
300        }
301    }
302
303    // The rectangle, counter-clockwise.
304    let mut lo = [f64::INFINITY; 2];
305    let mut hi = [f64::NEG_INFINITY; 2];
306    for q in plane.iter().copied().chain(clear.iter().map(|&p| flat(p))) {
307        for k in 0..2 {
308            lo[k] = lo[k].min(q[k]);
309            hi[k] = hi[k].max(q[k]);
310        }
311    }
312    let (x0, y0, x1, y1) = (
313        lo[0] - margin,
314        lo[1] - margin,
315        hi[0] + margin,
316        hi[1] + margin,
317    );
318    let frame = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
319
320    // The rims' vertices, then the rectangle's corners.
321    let mut rim_vertices: Vec<u32> = rim.iter().flat_map(|&(x, y)| [x, y]).collect();
322    rim_vertices.sort_unstable();
323    rim_vertices.dedup();
324    let mut coordinates: Vec<[f64; 2]> = rim_vertices.iter().map(|&w| plane[w as usize]).collect();
325    coordinates.extend(frame);
326    if coordinates
327        .iter()
328        .flatten()
329        .any(|&x| spade::validate_coordinate(x).is_err())
330    {
331        ogeom_bail!(
332            Construction,
333            "the region's rim cannot be triangulated over its plane"
334        );
335    }
336    let mut order: Vec<usize> = (0..coordinates.len()).collect();
337    order.sort_by(|&a, &b| {
338        let (p, q) = (coordinates[a], coordinates[b]);
339        p[0].total_cmp(&q[0]).then(p[1].total_cmp(&q[1]))
340    });
341    if order
342        .windows(2)
343        .any(|pair| coordinates[pair[0]] == coordinates[pair[1]])
344    {
345        ogeom_bail!(
346            Construction,
347            "two vertices of the region's rims meet seen along its average normal"
348        );
349    }
350    let slot = |w: u32| rim_vertices.binary_search(&w).unwrap_or(0);
351    let points: Vec<spade::Point2<f64>> = coordinates
352        .iter()
353        .map(|c| spade::Point2::new(c[0], c[1]))
354        .collect();
355    let Ok(mut cdt) =
356        ConstrainedDelaunayTriangulation::<spade::Point2<f64>>::bulk_load_cdt(points, Vec::new())
357    else {
358        ogeom_bail!(
359            Construction,
360            "the region's rim cannot be triangulated over its plane"
361        );
362    };
363    let crossed = || {
364        ogeom_err!(
365            Construction,
366            "the region's rims cross or touch seen along its average normal"
367        )
368    };
369    let mut constrained: Vec<FixedDirectedEdgeHandle> = Vec::with_capacity(rim.len());
370    for &(x, y) in &rim {
371        let (from, to) = (
372            FixedVertexHandle::from_index(slot(x)),
373            FixedVertexHandle::from_index(slot(y)),
374        );
375        let made = cdt.try_add_constraint(from, to);
376        let [e] = made[..] else {
377            return Err(crossed());
378        };
379        let forward = cdt.directed_edge(e).from().fix() == from;
380        constrained.push(if forward { e } else { e.rev() });
381    }
382
383    // How many times the rims wind about each face, walked from the
384    // unbounded face: the face on a rim edge's left counts one more than
385    // the face on its right.
386    let mut weight = vec![0_i64; cdt.num_directed_edges()];
387    for &e in &constrained {
388        weight[e.index()] += 1;
389        weight[e.rev().index()] -= 1;
390    }
391    let mut count: Vec<Option<i64>> = vec![None; cdt.num_all_faces()];
392    let outer = cdt.outer_face().fix().index();
393    count[outer] = Some(0);
394    let mut sides = vec![[None; 3]; cdt.num_all_faces()];
395    for face in cdt.inner_faces() {
396        sides[face.fix().index()] = face.adjacent_edges().map(|e| Some(e.fix()));
397    }
398    let mut stack = vec![outer];
399    let mut around: Vec<FixedDirectedEdgeHandle> = Vec::with_capacity(4);
400    while let Some(f) = stack.pop() {
401        let Some(here) = count[f] else { continue };
402        around.clear();
403        if f == outer {
404            for e in cdt.convex_hull() {
405                around.push(if e.face().is_outer() {
406                    e.fix()
407                } else {
408                    e.rev().fix()
409                });
410            }
411        } else {
412            around.extend(sides[f].iter().flatten());
413        }
414        for &e in &around {
415            let g = cdt.directed_edge(e).rev().face().fix().index();
416            if count[g].is_none() {
417                count[g] = Some(here - weight[e.index()]);
418                stack.push(g);
419            }
420        }
421    }
422    if count.iter().any(|c| !matches!(c, Some(0 | 1))) {
423        return Err(crossed());
424    }
425
426    // The sheet: the break, then the rectangle's corners on the average
427    // plane, then the faces the rims wind about nothing.
428    let mut out = sheet.clone();
429    out.normals.clear();
430    out.parameters.clear();
431    let first_corner = u32::try_from(out.positions.len())
432        .ok()
433        .filter(|&n| n.checked_add(4).is_some())
434        .ok_or_else(|| ogeom_err!(Construction, "the region has too many vertices to extend"))?;
435    for [x, y] in frame {
436        out.positions.push(origin + u * x + v * y);
437    }
438    let vertex = |k: usize| -> u32 {
439        if k < rim_vertices.len() {
440            rim_vertices[k]
441        } else {
442            #[allow(clippy::cast_possible_truncation, reason = "one of four corners")]
443            let corner = (k - rim_vertices.len()) as u32;
444            first_corner + corner
445        }
446    };
447    for face in cdt.inner_faces() {
448        if count[face.fix().index()] != Some(0) {
449            continue;
450        }
451        let [a, b, c] = face.vertices().map(|h| h.fix().index());
452        if Exact::orient2d(coordinates[a], coordinates[b], coordinates[c]) != Sign::Positive {
453            ogeom_bail!(
454                Construction,
455                "the region's rim cannot be triangulated over its plane"
456            );
457        }
458        out.triangles.push([vertex(a), vertex(b), vertex(c)]);
459    }
460    Ok(Extended { mesh: out, normal })
461}
462
463/// The side of the sheet `piece` lies on: read at its vertex farthest from
464/// the sheet along the normal.
465fn side_of(model: &Model, piece: &Shape, sheet: &Bvh, normal: Vector) -> OgeomResult<Side> {
466    let mut best: Option<(f64, Side)> = None;
467    for vertex in explore_unique(model, piece, ShapeType::Vertex)? {
468        let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) else {
469            ogeom_bail!(Dangling, "a vertex of the cut is not in this model");
470        };
471        let p = vertex.transform(model.datums())?.apply(data.point);
472        let up = sheet.ray(p, normal, f64::INFINITY, true);
473        let down = sheet.ray(p, -normal, f64::INFINITY, true);
474        let read = match (up, down) {
475            (Some(hit), None) => (hit.distance, Side::Below),
476            (None, Some(hit)) => (hit.distance, Side::Above),
477            _ => continue,
478        };
479        if best.is_none_or(|(d, _)| read.0 > d) {
480            best = Some(read);
481        }
482    }
483    match best {
484        Some((d, side)) if d > 0.0 => Ok(side),
485        _ => ogeom_bail!(
486            NotDone,
487            "a piece of the cut has no vertex off the break to tell its side by"
488        ),
489    }
490}