Files
effectcraft/crates/effects/src/puppet/mesh.rs
T
Brandon ThomasandClaude Opus 5.5 d6f6a10757 M13.1/M13.2: paint (Brush, Clone Stamp, Eraser) and puppet pins
Paint: non-destructive Paint effect with Brush/Clone/Eraser stroke groups
(Path, Stroke Options, Transform, Duration spans, Write On End keys),
dab rasterizer (diameter/angle/roundness/hardness/spacing/flow/opacity,
pressure dynamics, modes, channels), eraser modes, clone from layer/time,
cache keys include visible strokes. paint.stroke/options/brushPreset/
setCloneSource/removeStroke/presets. Layer panel with paint tools, Paint
and Brushes panels.

Puppet: Puppet effect with meshes and Position/Advanced/Bend/Starch/Overlap
pins; alpha contour -> expansion (exact EDT) -> simplified outline ->
Delaunay with constraint recovery; ARAP (Igarashi 2005) two-step solve with
CG; anti-aliased texture-mapped rendering with overlap depth. puppet.addPin/
movePin/setPin/removePin/mesh/info. Puppet tools in the viewer with mesh
overlay and header options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:12:10 -04:00

636 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Puppet meshes: the layer's alpha outline → contour → simplified polygon → expansion →
//! constrained triangulation with density control.
//!
//! * The alpha channel is thresholded at 50 % and grown (or shrunk) by Expansion with an exact
//! Euclidean distance transform (the lower-envelope-of-parabolas method of Felzenszwalb &
//! Huttenlocher, "Distance Transforms of Sampled Functions").
//! * The 4-connected region under the click (holes filled) is traced along pixel cracks into a
//! closed polygon, simplified with Douglas–Peucker and split so no edge is longer than the
//! target edge length.
//! * Interior points on a jittered hexagonal lattice are added, everything is triangulated with
//! Bowyer–Watson Delaunay insertion, missing outline edges are recovered by edge flips (the
//! textbook constrained-Delaunay edge-recovery procedure), and triangles outside the outline
//! are dropped.
use std::collections::{BTreeMap, HashMap, VecDeque};
/// A triangle mesh in layer space. Triangles are counter-clockwise in the (x right, y down)
/// layer coordinates, i.e. `cross(b - a, c - a) > 0`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Mesh {
pub verts: Vec<[f64; 2]>,
pub tris: Vec<[usize; 3]>,
/// The outline polygon (layer space).
pub outline: Vec<[f64; 2]>,
}
/// Binary coverage grid in buffer pixels.
#[derive(Clone, Debug)]
pub struct Grid {
pub w: usize,
pub h: usize,
pub on: Vec<bool>,
}
impl Grid {
#[inline]
pub fn at(&self, x: i64, y: i64) -> bool {
x >= 0 && y >= 0 && (x as usize) < self.w && (y as usize) < self.h && self.on[y as usize * self.w + x as usize]
}
}
pub fn cross(o: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
}
fn dist2(a: [f64; 2], b: [f64; 2]) -> f64 {
(a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)
}
/// 1D squared-distance transform of `f` (Felzenszwalb & Huttenlocher).
fn dt1(f: &[f64], out: &mut [f64]) {
let n = f.len();
if n == 0 {
return;
}
let mut v = vec![0usize; n];
let mut z = vec![0.0f64; n + 1];
let mut k = 0usize;
z[0] = f64::NEG_INFINITY;
z[1] = f64::INFINITY;
let inter = |q: usize, p: usize| ((f[q] + (q * q) as f64) - (f[p] + (p * p) as f64)) / (2.0 * q as f64 - 2.0 * p as f64);
for q in 1..n {
let mut s = inter(q, v[k]);
while s <= z[k] {
k -= 1;
s = inter(q, v[k]);
}
k += 1;
v[k] = q;
z[k] = s;
z[k + 1] = f64::INFINITY;
}
let mut k = 0;
for (q, o) in out.iter_mut().enumerate() {
while z[k + 1] < q as f64 {
k += 1;
}
let p = v[k];
*o = (q as f64 - p as f64).powi(2) + f[p];
}
}
/// Squared Euclidean distance from every cell to the nearest cell where `src` is true.
pub fn distance_sq(w: usize, h: usize, src: &[bool]) -> Vec<f64> {
let inf = 1e20;
let mut g = vec![inf; w * h];
let mut col = vec![0.0; h];
let mut colo = vec![0.0; h];
for x in 0..w {
for y in 0..h {
col[y] = if src[y * w + x] { 0.0 } else { inf };
}
dt1(&col, &mut colo);
for y in 0..h {
g[y * w + x] = colo[y];
}
}
let mut out = vec![0.0; w * h];
for y in 0..h {
dt1(&g[y * w..(y + 1) * w], &mut out[y * w..(y + 1) * w]);
}
out
}
/// Grow (`e > 0`) or shrink (`e < 0`) a grid by `e` cells.
pub fn expand(g: &Grid, e: f64) -> Grid {
if e.abs() < 1e-9 {
return g.clone();
}
if e > 0.0 {
let d = distance_sq(g.w, g.h, &g.on);
Grid { w: g.w, h: g.h, on: d.iter().map(|v| *v <= e * e).collect() }
} else {
let off: Vec<bool> = g.on.iter().map(|b| !b).collect();
let d = distance_sq(g.w, g.h, &off);
Grid { w: g.w, h: g.h, on: d.iter().zip(&g.on).map(|(v, on)| *on && *v > e * e).collect() }
}
}
/// The 4-connected region containing (or nearest to) `seed`, with holes filled.
pub fn region(g: &Grid, seed: [f64; 2]) -> Option<Grid> {
let (w, h) = (g.w, g.h);
if w == 0 || h == 0 {
return None;
}
let sx = (seed[0].floor() as i64).clamp(0, w as i64 - 1);
let sy = (seed[1].floor() as i64).clamp(0, h as i64 - 1);
let start = if g.at(sx, sy) {
(sx as usize, sy as usize)
} else {
// Nearest on-cell.
let mut best: Option<(i64, usize, usize)> = None;
for y in 0..h {
for x in 0..w {
if g.on[y * w + x] {
let d = (x as i64 - sx).pow(2) + (y as i64 - sy).pow(2);
if best.is_none_or(|b| d < b.0) {
best = Some((d, x, y));
}
}
}
}
let b = best?;
(b.1, b.2)
};
let mut on = vec![false; w * h];
let mut q = VecDeque::new();
on[start.1 * w + start.0] = true;
q.push_back(start);
while let Some((x, y)) = q.pop_front() {
let mut visit = |nx: i64, ny: i64| {
if g.at(nx, ny) && !on[ny as usize * w + nx as usize] {
on[ny as usize * w + nx as usize] = true;
q.push_back((nx as usize, ny as usize));
}
};
visit(x as i64 - 1, y as i64);
visit(x as i64 + 1, y as i64);
visit(x as i64, y as i64 - 1);
visit(x as i64, y as i64 + 1);
}
// Fill holes: everything not reachable from the border through off-cells.
let mut outside = vec![false; w * h];
let mut q = VecDeque::new();
for x in 0..w {
for y in [0, h - 1] {
if !on[y * w + x] && !outside[y * w + x] {
outside[y * w + x] = true;
q.push_back((x, y));
}
}
}
for y in 0..h {
for x in [0, w - 1] {
if !on[y * w + x] && !outside[y * w + x] {
outside[y * w + x] = true;
q.push_back((x, y));
}
}
}
while let Some((x, y)) = q.pop_front() {
for (dx, dy) in [(-1i64, 0i64), (1, 0), (0, -1), (0, 1)] {
let (nx, ny) = (x as i64 + dx, y as i64 + dy);
if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 {
continue;
}
let i = ny as usize * w + nx as usize;
if !on[i] && !outside[i] {
outside[i] = true;
q.push_back((nx as usize, ny as usize));
}
}
}
Some(Grid { w, h, on: outside.iter().map(|o| !o).collect() })
}
/// Trace the outer boundary of a hole-free region along pixel cracks: a closed polygon through
/// pixel corners, region on the left when walking (counter-clockwise in y-down coordinates).
pub fn trace(g: &Grid) -> Vec<[f64; 2]> {
// Directed boundary edges between corners; region on the right of the edge in y-down
// screen terms (we fix orientation at the end anyway).
let mut next: BTreeMap<(i64, i64), Vec<(i64, i64)>> = BTreeMap::new();
for y in 0..g.h as i64 {
for x in 0..g.w as i64 {
if !g.at(x, y) {
continue;
}
if !g.at(x, y - 1) {
next.entry((x, y)).or_default().push((x + 1, y));
}
if !g.at(x + 1, y) {
next.entry((x + 1, y)).or_default().push((x + 1, y + 1));
}
if !g.at(x, y + 1) {
next.entry((x + 1, y + 1)).or_default().push((x, y + 1));
}
if !g.at(x - 1, y) {
next.entry((x, y + 1)).or_default().push((x, y));
}
}
}
let mut best: Vec<[f64; 2]> = vec![];
let mut best_area = 0.0;
while let Some(&start) = next.iter().find(|(_, v)| !v.is_empty()).map(|(k, _)| k) {
let mut lp = vec![start];
let mut cur = start;
let mut dir = (0i64, 0i64);
let mut guard = 0;
loop {
guard += 1;
if guard > 4 * (g.w + 2) * (g.h + 2) {
break;
}
let Some(outs) = next.get_mut(&cur) else { break };
if outs.is_empty() {
break;
}
// At a saddle corner pick the right-most turn relative to the incoming direction
// (keeps diagonal-touching parts separate).
let k = if outs.len() == 1 || dir == (0, 0) {
0
} else {
let score = |o: &(i64, i64)| {
let d = (o.0 - cur.0, o.1 - cur.1);
dir.0 * d.1 - dir.1 * d.0
};
(0..outs.len()).max_by_key(|i| score(&outs[*i])).unwrap_or(0)
};
let nx = outs.swap_remove(k);
dir = (nx.0 - cur.0, nx.1 - cur.1);
cur = nx;
if cur == start {
break;
}
lp.push(cur);
}
let pts: Vec<[f64; 2]> = lp.iter().map(|p| [p.0 as f64, p.1 as f64]).collect();
let a = signed_area(&pts).abs();
if a > best_area {
best_area = a;
best = pts;
}
}
// Drop collinear corners.
let n = best.len();
if n < 3 {
return best;
}
let mut out = vec![];
for i in 0..n {
let (a, b, c) = (best[(i + n - 1) % n], best[i], best[(i + 1) % n]);
if cross(a, b, c).abs() > 1e-12 {
out.push(b);
}
}
out
}
pub fn signed_area(p: &[[f64; 2]]) -> f64 {
let n = p.len();
let mut a = 0.0;
for i in 0..n {
let (u, v) = (p[i], p[(i + 1) % n]);
a += u[0] * v[1] - v[0] * u[1];
}
a * 0.5
}
fn seg_dist2(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
let ab = [b[0] - a[0], b[1] - a[1]];
let l = ab[0] * ab[0] + ab[1] * ab[1];
let t = if l > 0.0 { (((p[0] - a[0]) * ab[0] + (p[1] - a[1]) * ab[1]) / l).clamp(0.0, 1.0) } else { 0.0 };
dist2(p, [a[0] + ab[0] * t, a[1] + ab[1] * t])
}
/// Douglas–Peucker simplification of a closed polygon.
pub fn simplify(p: &[[f64; 2]], tol: f64) -> Vec<[f64; 2]> {
let n = p.len();
if n <= 4 {
return p.to_vec();
}
// Split the loop at vertex 0 and the vertex farthest from it; simplify both open chains.
let far = (1..n).max_by(|a, b| dist2(p[0], p[*a]).total_cmp(&dist2(p[0], p[*b]))).unwrap_or(n / 2);
let mut ext = p.to_vec();
ext.push(p[0]);
let mut keep = vec![false; n + 1];
let tol2 = tol * tol;
let mut stack = vec![(0usize, far), (far, n)];
keep[0] = true;
keep[far] = true;
while let Some((a, b)) = stack.pop() {
if b <= a + 1 {
continue;
}
let mut best = (0.0, 0);
for i in a + 1..b {
let d = seg_dist2(ext[i], ext[a], ext[b]);
if d > best.0 {
best = (d, i);
}
}
if best.0 > tol2 {
keep[best.1] = true;
stack.push((a, best.1));
stack.push((best.1, b));
}
}
(0..n).filter(|i| keep[*i]).map(|i| p[i]).collect()
}
/// Split polygon edges so none is longer than `max_len`.
pub fn densify(p: &[[f64; 2]], max_len: f64) -> Vec<[f64; 2]> {
let n = p.len();
let mut out = vec![];
for i in 0..n {
let (a, b) = (p[i], p[(i + 1) % n]);
out.push(a);
let l = dist2(a, b).sqrt();
let k = (l / max_len.max(1e-6)).ceil() as usize;
for j in 1..k {
let t = j as f64 / k as f64;
out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
}
}
out
}
/// Even-odd point-in-polygon test.
pub fn inside(poly: &[[f64; 2]], p: [f64; 2]) -> bool {
let n = poly.len();
let mut c = false;
let mut j = n.wrapping_sub(1);
for i in 0..n {
let (a, b) = (poly[i], poly[j]);
if (a[1] > p[1]) != (b[1] > p[1]) && p[0] < (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0] {
c = !c;
}
j = i;
}
c
}
fn poly_dist2(poly: &[[f64; 2]], p: [f64; 2]) -> f64 {
let n = poly.len();
(0..n).map(|i| seg_dist2(p, poly[i], poly[(i + 1) % n])).fold(f64::MAX, f64::min)
}
/// Circumcircle test: is `d` strictly inside the circumcircle of CCW (`cross > 0`) triangle abc?
fn in_circle(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool {
let (ax, ay) = (a[0] - d[0], a[1] - d[1]);
let (bx, by) = (b[0] - d[0], b[1] - d[1]);
let (cx, cy) = (c[0] - d[0], c[1] - d[1]);
let (la, lb, lc) = (ax * ax + ay * ay, bx * bx + by * by, cx * cx + cy * cy);
let det = la * (bx * cy - cx * by) - lb * (ax * cy - cx * ay) + lc * (ax * by - bx * ay);
let m = la.max(lb).max(lc);
det > 1e-12 * m * m
}
/// Bowyer–Watson Delaunay triangulation of `pts` (CCW triangles, `cross > 0`).
pub fn delaunay(pts: &[[f64; 2]]) -> Vec<[usize; 3]> {
let n = pts.len();
if n < 3 {
return vec![];
}
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for p in pts {
x0 = x0.min(p[0]);
y0 = y0.min(p[1]);
x1 = x1.max(p[0]);
y1 = y1.max(p[1]);
}
let d = (x1 - x0).max(y1 - y0).max(1.0) * 20.0;
let (cx, cy) = ((x0 + x1) * 0.5, (y0 + y1) * 0.5);
let mut v = pts.to_vec();
v.push([cx - d, cy - d]);
v.push([cx + d, cy - d]);
v.push([cx, cy + d]);
let mut tris: Vec<[usize; 3]> = vec![orient(&v, [n, n + 1, n + 2])];
for i in 0..n {
let p = v[i];
let mut bad = vec![];
for (k, t) in tris.iter().enumerate() {
if in_circle(v[t[0]], v[t[1]], v[t[2]], p) {
bad.push(k);
}
}
if bad.is_empty() {
// On a circle or exactly on an edge: find the containing triangle instead.
if let Some(k) =
tris.iter().position(|t| cross(v[t[0]], v[t[1]], p) >= 0.0 && cross(v[t[1]], v[t[2]], p) >= 0.0 && cross(v[t[2]], v[t[0]], p) >= 0.0)
{
bad.push(k);
} else {
continue;
}
}
// Cavity boundary: edges of bad triangles not shared by two of them.
let mut count: HashMap<(usize, usize), u32> = HashMap::new();
for k in &bad {
let t = tris[*k];
for e in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
*count.entry((e.0.min(e.1), e.0.max(e.1))).or_default() += 1;
}
}
let mut edges = vec![];
for k in &bad {
let t = tris[*k];
for e in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
if count[&(e.0.min(e.1), e.0.max(e.1))] == 1 {
edges.push(e);
}
}
}
let mut sorted = bad.clone();
sorted.sort_unstable_by(|a, b| b.cmp(a));
for k in sorted {
tris.swap_remove(k);
}
for (a, b) in edges {
if cross(v[a], v[b], p).abs() > 1e-12 {
tris.push(orient(&v, [a, b, i]));
}
}
}
tris.retain(|t| t.iter().all(|i| *i < n));
tris
}
fn orient(v: &[[f64; 2]], t: [usize; 3]) -> [usize; 3] {
if cross(v[t[0]], v[t[1]], v[t[2]]) < 0.0 { [t[0], t[2], t[1]] } else { t }
}
fn seg_cross(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool {
let d1 = cross(a, b, c);
let d2 = cross(a, b, d);
let d3 = cross(c, d, a);
let d4 = cross(c, d, b);
((d1 > 1e-12 && d2 < -1e-12) || (d1 < -1e-12 && d2 > 1e-12)) && ((d3 > 1e-12 && d4 < -1e-12) || (d3 < -1e-12 && d4 > 1e-12))
}
fn key(a: usize, b: usize) -> (usize, usize) {
(a.min(b), a.max(b))
}
/// Recover constraint edges in a triangulation by flipping crossing edges.
pub fn recover_edges(v: &[[f64; 2]], tris: &mut [[usize; 3]], constraints: &[(usize, usize)]) {
let mut edge_tris: BTreeMap<(usize, usize), Vec<usize>> = BTreeMap::new();
for (k, t) in tris.iter().enumerate() {
for e in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
edge_tris.entry(key(e.0, e.1)).or_default().push(k);
}
}
let fixed: std::collections::HashSet<(usize, usize)> = constraints.iter().map(|&(a, b)| key(a, b)).collect();
for &(a, b) in constraints {
if edge_tris.get(&key(a, b)).is_some_and(|t| !t.is_empty()) {
continue;
}
let mut queue: VecDeque<(usize, usize)> =
edge_tris.iter().filter(|(e, t)| t.len() == 2 && seg_cross(v[a], v[b], v[e.0], v[e.1])).map(|(e, _)| *e).collect();
let mut guard = 0;
while let Some(e) = queue.pop_front() {
guard += 1;
if guard > 20_000 {
break;
}
let Some(ts) = edge_tris.get(&e).cloned() else { continue };
if ts.len() != 2 || fixed.contains(&e) {
continue;
}
let (t0, t1) = (tris[ts[0]], tris[ts[1]]);
let p = t0.iter().copied().find(|x| *x != e.0 && *x != e.1);
let q = t1.iter().copied().find(|x| *x != e.0 && *x != e.1);
let (Some(p), Some(q)) = (p, q) else { continue };
// Convex quad (u, p, w, q)? Then the diagonal pq crosses uw.
if !seg_cross(v[p], v[q], v[e.0], v[e.1]) {
if queue.is_empty() {
break;
}
queue.push_back(e);
continue;
}
// Flip.
for (k, t) in [(ts[0], t0), (ts[1], t1)] {
for ed in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
if let Some(l) = edge_tris.get_mut(&key(ed.0, ed.1)) {
l.retain(|x| *x != k);
}
}
}
let n0 = orient(v, [p, q, e.0]);
let n1 = orient(v, [p, q, e.1]);
tris[ts[0]] = n0;
tris[ts[1]] = n1;
for (k, t) in [(ts[0], n0), (ts[1], n1)] {
for ed in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
edge_tris.entry(key(ed.0, ed.1)).or_default().push(k);
}
}
edge_tris.retain(|_, l| !l.is_empty());
if seg_cross(v[a], v[b], v[p], v[q]) {
queue.push_back(key(p, q));
}
}
}
}
/// Mesh options (Puppet ▸ Mesh: Triangles, Density, Expansion).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MeshOpts {
/// Target triangle count.
pub triangles: f64,
/// 0–100; 50 = the target count, each 25 doubles / halves it.
pub density: f64,
/// Outline growth in layer pixels.
pub expansion: f64,
}
impl Default for MeshOpts {
fn default() -> Self {
MeshOpts { triangles: 350.0, density: 50.0, expansion: 3.0 }
}
}
/// Deterministic jitter in [-0.5, 0.5).
fn jitter(i: u64) -> f64 {
let mut x = i.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ 0x2545_f491_4f6c_dd1d;
x ^= x >> 31;
x = x.wrapping_mul(0xbf58_476d_1ce4_e5b9);
x ^= x >> 29;
(x >> 11) as f64 / (1u64 << 53) as f64 - 0.5
}
/// Build a mesh over a polygon outline (layer space) with the option's target size.
pub fn triangulate(outline: &[[f64; 2]], opts: &MeshOpts) -> Mesh {
let mut poly = outline.to_vec();
if signed_area(&poly) < 0.0 {
poly.reverse();
}
let area = signed_area(&poly).abs();
if poly.len() < 3 || area <= 0.0 {
return Mesh::default();
}
let target = (opts.triangles.max(2.0) * 2f64.powf((opts.density - 50.0) / 25.0)).clamp(2.0, 20_000.0);
let l = (4.0 * area / (3f64.sqrt() * target)).sqrt().max(0.5);
let boundary = densify(&poly, l);
let nb = boundary.len();
let mut pts = boundary.clone();
// Interior: a jittered hexagonal lattice, kept away from the outline.
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for p in &poly {
x0 = x0.min(p[0]);
y0 = y0.min(p[1]);
x1 = x1.max(p[0]);
y1 = y1.max(p[1]);
}
let dy = l * 3f64.sqrt() * 0.5;
let mut row = 0u64;
let mut y = y0 + dy * 0.5;
let min_d2 = (0.55 * l).powi(2);
while y < y1 {
let mut x = x0 + if row % 2 == 1 { l * 0.5 } else { 0.0 } + l * 0.25;
let mut col = 0u64;
while x < x1 {
let id = row * 100_003 + col;
let p = [x + jitter(id * 2) * l * 0.08, y + jitter(id * 2 + 1) * l * 0.08];
if inside(&poly, p) && poly_dist2(&boundary, p) >= min_d2 {
pts.push(p);
}
x += l;
col += 1;
}
y += dy;
row += 1;
}
let mut tris = delaunay(&pts);
let constraints: Vec<(usize, usize)> = (0..nb).map(|i| (i, (i + 1) % nb)).collect();
recover_edges(&pts, &mut tris, &constraints);
tris.retain(|t| {
let c = [(pts[t[0]][0] + pts[t[1]][0] + pts[t[2]][0]) / 3.0, (pts[t[0]][1] + pts[t[1]][1] + pts[t[2]][1]) / 3.0];
cross(pts[t[0]], pts[t[1]], pts[t[2]]) > 1e-9 && inside(&poly, c)
});
// Compact the vertex list.
let mut map = vec![usize::MAX; pts.len()];
let mut verts = vec![];
for t in tris.iter_mut() {
for i in t.iter_mut() {
if map[*i] == usize::MAX {
map[*i] = verts.len();
verts.push(pts[*i]);
}
*i = map[*i];
}
}
Mesh { verts, tris, outline: poly }
}
/// The outline of the alpha region under `seed` (buffer pixels → layer space via `scale` and
/// `offset`), expanded by `expansion` layer pixels.
pub fn outline(alpha: &[f32], w: usize, h: usize, scale: f64, offset: [f64; 2], seed: [f64; 2], expansion: f64) -> Vec<[f64; 2]> {
// Pad so expansion can grow past the buffer edge.
let pad = (expansion.max(0.0) * scale).ceil() as usize + 2;
let (pw, ph) = (w + 2 * pad, h + 2 * pad);
let mut on = vec![false; pw * ph];
for y in 0..h {
for x in 0..w {
on[(y + pad) * pw + x + pad] = alpha[y * w + x] >= 0.5;
}
}
let g = expand(&Grid { w: pw, h: ph, on }, expansion * scale);
let sp = [seed[0] * scale + offset[0] + pad as f64, seed[1] * scale + offset[1] + pad as f64];
let Some(r) = region(&g, sp) else { return vec![] };
let c = trace(&r);
let c = simplify(&c, 0.75);
c.iter().map(|p| [(p[0] - pad as f64 - offset[0]) / scale, (p[1] - pad as f64 - offset[1]) / scale]).collect()
}