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 && std::env::var("BREP_HORIZON_SINGLE_LOOP").as_deref() == Ok("0") {
314        return Ok(None);
315    }
316    let [u0, u1] = face.surface.domain_u()?;
317    let u_period = u1 - u0;
318    if u_period <= 0.0 {
319        return Ok(None);
320    }
321    let debug = std::env::var("BREP_DEBUG_HORIZON").is_ok();
322    let mut loops: Vec<Vec<Vec2>> = Vec::new();
323    let mut straddling = false;
324    for loop_record in &face.loops {
325        let mut points: Vec<Vec2> = Vec::new();
326        for coedge in &loop_record.coedges {
327            let curve = &coedge.pcurve;
328            let [start, end] = curve.domain()?;
329            let sample_count =
330                2usize.max((interior_knot_count(curve) + 1) * (curve.degree + 1) * 4);
331            for index in 0..sample_count {
332                let parameter = start + (end - start) * index as f64 / sample_count as f64;
333                let evaluated = curve.evaluate(parameter)?;
334                points.push(Vec2 {
335                    x: evaluated.x,
336                    y: evaluated.y,
337                });
338            }
339        }
340        if points.len() < 3 {
341            continue;
342        }
343        // Unwrap into the covering plane: place each sample at the previous
344        // one plus the period-folded delta. A seam-straddling loop becomes a
345        // continuous closed polygon; a WINDING loop does not close — bail.
346        let mut unwrapped = Vec::with_capacity(points.len());
347        let mut jumps = 0usize;
348        let mut cursor = points[0];
349        unwrapped.push(cursor);
350        for pair in points.windows(2) {
351            let mut du = pair[1].x - pair[0].x;
352            let folded = du - u_period * (du / u_period).round();
353            if (du - folded).abs() > 0.25 * u_period {
354                jumps += 1;
355            }
356            du = folded;
357            cursor = Vec2 {
358                x: cursor.x + du,
359                y: pair[1].y,
360            };
361            unwrapped.push(cursor);
362        }
363        let closure = (unwrapped[0].x - unwrapped[unwrapped.len() - 1].x).abs();
364        if closure > 0.25 * u_period {
365            if debug {
366                eprintln!(
367                    "horizon: face {} loop winds the period (closure {closure:.3}) — bail",
368                    face.id
369                );
370            }
371            return Ok(None); // winding loop: unwrapping cannot close it
372        }
373        if jumps > 0 {
374            straddling = true;
375        }
376        loops.push(unwrapped);
377    }
378    if !straddling || loops.is_empty() {
379        return Ok(None);
380    }
381    // Even-odd on the QUOTIENT cylinder: each unwrapped loop is tested at
382    // every period image of the query and contributes its own parity. The old
383    // combining ("any single image with an odd crossing count ⇒ Inside") is
384    // wrong whenever two loops live in DIFFERENT period frames after
385    // unwrapping — t181/00000312#p7: face 519's seam-straddling HOLE unwraps
386    // to u∈[−0.039, 0.039] while the outer rectangle spans [0, 1], so a query
387    // inside the hole at u≈0.986 is contained by the outer loop at shift 0
388    // (odd ⇒ Inside) and by the hole at shift −P (odd ⇒ Inside again); the
389    // true even count (outer + hole = 2 ⇒ Outside) is never assembled at any
390    // single image. Fragment selection then kept a PHANTOM half-hole region
391    // whose boundary (half the hole loop + a derived chord) minted same-sense
392    // coedges at assembly. Summing per-loop parities across images restores
393    // the covering-space even-odd. Escape hatch: BREP_HORIZON_CROSS_FRAME=0
394    // restores the old per-image combining.
395    if std::env::var("BREP_HORIZON_CROSS_FRAME").as_deref() == Ok("0") {
396        let mut best: Option<PolygonClass> = None;
397        for shift in [-u_period, 0.0, u_period] {
398            let image = Vec2 {
399                x: point.x + shift,
400                y: point.y,
401            };
402            let mut crossings = 0usize;
403            for polygon in &loops {
404                match point_in_polygon(image, polygon, tolerance) {
405                    PolygonClass::Boundary => return Ok(Some(PolygonClass::Boundary)),
406                    PolygonClass::Inside => crossings += 1,
407                    PolygonClass::Outside => {}
408                }
409            }
410            if crossings % 2 == 1 {
411                best = Some(PolygonClass::Inside);
412            } else if best.is_none() {
413                best = Some(PolygonClass::Outside);
414            }
415        }
416        if debug {
417            eprintln!(
418                "horizon: face {} loops={} straddling (legacy per-image) -> {:?}",
419                face.id,
420                loops.len(),
421                best
422            );
423        }
424        return Ok(best);
425    }
426    let mut crossings = 0usize;
427    for polygon in &loops {
428        let mut image_hits = 0usize;
429        for shift in [-u_period, 0.0, u_period] {
430            let image = Vec2 {
431                x: point.x + shift,
432                y: point.y,
433            };
434            match point_in_polygon(image, polygon, tolerance) {
435                PolygonClass::Boundary => return Ok(Some(PolygonClass::Boundary)),
436                PolygonClass::Inside => image_hits += 1,
437                PolygonClass::Outside => {}
438            }
439        }
440        crossings += image_hits % 2;
441    }
442    let class = if crossings % 2 == 1 {
443        PolygonClass::Inside
444    } else {
445        PolygonClass::Outside
446    };
447    if debug {
448        eprintln!(
449            "horizon: face {} loops={} straddling -> {:?}",
450            face.id,
451            loops.len(),
452            class
453        );
454    }
455    Ok(Some(class))
456}
457
458/// Classify a spherical cap represented by a varying full-wrap contact rim and
459/// the oppositely wound collapsed rim at one sphere pole.  This is the natural
460/// topology when a circle about an oblique axis encloses a pole of the sphere's
461/// stored parameter frame.
462fn winding_sphere_cap_point_in_face(
463    face: &FaceRecord,
464    point: Vec2,
465    tolerance: f64,
466) -> Result<Option<PolygonClass>, String> {
467    if !matches!(
468        face.surface.analytic(),
469        Some(crate::AnalyticSurface::Sphere { .. })
470    ) || face.surface.closed_directions()? != (true, false)
471        || face.loops.len() != 2
472    {
473        return Ok(None);
474    }
475    let [u0, u1] = face.surface.domain_u()?;
476    let [v0, v1] = face.surface.domain_v()?;
477    let period = u1 - u0;
478    let v_span = v1 - v0;
479    if !(period > 0.0 && v_span > 0.0) {
480        return Ok(None);
481    }
482    struct WindingLoop {
483        points: Vec<Vec2>,
484        winding: f64,
485        vmin: f64,
486        vmax: f64,
487    }
488    let mut loops = Vec::with_capacity(2);
489    for loop_record in &face.loops {
490        let mut points = Vec::new();
491        for coedge in &loop_record.coedges {
492            let [start, end] = coedge.pcurve.domain()?;
493            let samples = 2usize
494                .max((interior_knot_count(&coedge.pcurve) + 1) * (coedge.pcurve.degree + 1) * 4);
495            for index in 0..samples {
496                let parameter = start + (end - start) * index as f64 / samples as f64;
497                let p = coedge.pcurve.evaluate(parameter)?;
498                points.push(Vec2 { x: p.x, y: p.y });
499            }
500        }
501        if points.len() < 2 {
502            return Ok(None);
503        }
504        let first = points[0];
505        let mut cursor = first;
506        let mut unwrapped = vec![cursor];
507        for next in points.iter().skip(1) {
508            let du = next.x - cursor.x;
509            let folded = du - period * (du / period).round();
510            cursor = Vec2 {
511                x: cursor.x + folded,
512                y: next.y,
513            };
514            unwrapped.push(cursor);
515        }
516        let last_raw = *points.last().unwrap();
517        let closing_du = first.x - last_raw.x;
518        let closing_folded = closing_du - period * (closing_du / period).round();
519        let winding = cursor.x + closing_folded - first.x;
520        let (mut vmin, mut vmax) = (f64::INFINITY, f64::NEG_INFINITY);
521        for p in &unwrapped {
522            vmin = vmin.min(p.y);
523            vmax = vmax.max(p.y);
524        }
525        loops.push(WindingLoop {
526            points: unwrapped,
527            winding,
528            vmin,
529            vmax,
530        });
531    }
532    if loops
533        .iter()
534        .any(|loop_data| (loop_data.winding.abs() - period).abs() > 0.05 * period)
535        || loops[0].winding * loops[1].winding >= 0.0
536    {
537        return Ok(None);
538    }
539    let flat = |loop_data: &WindingLoop| loop_data.vmax - loop_data.vmin <= 1e-6 * v_span;
540    let (pole, rim) = match (flat(&loops[0]), flat(&loops[1])) {
541        (true, false) => (&loops[0], &loops[1]),
542        (false, true) => (&loops[1], &loops[0]),
543        _ => return Ok(None),
544    };
545    let pole_v = 0.5 * (pole.vmin + pole.vmax);
546    if (pole_v - v0).abs() > 1e-6 * v_span && (pole_v - v1).abs() > 1e-6 * v_span {
547        return Ok(None);
548    }
549
550    let window_start = rim.points[0].x.min(rim.points[rim.points.len() - 1].x);
551    let query_u = window_start + (point.x - window_start).rem_euclid(period);
552    let mut crossings = Vec::new();
553    for pair in rim.points.windows(2) {
554        let (a, b) = (pair[0], pair[1]);
555        if (a.x > query_u) != (b.x > query_u) {
556            crossings.push(a.y + (query_u - a.x) / (b.x - a.x) * (b.y - a.y));
557        }
558        for image in [-period, 0.0, period] {
559            if point_segment_distance(
560                Vec2 {
561                    x: point.x + image,
562                    y: point.y,
563                },
564                a,
565                b,
566            ) <= tolerance
567            {
568                return Ok(Some(PolygonClass::Boundary));
569            }
570        }
571    }
572    let Some(rim_v) = crossings
573        .into_iter()
574        .min_by(|a, b| (a - point.y).abs().total_cmp(&(b - point.y).abs()))
575    else {
576        return Ok(None);
577    };
578    let inside = if pole_v < rim_v {
579        point.y <= rim_v + tolerance
580    } else {
581        point.y >= rim_v - tolerance
582    };
583    Ok(Some(if inside {
584        PolygonClass::Inside
585    } else {
586        PolygonClass::Outside
587    }))
588}
589
590/// Point-in-face for a SINGLY-PERIODIC (closed-u) FULL-BAND STRIP whose two
591/// full-wrap rim loops are drawn as COVERING-PLANE pcurves running OUT OF the
592/// u-domain by up to a whole period (case 08:10-genus face 1513, the 4th seam
593/// representation after helmet-unwrapped / band-complement / 675-hops): the
594/// raw even-odd sees u-winding loop polygons whose parity is meaningless, so
595/// nearly the whole material strip reads Outside, the marched clip crumbles
596/// the section curves to sub-mm bits, and the neighbours strand one-use.
597///
598/// The material region is the strip BETWEEN the two rims: on an open-v
599/// surface a 2-loop face whose loops both wrap the full period has no other
600/// representable region. The verdict is a sampled per-u hull — each rim is a
601/// single periodic polyline v = rim(u) in the covering plane, and the query
602/// point is between the rims iff an upward (+v) vertical ray at the query's
603/// u (folded modulo the period into each rim's own one-period window) crosses
604/// the two rim polylines an odd number of times in total. NO flat-level
605/// approximation is used (the rims are scalloped: 1513's upper rim is flat at
606/// v=1 on parts of u but dips to v=0.745 elsewhere — a v-between-extremes
607/// rule is provably wrong on it).
608///
609/// STRUCTURAL gates (all must hold; anything else returns None and keeps the
610/// previous classification bit-identically):
611///   - surface closed in exactly u; exactly 2 loops;
612///   - at least one loop's samples run OUT of the u-domain by >1e-3 period
613///     (the covering-plane discriminator — in-domain 2-rim bands keep their
614///     current path);
615///   - each loop's unwrapped net u-travel is exactly ±one period and its v
616///     closes (a true full-wrap rim, not a partial arc);
617///   - opposite travel directions, disjoint v-hulls, and the material-left
618///     winding rule confirming the BETWEEN strip (a complement verdict cannot
619///     be represented on an open-v surface — decline, never guess).
620/// Escape hatch: BREP_COVERING_RIM_STRIP=0.
621fn covering_rim_strip_point_in_face(
622    face: &FaceRecord,
623    point: Vec2,
624    tolerance: f64,
625) -> Result<Option<PolygonClass>, String> {
626    if face.surface.closed_directions()? != (true, false) || face.loops.len() != 2 {
627        return Ok(None);
628    }
629    if std::env::var("BREP_COVERING_RIM_STRIP").as_deref() == Ok("0") {
630        return Ok(None);
631    }
632    let [u0, u1] = face.surface.domain_u()?;
633    let [v0, v1] = face.surface.domain_v()?;
634    let period = u1 - u0;
635    if !(period > 0.0) {
636        return Ok(None);
637    }
638    let v_span = (v1 - v0).abs().max(1e-30);
639    struct Rim {
640        points: Vec<Vec2>,
641        vmin: f64,
642        vmax: f64,
643        ascending: bool,
644    }
645    let mut rims: Vec<Rim> = Vec::with_capacity(2);
646    let mut out_of_domain = false;
647    for loop_record in &face.loops {
648        let mut points: Vec<Vec2> = Vec::new();
649        for coedge in &loop_record.coedges {
650            let curve = &coedge.pcurve;
651            let [start, end] = curve.domain()?;
652            let sample_count =
653                2usize.max((interior_knot_count(curve) + 1) * (curve.degree + 1) * 4);
654            for index in 0..=sample_count {
655                let parameter = start + (end - start) * index as f64 / sample_count as f64;
656                let evaluated = curve.evaluate(parameter)?;
657                points.push(Vec2 {
658                    x: evaluated.x,
659                    y: evaluated.y,
660                });
661            }
662        }
663        if points.len() < 3 {
664            return Ok(None);
665        }
666        // Covering-plane discriminator: this lane exists for loops drawn PAST
667        // the domain; everything in-domain keeps the existing classifiers.
668        for p in &points {
669            if p.x < u0 - 1e-3 * period || p.x > u1 + 1e-3 * period {
670                out_of_domain = true;
671            }
672        }
673        // Unwrap into the covering plane (period-folded deltas — a no-op for
674        // an already-continuous covering-plane representation, and the same
675        // fold the horizon lane uses for in-domain seam hops).
676        let mut unwrapped: Vec<Vec2> = Vec::with_capacity(points.len());
677        let mut cursor = points[0];
678        unwrapped.push(cursor);
679        for pair in points.windows(2) {
680            let du = pair[1].x - pair[0].x;
681            let folded = du - period * (du / period).round();
682            cursor = Vec2 {
683                x: cursor.x + folded,
684                y: pair[1].y,
685            };
686            unwrapped.push(cursor);
687        }
688        let net = unwrapped[unwrapped.len() - 1].x - unwrapped[0].x;
689        if (net.abs() - period).abs() > 2e-2 * period {
690            return Ok(None); // not a clean single full wrap
691        }
692        if (unwrapped[unwrapped.len() - 1].y - unwrapped[0].y).abs() > 1e-3 * v_span {
693            return Ok(None); // rim does not close in v
694        }
695        let (mut vmin, mut vmax) = (f64::INFINITY, f64::NEG_INFINITY);
696        for p in &unwrapped {
697            vmin = vmin.min(p.y);
698            vmax = vmax.max(p.y);
699        }
700        rims.push(Rim {
701            points: unwrapped,
702            vmin,
703            vmax,
704            ascending: net > 0.0,
705        });
706    }
707    if !out_of_domain {
708        return Ok(None);
709    }
710    if rims[0].ascending == rims[1].ascending {
711        return Ok(None); // rims must traverse opposite u directions
712    }
713    let (lower, upper) = if rims[0].vmax <= rims[1].vmin {
714        (&rims[0], &rims[1])
715    } else if rims[1].vmax <= rims[0].vmin {
716        (&rims[1], &rims[0])
717    } else {
718        return Ok(None); // interleaved v-hulls: not a clean strip
719    };
720    if upper.vmin - lower.vmax <= 1e-6 * v_span {
721        return Ok(None);
722    }
723    // Material-left rule (same convention as the doubly-periodic band lanes):
724    // the between strip is CCW in uv iff the LOWER rim travels +u; that must
725    // match the face sense, else the material would be the complement — which
726    // an open-v surface cannot represent. Decline rather than guess.
727    if (lower.ascending) != face.same_sense {
728        return Ok(None);
729    }
730    // Boundary: proximity to either rim polyline at the query's period images.
731    for rim in [lower, upper] {
732        for shift in [-period, 0.0, period] {
733            let image = Vec2 {
734                x: point.x + shift,
735                y: point.y,
736            };
737            for pair in rim.points.windows(2) {
738                if point_segment_distance(image, pair[0], pair[1]) <= tolerance {
739                    return Ok(Some(PolygonClass::Boundary));
740                }
741            }
742        }
743    }
744    // Sampled per-u hull: fold the query into each rim's own one-period
745    // window and count upward-ray crossings; odd total = between the rims.
746    let mut crossings = 0usize;
747    for rim in [lower, upper] {
748        let window_base = rim.points[0].x.min(rim.points[rim.points.len() - 1].x);
749        let x = window_base + (point.x - window_base).rem_euclid(period);
750        for pair in rim.points.windows(2) {
751            let (a, b) = (pair[0], pair[1]);
752            if (a.x > x) != (b.x > x) {
753                let v_cross = a.y + (x - a.x) / (b.x - a.x) * (b.y - a.y);
754                if v_cross > point.y {
755                    crossings += 1;
756                }
757            }
758        }
759    }
760    Ok(Some(if crossings % 2 == 1 {
761        PolygonClass::Inside
762    } else {
763        PolygonClass::Outside
764    }))
765}
766
767pub fn parameter_point_in_face(
768    face: &FaceRecord,
769    point: Vec2,
770    tolerance: f64,
771) -> Result<PolygonClass, String> {
772    if let Some(class) = seam_band_point_in_face(face, point, tolerance)? {
773        return Ok(class);
774    }
775    if let Some(class) = winding_sphere_cap_point_in_face(face, point, tolerance)? {
776        return Ok(class);
777    }
778    if let Some(class) = wrapped_horizon_point_in_face(face, point, tolerance)? {
779        return Ok(class);
780    }
781    if let Some(class) = covering_rim_strip_point_in_face(face, point, tolerance)? {
782        return Ok(class);
783    }
784    let mut crossings = 0;
785    let mut boundary = false;
786    let mut nearest: Option<(SegmentReference<'_>, f64)> = None;
787    for loop_record in &face.loops {
788        let mut polygon = Vec::new();
789        let mut segments = Vec::new();
790        for coedge in &loop_record.coedges {
791            let curve = &coedge.pcurve;
792            let [start, end] = curve.domain()?;
793            let sample_count =
794                2usize.max((interior_knot_count(curve) + 1) * (curve.degree + 1) * 4);
795            for index in 0..sample_count {
796                let parameter = start + (end - start) * index as f64 / sample_count as f64;
797                let evaluated = curve.evaluate(parameter)?;
798                polygon.push(Vec2 {
799                    x: evaluated.x,
800                    y: evaluated.y,
801                });
802                let parameter_end = if index + 1 < sample_count {
803                    start + (end - start) * (index + 1) as f64 / sample_count as f64
804                } else {
805                    end
806                };
807                segments.push(SegmentReference {
808                    start: Vec2 {
809                        x: evaluated.x,
810                        y: evaluated.y,
811                    },
812                    end: Vec2 { x: 0.0, y: 0.0 },
813                    curve,
814                    parameter_start: parameter,
815                    parameter_end,
816                });
817            }
818        }
819        for index in 0..polygon.len() {
820            segments[index].end = polygon[(index + 1) % polygon.len()];
821        }
822        match point_in_polygon(point, &polygon, tolerance) {
823            PolygonClass::Boundary => boundary = true,
824            PolygonClass::Inside => crossings += 1,
825            PolygonClass::Outside => {}
826        }
827        for segment in segments {
828            let distance = point_segment_distance(point, segment.start, segment.end);
829            if nearest
830                .as_ref()
831                .is_none_or(|(_, nearest_distance)| distance < *nearest_distance)
832            {
833                nearest = Some((segment, distance));
834            }
835        }
836    }
837    if boundary {
838        return Ok(PolygonClass::Boundary);
839    }
840    let parity = if crossings % 2 == 1 {
841        PolygonClass::Inside
842    } else {
843        PolygonClass::Outside
844    };
845    let Some((segment, distance)) = nearest else {
846        return Ok(parity);
847    };
848    let segment_length = segment.end.sub(segment.start).length();
849    if distance > segment_length || segment_length <= 0.0 {
850        return Ok(parity);
851    }
852    let chord_parameter = segment.parameter_start
853        + (segment.parameter_end - segment.parameter_start)
854            * (point.sub(segment.start).dot(segment.end.sub(segment.start))
855                / (segment_length * segment_length))
856                .clamp(0.0, 1.0);
857    let [domain_start, domain_end] = segment.curve.domain()?;
858    let mut parameter = chord_parameter;
859    for _ in 0..12 {
860        let derivatives = segment.curve.derivatives(parameter, 2)?;
861        let on_curve = Vec2 {
862            x: derivatives[0].x,
863            y: derivatives[0].y,
864        };
865        let tangent = Vec2 {
866            x: derivatives[1].x,
867            y: derivatives[1].y,
868        };
869        let second = Vec2 {
870            x: derivatives[2].x,
871            y: derivatives[2].y,
872        };
873        let residual = on_curve.sub(point);
874        let denominator = tangent.dot(tangent) + residual.dot(second);
875        if denominator.abs() < 1e-30 {
876            break;
877        }
878        let step = -residual.dot(tangent) / denominator;
879        parameter = (parameter + step).clamp(domain_start, domain_end);
880        if step.abs() < 1e-14 * (domain_end - domain_start + 1.0) {
881            break;
882        }
883    }
884    let margin = 1e-9 * (domain_end - domain_start);
885    if parameter > domain_start + margin && parameter < domain_end - margin {
886        let derivatives = segment.curve.derivatives(parameter, 1)?;
887        let on_curve = Vec2 {
888            x: derivatives[0].x,
889            y: derivatives[0].y,
890        };
891        let tangent = Vec2 {
892            x: derivatives[1].x,
893            y: derivatives[1].y,
894        };
895        let offset = point.sub(on_curve);
896        if offset.length() <= tolerance {
897            return Ok(PolygonClass::Boundary);
898        }
899        let cross = tangent.x * offset.y - tangent.y * offset.x;
900        if cross.abs() > 1e-30 {
901            return Ok(if (cross > 0.0) == face.same_sense {
902                PolygonClass::Inside
903            } else {
904                PolygonClass::Outside
905            });
906        }
907    }
908    Ok(parity)
909}