1use ogeom_core::{OgeomResult, Tolerances};
30use ogeom_geom::{BSplineSurface, Surface as _, SurfaceGeometry, TrimmedSurface};
31use ogeom_math::{Point, Point2, Vector};
32use ogeom_topo::{Model, Shape};
33
34#[derive(Debug, Clone, PartialEq)]
36pub enum HalfSpaces {
37 Below {
41 normal: Vector,
43 at: Point,
45 },
46 Every(Vec<HalfSpaces>),
49 Any(Vec<HalfSpaces>),
51}
52
53impl HalfSpaces {
54 #[must_use]
57 pub fn height(&self, p: Point) -> (f64, Vector) {
58 match self {
59 Self::Below { normal, at } => (normal.dot(p - *at), *normal),
60 Self::Every(pieces) => pieces.iter().map(|r| r.height(p)).fold(
61 (f64::NEG_INFINITY, Vector::ZERO),
62 |a, b| {
63 if b.0 > a.0 { b } else { a }
64 },
65 ),
66 Self::Any(pieces) => {
67 pieces
68 .iter()
69 .map(|r| r.height(p))
70 .fold(
71 (f64::INFINITY, Vector::ZERO),
72 |a, b| {
73 if b.0 < a.0 { b } else { a }
74 },
75 )
76 }
77 }
78 }
79
80 #[must_use]
84 pub fn bound_over(&self, hull: &[Point]) -> f64 {
85 match self {
86 Self::Below { normal, at } => hull
87 .iter()
88 .map(|p| normal.dot(*p - *at))
89 .fold(f64::NEG_INFINITY, f64::max),
90 Self::Every(pieces) => pieces
91 .iter()
92 .map(|r| r.bound_over(hull))
93 .fold(f64::NEG_INFINITY, f64::max),
94 Self::Any(pieces) => pieces
95 .iter()
96 .map(|r| r.bound_over(hull))
97 .fold(f64::INFINITY, f64::min),
98 }
99 }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq)]
104pub struct Uncertified {
105 pub past: f64,
108 pub at: Point,
111 pub found: bool,
114}
115
116pub const DEPTH: u32 = 20;
119
120pub const BUDGET: usize = 1 << 20;
123
124pub const SLACK: f64 = 2.0;
127
128pub fn certify_face_within(
137 model: &Model,
138 face: &Shape,
139 region: &HalfSpaces,
140 fit: f64,
141 tol: Tolerances,
142) -> OgeomResult<Result<(), Uncertified>> {
143 let Some(data) = model.node(face).and_then(|n| n.data().as_face()) else {
144 ogeom_core::ogeom_bail!(Dangling, "face is not in this model");
145 };
146 let Some(surface) = model.geometry().surface(data.surface).cloned() else {
147 ogeom_core::ogeom_bail!(Dangling, "face refers to a surface not in this model");
148 };
149 let place = face.transform(model.datums())?;
150 let chord = fit / 4.0;
151 let deflection = ogeom_mesh::Deflection {
152 chord,
153 ..ogeom_mesh::Deflection::default()
154 };
155 let Some(rings) = ogeom_mesh::face_boundary_in_chart_met(model, face, deflection, tol)? else {
156 let ((u, _), (v, _)) = surface.domain();
158 return Ok(Err(Uncertified {
159 past: f64::INFINITY,
160 at: place.apply(surface.point_at(u, v, tol)?),
161 found: false,
162 }));
163 };
164 let Some(trim) = Trim::of(&rings) else {
165 return Ok(Ok(()));
166 };
167 let spline = match &surface {
170 SurfaceGeometry::BSpline(s) => {
171 let ((u0, u1), (v0, v1)) = s.domain();
172 let (lo, hi) = (trim.lo, trim.hi);
173 Some(s.segment(
174 (lo.x.max(u0), hi.x.min(u1)),
175 (lo.y.max(v0), hi.y.min(v1)),
176 tol,
177 )?)
178 }
179 _ => None,
180 };
181 let start = match &spline {
182 Some(s) => {
183 let ((u0, u1), (v0, v1)) = s.domain();
184 (Point2::new(u0, v0), Point2::new(u1, v1))
185 }
186 None => (trim.lo, trim.hi),
187 };
188 let mut pending: Vec<(Point2, Point2, u32, Option<BSplineSurface>)> =
189 vec![(start.0, start.1, 0, spline)];
190 let mut tried = 0usize;
191 while let Some((a, b, depth, parent)) = pending.pop() {
192 tried += 1;
193 let centre = Point2::new(f64::midpoint(a.x, b.x), f64::midpoint(a.y, b.y));
194 let inside = trim.inside(centre);
195 if !inside {
196 let (du, dv) = surface.d1_at(centre.x, centre.y, tol)?;
199 let scale = du.magnitude().min(dv.magnitude());
200 let slack = if scale > 0.0 {
201 SLACK * chord / scale
202 } else {
203 f64::INFINITY
204 };
205 let half = (b.x - a.x).hypot(b.y - a.y) * 0.5;
206 if !trim.near(centre, half + slack) {
207 continue;
208 }
209 }
210 let piece = match &parent {
211 Some(p) => p.segment((a.x, b.x), (a.y, b.y), tol)?,
212 None => SurfaceGeometry::Trimmed(Box::new(TrimmedSurface::new(
213 surface.clone(),
214 (a.x, b.x),
215 (a.y, b.y),
216 tol,
217 )?))
218 .to_bspline(tol)?,
219 };
220 let net = piece.grid().points();
221 let bound = if net.iter().all(|w| w.weight > 0.0) {
222 let hull: Vec<Point> = net.iter().map(|w| place.apply(w.point())).collect();
223 region.bound_over(&hull)
224 } else {
225 f64::INFINITY
226 };
227 if bound <= fit {
228 continue;
229 }
230 let at = place.apply(surface.point_at(centre.x, centre.y, tol)?);
231 if inside {
232 let (past, _) = region.height(at);
233 if past > fit {
234 return Ok(Err(Uncertified {
235 past,
236 at,
237 found: true,
238 }));
239 }
240 }
241 if depth >= DEPTH || tried >= BUDGET {
242 return Ok(Err(Uncertified {
243 past: bound,
244 at,
245 found: false,
246 }));
247 }
248 let carried = parent.is_some().then_some(piece);
249 for (x0, x1) in [(a.x, centre.x), (centre.x, b.x)] {
250 for (y0, y1) in [(a.y, centre.y), (centre.y, b.y)] {
251 pending.push((
252 Point2::new(x0, y0),
253 Point2::new(x1, y1),
254 depth + 1,
255 carried.clone(),
256 ));
257 }
258 }
259 }
260 Ok(Ok(()))
261}
262
263struct Trim {
267 segments: Vec<(Point2, Point2)>,
268 lo: Point2,
269 hi: Point2,
270 step: (f64, f64),
271 cells: Vec<Vec<usize>>,
272 bands: Vec<Vec<usize>>,
273}
274
275const CELLS: usize = 64;
277
278impl Trim {
279 fn of(rings: &[Vec<Point2>]) -> Option<Self> {
281 let segments: Vec<(Point2, Point2)> = rings
282 .iter()
283 .flat_map(|ring| (0..ring.len()).map(move |k| (ring[k], ring[(k + 1) % ring.len()])))
284 .collect();
285 if segments.is_empty() {
286 return None;
287 }
288 let (mut lo, mut hi) = (
289 Point2::new(f64::INFINITY, f64::INFINITY),
290 Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
291 );
292 for (a, _) in &segments {
293 lo = Point2::new(lo.x.min(a.x), lo.y.min(a.y));
294 hi = Point2::new(hi.x.max(a.x), hi.y.max(a.y));
295 }
296 #[expect(clippy::cast_precision_loss, reason = "a cell count of sixty-four")]
297 let step = (
298 ((hi.x - lo.x) / CELLS as f64).max(f64::MIN_POSITIVE),
299 ((hi.y - lo.y) / CELLS as f64).max(f64::MIN_POSITIVE),
300 );
301 let mut trim = Self {
302 segments,
303 lo,
304 hi,
305 step,
306 cells: vec![Vec::new(); CELLS * CELLS],
307 bands: vec![Vec::new(); CELLS],
308 };
309 for k in 0..trim.segments.len() {
310 let (a, b) = trim.segments[k];
311 let (i0, j0) = trim.cell(Point2::new(a.x.min(b.x), a.y.min(b.y)));
312 let (i1, j1) = trim.cell(Point2::new(a.x.max(b.x), a.y.max(b.y)));
313 for i in i0..=i1 {
314 for j in j0..=j1 {
315 trim.cells[i * CELLS + j].push(k);
316 }
317 }
318 for band in &mut trim.bands[j0..=j1] {
319 band.push(k);
320 }
321 }
322 Some(trim)
323 }
324
325 fn cell(&self, p: Point2) -> (usize, usize) {
327 #[expect(
328 clippy::cast_possible_truncation,
329 clippy::cast_sign_loss,
330 reason = "clamped to the grid's sixty-four cells"
331 )]
332 let at = |x: f64, lo: f64, step: f64| {
333 (((x - lo) / step).floor().max(0.0) as usize).min(CELLS - 1)
334 };
335 (
336 at(p.x, self.lo.x, self.step.0),
337 at(p.y, self.lo.y, self.step.1),
338 )
339 }
340
341 fn near(&self, p: Point2, reach: f64) -> bool {
343 if !reach.is_finite() {
344 return true;
345 }
346 if p.x + reach < self.lo.x
347 || p.x - reach > self.hi.x
348 || p.y + reach < self.lo.y
349 || p.y - reach > self.hi.y
350 {
351 return false;
352 }
353 let (i0, j0) = self.cell(Point2::new(p.x - reach, p.y - reach));
354 let (i1, j1) = self.cell(Point2::new(p.x + reach, p.y + reach));
355 (i0..=i1).any(|i| {
356 (j0..=j1).any(|j| {
357 self.cells[i * CELLS + j]
358 .iter()
359 .any(|&k| distance_to(p, self.segments[k]) <= reach)
360 })
361 })
362 }
363
364 fn inside(&self, p: Point2) -> bool {
367 if p.y < self.lo.y || p.y > self.hi.y {
368 return false;
369 }
370 let (_, j) = self.cell(p);
371 let mut crossings = 0usize;
372 for &k in &self.bands[j] {
373 let (a, b) = self.segments[k];
374 if (a.y > p.y) != (b.y > p.y) {
375 let x = a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x);
376 if x > p.x {
377 crossings += 1;
378 }
379 }
380 }
381 crossings % 2 == 1
382 }
383}
384
385fn distance_to(p: Point2, (a, b): (Point2, Point2)) -> f64 {
387 let (ux, uy) = (b.x - a.x, b.y - a.y);
388 let length = ux.mul_add(ux, uy * uy);
389 let t = if length > 0.0 {
390 (((p.x - a.x) * ux + (p.y - a.y) * uy) / length).clamp(0.0, 1.0)
391 } else {
392 0.0
393 };
394 (p.x - (a.x + ux * t)).hypot(p.y - (a.y + uy * t))
395}
396
397#[cfg(test)]
398mod tests {
399 #![allow(clippy::unwrap_used, reason = "test code")]
400 use super::*;
401 use ogeom_geom::BSplineSurface;
402 use ogeom_math::{ControlGrid, Frame, KnotVector};
403 use ogeom_topo::{ShapeType, explore_unique};
404
405 const T: Tolerances = Tolerances::millimetres();
406 const FIT: f64 = 2e-4;
407
408 fn below(normal: Vector, at: Point) -> HalfSpaces {
409 HalfSpaces::Below { normal, at }
410 }
411
412 fn square(half: f64) -> HalfSpaces {
414 let side =
415 |x: f64, y: f64| below(Vector::new(x, y, 0.0), Point::new(x * half, y * half, 0.0));
416 HalfSpaces::Every(vec![
417 side(1.0, 0.0),
418 side(-1.0, 0.0),
419 side(0.0, 1.0),
420 side(0.0, -1.0),
421 ])
422 }
423
424 fn faces_of(model: &Model, solid: &Shape, kind: ogeom_geom::SurfaceKind) -> Vec<Shape> {
426 explore_unique(model, solid, ShapeType::Face)
427 .unwrap()
428 .into_iter()
429 .filter(|f| {
430 let data = model.node(f).unwrap().data().as_face().unwrap();
431 model.geometry().surface(data.surface).unwrap().kind() == kind
432 })
433 .collect()
434 }
435
436 #[test]
441 fn a_cylinder_inside_its_square_is_proved_and_one_poking_out_is_found() {
442 let mut m = Model::new();
443 let solid = crate::make_cylinder(&mut m, Frame::WORLD, 1.0, 3.0, T)
444 .unwrap()
445 .shape;
446 let side = faces_of(&m, &solid, ogeom_geom::SurfaceKind::Cylinder);
447 assert_eq!(side.len(), 1);
448 assert_eq!(
449 certify_face_within(&m, &side[0], &square(1.0), FIT, T).unwrap(),
450 Ok(())
451 );
452 let out = certify_face_within(&m, &side[0], &square(0.9), FIT, T)
453 .unwrap()
454 .unwrap_err();
455 assert!(out.found, "{out:?}");
456 assert!(out.past > FIT && out.past <= 0.1 + 1e-12, "{out:?}");
457 }
458
459 #[test]
463 fn a_sphere_in_its_cube_is_proved_and_past_a_drawn_in_side_is_found() {
464 let mut m = Model::new();
465 let solid = crate::make_sphere(&mut m, Frame::WORLD, 2.0, T)
466 .unwrap()
467 .shape;
468 let faces = faces_of(&m, &solid, ogeom_geom::SurfaceKind::Sphere);
469 let side = |n: Vector, d: f64| below(n, Point::ORIGIN + n * d);
470 let axes = [
471 Vector::new(1.0, 0.0, 0.0),
472 Vector::new(-1.0, 0.0, 0.0),
473 Vector::new(0.0, 1.0, 0.0),
474 Vector::new(0.0, -1.0, 0.0),
475 Vector::new(0.0, 0.0, 1.0),
476 Vector::new(0.0, 0.0, -1.0),
477 ];
478 let cube = HalfSpaces::Every(axes.iter().map(|n| side(*n, 2.0)).collect());
479 for f in &faces {
480 assert_eq!(certify_face_within(&m, f, &cube, FIT, T).unwrap(), Ok(()));
481 }
482 let mut pieces: Vec<HalfSpaces> = axes.iter().map(|n| side(*n, 2.0)).collect();
483 pieces[4] = side(axes[4], 1.95);
484 let drawn = HalfSpaces::Every(pieces);
485 let worst = faces
486 .iter()
487 .filter_map(|f| certify_face_within(&m, f, &drawn, FIT, T).unwrap().err())
488 .collect::<Vec<_>>();
489 assert!(!worst.is_empty());
490 assert!(
491 worst
492 .iter()
493 .all(|u| u.found && u.past > FIT && u.past <= 0.05 + 1e-12)
494 );
495 }
496
497 #[test]
504 fn a_patch_bulging_past_its_plane_between_its_vertices_is_found() {
505 let mut m = Model::new();
506 let mut points = Vec::new();
507 for i in 0..3 {
508 for j in 0..3 {
509 let z = if i == 1 && j == 1 { 1.0 } else { 0.0 };
510 points.push(Point::new(f64::from(i) * 0.5, f64::from(j) * 0.5, z));
511 }
512 }
513 let knots = || KnotVector::new(vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], 2).unwrap();
514 let grid = ControlGrid::new(points, 3, 3).unwrap();
515 let surface = BSplineSurface::new(knots(), knots(), &grid, T).unwrap();
516 let face = crate::make_natural_face(&mut m, surface.into())
517 .unwrap()
518 .shape;
519 let plane = below(Vector::new(0.0, 0.0, 1.0), Point::ORIGIN);
520 let out = certify_face_within(&m, &face, &plane, FIT, T)
521 .unwrap()
522 .unwrap_err();
523 assert!(out.found, "{out:?}");
524 assert_eq!(out.past, 0.25, "{out:?}");
525 let raised = below(Vector::new(0.0, 0.0, 1.0), Point::new(0.0, 0.0, 0.25));
527 assert_eq!(
528 certify_face_within(&m, &face, &raised, FIT, T).unwrap(),
529 Ok(())
530 );
531 }
532
533 #[test]
538 fn the_least_of_pieces_proves_a_union_and_finds_what_falls_between() {
539 let mut m = Model::new();
540 let solid = crate::make_cylinder(&mut m, Frame::WORLD, 1.0, 3.0, T)
541 .unwrap()
542 .shape;
543 let side = &faces_of(&m, &solid, ogeom_geom::SurfaceKind::Cylinder)[0];
544 let x = Vector::new(1.0, 0.0, 0.0);
545 let both = HalfSpaces::Any(vec![
546 below(x, Point::new(1.0, 0.0, 0.0)),
547 below(-x, Point::new(-1.0, 0.0, 0.0)),
548 ]);
549 assert_eq!(
550 certify_face_within(&m, side, &both, FIT, T).unwrap(),
551 Ok(())
552 );
553 let apart = HalfSpaces::Any(vec![
554 below(x, Point::new(-0.5, 0.0, 0.0)),
555 below(-x, Point::new(0.5, 0.0, 0.0)),
556 ]);
557 let out = certify_face_within(&m, side, &apart, FIT, T)
558 .unwrap()
559 .unwrap_err();
560 assert!(
561 out.found && out.past > FIT && out.past <= 0.5 + 1e-12,
562 "{out:?}"
563 );
564 }
565}