Skip to main content

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}