Skip to main content

brep_kernel/brep/classification/
face_containment.rs

1use super::*;
2
3fn point_segment_distance(point: Vec2, start: Vec2, end: Vec2) -> f64 {
4    let segment = end.sub(start);
5    let length_squared = segment.dot(segment);
6    if length_squared <= 1e-30 {
7        return point.sub(start).length();
8    }
9    let parameter = (point.sub(start).dot(segment) / length_squared).clamp(0.0, 1.0);
10    point.sub(start.add(segment.scale(parameter))).length()
11}
12
13#[derive(Clone, Copy, Debug, PartialEq)]
14pub enum PolygonClass {
15    Inside,
16    Outside,
17    Boundary,
18}
19
20fn point_in_polygon(point: Vec2, polygon: &[Vec2], tolerance: f64) -> PolygonClass {
21    for index in 0..polygon.len() {
22        if point_segment_distance(point, polygon[index], polygon[(index + 1) % polygon.len()])
23            <= tolerance
24        {
25            return PolygonClass::Boundary;
26        }
27    }
28    let mut inside = false;
29    for index in 0..polygon.len() {
30        let a = polygon[index];
31        let b = polygon[(index + 1) % polygon.len()];
32        if (a.y > point.y) != (b.y > point.y) {
33            let crossing = a.x + (point.y - a.y) / (b.y - a.y) * (b.x - a.x);
34            if crossing > point.x {
35                inside = !inside;
36            }
37        }
38    }
39    if inside {
40        PolygonClass::Inside
41    } else {
42        PolygonClass::Outside
43    }
44}
45
46struct SegmentReference<'a> {
47    start: Vec2,
48    end: Vec2,
49    curve: &'a NurbsCurve,
50    parameter_start: f64,
51    parameter_end: f64,
52}
53
54fn interior_knot_count(curve: &NurbsCurve) -> usize {
55    let start = curve.knots[curve.degree];
56    let end = curve.knots[curve.knots.len() - 1 - curve.degree];
57    let mut previous = None;
58    let mut count = 0;
59    for &knot in &curve.knots {
60        if knot <= start + 1e-12 || knot >= end - 1e-12 {
61            continue;
62        }
63        if previous.is_none_or(|value: f64| (value - knot).abs() > 1e-12) {
64            previous = Some(knot);
65            count += 1;
66        }
67    }
68    count
69}
70
71/// Point-in-face for a DOUBLY-periodic (torus) seam-band face, whose material
72/// region the raw even-odd/tangent test below gets wrong: the constant-level
73/// seam rim is a zero-area iso line and the wavy cut's own polygon encloses only
74/// the thin strip it bounds, so a point in the band reads Outside (or flips on
75/// the fragile nearest-tangent refinement). Reconstruct the band as one simple
76/// (u,v) polygon — identical topology to the mass integrator and tessellator —
77/// and test the point against it, so all three subsystems agree on the material
78/// side. Returns None (keep the general path) for every other face.
79fn seam_band_point_in_face(
80    face: &FaceRecord,
81    point: Vec2,
82    tolerance: f64,
83) -> Result<Option<PolygonClass>, String> {
84    if face.surface.closed_directions()? != (true, true) {
85        return Ok(None);
86    }
87    let [u0, u1] = face.surface.domain_u()?;
88    let [v0, v1] = face.surface.domain_v()?;
89    if crate::topology::doubly_periodic_has_only_collapsed_loops(face)? {
90        return Ok(Some(PolygonClass::Inside));
91    }
92    let u_span = (u1 - u0).abs().max(1e-30);
93    let v_span = (v1 - v0).abs().max(1e-30);
94    let mut loops_uv: Vec<Vec<[f64; 2]>> = Vec::with_capacity(face.loops.len());
95    for loop_record in &face.loops {
96        let mut points: Vec<[f64; 2]> = Vec::new();
97        for coedge in &loop_record.coedges {
98            let [d0, d1] = coedge.pcurve.domain()?;
99            let samples = 24;
100            for k in 0..=samples {
101                let t = d0 + (d1 - d0) * k as f64 / samples as f64;
102                let p = coedge.pcurve.evaluate(t)?;
103                points.push([p.x, p.y]);
104            }
105        }
106        let (mut umin, mut umax, mut vmin, mut vmax) = (
107            f64::INFINITY,
108            f64::NEG_INFINITY,
109            f64::INFINITY,
110            f64::NEG_INFINITY,
111        );
112        for p in &points {
113            umin = umin.min(p[0]);
114            umax = umax.max(p[0]);
115            vmin = vmin.min(p[1]);
116            vmax = vmax.max(p[1]);
117        }
118        // Mirror `mass_properties::biperiodic_band_range`: a torus has no
119        // geometric pole, so a VERTEX_LOOP whose whole pcurve trace collapses
120        // to one parameter point is a zero-area puncture, not a band boundary.
121        // Restrict the new behavior to faces that actually contain such an
122        // extra loop; ordinary two-loop torus classification stays unchanged.
123        if face.loops.len() > 2 && (umax - umin) <= 1e-3 * u_span && (vmax - vmin) <= 1e-3 * v_span
124        {
125            continue;
126        }
127        loops_uv.push(points);
128    }
129    // MERGED SEAM-CARRYING BAND (single loop): `insert_periodic_band_seam_edges`
130    // fuses a wrapped band's two rims into ONE loop joined by seam columns, so
131    // the two-loop paths below never see it, and the plain even-odd polygon
132    // carries a period-magnitude hop at each rim→column junction (a phantom
133    // diagonal across the domain) that misclassifies the whole middle zone.
134    // Unwrap the loop onto the covering plane (`loop_seam_offsets`, the same
135    // fold mass integration and tessellation use), where it IS a simple
136    // polygon, and even-odd the query's period images against it. Gated on a
137    // genuinely seam-crossing single loop on a doubly-periodic surface; every
138    // other face falls through unchanged. Hatch: BREP_SEAM_BAND_MERGED=0.
139    if face.loops.len() == 1
140        && loops_uv.len() == 1
141        && std::env::var("BREP_SEAM_BAND_MERGED").as_deref() != Ok("0")
142    {
143        let coedges = &face.loops[0].coedges;
144        let offsets = crate::topology::loop_seam_offsets(coedges, true, true, u_span, v_span)?;
145        if offsets.iter().any(|o| o[0] != 0.0 || o[1] != 0.0) {
146            let mut polygon: Vec<Vec2> = Vec::new();
147            for (coedge_index, coedge) in coedges.iter().enumerate() {
148                let [d0, d1] = coedge.pcurve.domain()?;
149                let samples = 24;
150                for k in 0..samples {
151                    let t = d0 + (d1 - d0) * k as f64 / samples as f64;
152                    let p = coedge.pcurve.evaluate(t)?;
153                    polygon.push(Vec2 {
154                        x: p.x + offsets[coedge_index][0],
155                        y: p.y + offsets[coedge_index][1],
156                    });
157                }
158            }
159            let mut best = PolygonClass::Outside;
160            'images: for du in [-1.0, 0.0, 1.0] {
161                for dv in [-1.0, 0.0, 1.0] {
162                    let image = Vec2 {
163                        x: point.x + du * u_span,
164                        y: point.y + dv * v_span,
165                    };
166                    match point_in_polygon(image, &polygon, tolerance) {
167                        PolygonClass::Boundary => {
168                            best = PolygonClass::Boundary;
169                            break 'images;
170                        }
171                        PolygonClass::Inside => best = PolygonClass::Inside,
172                        PolygonClass::Outside => {}
173                    }
174                }
175            }
176            return Ok(Some(best));
177        }
178    }
179    if loops_uv.len() != 2 {
180        return Ok(None);
181    }
182    if let Some(band) = crate::topology::analyze_doubly_periodic_seam_band(
183        &loops_uv,
184        [u0, u1, v0, v1],
185        face.same_sense,
186    ) {
187        let polygon: Vec<Vec2> =
188            crate::topology::seam_band_uv_polygon(&loops_uv, [u0, u1, v0, v1], &band)
189                .into_iter()
190                .map(|p| Vec2 { x: p[0], y: p[1] })
191                .collect();
192        return Ok(Some(point_in_polygon(point, &polygon, tolerance)));
193    }
194    // TWO clean full-wrap rims are the companion `biperiodic_band_range` case,
195    // with or without an extra collapsed puncture. Classify only the cross
196    // parameter: the material is either the strip between the rims or its
197    // periodic complement. The exact two-loop/full-wrap/constant-cross-level
198    // gates below mirror mass integration and tessellation; every other torus
199    // face retains the general classifier.
200    for p_is_u in [true, false] {
201        let (period, q_extent) = if p_is_u {
202            (u1 - u0, v_span)
203        } else {
204            (v1 - v0, u_span)
205        };
206        if !(period > 0.0) {
207            continue;
208        }
209        let coord = |p: &[f64; 2]| if p_is_u { (p[0], p[1]) } else { (p[1], p[0]) };
210        let mut rings = Vec::with_capacity(2);
211        for points in &loops_uv {
212            let (mut pmin, mut pmax, mut qmin, mut qmax) = (
213                f64::INFINITY,
214                f64::NEG_INFINITY,
215                f64::INFINITY,
216                f64::NEG_INFINITY,
217            );
218            for p in points {
219                let (periodic, cross) = coord(p);
220                pmin = pmin.min(periodic);
221                pmax = pmax.max(periodic);
222                qmin = qmin.min(cross);
223                qmax = qmax.max(cross);
224            }
225            if (pmax - pmin) < 0.6 * period || (qmax - qmin) > 0.05 * q_extent {
226                rings.clear();
227                break;
228            }
229            let mut net = 0.0;
230            for pair in points.windows(2) {
231                let mut delta = coord(&pair[1]).0 - coord(&pair[0]).0;
232                if delta > 0.5 * period {
233                    delta -= period;
234                } else if delta < -0.5 * period {
235                    delta += period;
236                }
237                net += delta;
238            }
239            let direction = if net > 0.25 * period {
240                1
241            } else if net < -0.25 * period {
242                -1
243            } else {
244                0
245            };
246            rings.push((0.5 * (qmin + qmax), direction));
247        }
248        if rings.len() != 2 {
249            continue;
250        }
251        rings.sort_by(|a, b| a.0.total_cmp(&b.0));
252        let [(q_lo, lower_direction), (q_hi, upper_direction)] = rings.as_slice() else {
253            unreachable!()
254        };
255        let inconclusive =
256            *lower_direction == 0 || *upper_direction == 0 || lower_direction == upper_direction;
257        let between_is_ccw_uv = if p_is_u {
258            *lower_direction > 0
259        } else {
260            *lower_direction < 0
261        };
262        let complement = !inconclusive && between_is_ccw_uv != face.same_sense;
263        let q = if p_is_u { point.y } else { point.x };
264        if (q - q_lo).abs() <= tolerance || (q - q_hi).abs() <= tolerance {
265            return Ok(Some(PolygonClass::Boundary));
266        }
267        let between = q > *q_lo && q < *q_hi;
268        return Ok(Some(if between != complement {
269            PolygonClass::Inside
270        } else {
271            PolygonClass::Outside
272        }));
273    }
274    Ok(None)
275}
276
277/// Point-in-face for a SINGLY-PERIODIC face with a SEAM-STRADDLING loop: a
278/// trim loop crossing the u seam is stored with its samples folded into the
279/// domain, so its flat polygon is TORN at the seam and plain even-odd
280/// misclassifies near the tear (STEP 00000585-family: face 519's big inner
281/// loop straddles the seam and a REAL section sub-segment between two accepted
282/// pieces read Outside in `process_curve`'s midpoint gate — trial 174's
283/// missing middle segment). Mirror fragment.rs's proven seam-straddling
284/// fallback: UNWRAP every loop by accumulating period-folded deltas (a
285/// zero-winding loop closes in the covering plane), then even-odd the query at
286/// its −P/0/+P period images across all unwrapped loops. Fires only when the
287/// face is closed in exactly u, has >2 loops, and at least one loop straddles
288/// the seam (≥1 fold jump); every other face returns None and keeps the plain
289/// classifier bit-identically. Loops that WIND the period (net winding ≠ 0)
290/// do not close under unwrapping — bail to the plain path rather than guess.
291/// Escape hatch `BREP_HORIZON_CONTAINMENT=0`.
292fn wrapped_horizon_point_in_face(
293    face: &FaceRecord,
294    point: Vec2,
295    tolerance: f64,
296) -> Result<Option<PolygonClass>, String> {
297    if face.surface.closed_directions()? != (true, false) || face.loops.is_empty() {
298        return Ok(None);
299    }
300    if std::env::var("BREP_HORIZON_CONTAINMENT").as_deref() == Ok("0") {
301        return Ok(None);
302    }
303    // SINGLE/DOUBLE-LOOP straddlers (t91's face 675: ONE loop drawn in-domain
304    // that hops the u-seam twice, trimming a sliver strip AT the seam) used
305    // to be excluded by a `loops.len() <= 2` gate and fell to the plain
306    // even-odd, whose verdict on the hopped polygon is INVERTED (the strip
307    // interior read Outside, far azimuths read Inside) — minting bogus
308    // far-azimuth section pieces and discarding the real ones. The unwrap +
309    // period-image even-odd below is loop-count-agnostic, so the gate now
310    // only requires a non-empty loop set; non-straddling loops still bail to
311    // the plain path unchanged. Escape hatch: BREP_HORIZON_SINGLE_LOOP=0
312    // restores the old minimum.
313    if face.loops.len() <= 2
314        && std::env::var("BREP_HORIZON_SINGLE_LOOP").as_deref() == Ok("0")
315    {
316        return Ok(None);
317    }
318    let [u0, u1] = face.surface.domain_u()?;
319    let u_period = u1 - u0;
320    if u_period <= 0.0 {
321        return Ok(None);
322    }
323    let debug = std::env::var("BREP_DEBUG_HORIZON").is_ok();
324    let mut loops: Vec<Vec<Vec2>> = Vec::new();
325    let mut straddling = false;
326    for loop_record in &face.loops {
327        let mut points: Vec<Vec2> = Vec::new();
328        for coedge in &loop_record.coedges {
329            let curve = &coedge.pcurve;
330            let [start, end] = curve.domain()?;
331            let sample_count =
332                2usize.max((interior_knot_count(curve) + 1) * (curve.degree + 1) * 4);
333            for index in 0..sample_count {
334                let parameter = start + (end - start) * index as f64 / sample_count as f64;
335                let evaluated = curve.evaluate(parameter)?;
336                points.push(Vec2 {
337                    x: evaluated.x,
338                    y: evaluated.y,
339                });
340            }
341        }
342        if points.len() < 3 {
343            continue;
344        }
345        // Unwrap into the covering plane: place each sample at the previous
346        // one plus the period-folded delta. A seam-straddling loop becomes a
347        // continuous closed polygon; a WINDING loop does not close — bail.
348        let mut unwrapped = Vec::with_capacity(points.len());
349        let mut jumps = 0usize;
350        let mut cursor = points[0];
351        unwrapped.push(cursor);
352        for pair in points.windows(2) {
353            let mut du = pair[1].x - pair[0].x;
354            let folded = du - u_period * (du / u_period).round();
355            if (du - folded).abs() > 0.25 * u_period {
356                jumps += 1;
357            }
358            du = folded;
359            cursor = Vec2 {
360                x: cursor.x + du,
361                y: pair[1].y,
362            };
363            unwrapped.push(cursor);
364        }
365        let closure = (unwrapped[0].x - unwrapped[unwrapped.len() - 1].x).abs();
366        if closure > 0.25 * u_period {
367            if debug {
368                eprintln!(
369                    "horizon: face {} loop winds the period (closure {closure:.3}) — bail",
370                    face.id
371                );
372            }
373            return Ok(None); // winding loop: unwrapping cannot close it
374        }
375        if jumps > 0 {
376            straddling = true;
377        }
378        loops.push(unwrapped);
379    }
380    if !straddling || loops.is_empty() {
381        return Ok(None);
382    }
383    // Even-odd on the QUOTIENT cylinder: each unwrapped loop is tested at
384    // every period image of the query and contributes its own parity. The old
385    // combining ("any single image with an odd crossing count ⇒ Inside") is
386    // wrong whenever two loops live in DIFFERENT period frames after
387    // unwrapping — t181/00000312#p7: face 519's seam-straddling HOLE unwraps
388    // to u∈[−0.039, 0.039] while the outer rectangle spans [0, 1], so a query
389    // inside the hole at u≈0.986 is contained by the outer loop at shift 0
390    // (odd ⇒ Inside) and by the hole at shift −P (odd ⇒ Inside again); the
391    // true even count (outer + hole = 2 ⇒ Outside) is never assembled at any
392    // single image. Fragment selection then kept a PHANTOM half-hole region
393    // whose boundary (half the hole loop + a derived chord) minted same-sense
394    // coedges at assembly. Summing per-loop parities across images restores
395    // the covering-space even-odd. Escape hatch: BREP_HORIZON_CROSS_FRAME=0
396    // restores the old per-image combining.
397    if std::env::var("BREP_HORIZON_CROSS_FRAME").as_deref() == Ok("0") {
398        let mut best: Option<PolygonClass> = None;
399        for shift in [-u_period, 0.0, u_period] {
400            let image = Vec2 {
401                x: point.x + shift,
402                y: point.y,
403            };
404            let mut crossings = 0usize;
405            for polygon in &loops {
406                match point_in_polygon(image, polygon, tolerance) {
407                    PolygonClass::Boundary => return Ok(Some(PolygonClass::Boundary)),
408                    PolygonClass::Inside => crossings += 1,
409                    PolygonClass::Outside => {}
410                }
411            }
412            if crossings % 2 == 1 {
413                best = Some(PolygonClass::Inside);
414            } else if best.is_none() {
415                best = Some(PolygonClass::Outside);
416            }
417        }
418        if debug {
419            eprintln!(
420                "horizon: face {} loops={} straddling (legacy per-image) -> {:?}",
421                face.id,
422                loops.len(),
423                best
424            );
425        }
426        return Ok(best);
427    }
428    let mut crossings = 0usize;
429    for polygon in &loops {
430        let mut image_hits = 0usize;
431        for shift in [-u_period, 0.0, u_period] {
432            let image = Vec2 {
433                x: point.x + shift,
434                y: point.y,
435            };
436            match point_in_polygon(image, polygon, tolerance) {
437                PolygonClass::Boundary => return Ok(Some(PolygonClass::Boundary)),
438                PolygonClass::Inside => image_hits += 1,
439                PolygonClass::Outside => {}
440            }
441        }
442        crossings += image_hits % 2;
443    }
444    let class = if crossings % 2 == 1 {
445        PolygonClass::Inside
446    } else {
447        PolygonClass::Outside
448    };
449    if debug {
450        eprintln!(
451            "horizon: face {} loops={} straddling -> {:?}",
452            face.id,
453            loops.len(),
454            class
455        );
456    }
457    Ok(Some(class))
458}
459
460
461/// Point-in-face for a SINGLY-PERIODIC (closed-u) FULL-BAND STRIP whose two
462/// full-wrap rim loops are drawn as COVERING-PLANE pcurves running OUT OF the
463/// u-domain by up to a whole period (case 08:10-genus face 1513, the 4th seam
464/// representation after helmet-unwrapped / band-complement / 675-hops): the
465/// raw even-odd sees u-winding loop polygons whose parity is meaningless, so
466/// nearly the whole material strip reads Outside, the marched clip crumbles
467/// the section curves to sub-mm bits, and the neighbours strand one-use.
468///
469/// The material region is the strip BETWEEN the two rims: on an open-v
470/// surface a 2-loop face whose loops both wrap the full period has no other
471/// representable region. The verdict is a sampled per-u hull — each rim is a
472/// single periodic polyline v = rim(u) in the covering plane, and the query
473/// point is between the rims iff an upward (+v) vertical ray at the query's
474/// u (folded modulo the period into each rim's own one-period window) crosses
475/// the two rim polylines an odd number of times in total. NO flat-level
476/// approximation is used (the rims are scalloped: 1513's upper rim is flat at
477/// v=1 on parts of u but dips to v=0.745 elsewhere — a v-between-extremes
478/// rule is provably wrong on it).
479///
480/// STRUCTURAL gates (all must hold; anything else returns None and keeps the
481/// previous classification bit-identically):
482///   - surface closed in exactly u; exactly 2 loops;
483///   - at least one loop's samples run OUT of the u-domain by >1e-3 period
484///     (the covering-plane discriminator — in-domain 2-rim bands keep their
485///     current path);
486///   - each loop's unwrapped net u-travel is exactly ±one period and its v
487///     closes (a true full-wrap rim, not a partial arc);
488///   - opposite travel directions, disjoint v-hulls, and the material-left
489///     winding rule confirming the BETWEEN strip (a complement verdict cannot
490///     be represented on an open-v surface — decline, never guess).
491/// Escape hatch: BREP_COVERING_RIM_STRIP=0.
492fn covering_rim_strip_point_in_face(
493    face: &FaceRecord,
494    point: Vec2,
495    tolerance: f64,
496) -> Result<Option<PolygonClass>, String> {
497    if face.surface.closed_directions()? != (true, false) || face.loops.len() != 2 {
498        return Ok(None);
499    }
500    if std::env::var("BREP_COVERING_RIM_STRIP").as_deref() == Ok("0") {
501        return Ok(None);
502    }
503    let [u0, u1] = face.surface.domain_u()?;
504    let [v0, v1] = face.surface.domain_v()?;
505    let period = u1 - u0;
506    if !(period > 0.0) {
507        return Ok(None);
508    }
509    let v_span = (v1 - v0).abs().max(1e-30);
510    struct Rim {
511        points: Vec<Vec2>,
512        vmin: f64,
513        vmax: f64,
514        ascending: bool,
515    }
516    let mut rims: Vec<Rim> = Vec::with_capacity(2);
517    let mut out_of_domain = false;
518    for loop_record in &face.loops {
519        let mut points: Vec<Vec2> = Vec::new();
520        for coedge in &loop_record.coedges {
521            let curve = &coedge.pcurve;
522            let [start, end] = curve.domain()?;
523            let sample_count =
524                2usize.max((interior_knot_count(curve) + 1) * (curve.degree + 1) * 4);
525            for index in 0..=sample_count {
526                let parameter = start + (end - start) * index as f64 / sample_count as f64;
527                let evaluated = curve.evaluate(parameter)?;
528                points.push(Vec2 {
529                    x: evaluated.x,
530                    y: evaluated.y,
531                });
532            }
533        }
534        if points.len() < 3 {
535            return Ok(None);
536        }
537        // Covering-plane discriminator: this lane exists for loops drawn PAST
538        // the domain; everything in-domain keeps the existing classifiers.
539        for p in &points {
540            if p.x < u0 - 1e-3 * period || p.x > u1 + 1e-3 * period {
541                out_of_domain = true;
542            }
543        }
544        // Unwrap into the covering plane (period-folded deltas — a no-op for
545        // an already-continuous covering-plane representation, and the same
546        // fold the horizon lane uses for in-domain seam hops).
547        let mut unwrapped: Vec<Vec2> = Vec::with_capacity(points.len());
548        let mut cursor = points[0];
549        unwrapped.push(cursor);
550        for pair in points.windows(2) {
551            let du = pair[1].x - pair[0].x;
552            let folded = du - period * (du / period).round();
553            cursor = Vec2 {
554                x: cursor.x + folded,
555                y: pair[1].y,
556            };
557            unwrapped.push(cursor);
558        }
559        let net = unwrapped[unwrapped.len() - 1].x - unwrapped[0].x;
560        if (net.abs() - period).abs() > 2e-2 * period {
561            return Ok(None); // not a clean single full wrap
562        }
563        if (unwrapped[unwrapped.len() - 1].y - unwrapped[0].y).abs() > 1e-3 * v_span {
564            return Ok(None); // rim does not close in v
565        }
566        let (mut vmin, mut vmax) = (f64::INFINITY, f64::NEG_INFINITY);
567        for p in &unwrapped {
568            vmin = vmin.min(p.y);
569            vmax = vmax.max(p.y);
570        }
571        rims.push(Rim {
572            points: unwrapped,
573            vmin,
574            vmax,
575            ascending: net > 0.0,
576        });
577    }
578    if !out_of_domain {
579        return Ok(None);
580    }
581    if rims[0].ascending == rims[1].ascending {
582        return Ok(None); // rims must traverse opposite u directions
583    }
584    let (lower, upper) = if rims[0].vmax <= rims[1].vmin {
585        (&rims[0], &rims[1])
586    } else if rims[1].vmax <= rims[0].vmin {
587        (&rims[1], &rims[0])
588    } else {
589        return Ok(None); // interleaved v-hulls: not a clean strip
590    };
591    if upper.vmin - lower.vmax <= 1e-6 * v_span {
592        return Ok(None);
593    }
594    // Material-left rule (same convention as the doubly-periodic band lanes):
595    // the between strip is CCW in uv iff the LOWER rim travels +u; that must
596    // match the face sense, else the material would be the complement — which
597    // an open-v surface cannot represent. Decline rather than guess.
598    if (lower.ascending) != face.same_sense {
599        return Ok(None);
600    }
601    // Boundary: proximity to either rim polyline at the query's period images.
602    for rim in [lower, upper] {
603        for shift in [-period, 0.0, period] {
604            let image = Vec2 {
605                x: point.x + shift,
606                y: point.y,
607            };
608            for pair in rim.points.windows(2) {
609                if point_segment_distance(image, pair[0], pair[1]) <= tolerance {
610                    return Ok(Some(PolygonClass::Boundary));
611                }
612            }
613        }
614    }
615    // Sampled per-u hull: fold the query into each rim's own one-period
616    // window and count upward-ray crossings; odd total = between the rims.
617    let mut crossings = 0usize;
618    for rim in [lower, upper] {
619        let window_base = rim.points[0].x.min(rim.points[rim.points.len() - 1].x);
620        let x = window_base + (point.x - window_base).rem_euclid(period);
621        for pair in rim.points.windows(2) {
622            let (a, b) = (pair[0], pair[1]);
623            if (a.x > x) != (b.x > x) {
624                let v_cross = a.y + (x - a.x) / (b.x - a.x) * (b.y - a.y);
625                if v_cross > point.y {
626                    crossings += 1;
627                }
628            }
629        }
630    }
631    Ok(Some(if crossings % 2 == 1 {
632        PolygonClass::Inside
633    } else {
634        PolygonClass::Outside
635    }))
636}
637
638pub fn parameter_point_in_face(
639    face: &FaceRecord,
640    point: Vec2,
641    tolerance: f64,
642) -> Result<PolygonClass, String> {
643    if let Some(class) = seam_band_point_in_face(face, point, tolerance)? {
644        return Ok(class);
645    }
646    if let Some(class) = wrapped_horizon_point_in_face(face, point, tolerance)? {
647        return Ok(class);
648    }
649    if let Some(class) = covering_rim_strip_point_in_face(face, point, tolerance)? {
650        return Ok(class);
651    }
652    let mut crossings = 0;
653    let mut boundary = false;
654    let mut nearest: Option<(SegmentReference<'_>, f64)> = None;
655    for loop_record in &face.loops {
656        let mut polygon = Vec::new();
657        let mut segments = Vec::new();
658        for coedge in &loop_record.coedges {
659            let curve = &coedge.pcurve;
660            let [start, end] = curve.domain()?;
661            let sample_count =
662                2usize.max((interior_knot_count(curve) + 1) * (curve.degree + 1) * 4);
663            for index in 0..sample_count {
664                let parameter = start + (end - start) * index as f64 / sample_count as f64;
665                let evaluated = curve.evaluate(parameter)?;
666                polygon.push(Vec2 {
667                    x: evaluated.x,
668                    y: evaluated.y,
669                });
670                let parameter_end = if index + 1 < sample_count {
671                    start + (end - start) * (index + 1) as f64 / sample_count as f64
672                } else {
673                    end
674                };
675                segments.push(SegmentReference {
676                    start: Vec2 {
677                        x: evaluated.x,
678                        y: evaluated.y,
679                    },
680                    end: Vec2 { x: 0.0, y: 0.0 },
681                    curve,
682                    parameter_start: parameter,
683                    parameter_end,
684                });
685            }
686        }
687        for index in 0..polygon.len() {
688            segments[index].end = polygon[(index + 1) % polygon.len()];
689        }
690        match point_in_polygon(point, &polygon, tolerance) {
691            PolygonClass::Boundary => boundary = true,
692            PolygonClass::Inside => crossings += 1,
693            PolygonClass::Outside => {}
694        }
695        for segment in segments {
696            let distance = point_segment_distance(point, segment.start, segment.end);
697            if nearest
698                .as_ref()
699                .is_none_or(|(_, nearest_distance)| distance < *nearest_distance)
700            {
701                nearest = Some((segment, distance));
702            }
703        }
704    }
705    if boundary {
706        return Ok(PolygonClass::Boundary);
707    }
708    let parity = if crossings % 2 == 1 {
709        PolygonClass::Inside
710    } else {
711        PolygonClass::Outside
712    };
713    let Some((segment, distance)) = nearest else {
714        return Ok(parity);
715    };
716    let segment_length = segment.end.sub(segment.start).length();
717    if distance > segment_length || segment_length <= 0.0 {
718        return Ok(parity);
719    }
720    let chord_parameter = segment.parameter_start
721        + (segment.parameter_end - segment.parameter_start)
722            * (point.sub(segment.start).dot(segment.end.sub(segment.start))
723                / (segment_length * segment_length))
724                .clamp(0.0, 1.0);
725    let [domain_start, domain_end] = segment.curve.domain()?;
726    let mut parameter = chord_parameter;
727    for _ in 0..12 {
728        let derivatives = segment.curve.derivatives(parameter, 2)?;
729        let on_curve = Vec2 {
730            x: derivatives[0].x,
731            y: derivatives[0].y,
732        };
733        let tangent = Vec2 {
734            x: derivatives[1].x,
735            y: derivatives[1].y,
736        };
737        let second = Vec2 {
738            x: derivatives[2].x,
739            y: derivatives[2].y,
740        };
741        let residual = on_curve.sub(point);
742        let denominator = tangent.dot(tangent) + residual.dot(second);
743        if denominator.abs() < 1e-30 {
744            break;
745        }
746        let step = -residual.dot(tangent) / denominator;
747        parameter = (parameter + step).clamp(domain_start, domain_end);
748        if step.abs() < 1e-14 * (domain_end - domain_start + 1.0) {
749            break;
750        }
751    }
752    let margin = 1e-9 * (domain_end - domain_start);
753    if parameter > domain_start + margin && parameter < domain_end - margin {
754        let derivatives = segment.curve.derivatives(parameter, 1)?;
755        let on_curve = Vec2 {
756            x: derivatives[0].x,
757            y: derivatives[0].y,
758        };
759        let tangent = Vec2 {
760            x: derivatives[1].x,
761            y: derivatives[1].y,
762        };
763        let offset = point.sub(on_curve);
764        if offset.length() <= tolerance {
765            return Ok(PolygonClass::Boundary);
766        }
767        let cross = tangent.x * offset.y - tangent.y * offset.x;
768        if cross.abs() > 1e-30 {
769            return Ok(if (cross > 0.0) == face.same_sense {
770                PolygonClass::Inside
771            } else {
772                PolygonClass::Outside
773            });
774        }
775    }
776    Ok(parity)
777}