geop_core_topology/contains/face.rs
1use geop_core_geometry::{
2 contains::curve::curve_could_contain,
3 intersection::{curve_curve_intersect, refine_curve_curve_crossing},
4 nurb_curve::{NurbCurve, NurbCurve2D},
5 nurb_surface::NurbSurface3D,
6};
7use geop_core_math::{
8 geop_error::{GeopError, GeopResult, WithContext},
9 scalars::Scalar,
10 solvers::probe::{Probe, attempts, halve},
11 vector::{Vector2, Vector3},
12};
13
14use crate::{CoedgeId, FaceId, Model, boundary::BoundaryType, contains::rng::Rng};
15
16/// Result of classifying a query point against a face's trimmed boundary.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum PointClassification {
19 /// The query point coincides with a vertex (shared by two coedges).
20 OnVertex,
21 /// The query point lies on a coedge's pcurve, away from its endpoints.
22 OnCoedge,
23 /// The query point is strictly inside the trimmed boundary.
24 Inside,
25 /// The query point is strictly outside the trimmed boundary (or inside
26 /// a hole).
27 Outside,
28}
29
30const MAX_RAY_ATTEMPTS: usize = 64;
31
32/// Classify `(u, v)` against `face_id`'s trimmed boundary (outer loop minus
33/// holes): [`PointClassification::OnVertex`] / [`PointClassification::OnCoedge`]
34/// if the query point itself coincides with a vertex or lies on a coedge's
35/// pcurve, else [`PointClassification::Inside`]/[`PointClassification::Outside`]
36/// via ray casting in parameter space.
37///
38/// The ray direction is drawn from a seeded PRNG (see [`Rng`]) and retried
39/// (up to a bounded number of attempts) until every crossing it finds lands
40/// strictly inside a coedge's pcurve, away from any vertex — vertex grazes
41/// are ambiguous to count (shared by two coedges) so they're avoided rather
42/// than specially classified. Once such a direction is found, the parity
43/// (even/odd) of its crossing count determines inside/outside; this needs no
44/// normal or winding-direction information, so it works regardless of a
45/// face's loop orientation.
46///
47/// `max_nodes` bounds both the `curve_could_contain` BFS subdivision search
48/// (on-vertex/on-coedge tests) and the `curve_curve_intersect` DFS search
49/// (edge-interior hits). `seed` seeds the direction PRNG.
50pub fn face_contains(
51 model: &Model,
52 face_id: FaceId,
53 u: Scalar,
54 v: Scalar,
55 max_nodes: usize,
56 epsilon: Scalar,
57 seed: u64,
58) -> GeopResult<PointClassification> {
59 let face = &model.faces[&face_id];
60 // A hole that is a bare vertex (another body touching the face at a
61 // point) has no pcurve for the loops to find it by: it is a point of
62 // the boundary all the same, and a query that could be it is on it.
63 for boundary in face.boundaries() {
64 if let BoundaryType::Vertex(vertex_id) = boundary {
65 let at = face.surface.evaluate(u, v)?;
66 if at.could_be_equal(&model.get_vertex(vertex_id)?.point) {
67 return Ok(PointClassification::OnVertex);
68 }
69 }
70 }
71 let coedges: Vec<CoedgeId> = model.iterate_face_coedges(face_id).collect();
72 loops_contain(
73 model,
74 &face.surface,
75 &coedges,
76 Vector2::from_array([u, v]),
77 max_nodes,
78 epsilon,
79 seed,
80 )
81}
82
83/// The same classification as [`face_contains`], but against an explicit set
84/// of loops rather than all of a face's.
85///
86/// Exists because "inside this face" and "inside this face's *outer* loop"
87/// are different questions, and validation needs the second: a hole has to
88/// lie within the outer boundary, and asking [`face_contains`] would only
89/// ever answer `OnCoedge` for a point taken from the hole itself. Splitting
90/// the ray casting out here keeps one implementation of it rather than a
91/// second copy that could drift.
92pub fn loops_contain(
93 model: &Model,
94 surface: &NurbSurface3D,
95 coedges: &[CoedgeId],
96 query: Vector2,
97 max_nodes: usize,
98 epsilon: Scalar,
99 seed: u64,
100) -> GeopResult<PointClassification> {
101 let pcurves: Vec<&NurbCurve2D> = coedges
102 .iter()
103 .map(|c| Ok(&model.get_coedge(*c)?.pcurve))
104 .collect::<GeopResult<_>>()?;
105 pcurves_contain(surface, &pcurves, query, max_nodes, epsilon, seed).with_context(
106 &|e: GeopError| {
107 e.with_context(format!(
108 "loops_contain(coedges={coedges:?}, pcurve k is coedge k)"
109 ))
110 },
111 )
112}
113
114/// The classification of [`loops_contain`], against the closed loops that
115/// `pcurves` run around on `surface` — for loops not in a model yet, being
116/// built. `pcurve k` in an error is `pcurves[k]`.
117pub fn pcurves_contain(
118 surface: &NurbSurface3D,
119 pcurves: &[&NurbCurve2D],
120 query: Vector2,
121 max_nodes: usize,
122 epsilon: Scalar,
123 seed: u64,
124) -> GeopResult<PointClassification> {
125 // Is the query point itself a vertex, or on some coedge's pcurve?
126 // Checked as two full passes (all vertices, then all curves) so
127 // `OnVertex` always takes priority over `OnCoedge` regardless of
128 // coedge iteration order.
129 for pcurve in pcurves {
130 let vertex_pt = pcurve.evaluate(pcurve.domain().0)?;
131 if vertex_pt.could_be_equal(&query) {
132 return Ok(PointClassification::OnVertex);
133 }
134 }
135 for pcurve in pcurves {
136 if curve_could_contain(pcurve, &query, max_nodes, epsilon)?.is_some() {
137 return Ok(PointClassification::OnCoedge);
138 }
139 }
140
141 // The query touches no vertex and no pcurve, so — connected as it is —
142 // all of it lies on one side of the boundary, and any one point of it
143 // decides which: the ray is cast from a sharp one. Cast from a wide
144 // query, the ray would be a strip, and a strip merely grazing a pcurve
145 // overlaps it as a crossing does — one hit, flipping the parity — where
146 // a ray from any single point of the query misses it or crosses twice.
147 let origin = Vector2::from_array([query[0].midpoint(), query[1].midpoint()]);
148
149 let (u_lo, u_hi) = surface.domain_u();
150 let (v_lo, v_hi) = surface.domain_v();
151 let du = u_hi.sub(u_lo);
152 let dv = v_hi.sub(v_lo);
153 let diag = du.mul(du).add(dv.mul(dv)).sqrt()?;
154 let ray_length = diag.mul(Scalar::from_f64(3.0)).add(Scalar::ONE);
155
156 let mut rng = Rng::new(seed);
157 // Why the most recent direction was given up on — reported if every one
158 // is, since "no clear direction" alone says nothing about the cause.
159 let mut last_rejection = String::new();
160 // Directions tried in turn until one is clear.
161 let found = attempts(MAX_RAY_ATTEMPTS, |_| -> GeopResult<_> {
162 let dir = rng.next_direction2();
163 let far = origin.add(&dir.prod_scalar(ray_length));
164 let ray: NurbCurve2D = NurbCurve::try_new(
165 1,
166 vec![
167 Vector3::from_array([origin[0], origin[1], Scalar::ONE]),
168 Vector3::from_array([far[0], far[1], Scalar::ONE]),
169 ],
170 vec![Scalar::ZERO, Scalar::ZERO, Scalar::ONE, Scalar::ONE],
171 )?;
172
173 for (k, pcurve) in pcurves.iter().enumerate() {
174 let vertex_pt = pcurve.evaluate(pcurve.domain().0)?;
175 if curve_could_contain(&ray, &vertex_pt, max_nodes, epsilon)?.is_some() {
176 last_rejection =
177 format!("ray {ray:?} could pass through pcurve {k}'s start {vertex_pt:?}");
178 return Ok(Probe::Next);
179 }
180 }
181
182 let mut count = 0usize;
183 for (k, pcurve) in pcurves.iter().enumerate() {
184 let (d0, d1) = pcurve.domain();
185 // An `Err` here means the search exhausted its node budget
186 // before converging — the same ambiguous signal as a vertex/edge
187 // graze (see `curve_curve_intersect`'s own doc comment: budget
188 // exhaustion is itself evidence of a near-tangential or
189 // coincident ray, not a reliable crossing count), so it's
190 // handled the same way: retry with a fresh direction rather than
191 // propagating a hard failure.
192 let hits = match curve_curve_intersect(&ray, pcurve, max_nodes, max_nodes, epsilon) {
193 Ok(hits) => hits.into_vec(),
194 Err(e) => {
195 last_rejection = format!("ray {ray:?} x pcurve {k} pcurve {pcurve:?}: {e:?}");
196 return Ok(Probe::Next);
197 }
198 };
199 for (t, mid) in hits {
200 // A crossing the search cannot place beyond the query point
201 // is refined until it can: a crossing right next to the query
202 // is a real one — the boundary is just there — and has to be
203 // counted. Dropping every hit within `epsilon` of the query,
204 // as this once did, turned a point 6e-5 outside a face into
205 // one inside it.
206 let (t, mid, refined) = if t.definitely_greater(Scalar::ZERO) {
207 (t, mid, false)
208 } else {
209 let (rt, rmid) = refine_curve_curve_crossing(&ray, pcurve, t, mid);
210 // Refinement returns the box unchanged where it fails;
211 // where it converges, the box is far below the search's
212 // resolution.
213 let same = rt.is_subset_of(t) && t.is_subset_of(rt);
214 (rt, rmid, !same && rt.width().definitely_less(epsilon))
215 };
216 if !t.definitely_greater(Scalar::ZERO) {
217 // Still not beyond it. Either the query is on this
218 // pcurve to within what the numbers can tell — the
219 // check above asks about the sharp query, and a point
220 // 6e-16 off the boundary passes it — or the search met
221 // the pcurve only at its own resolution, near the query
222 // but not on it, and this ray is as ambiguous as a
223 // vertex graze. A crossing Newton has isolated is the
224 // first: the ray meets the pcurve where it starts, as
225 // far as rounding can tell. A point 4e-16 inside a face
226 // met its boundary so along every ray, and was never
227 // classified.
228 if refined || pcurve.evaluate(mid)?.could_be_equal(&query) {
229 return Ok(Probe::Found(PointClassification::OnCoedge));
230 }
231 last_rejection = format!(
232 "ray {ray:?} meets pcurve {k} at t={t:?}, which cannot be told from the query point {query:?}; the pcurve there is {:?}, the pcurve {pcurve:?}",
233 pcurve.evaluate(mid)?
234 );
235 return Ok(Probe::Next);
236 }
237 // `curve_curve_intersect` honestly returns the whole
238 // surviving span of its converged leaf, not an arbitrarily
239 // narrowed midpoint — sharpen before comparing against the
240 // pcurve's own endpoints.
241 let mid = mid.midpoint();
242 if !mid.sub(d0).abs().definitely_greater(epsilon)
243 || !mid.sub(d1).abs().definitely_greater(epsilon)
244 {
245 // Grazes a vertex despite the check above (numerical
246 // slop right at the boundary) — retry with a fresh
247 // direction rather than risk mis-counting it.
248 last_rejection = format!(
249 "ray {ray:?} grazes pcurve {k}'s end at t={mid:?} (domain {d0:?}..{d1:?})"
250 );
251 return Ok(Probe::Next);
252 }
253 count += 1;
254 }
255 }
256 Ok(Probe::Found(if count % 2 == 1 {
257 PointClassification::Inside
258 } else {
259 PointClassification::Outside
260 }))
261 })?;
262 if let Some(classification) = found {
263 return Ok(classification);
264 }
265 Err(GeopError::new(format!(
266 "loops_contain: could not find a ray direction clear of every vertex after many attempts; \
267 the last one was rejected because {last_rejection}"
268 )))
269}
270
271/// How many interior points [`face_interior_point_where`] offers from one
272/// boundary base point before moving to the next: enough for two genuinely
273/// different points, few enough that rejecting everything stays cheap.
274const POINTS_PER_BASE: usize = 2;
275
276/// How many times [`face_interior_point`] may halve its step before giving
277/// up. Bounds effort only: each halving is another attempt to land inside the
278/// trim, and exhausting them is reported as an error rather than accepted.
279const MAX_HALVINGS: usize = 40;
280
281/// How many times [`face_interior_point_where`] makes its margin from the
282/// boundary finer, by [`MARGIN_RATIO`] each time, before it gives up: from
283/// the usual `1e-4` to `1.5e-9` of the surface's parameters.
284const FINER_MARGINS: usize = 4;
285/// How much finer each margin is than the last.
286const MARGIN_RATIO: f64 = 16.0;
287
288/// A `(u, v)` strictly inside `face_id`'s trimmed region.
289///
290/// Found the way the operation itself suggests: start on the boundary and
291/// step inward. The step is taken along the inward normal of the outer loop
292/// at a boundary point — inward in `(u, v)`, obtained by rotating the loop's
293/// own tangent — and halved whenever the point it lands on is not
294/// `Inside`. Halving converges on any non-degenerate face, since a
295/// sufficiently short inward step from a boundary point is always interior,
296/// and it needs no guess about the face's size.
297///
298/// The domain midpoint is not usable for this: a trimmed face need not
299/// contain it, and for a face carved out by a boolean's remesh it very often
300/// does not.
301///
302/// `max_nodes`/`seed` are passed straight to [`face_contains`], and `epsilon` as
303/// its first tolerance: how far clear of the boundary, in `(u, v)`, a point
304/// has to be — made finer where none is that far (see
305/// [`face_interior_point_where`]).
306pub fn face_interior_point(
307 model: &Model,
308 face_id: FaceId,
309 max_nodes: usize,
310 epsilon: Scalar,
311 seed: u64,
312) -> GeopResult<(Scalar, Scalar)> {
313 let found =
314 face_interior_point_where(model, face_id, max_nodes, epsilon, seed, |_, _| Ok(true))?;
315 Ok(found.expect("the first interior point found is always accepted"))
316}
317
318/// Like [`face_interior_point`], but for a caller that needs a point with
319/// some further property: up to one interior point per outer coedge (stepped
320/// in from it, in loop order) is handed to `accept`, and the first it accepts
321/// is returned. `Ok(None)` means interior points were found but none was
322/// accepted; an error, as for `face_interior_point`, that none was found.
323pub fn face_interior_point_where(
324 model: &Model,
325 face_id: FaceId,
326 max_nodes: usize,
327 epsilon: Scalar,
328 seed: u64,
329 mut accept: impl FnMut(Scalar, Scalar) -> GeopResult<bool>,
330) -> GeopResult<Option<(Scalar, Scalar)>> {
331 let face = &model.faces[&face_id];
332 let BoundaryType::Loop(anchor) = face.outer else {
333 return Err(GeopError::new(format!(
334 "face_interior_point: face {face_id} is bounded by a bare vertex, so it has no interior to sample"
335 )));
336 };
337
338 let (u_lo, u_hi) = face.surface.domain_u();
339 let (v_lo, v_hi) = face.surface.domain_v();
340 let du = u_hi.sub(u_lo);
341 let dv = v_hi.sub(v_lo);
342 let diagonal = if dv.definitely_greater(du) { dv } else { du };
343
344 // Every coedge of the outer loop is a candidate base point, not just the
345 // anchor's. One base point is not enough in practice: a loop can pass
346 // through a degenerate stretch (a revolve pole, a sliver left by a face
347 // split) where the tangent is unusable or where the face is locally
348 // thinner than `face_contains`' own tolerance band, and there the search
349 // fails however finely it steps — while a different side of the very same
350 // face offers an easy interior point.
351 let coedges: Vec<CoedgeId> = model
352 .iterate_loop_coedges(anchor)
353 .take(model.coedges.len() + 1)
354 .collect();
355
356 // How far clear of the boundary a point has to be, in `(u, v)`: the
357 // search's resolution `epsilon` first, then finer. A face thinner in its
358 // surface's parameters than that resolution — a fillet's corner piece
359 // on a face a hundred times its size — has interior points all the
360 // same; the margin only says how hard the search tries to find one, and
361 // a finer one takes `face_contains` more subdivision.
362 let mut found_any = false;
363 let mut margin = epsilon;
364 for _ in 0..=FINER_MARGINS {
365 for &coedge_id in &coedges {
366 let pcurve = &model.get_coedge(coedge_id)?.pcurve;
367 let t = pcurve.mid_parameter()?;
368 let Ok(base) = pcurve.evaluate(t) else {
369 continue;
370 };
371 let Ok(tangent) = pcurve.tangent(t).and_then(|d| d.normalize()) else {
372 continue;
373 };
374
375 // Rotate the tangent a quarter turn in `(u, v)`. Which of the two
376 // perpendiculars points *into* the face depends on the loop's
377 // winding, so both are tried and whichever lands inside wins —
378 // cheaper and more robust than deriving the winding.
379 let normals = [
380 tangent.perp(),
381 Vector2::from_array([tangent[1], tangent[0].neg()]),
382 ];
383
384 let mut offered = 0;
385 let found = halve(
386 diagonal.div(Scalar::TWO)?,
387 MAX_HALVINGS,
388 |&step| -> GeopResult<_> {
389 for inward in normals {
390 // Any point inside the face will do — a free choice — so the
391 // candidate is sharp: it doesn't inherit the width of the
392 // pcurve it was stepped from, and every later computation
393 // from it (a classification ray, say) starts from a point.
394 let u = base[0].add(inward[0].mul(step)).sharpen();
395 let v = base[1].add(inward[1].mul(step)).sharpen();
396 if u.definitely_less(u_lo)
397 || u.definitely_greater(u_hi)
398 || v.definitely_less(v_lo)
399 || v.definitely_greater(v_hi)
400 {
401 continue;
402 }
403 // Accepted only if the whole box of radius `margin` around it
404 // is inside, not just the point: the point is a free choice,
405 // and every later use of it (a classification ray, a surface
406 // evaluation compared against other solids) works at that
407 // resolution. A point merely strictly inside can sit 5e-16
408 // off the boundary — one inward step exactly the face's width
409 // lands there — and at `margin` it *is* on the boundary.
410 let neighbourhood = |t: Scalar| t.sub(margin).union(t.add(margin));
411 if matches!(
412 face_contains(
413 model,
414 face_id,
415 neighbourhood(u),
416 neighbourhood(v),
417 max_nodes,
418 margin,
419 seed
420 )?,
421 PointClassification::Inside
422 ) {
423 found_any = true;
424 if accept(u, v)? {
425 return Ok(Probe::Found((u, v)));
426 }
427 // Rejected: the next point comes from half this step, so
428 // it differs from this one — stepping in from each side
429 // of a symmetric face lands every base on the same
430 // centre, so bases alone don't give distinct points. Two
431 // points per base keep a caller that rejects everything
432 // (every point it tries turns out alike) cheap.
433 offered += 1;
434 if offered == POINTS_PER_BASE {
435 return Ok(Probe::Stop);
436 }
437 // Leave the direction loop, reaching the halving below.
438 break;
439 }
440 }
441 // Halving walks toward the boundary, and `face_contains` reports
442 // `OnCoedge` for anything within its own tolerance of it — so
443 // below that tolerance every step is `OnCoedge` and no amount of
444 // further halving can succeed. Stop and let the next base point
445 // try instead of burning the remaining budget here.
446 Ok(if step.definitely_greater(margin) {
447 Probe::Next
448 } else {
449 Probe::Stop
450 })
451 },
452 )?;
453 if found.is_some() {
454 return Ok(found);
455 }
456 }
457 if found_any {
458 break;
459 }
460 margin = margin.div(Scalar::from_f64(MARGIN_RATIO))?;
461 }
462
463 if found_any {
464 return Ok(None);
465 }
466
467 // Report the outer loop's own `(u, v)` extent. A loop that encloses no
468 // area has nothing inside it and the failure is correct — a degenerate
469 // face, to be fixed wherever it was created. A loop with real extent
470 // means the search failed on a face that does have an interior, which is
471 // this function's bug. The two need opposite fixes and are otherwise
472 // indistinguishable from the message.
473 let mut u_extent = None;
474 let mut v_extent = None;
475 for &coedge_id in &coedges {
476 let Ok(coedge) = model.get_coedge(coedge_id) else {
477 continue;
478 };
479 let (t0, t1) = coedge.pcurve.domain();
480 for i in 0..=4 {
481 let Ok(frac) = Scalar::from_ratio(i, 4) else {
482 continue;
483 };
484 let Ok(uv) = coedge.pcurve.evaluate(t0.add(t1.sub(t0).mul(frac))) else {
485 continue;
486 };
487 u_extent = Some(match u_extent {
488 None => uv[0],
489 Some(e) => Scalar::union(e, uv[0]),
490 });
491 v_extent = Some(match v_extent {
492 None => uv[1],
493 Some(e) => Scalar::union(e, uv[1]),
494 });
495 }
496 }
497 // Each coedge's edge, start vertex and pcurve start: enough to recognise
498 // the face in the model, to see a loop that doubles back on itself (an
499 // edge appearing twice, as a spur), and to see a pcurve whose `(u, v)`
500 // disagrees with where its 3-D vertex actually is on the surface.
501 let loop_description: Vec<String> = coedges
502 .iter()
503 .map(|&coedge_id| {
504 let start = model.coedge_start_vertex(coedge_id).map(|v| v.point);
505 let coedge = model.get_coedge(coedge_id);
506 let geometry = coedge.as_ref().ok().map(|c| c.geometry);
507 let start_uv = coedge.and_then(|c| c.pcurve.evaluate(c.pcurve.domain().0));
508 format!("{coedge_id} ({geometry:?}): starts at {start:?}, (u, v) = {start_uv:?}")
509 })
510 .collect();
511 Err(GeopError::new(format!(
512 "face_interior_point: no point strictly inside face {face_id} was found, stepping inward from the midpoint of each of its {} outer coedges; that loop spans u={u_extent:?}, v={v_extent:?} (a loop spanning nothing encloses no area, so the face is degenerate) within the surface's domain u={:?}, v={:?}; the loop: [{}]",
513 coedges.len(),
514 (u_lo, u_hi),
515 (v_lo, v_hi),
516 loop_description.join("; ")
517 )))
518}
519
520#[cfg(test)]
521mod interior_point_tests {
522 use super::face_interior_point;
523 use crate::{Model, test_fixtures::test_cube_solid};
524 use geop_core_math::scalars::Scalar;
525
526 const MAX: usize = 20000;
527 const SEED: u64 = 99;
528
529 fn eps() -> Scalar {
530 Scalar::from_f64(1e-4)
531 }
532
533 /// Every face of a plain cube must yield an interior point.
534 #[test]
535 fn cube_faces_all_have_interior_points() {
536 let mut model = Model::new();
537 let solid = test_cube_solid(&mut model);
538 for face_id in model.solid_faces(solid).unwrap() {
539 face_interior_point(&model, face_id, MAX, eps(), SEED)
540 .unwrap_or_else(|e| panic!("face {face_id}: {e}"));
541 }
542 }
543
544 // `sphere_faces_all_have_interior_points` (exercising seam curves and
545 // degenerate poles, unlike the plain cube above) needs a real
546 // `sphere_solid` — that lives in `geop-lib-sweep`, which
547 // depends on this crate, so it can't be reached from here without
548 // Cargo compiling this crate twice (see `test_fixtures`'s doc
549 // comment). Covered instead by `geop-lib-sweep`'s `sphere_solid`'s
550 // own tests, which build the same solid `face_interior_point` runs on
551 // here.
552}
553
554#[cfg(test)]
555mod tests {
556 use super::{PointClassification, face_contains};
557 use crate::test_fixtures::line2;
558 use crate::{
559 Coedge, CoedgeGeometry, CoedgeId, Edge, Face, FaceId, Model, Sense, ShellId, Vertex,
560 VertexId, boundary::BoundaryType, model::Curve3,
561 };
562 use geop_core_geometry::nurb_surface::NurbSurface3D;
563 use geop_core_math::{
564 scalars::Scalar,
565 vector::{Vector3, Vector4},
566 };
567
568 const MAX: usize = 200;
569 const MIN_SUBDIVISION_SIZE: f64 = 1e-3;
570 const SEED: u64 = 12345;
571
572 /// A polygon face on the unit-square `[0,1]^2` parameter surface, built
573 /// from `points` (CCW, `(u, v) == (x, y)`).
574 fn polygon_face(model: &mut Model, points: &[(f64, f64)]) -> FaceId {
575 let p = |x: f64, y: f64| {
576 Vector4::from_array([
577 Scalar::from_f64(x),
578 Scalar::from_f64(y),
579 Scalar::ZERO,
580 Scalar::ONE,
581 ])
582 };
583 let surface = NurbSurface3D::try_new(
584 1,
585 1,
586 vec![p(0.0, 0.0), p(0.0, 1.0), p(1.0, 0.0), p(1.0, 1.0)],
587 vec![Scalar::ZERO, Scalar::ZERO, Scalar::ONE, Scalar::ONE],
588 vec![Scalar::ZERO, Scalar::ZERO, Scalar::ONE, Scalar::ONE],
589 )
590 .unwrap();
591
592 let face_id = model.insert_face(Face {
593 surface,
594 outer: BoundaryType::Vertex(VertexId(0)),
595 holes: Vec::new(),
596 shell: ShellId(999),
597 });
598
599 let n = points.len();
600 let verts: Vec<VertexId> = points
601 .iter()
602 .map(|&(x, y)| {
603 model.insert_vertex(Vertex {
604 point: Vector3::from_array([
605 Scalar::from_f64(x),
606 Scalar::from_f64(y),
607 Scalar::ZERO,
608 ]),
609 })
610 })
611 .collect();
612 let edges = (0..n)
613 .map(|i| {
614 model.insert_edge(Edge {
615 curve: Curve3::try_new(
616 1,
617 vec![
618 Vector4::from_array([
619 Scalar::from_f64(points[i].0),
620 Scalar::from_f64(points[i].1),
621 Scalar::ZERO,
622 Scalar::ONE,
623 ]),
624 Vector4::from_array([
625 Scalar::from_f64(points[(i + 1) % n].0),
626 Scalar::from_f64(points[(i + 1) % n].1),
627 Scalar::ZERO,
628 Scalar::ONE,
629 ]),
630 ],
631 vec![Scalar::ZERO, Scalar::ZERO, Scalar::ONE, Scalar::ONE],
632 )
633 .unwrap(),
634 start_vertex: verts[i],
635 end_vertex: verts[(i + 1) % n],
636 })
637 })
638 .collect::<Vec<_>>();
639 let coedges: Vec<CoedgeId> = (0..n)
640 .map(|i| {
641 model.insert_coedge(Coedge {
642 geometry: CoedgeGeometry::Edge(edges[i]),
643 sense: Sense::Forward,
644 pcurve: line2(points[i], points[(i + 1) % n]),
645 next: CoedgeId(0),
646 prev: CoedgeId(0),
647 face: face_id,
648 })
649 })
650 .collect();
651 for i in 0..n {
652 model.coedges.get_mut(&coedges[i]).unwrap().next = coedges[(i + 1) % n];
653 model.coedges.get_mut(&coedges[i]).unwrap().prev = coedges[(i + n - 1) % n];
654 }
655 model.faces.get_mut(&face_id).unwrap().outer = BoundaryType::Loop(coedges[0]);
656
657 face_id
658 }
659
660 /// Diamond with corners at (1,.5) right, (.5,1) top, (0,.5) left,
661 /// (.5,0) bottom, traversed CCW.
662 fn diamond_face(model: &mut Model) -> FaceId {
663 polygon_face(model, &[(1., 0.5), (0.5, 1.), (0., 0.5), (0.5, 0.)])
664 }
665
666 /// A block's cap is a patch whose corner is the box of its profile
667 /// rounded outwards: a side at `x = 0` lay at `u = MIN_POSITIVE / 20`
668 /// in it, not at the domain's edge. A point a trace's corrector clamped
669 /// onto the domain's edge, `u = 0`, is outside the face by that much:
670 /// every ray from it met the side at a parameter the search could not
671 /// tell from zero (a block turned a quarter round its edge and joined).
672 #[test]
673 fn a_point_a_subnormal_outside_a_side_is_classified() {
674 let side = f64::MIN_POSITIVE / 20.0;
675 let mut model = Model::new();
676 let face_id = polygon_face(
677 &mut model,
678 &[(side, side), (1., side), (1., 1.), (side, 1.)],
679 );
680 let found = face_contains(
681 &model,
682 face_id,
683 Scalar::ZERO,
684 Scalar::from_f64(0.0698771242968679),
685 MAX,
686 Scalar::from_f64(1e-4),
687 SEED,
688 )
689 .unwrap();
690 assert_ne!(found, PointClassification::Inside);
691 }
692
693 #[test]
694 fn diamond_interior_point_is_contained() {
695 let mut model = Model::new();
696 let face_id = diamond_face(&mut model);
697 assert_eq!(
698 face_contains(
699 &model,
700 face_id,
701 Scalar::from_f64(0.5),
702 Scalar::from_f64(0.3),
703 MAX,
704 Scalar::from_f64(MIN_SUBDIVISION_SIZE),
705 SEED
706 )
707 .unwrap(),
708 PointClassification::Inside
709 );
710 }
711
712 #[test]
713 fn diamond_exterior_point_is_not_contained() {
714 let mut model = Model::new();
715 let face_id = diamond_face(&mut model);
716 assert_eq!(
717 face_contains(
718 &model,
719 face_id,
720 Scalar::from_f64(0.1),
721 Scalar::from_f64(0.3),
722 MAX,
723 Scalar::from_f64(MIN_SUBDIVISION_SIZE),
724 SEED
725 )
726 .unwrap(),
727 PointClassification::Outside
728 );
729 }
730
731 #[test]
732 fn diamond_center_hits_convex_vertex_from_inside() {
733 let mut model = Model::new();
734 let face_id = diamond_face(&mut model);
735 assert_eq!(
736 face_contains(
737 &model,
738 face_id,
739 Scalar::from_f64(0.5),
740 Scalar::from_f64(0.5),
741 MAX,
742 Scalar::from_f64(MIN_SUBDIVISION_SIZE),
743 SEED
744 )
745 .unwrap(),
746 PointClassification::Inside
747 );
748 }
749
750 #[test]
751 fn diamond_vertex_query_is_on_vertex() {
752 let mut model = Model::new();
753 let face_id = diamond_face(&mut model);
754 assert_eq!(
755 face_contains(
756 &model,
757 face_id,
758 Scalar::ONE,
759 Scalar::from_f64(0.5),
760 MAX,
761 Scalar::from_f64(MIN_SUBDIVISION_SIZE),
762 SEED
763 )
764 .unwrap(),
765 PointClassification::OnVertex
766 );
767 }
768
769 #[test]
770 fn diamond_edge_query_is_on_coedge() {
771 let mut model = Model::new();
772 let face_id = diamond_face(&mut model);
773 assert_eq!(
774 face_contains(
775 &model,
776 face_id,
777 Scalar::from_f64(0.75),
778 Scalar::from_f64(0.75),
779 MAX,
780 Scalar::from_f64(MIN_SUBDIVISION_SIZE),
781 SEED
782 )
783 .unwrap(),
784 PointClassification::OnCoedge
785 );
786 }
787}