Skip to main content

hypercurve/
finite_projection.rs

1//! Finite polyline projection adapters for native hyper curves.
2//!
3//! Projection is an IO/rendering boundary, not a topology kernel. The methods
4//! in this module preserve line segments exactly, approximate circular arcs by
5//! a chord-error budget, and return primitive `f64` coordinates only after the
6//! source [`Real`](hyperreal::Real) coordinates can be exported finitely. This
7//! follows Yap, "Towards Exact Geometric Computation," *Computational
8//! Geometry* 7(1-2), 1997 (<https://doi.org/10.1016/0925-7721(95)00040-2>):
9//! exact objects own CAD/topology; finite samples are boundary products.
10//! Boundary and containment decisions should continue to use the exact
11//! contour/region APIs surveyed by Hormann and Agathos, "The Point in Polygon
12//! Problem for Arbitrary Polygons," *Computational Geometry* 20(3), 2001
13//! (<https://doi.org/10.1016/S0925-7721(01)00012-8>).
14
15use std::f64::consts::PI;
16
17use crate::{
18    CircularArc2, Classification, Contour2, CurveError, CurvePolicy, CurveResult, CurveString2,
19    Point2, Region2, RegionContourProfile, RegionView2, Segment2,
20};
21
22/// Options for projecting native curves to finite `f64` polylines.
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub struct FiniteProjectionOptions {
25    arc_chord_error: f64,
26}
27
28/// Finite `f64` polyline emitted from a native curve object.
29#[derive(Clone, Debug, PartialEq)]
30pub struct FinitePolyline2 {
31    points: Vec<[f64; 2]>,
32    arc_chord_error: f64,
33    closed: bool,
34}
35
36/// Finite `f64` projection of a region with material and hole roles retained.
37///
38/// This is an IO/display object. Exact containment, area, and boolean topology
39/// remain on [`Region2`] / [`RegionView2`].
40#[derive(Clone, Debug, PartialEq)]
41pub struct FiniteRegionProjection2 {
42    material_rings: Vec<FinitePolyline2>,
43    hole_rings: Vec<FinitePolyline2>,
44}
45
46/// A finite material ring and the finite hole rings owned by it.
47///
48/// This is the projected counterpart to [`RegionContourProfile`]. Ownership is
49/// still decided in exact hypercurve topology before any finite ring is
50/// emitted; this type only carries the boundary result.
51#[derive(Clone, Debug, PartialEq)]
52pub struct FiniteRegionProfile2 {
53    material: FinitePolyline2,
54    holes: Vec<FinitePolyline2>,
55}
56
57impl FiniteProjectionOptions {
58    /// Constructs projection options with a positive finite arc chord-error budget.
59    pub fn try_new(arc_chord_error: f64) -> CurveResult<Self> {
60        if arc_chord_error.is_finite() && arc_chord_error > 0.0 {
61            Ok(Self { arc_chord_error })
62        } else {
63            Err(CurveError::InvalidFiniteProjectionOptions)
64        }
65    }
66
67    /// Returns the maximum requested circular-arc chord error.
68    pub const fn arc_chord_error(&self) -> f64 {
69        self.arc_chord_error
70    }
71}
72
73impl FinitePolyline2 {
74    fn new(points: Vec<[f64; 2]>, arc_chord_error: f64, closed: bool) -> Self {
75        Self {
76            points,
77            arc_chord_error,
78            closed,
79        }
80    }
81
82    /// Returns the finite projected vertices.
83    pub fn points(&self) -> &[[f64; 2]] {
84        &self.points
85    }
86
87    /// Consumes the projection and returns finite vertices.
88    pub fn into_points(self) -> Vec<[f64; 2]> {
89        self.points
90    }
91
92    /// Returns the arc chord-error budget requested for this projection.
93    pub const fn arc_chord_error(&self) -> f64 {
94        self.arc_chord_error
95    }
96
97    /// Returns true when this polyline was explicitly closed for a contour.
98    pub const fn is_closed(&self) -> bool {
99        self.closed
100    }
101
102    /// Returns the finite signed shoelace area when this polyline is treated as
103    /// a ring.
104    ///
105    /// This is only a boundary/product measurement of projected vertices. Exact
106    /// contour area stays on [`Contour2::signed_area`] and
107    /// [`crate::Region2::filled_area`].
108    pub fn signed_ring_area(&self) -> f64 {
109        finite_ring_signed_area(&self.points)
110    }
111
112    /// Returns the checked finite signed shoelace area of this projected ring.
113    pub fn try_signed_ring_area(&self) -> CurveResult<f64> {
114        try_finite_ring_signed_area(&self.points)
115    }
116
117    /// Returns the arithmetic centroid of this finite projected polyline.
118    ///
119    /// This is a boundary-product measurement over emitted finite vertices, not
120    /// an exact centroid of the native curve or filled area. A repeated closing
121    /// vertex is ignored. Keeping this helper on the projected polyline type
122    /// prevents downstream crates from reimplementing small finite adapters
123    /// around hypercurve output. The exact-object/boundary split follows Yap,
124    /// "Towards Exact Geometric Computation," *Computational Geometry* 7(1-2),
125    /// 1997 (<https://doi.org/10.1016/0925-7721(95)00040-2>).
126    pub fn vertex_centroid(&self) -> Option<[f64; 2]> {
127        finite_polyline_vertex_centroid(&self.points)
128    }
129
130    /// Returns the checked arithmetic centroid of this projected polyline.
131    pub fn try_vertex_centroid(&self) -> CurveResult<Option<[f64; 2]>> {
132        try_finite_polyline_vertex_centroid(&self.points)
133    }
134}
135
136impl FiniteRegionProjection2 {
137    fn new(material_rings: Vec<FinitePolyline2>, hole_rings: Vec<FinitePolyline2>) -> Self {
138        Self {
139            material_rings,
140            hole_rings,
141        }
142    }
143
144    /// Returns projected material rings.
145    pub fn material_rings(&self) -> &[FinitePolyline2] {
146        &self.material_rings
147    }
148
149    /// Returns projected hole rings.
150    pub fn hole_rings(&self) -> &[FinitePolyline2] {
151        &self.hole_rings
152    }
153}
154
155impl FiniteRegionProfile2 {
156    fn new(material: FinitePolyline2, holes: Vec<FinitePolyline2>) -> Self {
157        Self { material, holes }
158    }
159
160    /// Returns the projected material ring.
161    pub const fn material(&self) -> &FinitePolyline2 {
162        &self.material
163    }
164
165    /// Returns the projected hole rings owned by the material ring.
166    pub fn holes(&self) -> &[FinitePolyline2] {
167        &self.holes
168    }
169
170    /// Returns the finite projected material-minus-hole area.
171    ///
172    /// Hole ownership has already been decided by native region topology before
173    /// this projected profile exists, so this method does not infer roles from
174    /// winding. It only measures the finite output rings with the shoelace
175    /// formula. Exact CAD area should use [`Region2::filled_area`]; this helper
176    /// exists for IO, diagnostics, and tests at the projection boundary.
177    pub fn projected_filled_area(&self) -> f64 {
178        let material = self.material.signed_ring_area().abs();
179        let holes = self
180            .holes
181            .iter()
182            .map(|hole| hole.signed_ring_area().abs())
183            .sum::<f64>();
184        material - holes
185    }
186
187    /// Returns the checked finite projected material-minus-hole area.
188    pub fn try_projected_filled_area(&self) -> CurveResult<f64> {
189        let material = self.material.try_signed_ring_area()?.abs();
190        let holes = self.holes.iter().try_fold(0.0, |sum, hole| {
191            let next = sum + hole.try_signed_ring_area()?.abs();
192            next.is_finite()
193                .then_some(next)
194                .ok_or(CurveError::NonFiniteProjectionPoint)
195        })?;
196        let area = material - holes;
197        area.is_finite()
198            .then_some(area)
199            .ok_or(CurveError::NonFiniteProjectionPoint)
200    }
201}
202
203/// Returns the finite signed shoelace area of projected ring vertices.
204///
205/// The closing edge is included even when the caller did not repeat the first
206/// vertex. This is the familiar Green's-theorem polygon formula applied only
207/// to finite boundary data; exact CAD area should use native contour/region
208/// area APIs instead. The boundary split follows Yap, "Towards Exact Geometric
209/// Computation," *Computational Geometry* 7(1-2), 1997
210/// (<https://doi.org/10.1016/0925-7721(95)00040-2>).
211pub fn finite_ring_signed_area(ring: &[[f64; 2]]) -> f64 {
212    if ring.len() < 3 {
213        return 0.0;
214    }
215    let mut area = 0.0;
216    for edge in ring.windows(2) {
217        area += edge[0][0] * edge[1][1] - edge[1][0] * edge[0][1];
218    }
219    if let (Some(first), Some(last)) = (ring.first(), ring.last()) {
220        area += last[0] * first[1] - first[0] * last[1];
221    }
222    0.5 * area
223}
224
225/// Returns the checked finite signed shoelace area of projected ring vertices.
226///
227/// Non-finite coordinates or arithmetic overflow are reported explicitly
228/// instead of leaking `NaN`/`inf` through a finite-boundary measurement.
229pub fn try_finite_ring_signed_area(ring: &[[f64; 2]]) -> CurveResult<f64> {
230    let ring = normalize_finite_ring_vertices(ring)?;
231    if ring.len() < 3 {
232        return Ok(0.0);
233    }
234
235    let mut area = 0.0;
236    for edge in ring.windows(2) {
237        area = checked_shoelace_sum(area, edge[0], edge[1])?;
238    }
239    if let (Some(first), Some(last)) = (ring.first(), ring.last()) {
240        area = checked_shoelace_sum(area, *last, *first)?;
241    }
242    let area = 0.5 * area;
243    area.is_finite()
244        .then_some(area)
245        .ok_or(CurveError::NonFiniteProjectionPoint)
246}
247
248/// Returns the arithmetic centroid of finite polyline vertices.
249///
250/// A repeated final closing point is ignored so closed-ring projections do not
251/// overweight the first vertex. This is a finite boundary statistic only; exact
252/// geometric centroids belong on native curve/region facts.
253pub fn finite_polyline_vertex_centroid(points: &[[f64; 2]]) -> Option<[f64; 2]> {
254    let unique = points
255        .iter()
256        .copied()
257        .enumerate()
258        .filter_map(|(index, point)| {
259            (index + 1 != points.len() || Some(&point) != points.first()).then_some(point)
260        })
261        .collect::<Vec<_>>();
262    if unique.is_empty() {
263        return None;
264    }
265    let count = unique.len() as f64;
266    let (sum_x, sum_y) = unique
267        .iter()
268        .fold((0.0, 0.0), |(x, y), point| (x + point[0], y + point[1]));
269    Some([sum_x / count, sum_y / count])
270}
271
272/// Returns the checked arithmetic centroid of finite polyline vertices.
273///
274/// A repeated final closing point is ignored. Non-finite coordinates or
275/// arithmetic overflow are reported explicitly.
276pub fn try_finite_polyline_vertex_centroid(points: &[[f64; 2]]) -> CurveResult<Option<[f64; 2]>> {
277    let unique = finite_unique_polyline_vertices(points)?;
278    if unique.is_empty() {
279        return Ok(None);
280    }
281    let count = unique.len() as f64;
282    let (sum_x, sum_y) = unique.iter().try_fold((0.0, 0.0), |(x, y), point| {
283        let next_x = x + point[0];
284        let next_y = y + point[1];
285        (next_x.is_finite() && next_y.is_finite())
286            .then_some((next_x, next_y))
287            .ok_or(CurveError::NonFiniteProjectionPoint)
288    })?;
289    let centroid = [sum_x / count, sum_y / count];
290    centroid
291        .iter()
292        .all(|value| value.is_finite())
293        .then_some(Some(centroid))
294        .ok_or(CurveError::NonFiniteProjectionPoint)
295}
296
297pub(crate) fn normalize_finite_ring_vertices(ring: &[[f64; 2]]) -> CurveResult<Vec<[f64; 2]>> {
298    let mut normalized = Vec::with_capacity(ring.len());
299    for point in finite_unique_polyline_vertices(ring)? {
300        if normalized.last() == Some(&point) {
301            continue;
302        }
303        normalized.push(point);
304    }
305    if normalized.len() > 1 && normalized.first() == normalized.last() {
306        normalized.pop();
307    }
308    Ok(normalized)
309}
310
311fn finite_unique_polyline_vertices(points: &[[f64; 2]]) -> CurveResult<Vec<[f64; 2]>> {
312    let mut unique = Vec::with_capacity(points.len());
313    for (index, &[x, y]) in points.iter().enumerate() {
314        if !x.is_finite() || !y.is_finite() {
315            return Err(CurveError::NonFiniteProjectionPoint);
316        }
317        let point = [x, y];
318        if index + 1 != points.len() || Some(&point) != points.first() {
319            unique.push(point);
320        }
321    }
322    Ok(unique)
323}
324
325fn checked_shoelace_sum(sum: f64, start: [f64; 2], end: [f64; 2]) -> CurveResult<f64> {
326    let term = start[0] * end[1] - end[0] * start[1];
327    let next = sum + term;
328    next.is_finite()
329        .then_some(next)
330        .ok_or(CurveError::NonFiniteProjectionPoint)
331}
332
333impl CurveString2 {
334    /// Projects this curve string to a finite polyline for IO and display.
335    ///
336    /// This is a lossy boundary view: circular arcs are sampled by chord error,
337    /// and the returned `f64` vertices must not be used as the source of exact
338    /// topology decisions.
339    pub fn project_to_finite_polyline(
340        &self,
341        options: &FiniteProjectionOptions,
342    ) -> CurveResult<FinitePolyline2> {
343        project_curve_string(self, options, false)
344    }
345}
346
347impl Contour2 {
348    /// Projects this closed contour to a finite closed ring for IO and display.
349    ///
350    /// The contour itself remains authoritative for area, containment, and
351    /// winding. This method only emits a finite boundary ring after all points
352    /// can cross the API boundary as `f64`.
353    pub fn project_to_finite_ring(
354        &self,
355        options: &FiniteProjectionOptions,
356    ) -> CurveResult<FinitePolyline2> {
357        project_curve_string(self.curve_string(), options, true)
358    }
359}
360
361impl Region2 {
362    /// Projects this region to finite material/hole rings for IO and display.
363    ///
364    /// Region roles are preserved, but the returned rings are boundary
365    /// products only. Exact point classification and area should continue to
366    /// use [`Region2::classify_point`] and [`Region2::filled_area`].
367    pub fn project_to_finite_region(
368        &self,
369        options: &FiniteProjectionOptions,
370    ) -> CurveResult<FiniteRegionProjection2> {
371        self.as_view().project_to_finite_region(options)
372    }
373
374    /// Projects exact material/hole ownership profiles to finite rings.
375    ///
376    /// Ownership is classified before projection with
377    /// [`Region2::contour_profiles`], so this method does not recover holes
378    /// from sampled centroids or winding heuristics. The returned rings are
379    /// still finite API-boundary products; exact topology remains in the
380    /// region. This follows Yap's exact-object/API-boundary split and the
381    /// boundary-first point-in-polygon structure surveyed by Hormann and
382    /// Agathos, both cited on [`Region2::contour_profiles`].
383    pub fn project_to_finite_profiles(
384        &self,
385        options: &FiniteProjectionOptions,
386        policy: &CurvePolicy,
387    ) -> CurveResult<Classification<Vec<FiniteRegionProfile2>>> {
388        self.as_view().project_to_finite_profiles(options, policy)
389    }
390}
391
392impl<'a> RegionView2<'a> {
393    /// Projects this borrowed region view to finite material/hole rings.
394    ///
395    /// This method exists for export adapters that already work with borrowed
396    /// topology. It clones only finite output vertices, not exact contours.
397    pub fn project_to_finite_region(
398        &self,
399        options: &FiniteProjectionOptions,
400    ) -> CurveResult<FiniteRegionProjection2> {
401        let material_rings = project_contour_slice(self.material_contours(), options)?;
402        let hole_rings = project_contour_slice(self.hole_contours(), options)?;
403        Ok(FiniteRegionProjection2::new(material_rings, hole_rings))
404    }
405
406    /// Projects exact material/hole ownership profiles to finite rings.
407    pub fn project_to_finite_profiles(
408        &self,
409        options: &FiniteProjectionOptions,
410        policy: &CurvePolicy,
411    ) -> CurveResult<Classification<Vec<FiniteRegionProfile2>>> {
412        match self.contour_profiles(policy) {
413            Classification::Decided(profiles) => profiles
414                .iter()
415                .map(|profile| project_region_profile(profile, options))
416                .collect::<CurveResult<Vec<_>>>()
417                .map(Classification::Decided),
418            Classification::Uncertain(reason) => Ok(Classification::Uncertain(reason)),
419        }
420    }
421}
422
423fn project_region_profile(
424    profile: &RegionContourProfile<'_>,
425    options: &FiniteProjectionOptions,
426) -> CurveResult<FiniteRegionProfile2> {
427    let material = profile.material.project_to_finite_ring(options)?;
428    let holes = profile
429        .holes
430        .iter()
431        .map(|hole| hole.project_to_finite_ring(options))
432        .collect::<CurveResult<Vec<_>>>()?;
433    Ok(FiniteRegionProfile2::new(material, holes))
434}
435
436fn project_contour_slice(
437    contours: &[&Contour2],
438    options: &FiniteProjectionOptions,
439) -> CurveResult<Vec<FinitePolyline2>> {
440    contours
441        .iter()
442        .map(|contour| contour.project_to_finite_ring(options))
443        .collect()
444}
445
446fn project_curve_string(
447    curve: &CurveString2,
448    options: &FiniteProjectionOptions,
449    close: bool,
450) -> CurveResult<FinitePolyline2> {
451    let first = curve.start().ok_or(CurveError::EmptyCurveString)?;
452    let mut points = Vec::with_capacity(curve.len() + 1);
453    push_if_new(&mut points, finite_point(first)?);
454
455    for segment in curve.segments() {
456        match segment {
457            Segment2::Line(line) => {
458                push_if_new(&mut points, finite_point(line.end())?);
459            }
460            Segment2::Arc(arc) => {
461                append_arc_samples(&mut points, arc, options.arc_chord_error)?;
462            }
463        }
464    }
465
466    if close {
467        close_ring(&mut points);
468    }
469
470    Ok(FinitePolyline2::new(points, options.arc_chord_error, close))
471}
472
473fn finite_point(point: &Point2) -> CurveResult<[f64; 2]> {
474    let x = point
475        .x()
476        .to_f64_lossy()
477        .filter(|value| value.is_finite())
478        .ok_or(CurveError::NonFiniteProjectionPoint)?;
479    let y = point
480        .y()
481        .to_f64_lossy()
482        .filter(|value| value.is_finite())
483        .ok_or(CurveError::NonFiniteProjectionPoint)?;
484    Ok([x, y])
485}
486
487fn append_arc_samples(
488    points: &mut Vec<[f64; 2]>,
489    arc: &CircularArc2,
490    chord_error: f64,
491) -> CurveResult<usize> {
492    let start = finite_point(arc.start())?;
493    let end = finite_point(arc.end())?;
494    let center = finite_point(arc.center())?;
495
496    let radius = ((start[0] - center[0]).powi(2) + (start[1] - center[1]).powi(2)).sqrt();
497    if !radius.is_finite() || radius <= f64::EPSILON {
498        return Err(CurveError::NonFiniteProjectionPoint);
499    }
500
501    let a0 = (start[1] - center[1]).atan2(start[0] - center[0]);
502    let a1 = (end[1] - center[1]).atan2(end[0] - center[0]);
503    let mut sweep = a1 - a0;
504    if arc.is_clockwise() {
505        if sweep > 0.0 {
506            sweep -= 2.0 * PI;
507        }
508    } else if sweep < 0.0 {
509        sweep += 2.0 * PI;
510    }
511
512    let max_angle = (1.0 - (chord_error / radius).min(1.0)).acos().max(1e-3) * 2.0;
513    let steps = ((sweep.abs() / max_angle).ceil() as usize).max(1);
514    let before = points.len();
515    for step in 1..=steps {
516        let t = step as f64 / steps as f64;
517        let angle = a0 + sweep * t;
518        let point = [
519            center[0] + radius * angle.cos(),
520            center[1] + radius * angle.sin(),
521        ];
522        if !point[0].is_finite() || !point[1].is_finite() {
523            return Err(CurveError::NonFiniteProjectionPoint);
524        }
525        push_if_new(points, point);
526    }
527    Ok(points.len() - before)
528}
529
530fn close_ring(points: &mut Vec<[f64; 2]>) {
531    if points.len() >= 2 && points.first() != points.last() {
532        points.push(points[0]);
533    }
534}
535
536fn push_if_new(points: &mut Vec<[f64; 2]>, point: [f64; 2]) {
537    if points.last().is_none_or(|last| *last != point) {
538        points.push(point);
539    }
540}