Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
573 lines
24 KiB
Rust
573 lines
24 KiB
Rust
//! Planar profiles: closed loops of lines, circular arcs, cubic Béziers and conics (rational
|
||
//! quadratic Béziers), and regions (an outer loop minus holes). Sketches produce them; the
|
||
//! kernel turns them into faces.
|
||
|
||
use std::f64::consts::TAU;
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::Vec2;
|
||
|
||
/// One boundary segment in sketch coordinates.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(tag = "type", rename_all = "snake_case")]
|
||
pub enum Seg2 {
|
||
Line {
|
||
a: Vec2,
|
||
b: Vec2,
|
||
},
|
||
/// Arc from `start` angle sweeping `sweep` radians (positive = counter-clockwise).
|
||
Arc {
|
||
center: Vec2,
|
||
radius: f64,
|
||
start: f64,
|
||
sweep: f64,
|
||
},
|
||
/// Cubic Bézier.
|
||
Cubic {
|
||
p0: Vec2,
|
||
p1: Vec2,
|
||
p2: Vec2,
|
||
p3: Vec2,
|
||
},
|
||
/// Rational quadratic Bézier from `a` to `b` with control `apex` of weight `w` (end
|
||
/// weights 1): exact conic arcs (ellipses, parabolas, hyperbolas).
|
||
Conic {
|
||
a: Vec2,
|
||
apex: Vec2,
|
||
b: Vec2,
|
||
w: f64,
|
||
},
|
||
/// Bézier of degree `n` (4…7) over `p[0..=n]` (the rest unused): pieces of higher-degree
|
||
/// splines.
|
||
Bezier {
|
||
n: u8,
|
||
p: [Vec2; 8],
|
||
},
|
||
}
|
||
|
||
/// Highest degree of [`Seg2::Bezier`].
|
||
pub const MAX_BEZIER_DEGREE: usize = 7;
|
||
|
||
/// A Bézier segment over `pts` (2…8 points): a line, a cubic or a higher-degree piece. A
|
||
/// quadratic becomes a conic of weight 1.
|
||
pub fn bezier(pts: &[Vec2]) -> Option<Seg2> {
|
||
Some(match *pts {
|
||
[a, b] => Seg2::Line { a, b },
|
||
[a, apex, b] => Seg2::Conic { a, apex, b, w: 1.0 },
|
||
[p0, p1, p2, p3] => Seg2::Cubic { p0, p1, p2, p3 },
|
||
_ if pts.len() <= MAX_BEZIER_DEGREE + 1 => {
|
||
let mut p = [Vec2::ZERO; 8];
|
||
for (d, s) in p.iter_mut().zip(pts) {
|
||
*d = *s;
|
||
}
|
||
Seg2::Bezier { n: (pts.len() - 1) as u8, p }
|
||
}
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// de Casteljau: the point at `t` and the two halves' control points.
|
||
fn casteljau(c: &[Vec2], t: f64) -> (Vec2, Vec<Vec2>, Vec<Vec2>) {
|
||
let mut w = c.to_vec();
|
||
let mut left = Vec::with_capacity(c.len());
|
||
let mut right = Vec::with_capacity(c.len());
|
||
while let Some(first) = w.first().copied() {
|
||
left.push(first);
|
||
if let Some(last) = w.last().copied() {
|
||
right.push(last);
|
||
}
|
||
if w.len() == 1 {
|
||
break;
|
||
}
|
||
w = w.windows(2).map(|x| x[0].lerp(x[1], t)).collect();
|
||
}
|
||
right.reverse();
|
||
(left.last().copied().unwrap_or(Vec2::ZERO), left, right)
|
||
}
|
||
|
||
/// Gauss–Legendre nodes and weights on [0, 1] (5 points).
|
||
const GL5: [(f64, f64); 5] = [
|
||
(0.046_910_077_030_668, 0.118_463_442_528_095),
|
||
(0.230_765_344_947_158, 0.239_314_335_249_683),
|
||
(0.5, 0.284_444_444_444_444),
|
||
(0.769_234_655_052_842, 0.239_314_335_249_683),
|
||
(0.953_089_922_969_332, 0.118_463_442_528_095),
|
||
];
|
||
|
||
impl Seg2 {
|
||
fn bez(n: u8, p: &[Vec2; 8]) -> &[Vec2] {
|
||
&p[..(n as usize).clamp(1, MAX_BEZIER_DEGREE) + 1]
|
||
}
|
||
/// Derivative with respect to the parameter at `t` (Béziers and conics; lines and arcs too).
|
||
pub fn derivative(&self, t: f64) -> Vec2 {
|
||
match *self {
|
||
Seg2::Line { a, b } => b - a,
|
||
Seg2::Arc { radius, start, sweep, .. } => Vec2::from_angle(start + sweep * t).perp() * (radius * sweep),
|
||
Seg2::Cubic { p0, p1, p2, p3 } => {
|
||
let u = 1.0 - t;
|
||
(p1 - p0) * (3.0 * u * u) + (p2 - p1) * (6.0 * u * t) + (p3 - p2) * (3.0 * t * t)
|
||
}
|
||
Seg2::Conic { a, apex, b, w } => {
|
||
let u = 1.0 - t;
|
||
let (n, d) = (a * (u * u) + apex * (2.0 * w * u * t) + b * (t * t), u * u + 2.0 * w * u * t + t * t);
|
||
let (dn, dd) = (a * (-2.0 * u) + apex * (2.0 * w * (1.0 - 2.0 * t)) + b * (2.0 * t), -2.0 * u + 2.0 * w * (1.0 - 2.0 * t) + 2.0 * t);
|
||
(dn * d - n * dd) / (d * d).max(1e-300)
|
||
}
|
||
Seg2::Bezier { n, ref p } => {
|
||
let c = Self::bez(n, p);
|
||
let diffs: Vec<Vec2> = c.windows(2).map(|w| (w[1] - w[0]) * (c.len() - 1) as f64).collect();
|
||
casteljau(&diffs, t).0
|
||
}
|
||
}
|
||
}
|
||
pub fn start(&self) -> Vec2 {
|
||
match *self {
|
||
Seg2::Line { a, .. } | Seg2::Conic { a, .. } => a,
|
||
Seg2::Cubic { p0, .. } => p0,
|
||
Seg2::Bezier { p, .. } => p[0],
|
||
Seg2::Arc { center, radius, start, .. } => center + Vec2::from_angle(start) * radius,
|
||
}
|
||
}
|
||
pub fn end(&self) -> Vec2 {
|
||
match *self {
|
||
Seg2::Line { b, .. } | Seg2::Conic { b, .. } => b,
|
||
Seg2::Cubic { p3, .. } => p3,
|
||
Seg2::Bezier { n, ref p } => Self::bez(n, p).last().copied().unwrap_or(p[0]),
|
||
Seg2::Arc { center, radius, start, sweep } => center + Vec2::from_angle(start + sweep) * radius,
|
||
}
|
||
}
|
||
pub fn point_at(&self, t: f64) -> Vec2 {
|
||
match *self {
|
||
Seg2::Line { a, b } => a.lerp(b, t),
|
||
Seg2::Arc { center, radius, start, sweep } => center + Vec2::from_angle(start + sweep * t) * radius,
|
||
Seg2::Cubic { p0, p1, p2, p3 } => {
|
||
let u = 1.0 - t;
|
||
p0 * (u * u * u) + p1 * (3.0 * u * u * t) + p2 * (3.0 * u * t * t) + p3 * (t * t * t)
|
||
}
|
||
Seg2::Conic { a, apex, b, w } => {
|
||
let u = 1.0 - t;
|
||
let d = u * u + 2.0 * w * u * t + t * t;
|
||
(a * (u * u) + apex * (2.0 * w * u * t) + b * (t * t)) / d.max(1e-300)
|
||
}
|
||
Seg2::Bezier { n, ref p } => casteljau(Self::bez(n, p), t).0,
|
||
}
|
||
}
|
||
pub fn mid(&self) -> Vec2 {
|
||
self.point_at(0.5)
|
||
}
|
||
/// Unit tangent at the start (direction of travel).
|
||
pub fn start_tangent(&self) -> Vec2 {
|
||
match *self {
|
||
Seg2::Line { a, b } => (b - a).normalized().unwrap_or(Vec2::X),
|
||
Seg2::Arc { start, sweep, .. } => Vec2::from_angle(start).perp() * sweep.signum(),
|
||
Seg2::Cubic { p0, p1, p2, .. } => (p1 - p0).normalized().or_else(|| (p2 - p0).normalized()).unwrap_or(Vec2::X),
|
||
Seg2::Conic { a, apex, b, .. } => (apex - a).normalized().or_else(|| (b - a).normalized()).unwrap_or(Vec2::X),
|
||
Seg2::Bezier { n, ref p } => {
|
||
let c = Self::bez(n, p);
|
||
c.iter().skip(1).find_map(|q| (*q - c[0]).normalized()).unwrap_or(Vec2::X)
|
||
}
|
||
}
|
||
}
|
||
/// Unit tangent at the end (direction of travel).
|
||
pub fn end_tangent(&self) -> Vec2 {
|
||
match *self {
|
||
Seg2::Line { a, b } => (b - a).normalized().unwrap_or(Vec2::X),
|
||
Seg2::Arc { start, sweep, .. } => Vec2::from_angle(start + sweep).perp() * sweep.signum(),
|
||
Seg2::Cubic { p1, p2, p3, .. } => (p3 - p2).normalized().or_else(|| (p3 - p1).normalized()).unwrap_or(Vec2::X),
|
||
Seg2::Conic { a, apex, b, .. } => (b - apex).normalized().or_else(|| (b - a).normalized()).unwrap_or(Vec2::X),
|
||
Seg2::Bezier { n, ref p } => {
|
||
let c = Self::bez(n, p);
|
||
let last = c.last().copied().unwrap_or(p[0]);
|
||
c.iter().rev().skip(1).find_map(|q| (last - *q).normalized()).unwrap_or(Vec2::X)
|
||
}
|
||
}
|
||
}
|
||
pub fn reversed(&self) -> Seg2 {
|
||
match *self {
|
||
Seg2::Line { a, b } => Seg2::Line { a: b, b: a },
|
||
Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center, radius, start: start + sweep, sweep: -sweep },
|
||
Seg2::Cubic { p0, p1, p2, p3 } => Seg2::Cubic { p0: p3, p1: p2, p2: p1, p3: p0 },
|
||
Seg2::Conic { a, apex, b, w } => Seg2::Conic { a: b, apex, b: a, w },
|
||
Seg2::Bezier { n, ref p } => {
|
||
let mut q = *p;
|
||
let k = (n as usize).clamp(1, MAX_BEZIER_DEGREE);
|
||
q[..=k].reverse();
|
||
Seg2::Bezier { n, p: q }
|
||
}
|
||
}
|
||
}
|
||
pub fn length(&self) -> f64 {
|
||
match *self {
|
||
Seg2::Line { a, b } => a.dist(b),
|
||
Seg2::Arc { radius, sweep, .. } => (radius * sweep).abs(),
|
||
Seg2::Cubic { .. } | Seg2::Conic { .. } | Seg2::Bezier { .. } => self.integrate(|s, t| s.derivative(t).len()),
|
||
}
|
||
}
|
||
/// The segment split at parameter `t` in (0, 1) (same kind, exact; conic halves are
|
||
/// re-normalised so their end weights stay 1).
|
||
pub fn split_at(&self, t: f64) -> (Seg2, Seg2) {
|
||
let t = t.clamp(1e-9, 1.0 - 1e-9);
|
||
match *self {
|
||
Seg2::Line { a, b } => {
|
||
let m = a.lerp(b, t);
|
||
(Seg2::Line { a, b: m }, Seg2::Line { a: m, b })
|
||
}
|
||
Seg2::Arc { center, radius, start, sweep } => (
|
||
Seg2::Arc { center, radius, start, sweep: sweep * t },
|
||
Seg2::Arc { center, radius, start: start + sweep * t, sweep: sweep * (1.0 - t) },
|
||
),
|
||
Seg2::Cubic { p0, p1, p2, p3 } => {
|
||
let (a, b, c) = (p0.lerp(p1, t), p1.lerp(p2, t), p2.lerp(p3, t));
|
||
let (d, e) = (a.lerp(b, t), b.lerp(c, t));
|
||
let m = d.lerp(e, t);
|
||
(Seg2::Cubic { p0, p1: a, p2: d, p3: m }, Seg2::Cubic { p0: m, p1: e, p2: c, p3 })
|
||
}
|
||
Seg2::Conic { a, apex, b, w } => {
|
||
// Homogeneous de Casteljau: (a, 1), (w·apex, w), (b, 1).
|
||
let h = |p: Vec2, wt: f64| (p * wt, wt);
|
||
let lerp = |x: (Vec2, f64), y: (Vec2, f64)| (x.0 * (1.0 - t) + y.0 * t, x.1 * (1.0 - t) + y.1 * t);
|
||
let (p0, p1, p2) = (h(a, 1.0), h(apex, w), h(b, 1.0));
|
||
let (q0, q1) = (lerp(p0, p1), lerp(p1, p2));
|
||
let r = lerp(q0, q1);
|
||
let pt = |x: (Vec2, f64)| x.0 / x.1.max(1e-300);
|
||
let m = pt(r);
|
||
(
|
||
Seg2::Conic { a, apex: pt(q0), b: m, w: q0.1 / r.1.max(1e-300).sqrt() },
|
||
Seg2::Conic { a: m, apex: pt(q1), b, w: q1.1 / r.1.max(1e-300).sqrt() },
|
||
)
|
||
}
|
||
Seg2::Bezier { n, ref p } => {
|
||
let (_, l, r) = casteljau(Self::bez(n, p), t);
|
||
let mk = |c: &[Vec2]| bezier(c).unwrap_or(*self);
|
||
(mk(&l), mk(&r))
|
||
}
|
||
}
|
||
}
|
||
/// Parameter of the point of the segment closest to `p` (sampling, then Newton).
|
||
pub fn closest_param(&self, p: Vec2) -> f64 {
|
||
let n = 64;
|
||
let mut t =
|
||
(0..=n).map(|i| i as f64 / n as f64).min_by(|x, y| self.point_at(*x).dist(p).total_cmp(&self.point_at(*y).dist(p))).unwrap_or(0.0);
|
||
for _ in 0..30 {
|
||
let (q, d) = (self.point_at(t), self.derivative(t));
|
||
let (d2, h) = ((self.derivative((t + 1e-6).min(1.0)) - self.derivative((t - 1e-6).max(0.0))) / 2e-6, q - p);
|
||
let f = h.dot(d);
|
||
let df = d.dot(d) + h.dot(d2);
|
||
if df.abs() < 1e-300 {
|
||
break;
|
||
}
|
||
let nt = (t - f / df).clamp(0.0, 1.0);
|
||
if (nt - t).abs() < 1e-14 {
|
||
t = nt;
|
||
break;
|
||
}
|
||
t = nt;
|
||
}
|
||
t
|
||
}
|
||
/// The two halves of the segment (same kind, exact).
|
||
pub fn split_half(&self) -> (Seg2, Seg2) {
|
||
match *self {
|
||
Seg2::Line { a, b } => {
|
||
let m = a.lerp(b, 0.5);
|
||
(Seg2::Line { a, b: m }, Seg2::Line { a: m, b })
|
||
}
|
||
Seg2::Arc { center, radius, start, sweep } => (
|
||
Seg2::Arc { center, radius, start, sweep: sweep / 2.0 },
|
||
Seg2::Arc { center, radius, start: start + sweep / 2.0, sweep: sweep / 2.0 },
|
||
),
|
||
Seg2::Cubic { p0, p1, p2, p3 } => {
|
||
let (a, b, c) = (p0.lerp(p1, 0.5), p1.lerp(p2, 0.5), p2.lerp(p3, 0.5));
|
||
let (d, e) = (a.lerp(b, 0.5), b.lerp(c, 0.5));
|
||
let m = d.lerp(e, 0.5);
|
||
(Seg2::Cubic { p0, p1: a, p2: d, p3: m }, Seg2::Cubic { p0: m, p1: e, p2: c, p3 })
|
||
}
|
||
Seg2::Conic { a, apex, b, w } => {
|
||
// Rational de Casteljau at the middle, weights normalised to 1 at the ends.
|
||
let m = self.point_at(0.5);
|
||
let w2 = ((1.0 + w) / 2.0).max(0.0).sqrt();
|
||
let (l, r) = ((a + apex * w) / (1.0 + w), (apex * w + b) / (1.0 + w));
|
||
(Seg2::Conic { a, apex: l, b: m, w: w2 }, Seg2::Conic { a: m, apex: r, b, w: w2 })
|
||
}
|
||
Seg2::Bezier { .. } => self.split_at(0.5),
|
||
}
|
||
}
|
||
/// ∫₀¹ f(t) dt by composite Gauss–Legendre (16 pieces of 5 points).
|
||
fn integrate(&self, f: impl Fn(&Seg2, f64) -> f64) -> f64 {
|
||
let n = 16;
|
||
let mut sum = 0.0;
|
||
for k in 0..n {
|
||
let (t0, h) = (k as f64 / n as f64, 1.0 / n as f64);
|
||
for (x, w) in GL5 {
|
||
sum += w * h * f(self, t0 + x * h);
|
||
}
|
||
}
|
||
sum
|
||
}
|
||
/// Contribution to the loop's signed area (shoelace integral ½∮(x dy − y dx)).
|
||
pub fn area_term(&self) -> f64 {
|
||
match *self {
|
||
Seg2::Line { a, b } => 0.5 * a.cross(b),
|
||
Seg2::Arc { radius, sweep, .. } => {
|
||
let (a, b) = (self.start(), self.end());
|
||
// Chord term plus the circular segment between chord and arc.
|
||
0.5 * a.cross(b) + 0.5 * radius * radius * (sweep - sweep.sin())
|
||
}
|
||
Seg2::Cubic { .. } | Seg2::Conic { .. } | Seg2::Bezier { .. } => self.integrate(|s, t| 0.5 * s.point_at(t).cross(s.derivative(t))),
|
||
}
|
||
}
|
||
/// Polyline approximation (including both end points) with at most `tol` chord error.
|
||
pub fn polyline(&self, tol: f64) -> Vec<Vec2> {
|
||
match *self {
|
||
Seg2::Line { a, b } => vec![a, b],
|
||
Seg2::Arc { radius, sweep, .. } => {
|
||
let tol = tol.max(radius.abs() * 1e-6).max(1e-9);
|
||
let step = if tol < radius { 2.0 * (1.0 - tol / radius).clamp(-1.0, 1.0).acos() } else { TAU / 8.0 };
|
||
let n = ((sweep.abs() / step.max(1e-6)).ceil() as usize).clamp(2, 4096);
|
||
(0..=n).map(|i| self.point_at(i as f64 / n as f64)).collect()
|
||
}
|
||
Seg2::Cubic { p0, p1, p2, p3 } => {
|
||
// Chord error of n pieces ≈ max second difference / (8 n²).
|
||
let bow = (p0 - p1 * 2.0 + p2).len().max((p1 - p2 * 2.0 + p3).len()) * 6.0;
|
||
let n = ((bow / (8.0 * tol.max(1e-9))).sqrt().ceil() as usize).clamp(2, 4096);
|
||
(0..=n).map(|i| self.point_at(i as f64 / n as f64)).collect()
|
||
}
|
||
Seg2::Conic { a, apex, b, w } => {
|
||
let bow = (a - apex * 2.0 + b).len() * 2.0 * w.max(1.0);
|
||
let n = ((bow / (8.0 * tol.max(1e-9))).sqrt().ceil() as usize).clamp(2, 4096);
|
||
(0..=n).map(|i| self.point_at(i as f64 / n as f64)).collect()
|
||
}
|
||
Seg2::Bezier { n, ref p } => {
|
||
// Second derivative ≤ n(n−1) × the largest second difference of the controls.
|
||
let c = Self::bez(n, p);
|
||
let d2 = c.windows(3).map(|w| (w[0] - w[1] * 2.0 + w[2]).len()).fold(0.0, f64::max) * (n as f64) * (n as f64 - 1.0);
|
||
let k = ((d2 / (8.0 * tol.max(1e-9))).sqrt().ceil() as usize).clamp(2, 4096);
|
||
(0..=k).map(|i| self.point_at(i as f64 / k as f64)).collect()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A closed loop of segments, each starting where the previous one ends.
|
||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
pub struct Loop2 {
|
||
pub segs: Vec<Seg2>,
|
||
}
|
||
|
||
impl Loop2 {
|
||
/// Full circle as two half arcs (counter-clockwise).
|
||
pub fn circle(center: Vec2, radius: f64) -> Loop2 {
|
||
Loop2 {
|
||
segs: vec![
|
||
Seg2::Arc { center, radius, start: 0.0, sweep: std::f64::consts::PI },
|
||
Seg2::Arc { center, radius, start: std::f64::consts::PI, sweep: std::f64::consts::PI },
|
||
],
|
||
}
|
||
}
|
||
/// Full circle starting (and split) at angle `start`.
|
||
pub fn circle_from(center: Vec2, radius: f64, start: f64) -> Loop2 {
|
||
use std::f64::consts::PI;
|
||
Loop2 { segs: vec![Seg2::Arc { center, radius, start, sweep: PI }, Seg2::Arc { center, radius, start: start + PI, sweep: PI }] }
|
||
}
|
||
pub fn polygon(pts: &[Vec2]) -> Loop2 {
|
||
let n = pts.len();
|
||
Loop2 { segs: (0..n).filter_map(|i| Some(Seg2::Line { a: *pts.get(i)?, b: *pts.get((i + 1) % n)? })).collect() }
|
||
}
|
||
/// Signed area: positive when counter-clockwise.
|
||
pub fn signed_area(&self) -> f64 {
|
||
self.segs.iter().map(Seg2::area_term).sum()
|
||
}
|
||
pub fn reversed(&self) -> Loop2 {
|
||
Loop2 { segs: self.segs.iter().rev().map(Seg2::reversed).collect() }
|
||
}
|
||
/// Counter-clockwise copy.
|
||
pub fn ccw(&self) -> Loop2 {
|
||
if self.signed_area() < 0.0 { self.reversed() } else { self.clone() }
|
||
}
|
||
pub fn polyline(&self, tol: f64) -> Vec<Vec2> {
|
||
let mut out = Vec::new();
|
||
for s in &self.segs {
|
||
let p = s.polyline(tol);
|
||
out.extend(p.iter().take(p.len().saturating_sub(1)));
|
||
}
|
||
out
|
||
}
|
||
/// Even-odd point containment against the polyline approximation.
|
||
pub fn contains(&self, p: Vec2) -> bool {
|
||
let poly = self.polyline(1e-3);
|
||
let n = poly.len();
|
||
let mut inside = false;
|
||
for i in 0..n {
|
||
let (Some(a), Some(b)) = (poly.get(i), poly.get((i + 1) % n)) else { continue };
|
||
if (a.y > p.y) != (b.y > p.y) {
|
||
let x = a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x);
|
||
if p.x < x {
|
||
inside = !inside;
|
||
}
|
||
}
|
||
}
|
||
inside
|
||
}
|
||
/// Area-weighted centroid (of the polyline approximation).
|
||
pub fn centroid(&self) -> Vec2 {
|
||
let poly = self.polyline(1e-3);
|
||
let n = poly.len();
|
||
let (mut a2, mut c) = (0.0, Vec2::ZERO);
|
||
for i in 0..n {
|
||
let (Some(p), Some(q)) = (poly.get(i), poly.get((i + 1) % n)) else { continue };
|
||
let k = p.cross(*q);
|
||
a2 += k;
|
||
c += (*p + *q) * k;
|
||
}
|
||
if a2.abs() < 1e-15 { poly.first().copied().unwrap_or_default() } else { c / (3.0 * a2) }
|
||
}
|
||
/// Closed within `tol`?
|
||
pub fn is_closed(&self, tol: f64) -> bool {
|
||
let n = self.segs.len();
|
||
n > 0
|
||
&& (0..n).all(|i| match (self.segs.get(i), self.segs.get((i + 1) % n)) {
|
||
(Some(a), Some(b)) => a.end().dist(b.start()) <= tol,
|
||
_ => false,
|
||
})
|
||
}
|
||
}
|
||
|
||
/// A planar region: outer loop (counter-clockwise) minus holes (clockwise).
|
||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
pub struct Region2 {
|
||
pub outer: Loop2,
|
||
pub holes: Vec<Loop2>,
|
||
}
|
||
|
||
impl Region2 {
|
||
pub fn area(&self) -> f64 {
|
||
self.outer.signed_area().abs() - self.holes.iter().map(|h| h.signed_area().abs()).sum::<f64>()
|
||
}
|
||
/// A point strictly inside the region (for picking and labels).
|
||
pub fn interior_point(&self) -> Vec2 {
|
||
let c = self.centroid();
|
||
if self.contains(c) {
|
||
return c;
|
||
}
|
||
// Scan a few horizontal lines for the midpoint of the widest inside span.
|
||
let poly = self.outer.polyline(1e-2);
|
||
let (mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY);
|
||
for p in &poly {
|
||
y0 = y0.min(p.y);
|
||
y1 = y1.max(p.y);
|
||
}
|
||
for k in 1..16 {
|
||
let y = y0 + (y1 - y0) * k as f64 / 16.0;
|
||
let mut xs: Vec<f64> = Vec::new();
|
||
for l in std::iter::once(&self.outer).chain(&self.holes) {
|
||
let pl = l.polyline(1e-2);
|
||
let n = pl.len();
|
||
for i in 0..n {
|
||
let (Some(a), Some(b)) = (pl.get(i), pl.get((i + 1) % n)) else { continue };
|
||
if (a.y > y) != (b.y > y) {
|
||
xs.push(a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x));
|
||
}
|
||
}
|
||
}
|
||
xs.sort_by(f64::total_cmp);
|
||
for w in xs.chunks(2) {
|
||
if let [a, b] = w {
|
||
let p = Vec2::new((a + b) * 0.5, y);
|
||
if self.contains(p) {
|
||
return p;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
c
|
||
}
|
||
pub fn contains(&self, p: Vec2) -> bool {
|
||
self.outer.contains(p) && !self.holes.iter().any(|h| h.contains(p))
|
||
}
|
||
/// Triangles covering the region (for filled display), by slabs between the vertex
|
||
/// heights: inside each slab the boundary edges don't cross, so consecutive crossings pair
|
||
/// up into trapezoids.
|
||
pub fn triangulate(&self, tol: f64) -> Vec<[Vec2; 3]> {
|
||
let mut edges: Vec<(Vec2, Vec2)> = Vec::new();
|
||
for l in std::iter::once(&self.outer).chain(&self.holes) {
|
||
let pl = l.polyline(tol);
|
||
let n = pl.len();
|
||
for i in 0..n {
|
||
let (Some(a), Some(b)) = (pl.get(i), pl.get((i + 1) % n)) else { continue };
|
||
if (a.y - b.y).abs() > 1e-12 {
|
||
edges.push(if a.y < b.y { (*a, *b) } else { (*b, *a) });
|
||
}
|
||
}
|
||
}
|
||
if edges.len() > 20_000 {
|
||
return Vec::new();
|
||
}
|
||
let mut ys: Vec<f64> = edges.iter().flat_map(|(a, b)| [a.y, b.y]).collect();
|
||
ys.sort_by(f64::total_cmp);
|
||
ys.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
|
||
let x_at = |e: &(Vec2, Vec2), y: f64| e.0.x + (y - e.0.y) / (e.1.y - e.0.y) * (e.1.x - e.0.x);
|
||
let mut out = Vec::new();
|
||
for w in ys.windows(2) {
|
||
let (y0, y1) = (w[0], w[1]);
|
||
let ym = (y0 + y1) * 0.5;
|
||
let mut span: Vec<&(Vec2, Vec2)> = edges.iter().filter(|e| e.0.y <= ym && e.1.y >= ym).collect();
|
||
span.sort_by(|a, b| x_at(a, ym).total_cmp(&x_at(b, ym)));
|
||
for pair in span.chunks(2) {
|
||
let [l, r] = pair else { continue };
|
||
let (a, b) = (Vec2::new(x_at(l, y0), y0), Vec2::new(x_at(r, y0), y0));
|
||
let (c, d) = (Vec2::new(x_at(r, y1), y1), Vec2::new(x_at(l, y1), y1));
|
||
out.push([a, b, c]);
|
||
out.push([a, c, d]);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
pub fn centroid(&self) -> Vec2 {
|
||
let ao = self.outer.signed_area().abs();
|
||
let mut c = self.outer.centroid() * ao;
|
||
let mut a = ao;
|
||
for h in &self.holes {
|
||
let ah = h.signed_area().abs();
|
||
c -= h.centroid() * ah;
|
||
a -= ah;
|
||
}
|
||
if a.abs() < 1e-15 { self.outer.centroid() } else { c / a }
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn triangulated_area() {
|
||
let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 8.0), Vec2::new(5.0, 3.0), Vec2::new(0.0, 8.0)]);
|
||
let r = Region2 { outer: sq, holes: vec![Loop2::circle(Vec2::new(3.0, 1.5), 1.0).reversed()] };
|
||
let tris = r.triangulate(1e-3);
|
||
let a: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).abs() * 0.5).sum();
|
||
// The circle is a polyline within the tolerance, so the areas agree to about that.
|
||
assert!((a - r.area()).abs() < 1e-3 * r.area(), "{a} vs {}", r.area());
|
||
for [a, b, c] in &tris {
|
||
let m = (*a + *b + *c) / 3.0;
|
||
assert!((*b - *a).cross(*c - *a).abs() < 1e-12 || r.contains(m), "{m:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn areas() {
|
||
let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(4.0, 3.0), Vec2::new(0.0, 3.0)]);
|
||
assert!((sq.signed_area() - 12.0).abs() < 1e-12);
|
||
assert!((sq.reversed().signed_area() + 12.0).abs() < 1e-12);
|
||
let c = Loop2::circle(Vec2::new(5.0, 5.0), 2.0);
|
||
assert!((c.signed_area() - std::f64::consts::PI * 4.0).abs() < 1e-9);
|
||
assert!(c.is_closed(1e-9));
|
||
let r = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.0), holes: vec![Loop2::circle(Vec2::ZERO, 5.0).reversed()] };
|
||
assert!((r.area() - std::f64::consts::PI * 75.0).abs() < 1e-9);
|
||
let ip = r.interior_point();
|
||
assert!(r.contains(ip), "{ip:?}");
|
||
assert!(sq.contains(Vec2::new(1.0, 1.0)) && !sq.contains(Vec2::new(5.0, 1.0)));
|
||
assert!(sq.centroid().dist(Vec2::new(2.0, 1.5)) < 1e-9);
|
||
}
|
||
}
|