Skip to main content

brepkit_check/
util.rs

1//! Shared utility functions for the check crate.
2
3use brepkit_math::aabb::Aabb3;
4use brepkit_math::vec::{Point2, Point3, Vec3};
5use brepkit_topology::Topology;
6use brepkit_topology::edge::EdgeCurve;
7use brepkit_topology::face::{FaceId, FaceSurface};
8
9use crate::CheckError;
10
11/// Compute the normal of a polygon via Newell's method.
12///
13/// Returns a unit-length normal, or `(0,0,1)` for degenerate polygons.
14#[must_use]
15pub fn polygon_normal(verts: &[Point3]) -> Vec3 {
16    let mut nx = 0.0;
17    let mut ny = 0.0;
18    let mut nz = 0.0;
19    let n = verts.len();
20    for i in 0..n {
21        let j = (i + 1) % n;
22        let vi = verts[i];
23        let vj = verts[j];
24        nx += (vi.y() - vj.y()) * (vi.z() + vj.z());
25        ny += (vi.z() - vj.z()) * (vi.x() + vj.x());
26        nz += (vi.x() - vj.x()) * (vi.y() + vj.y());
27    }
28    let len = (nx.mul_add(nx, ny.mul_add(ny, nz * nz))).sqrt();
29    if len < 1e-30 {
30        Vec3::new(0.0, 0.0, 1.0)
31    } else {
32        Vec3::new(nx / len, ny / len, nz / len)
33    }
34}
35
36/// Number of sample points for closed-curve edges.
37pub const CLOSED_CURVE_SAMPLES: usize = 32;
38
39/// Number of samples for an OPEN curved edge (arc or marched conic piece) in
40/// wire polygons; sized for sub-degree angular steps on typical arcs.
41pub const OPEN_CURVE_SAMPLES: usize = 32;
42
43/// Sample a closed-edge curve at `n` evenly spaced parameter values.
44///
45/// Returns an empty vector for `Line` edges (geometry determined by vertices).
46#[must_use]
47pub fn sample_edge_curve(curve: &EdgeCurve, n: usize) -> Vec<Point3> {
48    match curve {
49        EdgeCurve::Circle(c) => (0..n)
50            .map(|i| {
51                let t = std::f64::consts::TAU * (i as f64) / (n as f64);
52                c.evaluate(t)
53            })
54            .collect(),
55        EdgeCurve::Ellipse(e) => (0..n)
56            .map(|i| {
57                let t = std::f64::consts::TAU * (i as f64) / (n as f64);
58                e.evaluate(t)
59            })
60            .collect(),
61        EdgeCurve::NurbsCurve(nc) => {
62            let (u0, u1) = nc.domain();
63            let start_pt = nc.evaluate(u0);
64            let end_pt = nc.evaluate(u1);
65            let is_closed = (start_pt - end_pt).length() < 1e-6;
66            let divisor = if is_closed { n } else { n - 1 };
67            (0..n)
68                .map(|i| {
69                    let t = u0 + (u1 - u0) * (i as f64) / (divisor as f64);
70                    nc.evaluate(t)
71                })
72                .collect()
73        }
74        EdgeCurve::Line => vec![],
75    }
76}
77
78/// Build a polygon from the outer wire of a face by sampling vertex positions
79/// and closed-edge curves.
80///
81/// # Errors
82///
83/// Returns an error if any topology entity referenced by the face is missing.
84pub fn face_polygon(topo: &Topology, face_id: FaceId) -> Result<Vec<Point3>, CheckError> {
85    let face = topo.face(face_id)?;
86    wire_polygon(topo, face.outer_wire())
87}
88
89/// Build a polygon from a wire by sampling vertex positions and closed-edge
90/// curves.
91///
92/// Wires store edges in loop order, but the per-edge orientation flags are
93/// not guaranteed to chain head-to-tail; each edge's traversal direction is
94/// re-derived from vertex connectivity with the previous edge so the polygon
95/// follows the actual loop.
96///
97/// # Errors
98///
99/// Returns an error if any topology entity referenced by the wire is missing.
100pub fn wire_polygon(
101    topo: &Topology,
102    wire_id: brepkit_topology::wire::WireId,
103) -> Result<Vec<Point3>, CheckError> {
104    let wire = topo.wire(wire_id)?;
105    let mut pts: Vec<Point3> = Vec::new();
106    let mut prev_end: Option<brepkit_topology::vertex::VertexId> = None;
107
108    for oe in wire.edges() {
109        let edge = topo.edge(oe.edge())?;
110        let curve = edge.curve();
111        let start_vid = edge.start();
112        let end_vid = edge.end();
113        let forward = match prev_end {
114            Some(pe) if start_vid == pe && end_vid != pe => true,
115            Some(pe) if end_vid == pe && start_vid != pe => false,
116            // A closed edge's endpoints coincide, so positional chaining is
117            // meaningless — keep the stored traversal flag (the partial-turn
118            // torus band's rim phase coherence depends on it).
119            _ if start_vid == end_vid => oe.is_forward(),
120            // OPEN edges: consecutive wire edges can hold position-equal but
121            // DISTINCT vertex ids (assembly refinement mints sub-edge
122            // vertices from a different pool than the neighbours). Fall back
123            // to positional chaining against the last emitted point; an
124            // orientation-flag guess can bow-tie the polygon and flip
125            // containment inside the mis-ordered region.
126            _ => match pts.last() {
127                Some(&last) => {
128                    let sp = topo.vertex(start_vid)?.point();
129                    let ep = topo.vertex(end_vid)?.point();
130                    (sp - last).length_squared() <= (ep - last).length_squared()
131                }
132                None => oe.is_forward(),
133            },
134        };
135        let is_closed_edge = start_vid == end_vid
136            && matches!(
137                curve,
138                EdgeCurve::Circle(_) | EdgeCurve::Ellipse(_) | EdgeCurve::NurbsCurve(_)
139            );
140        if is_closed_edge {
141            // Start sampling at the edge's seam vertex so the polygon chains
142            // cleanly with adjacent edges; the curve's own parameter origin
143            // is unrelated to the vertex.
144            let seam_pt = topo.vertex(start_vid)?.point();
145            // Traversal must start at the seam vertex in both directions:
146            // forward covers [t0, t0 + period), reversed covers (t0, t0 + period]
147            // walked backwards — the next edge supplies the closing point.
148            #[allow(clippy::cast_precision_loss)]
149            let params = |n: usize, period: f64| -> Vec<f64> {
150                if forward {
151                    (0..n).map(|i| period * (i as f64) / (n as f64)).collect()
152                } else {
153                    (1..=n)
154                        .rev()
155                        .map(|i| period * (i as f64) / (n as f64))
156                        .collect()
157                }
158            };
159            let sampled: Vec<Point3> = match curve {
160                EdgeCurve::Circle(c) => {
161                    let t0 = c.project(seam_pt);
162                    params(CLOSED_CURVE_SAMPLES, std::f64::consts::TAU)
163                        .into_iter()
164                        .map(|dt| c.evaluate(t0 + dt))
165                        .collect()
166                }
167                EdgeCurve::Ellipse(e) => {
168                    let t0 = e.project(seam_pt);
169                    params(CLOSED_CURVE_SAMPLES, std::f64::consts::TAU)
170                        .into_iter()
171                        .map(|dt| e.evaluate(t0 + dt))
172                        .collect()
173                }
174                EdgeCurve::NurbsCurve(nc) => {
175                    let (u0, u1) = nc.domain();
176                    let span = u1 - u0;
177                    if span.is_finite() && span > 0.0 {
178                        let t0 = nurbs_seam_parameter(nc, seam_pt, u0, u1);
179                        params(CLOSED_CURVE_SAMPLES, span)
180                            .into_iter()
181                            .map(|dt| nc.evaluate(u0 + (t0 - u0 + dt).rem_euclid(span)))
182                            .collect()
183                    } else {
184                        let mut s = sample_edge_curve(curve, CLOSED_CURVE_SAMPLES);
185                        if !forward {
186                            s.reverse();
187                        }
188                        s
189                    }
190                }
191                EdgeCurve::Line => vec![],
192            };
193            pts.extend(sampled);
194            prev_end = Some(start_vid);
195        } else if matches!(curve, EdgeCurve::Line) {
196            let vid = if forward { start_vid } else { end_vid };
197            pts.push(topo.vertex(vid)?.point());
198            prev_end = Some(if forward { end_vid } else { start_vid });
199        } else {
200            // Open curved edge (an arc or a marched conic piece): sample its
201            // endpoint-trimmed span. A single vertex would represent the edge
202            // by its chord, and any UV containment built from the polygon
203            // then rejects real hits between the chord and the true curve —
204            // a winding-chain band's wall lobes classified Outside this way.
205            // Forward covers [t0, t1); reversed covers (t0, t1] walked
206            // backwards — the next edge supplies the closing point, matching
207            // the closed-edge convention above.
208            let sp = topo.vertex(start_vid)?.point();
209            let ep = topo.vertex(end_vid)?.point();
210            let (t0, t1) = curve.domain_with_endpoints(sp, ep);
211            let n = OPEN_CURVE_SAMPLES;
212            #[allow(clippy::cast_precision_loss)]
213            let mut seq: Vec<Point3> = (0..=n)
214                .map(|i| {
215                    curve.evaluate_with_endpoints(t0 + (t1 - t0) * (i as f64) / (n as f64), sp, ep)
216                })
217                .collect();
218            // The parametric walk follows the CURVE's own direction, which
219            // can oppose the vertex order (a marched conic stores whichever
220            // direction the fit produced). Orient to the traversal start
221            // positionally, then drop the far endpoint — the next edge
222            // supplies it, matching the closed-edge convention above.
223            let traversal_start = if forward { sp } else { ep };
224            if (seq[0] - traversal_start).length_squared()
225                > (seq[n] - traversal_start).length_squared()
226            {
227                seq.reverse();
228            }
229            seq.pop();
230            pts.extend(seq);
231            prev_end = Some(if forward { end_vid } else { start_vid });
232        }
233    }
234
235    Ok(pts)
236}
237
238/// Expand an AABB to account for surface curvature that may extend beyond
239/// the wire vertices.
240///
241/// Plane and Cone surfaces are bounded by their vertices, so this is a no-op
242/// for those types. Sphere, Cylinder, Torus, and NURBS surfaces can bulge
243/// beyond the vertex-derived bounding box.
244pub fn expand_aabb_for_surface(aabb: &mut Aabb3, surface: &FaceSurface) {
245    match surface {
246        FaceSurface::Sphere(s) => {
247            let c = s.center();
248            let r = s.radius();
249            aabb_include(aabb, Point3::new(c.x() - r, c.y() - r, c.z() - r));
250            aabb_include(aabb, Point3::new(c.x() + r, c.y() + r, c.z() + r));
251        }
252        FaceSurface::Cylinder(c) => {
253            let origin = c.origin();
254            let axis = c.axis();
255            let r = c.radius();
256            let rx = r * (1.0 - axis.x() * axis.x()).max(0.0).sqrt();
257            let ry = r * (1.0 - axis.y() * axis.y()).max(0.0).sqrt();
258            let rz = r * (1.0 - axis.z() * axis.z()).max(0.0).sqrt();
259            for corner in [aabb.min, aabb.max] {
260                let rel = Vec3::new(
261                    corner.x() - origin.x(),
262                    corner.y() - origin.y(),
263                    corner.z() - origin.z(),
264                );
265                let t = axis.dot(rel);
266                let coa = Point3::new(
267                    origin.x() + axis.x() * t,
268                    origin.y() + axis.y() * t,
269                    origin.z() + axis.z() * t,
270                );
271                aabb_include(aabb, Point3::new(coa.x() - rx, coa.y() - ry, coa.z() - rz));
272                aabb_include(aabb, Point3::new(coa.x() + rx, coa.y() + ry, coa.z() + rz));
273            }
274        }
275        FaceSurface::Torus(t) => {
276            let c = t.center();
277            let outer_r = t.major_radius() + t.minor_radius();
278            let axis = t.z_axis();
279            let axial_offset = Vec3::new(
280                axis.x() * t.minor_radius(),
281                axis.y() * t.minor_radius(),
282                axis.z() * t.minor_radius(),
283            );
284            aabb_include(
285                aabb,
286                Point3::new(
287                    c.x() - outer_r + axial_offset.x().min(0.0),
288                    c.y() - outer_r + axial_offset.y().min(0.0),
289                    c.z() - outer_r + axial_offset.z().min(0.0),
290                ),
291            );
292            aabb_include(
293                aabb,
294                Point3::new(
295                    c.x() + outer_r + axial_offset.x().max(0.0),
296                    c.y() + outer_r + axial_offset.y().max(0.0),
297                    c.z() + outer_r + axial_offset.z().max(0.0),
298                ),
299            );
300        }
301        FaceSurface::Nurbs(nurbs) => {
302            let (u_min, u_max) = nurbs.domain_u();
303            let (v_min, v_max) = nurbs.domain_v();
304            let n_samples = 8;
305            for iu in 0..=n_samples {
306                let u = u_min + (u_max - u_min) * (iu as f64) / (n_samples as f64);
307                for iv in 0..=n_samples {
308                    let v = v_min + (v_max - v_min) * (iv as f64) / (n_samples as f64);
309                    aabb_include(aabb, nurbs.evaluate(u, v));
310                }
311            }
312        }
313        FaceSurface::Plane { .. } | FaceSurface::Cone(_) => {}
314    }
315}
316
317/// Include a single point in an AABB.
318fn aabb_include(aabb: &mut Aabb3, p: Point3) {
319    *aabb = aabb.union(Aabb3 { min: p, max: p });
320}
321
322/// Squared distance below which the seam vertex counts as coincident with
323/// the curve's domain start (linear tolerance 1e-7, squared).
324const SEAM_COINCIDENT_SQ: f64 = 1e-14;
325
326/// Parameter of the seam vertex on a closed NURBS rim.
327///
328/// Closed NURBS edges normally place their seam vertex at the curve's domain
329/// start; when they do not, sampling from the domain origin breaks phase
330/// coherence with adjacent edges, so the vertex is projected onto the curve.
331fn nurbs_seam_parameter(
332    nc: &brepkit_math::nurbs::curve::NurbsCurve,
333    seam_pt: Point3,
334    u0: f64,
335    u1: f64,
336) -> f64 {
337    if (nc.evaluate(u0) - seam_pt).length_squared() <= SEAM_COINCIDENT_SQ {
338        return u0;
339    }
340    brepkit_math::nurbs::projection::project_point_to_curve(nc, seam_pt, 1e-9)
341        .map_or(u0, |proj| proj.parameter.clamp(u0, u1))
342}
343
344/// Expand an AABB to cover the full extent of a curved edge.
345///
346/// Vertex endpoints alone under-represent curved edges — a closed circle
347/// edge has ONE vertex, collapsing the box to a point and starving any
348/// AABB prefilter (a plane cap bounded by a single rim circle was never
349/// offered to the classifier's BVH, dropping its ray crossings). Circle
350/// and ellipse use the exact full-curve extent (a conservative superset
351/// for partial arcs); NURBS uses the control-point convex hull.
352fn expand_aabb_for_curve(aabb: &mut Aabb3, curve: &EdgeCurve) {
353    match curve {
354        EdgeCurve::Line => {}
355        EdgeCurve::Circle(c) => {
356            let cen = c.center();
357            let r = c.radius();
358            let (u, v) = (c.u_axis(), c.v_axis());
359            let ext = [
360                r * u.x().hypot(v.x()),
361                r * u.y().hypot(v.y()),
362                r * u.z().hypot(v.z()),
363            ];
364            aabb_include(
365                aabb,
366                Point3::new(cen.x() - ext[0], cen.y() - ext[1], cen.z() - ext[2]),
367            );
368            aabb_include(
369                aabb,
370                Point3::new(cen.x() + ext[0], cen.y() + ext[1], cen.z() + ext[2]),
371            );
372        }
373        EdgeCurve::Ellipse(e) => {
374            let cen = e.center();
375            let (a, b) = (e.semi_major(), e.semi_minor());
376            let (u, v) = (e.u_axis(), e.v_axis());
377            let ext = [
378                (a * u.x()).hypot(b * v.x()),
379                (a * u.y()).hypot(b * v.y()),
380                (a * u.z()).hypot(b * v.z()),
381            ];
382            aabb_include(
383                aabb,
384                Point3::new(cen.x() - ext[0], cen.y() - ext[1], cen.z() - ext[2]),
385            );
386            aabb_include(
387                aabb,
388                Point3::new(cen.x() + ext[0], cen.y() + ext[1], cen.z() + ext[2]),
389            );
390        }
391        EdgeCurve::NurbsCurve(nc) => {
392            for &p in nc.control_points() {
393                aabb_include(aabb, p);
394            }
395        }
396    }
397}
398
399/// Compute the axis-aligned bounding box of a face.
400///
401/// Starts from the wire vertex positions, expands for curved-edge extent,
402/// then expands for surface curvature (spheres, cylinders, tori, NURBS).
403///
404/// # Errors
405///
406/// Returns an error if any topology entity referenced by the face is missing.
407pub fn face_aabb(topo: &Topology, face_id: FaceId) -> Result<Aabb3, CheckError> {
408    let face = topo.face(face_id)?;
409    let wire = topo.wire(face.outer_wire())?;
410    let mut points = Vec::new();
411    for oe in wire.edges() {
412        let edge = topo.edge(oe.edge())?;
413        points.push(topo.vertex(edge.start())?.point());
414        points.push(topo.vertex(edge.end())?.point());
415    }
416    let mut aabb = Aabb3::try_from_points(points.iter().copied())
417        .ok_or_else(|| CheckError::ClassificationFailed("face has no vertices".into()))?;
418    for oe in wire.edges() {
419        let edge = topo.edge(oe.edge())?;
420        expand_aabb_for_curve(&mut aabb, edge.curve());
421    }
422    expand_aabb_for_surface(&mut aabb, face.surface());
423    Ok(aabb)
424}
425
426/// Test whether a 3D point lies inside a 3D polygon by projecting onto the
427/// dominant axis plane (the plane most aligned with the polygon normal).
428#[must_use]
429pub fn point_in_polygon_3d(point: &Point3, polygon: &[Point3], normal: &Vec3) -> bool {
430    use brepkit_math::predicates::point_in_polygon;
431
432    let ax = normal.x().abs();
433    let ay = normal.y().abs();
434    let az = normal.z().abs();
435
436    let (proj_pt, proj_poly): (Point2, Vec<Point2>) = if az >= ax && az >= ay {
437        (
438            Point2::new(point.x(), point.y()),
439            polygon.iter().map(|p| Point2::new(p.x(), p.y())).collect(),
440        )
441    } else if ay >= ax {
442        (
443            Point2::new(point.x(), point.z()),
444            polygon.iter().map(|p| Point2::new(p.x(), p.z())).collect(),
445        )
446    } else {
447        (
448            Point2::new(point.y(), point.z()),
449            polygon.iter().map(|p| Point2::new(p.y(), p.z())).collect(),
450        )
451    };
452
453    point_in_polygon(proj_pt, &proj_poly)
454}
455
456#[cfg(test)]
457mod tests {
458    #![allow(clippy::unwrap_used, clippy::expect_used)]
459
460    use super::*;
461    use brepkit_geometry::convert::curve_to_nurbs::circle_to_nurbs;
462    use brepkit_math::curves::Circle3D;
463    use brepkit_topology::edge::Edge;
464    use brepkit_topology::vertex::Vertex;
465    use brepkit_topology::wire::{OrientedEdge, Wire};
466
467    #[test]
468    fn wire_polygon_anchors_closed_nurbs_rim_at_seam_vertex() {
469        let radius = 2.0;
470        let circle =
471            Circle3D::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), radius).unwrap();
472        let nurbs = circle_to_nurbs(&circle, 0.0, std::f64::consts::TAU).unwrap();
473        // Seam vertex deliberately away from the curve's domain start.
474        let seam_pt = circle.evaluate(1.1);
475
476        let mut topo = Topology::new();
477        let v = topo.add_vertex(Vertex::new(seam_pt, 1e-7));
478        let e = topo.add_edge(Edge::new(v, v, EdgeCurve::NurbsCurve(nurbs)));
479        let wire = topo.add_wire(Wire::new(vec![OrientedEdge::new(e, true)], true).unwrap());
480
481        let pts = wire_polygon(&topo, wire).unwrap();
482        assert_eq!(pts.len(), CLOSED_CURVE_SAMPLES);
483        assert!(
484            (pts[0] - seam_pt).length() < 1e-6,
485            "first sample {:?} not anchored at seam {:?}",
486            pts[0],
487            seam_pt
488        );
489        for p in &pts {
490            assert!(
491                (p.x().hypot(p.y()) - radius).abs() < 1e-9,
492                "sample off the rim circle: {p:?}"
493            );
494        }
495    }
496}