1use 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
43pub 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
175fn 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
216struct Extended {
218 mesh: Triangulation,
221 normal: Vector,
223}
224
225fn 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 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 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 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 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 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 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 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
463fn 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}