mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
Create: Strokes drew every line with round caps: a square-ended line 10 px wide had its corners 2.94 px outside the stroke. - A line's ends are square when its ink reaches as far a third of the width to either side of its middle as along it (a round end stops short there by a seventh of the width); told from lines 3 px wide and more, all the ends of a line together. The stroke then has square caps (`TracedPath::square_caps`). - The last points of a wide line's skeleton can turn towards a corner of its end. An end that has left the line the skeleton followed a width back (by a quarter of the width, 1 px at least) is put back on it; and an end may be pulled back as well as pushed out to where the ink ends. Outline on the thin-line inputs: largest distance from the art 2.94 -> 0.88 px (clean 2.94 -> 0.41; aliased 0.88 -> 0.81), mean 0.055 -> 0.050 px, anchors 14 -> 10.
583 lines
24 KiB
Rust
583 lines
24 KiB
Rust
//! Create Strokes: the parts of a colour layer no wider than the Stroke width, traced as stroked
|
||
//! centre lines instead of filled outlines.
|
||
//!
|
||
//! 1. **Distance**: a chamfer (3-4) distance transform gives each pixel its distance to the nearest
|
||
//! pixel outside the layer.
|
||
//! 2. **Lines**: an 8-connected component whose widest point (twice its largest distance, less
|
||
//! one) is within the stroke width is a line; wider ones stay filled areas.
|
||
//! 3. **Skeleton**: the line is thinned to one pixel (Zhang–Suen, applied pixel by pixel so it
|
||
//! keeps its topology and two-pixel-wide lines don't vanish), then staircase corners that
|
||
//! don't connect anything are dropped.
|
||
//! 4. **Graph**: runs of skeleton pixels between ends and junctions (touching junction pixels
|
||
//! merged into one point) become polylines through the pixel centres; rings without ends
|
||
//! become closed ones. Short spurs that thinning leaves at junctions are pruned and the runs
|
||
//! on either side of a junction left with two of them are joined.
|
||
//! 5. **Ink**: the skeleton is right to a pixel only, so each of its points is moved to the middle
|
||
//! of the line's ink, read from the image: across the line (along the pixel column or row most
|
||
//! nearly square to it) each pixel is as much ink as its colour is the line's rather than the
|
||
//! paper's, and the ink's centroid is the line's middle there and its sum the line's width (an
|
||
//! anti-aliased or blurred line says both to a fraction of a pixel). The stroke width is the
|
||
//! median of those widths, and each end is put where the ink ends, less the half width a round
|
||
//! cap adds.
|
||
//! 6. **Curves**: each polyline is fitted as outlines are ([`fit::fit_piece`]): a straight line
|
||
//! is two anchors, corners stay corners, the rest is as few curves as Paths allows.
|
||
|
||
use std::collections::HashSet;
|
||
|
||
use vectorcraft_geom::{PathData, Point};
|
||
|
||
use crate::Raster;
|
||
use crate::fit;
|
||
use crate::logo::Style;
|
||
|
||
/// One line of a colour layer: its centre lines and the width to stroke them with.
|
||
pub(crate) struct Line {
|
||
pub path: PathData,
|
||
/// Stroke width in pixels.
|
||
pub width: f64,
|
||
pub pixels: usize,
|
||
/// Its ends are cut square (the ink is as long beside the line's middle as along it), not round.
|
||
pub square: bool,
|
||
}
|
||
|
||
/// The lines of colour `label` in `labels` (`w` × `h`, row-major) no wider than `max_width`
|
||
/// pixels, and which pixels they cover (to leave out of the filled areas).
|
||
/// `ink` is the colour of the layer's ink in `img` (its palette colour; in Black and White the
|
||
/// image's own black), which the lines' middles and widths are read against.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(crate) fn lines(
|
||
labels: &[u16],
|
||
label: u16,
|
||
w: usize,
|
||
h: usize,
|
||
max_width: f64,
|
||
img: &Raster,
|
||
ink: [f64; 3],
|
||
style: &Style,
|
||
) -> (Vec<bool>, Vec<Line>) {
|
||
let mut thin = vec![false; labels.len()];
|
||
let mut out = vec![];
|
||
if w == 0 || h == 0 || labels.len() != w * h {
|
||
return (thin, out);
|
||
}
|
||
let mask = |i: usize| labels.get(i) == Some(&label);
|
||
let dt = distance(&mask, w, h);
|
||
let mut seen = vec![false; w * h];
|
||
let mut stack = vec![];
|
||
for start in 0..w * h {
|
||
if !mask(start) || seen[start] {
|
||
continue;
|
||
}
|
||
let mut comp = vec![];
|
||
seen[start] = true;
|
||
stack.push(start);
|
||
while let Some(i) = stack.pop() {
|
||
comp.push(i);
|
||
for j in neighbours(i, w, h) {
|
||
if mask(j) && !seen[j] {
|
||
seen[j] = true;
|
||
stack.push(j);
|
||
}
|
||
}
|
||
}
|
||
let widest = 2.0 * f64::from(comp.iter().map(|&i| dt[i]).max().unwrap_or(0)) / 3.0 - 1.0;
|
||
if widest > max_width {
|
||
continue;
|
||
}
|
||
let Some(line) = trace_line(&comp, w, max_width, img, ink, style) else { continue };
|
||
for &i in &comp {
|
||
thin[i] = true;
|
||
}
|
||
out.push(line);
|
||
}
|
||
(thin, out)
|
||
}
|
||
|
||
/// The 8 neighbours of pixel `i` inside the `w` × `h` image.
|
||
fn neighbours(i: usize, w: usize, h: usize) -> impl Iterator<Item = usize> {
|
||
let (x, y) = ((i % w) as i64, (i / w) as i64);
|
||
const AROUND: [(i64, i64); 8] = [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)];
|
||
AROUND.into_iter().filter_map(move |(dx, dy)| {
|
||
let (nx, ny) = (x + dx, y + dy);
|
||
(nx >= 0 && ny >= 0 && nx < w as i64 && ny < h as i64).then(|| ny as usize * w + nx as usize)
|
||
})
|
||
}
|
||
|
||
/// Chamfer 3-4 distance (3 per pixel step, saturating) of each pixel in `mask` to the nearest
|
||
/// pixel outside it; outside the image counts as outside.
|
||
fn distance(mask: &impl Fn(usize) -> bool, w: usize, h: usize) -> Vec<u16> {
|
||
let mut d = vec![0u16; w * h];
|
||
let at = |d: &[u16], x: i64, y: i64| {
|
||
if x < 0 || y < 0 || x >= w as i64 || y >= h as i64 { 0 } else { d.get(y as usize * w + x as usize).copied().unwrap_or(0) }
|
||
};
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let i = y * w + x;
|
||
if mask(i) {
|
||
let (x, y) = (x as i64, y as i64);
|
||
let steps = [(x - 1, y, 3), (x - 1, y - 1, 4), (x, y - 1, 3), (x + 1, y - 1, 4)];
|
||
d[i] = steps.iter().map(|&(nx, ny, c)| at(&d, nx, ny).saturating_add(c)).min().unwrap_or(0);
|
||
}
|
||
}
|
||
}
|
||
for y in (0..h).rev() {
|
||
for x in (0..w).rev() {
|
||
let i = y * w + x;
|
||
if mask(i) {
|
||
let (xi, yi) = (x as i64, y as i64);
|
||
let steps = [(xi + 1, yi, 3), (xi + 1, yi + 1, 4), (xi, yi + 1, 3), (xi - 1, yi + 1, 4)];
|
||
let v = steps.iter().map(|&(nx, ny, c)| at(&d, nx, ny).saturating_add(c)).min().unwrap_or(0);
|
||
d[i] = d[i].min(v);
|
||
}
|
||
}
|
||
}
|
||
d
|
||
}
|
||
|
||
/// A grid around one line: its pixels with a one-pixel empty border.
|
||
struct Grid {
|
||
on: Vec<bool>,
|
||
w: usize,
|
||
h: usize,
|
||
/// Image position of grid pixel (0, 0).
|
||
x0: i64,
|
||
y0: i64,
|
||
}
|
||
|
||
impl Grid {
|
||
/// The 8 neighbours (N, NE, E, SE, S, SW, W, NW) of interior pixel `i`.
|
||
fn ring(&self, i: usize) -> [bool; 8] {
|
||
let w = self.w;
|
||
let g = |j: usize| self.on.get(j).copied().unwrap_or(false);
|
||
[g(i - w), g(i - w + 1), g(i + 1), g(i + w + 1), g(i + w), g(i + w - 1), g(i - 1), g(i - w - 1)]
|
||
}
|
||
fn interior(&self) -> impl Iterator<Item = usize> + use<> {
|
||
let (w, h) = (self.w, self.h);
|
||
(1..h.saturating_sub(1)).flat_map(move |y| (1..w.saturating_sub(1)).map(move |x| y * w + x))
|
||
}
|
||
fn neighbours(&self, i: usize) -> impl Iterator<Item = usize> + '_ {
|
||
let w = self.w;
|
||
[i - w, i - w + 1, i + 1, i + w + 1, i + w, i + w - 1, i - 1, i - w - 1].into_iter().filter(|&j| self.on.get(j).copied().unwrap_or(false))
|
||
}
|
||
fn degree(&self, i: usize) -> usize {
|
||
self.neighbours(i).count()
|
||
}
|
||
/// The centre of pixel `i` in image coordinates.
|
||
fn centre(&self, i: usize) -> Point {
|
||
Point::new((self.x0 + (i % self.w) as i64) as f64 + 0.5, (self.y0 + (i / self.w) as i64) as f64 + 0.5)
|
||
}
|
||
}
|
||
|
||
/// Zhang–Suen thinning. Each sub-iteration picks the pixels to remove from the same state, so the
|
||
/// skeleton stays centred; they are then removed one by one, each only if it still doesn't
|
||
/// disconnect anything, so a two-pixel-wide line thins to one pixel instead of vanishing.
|
||
fn thin(g: &mut Grid) {
|
||
let removable = |p: &[bool; 8]| {
|
||
let b = p.iter().filter(|v| **v).count();
|
||
let a = (0..8).filter(|&k| !p[k] && p[(k + 1) % 8]).count();
|
||
(2..=6).contains(&b) && a == 1
|
||
};
|
||
let mut picked = vec![];
|
||
loop {
|
||
let mut changed = false;
|
||
for step in 0..2 {
|
||
picked.clear();
|
||
for i in g.interior() {
|
||
if !g.on[i] {
|
||
continue;
|
||
}
|
||
let p = g.ring(i);
|
||
let (n, e, s, w) = (p[0], p[2], p[4], p[6]);
|
||
let side = if step == 0 { !(e && s && (n || w)) } else { !(n && w && (e || s)) };
|
||
if side && removable(&p) {
|
||
picked.push(i);
|
||
}
|
||
}
|
||
for &i in &picked {
|
||
if removable(&g.ring(i)) {
|
||
g.on[i] = false;
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
if !changed {
|
||
break;
|
||
}
|
||
}
|
||
// Staircase corners: a pixel whose neighbours stay connected without it (Yokoi's
|
||
// 8-connectivity number 1) and that doesn't end the line.
|
||
loop {
|
||
let mut changed = false;
|
||
for i in g.interior() {
|
||
if !g.on[i] || g.degree(i) < 2 {
|
||
continue;
|
||
}
|
||
let p = g.ring(i);
|
||
// x1 = E, x2 = NE, x3 = N, x4 = NW, x5 = W, x6 = SW, x7 = S, x8 = SE.
|
||
let x = [p[2], p[1], p[0], p[7], p[6], p[5], p[4], p[3]];
|
||
let off = |k: usize| !x[k % 8];
|
||
let c8: usize = [0, 2, 4, 6].iter().map(|&k| usize::from(off(k)) - usize::from(off(k) && off(k + 1) && off(k + 2))).sum();
|
||
if c8 == 1 {
|
||
g.on[i] = false;
|
||
changed = true;
|
||
}
|
||
}
|
||
if !changed {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A run of skeleton pixels between two nodes (`None`: none, a ring).
|
||
#[derive(Clone)]
|
||
struct Run {
|
||
pts: Vec<Point>,
|
||
ends: [Option<usize>; 2],
|
||
closed: bool,
|
||
}
|
||
|
||
impl Run {
|
||
fn length(&self) -> f64 {
|
||
self.pts.windows(2).map(|p| p[0].distance(p[1])).sum::<f64>()
|
||
+ if self.closed { self.pts.first().zip(self.pts.last()).map_or(0.0, |(a, b)| a.distance(*b)) } else { 0.0 }
|
||
}
|
||
}
|
||
|
||
/// The centre lines of one line component (pixel indices of the `w`-wide image).
|
||
fn trace_line(comp: &[usize], w: usize, max_width: f64, img: &Raster, ink: [f64; 3], style: &Style) -> Option<Line> {
|
||
let xs = comp.iter().map(|&i| i % w);
|
||
let ys = comp.iter().map(|&i| i / w);
|
||
let (x0, x1, y0, y1) = (xs.clone().min()?, xs.max()?, ys.clone().min()?, ys.max()?);
|
||
let (gw, gh) = (x1 - x0 + 3, y1 - y0 + 3);
|
||
let mut g = Grid { on: vec![false; gw * gh], w: gw, h: gh, x0: x0 as i64 - 1, y0: y0 as i64 - 1 };
|
||
for &i in comp {
|
||
g.on[(i / w - y0 + 1) * gw + (i % w - x0 + 1)] = true;
|
||
}
|
||
thin(&mut g);
|
||
let skeleton: Vec<usize> = g.interior().filter(|&i| g.on[i]).collect();
|
||
if skeleton.len() < 2 {
|
||
return None;
|
||
}
|
||
// Nodes: line ends, and junctions with the junction pixels touching them.
|
||
let mut node = vec![usize::MAX; g.on.len()];
|
||
let mut centres: Vec<Point> = vec![];
|
||
for &i in &skeleton {
|
||
let d = g.degree(i);
|
||
if d == 2 || node[i] != usize::MAX {
|
||
continue;
|
||
}
|
||
let id = centres.len();
|
||
let mut members = vec![i];
|
||
node[i] = id;
|
||
if d > 2 {
|
||
let mut k = 0;
|
||
while let Some(&p) = members.get(k) {
|
||
k += 1;
|
||
for q in g.neighbours(p).collect::<Vec<_>>() {
|
||
if node[q] == usize::MAX && g.degree(q) > 2 {
|
||
node[q] = id;
|
||
members.push(q);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let sum = members.iter().fold(Point::ZERO, |s, &m| s + g.centre(m).to_vec2());
|
||
centres.push(Point::new(sum.x / members.len() as f64, sum.y / members.len() as f64));
|
||
}
|
||
// Runs from each node, then the rings left.
|
||
let mut used: HashSet<(usize, usize)> = HashSet::new();
|
||
let edge = |a: usize, b: usize| (a.min(b), a.max(b));
|
||
let mut runs: Vec<Run> = vec![];
|
||
for &p in skeleton.iter().filter(|&&i| node[i] != usize::MAX) {
|
||
for q in g.neighbours(p).collect::<Vec<_>>() {
|
||
if node[q] == node[p] || !used.insert(edge(p, q)) {
|
||
continue;
|
||
}
|
||
let mut pts = vec![centres[node[p]], g.centre(q)];
|
||
let (mut prev, mut cur) = (p, q);
|
||
while node[cur] == usize::MAX {
|
||
let Some(next) = g.neighbours(cur).find(|&n| n != prev && !used.contains(&edge(cur, n))) else { break };
|
||
used.insert(edge(cur, next));
|
||
pts.push(g.centre(next));
|
||
(prev, cur) = (cur, next);
|
||
}
|
||
let end = (node[cur] != usize::MAX).then_some(node[cur]);
|
||
if let (Some(e), Some(last)) = (end, pts.last_mut()) {
|
||
*last = centres[e];
|
||
}
|
||
runs.push(Run { pts, ends: [Some(node[p]), end], closed: false });
|
||
}
|
||
}
|
||
for &s in &skeleton {
|
||
if node[s] != usize::MAX || g.neighbours(s).any(|n| used.contains(&edge(s, n))) {
|
||
continue;
|
||
}
|
||
let mut pts = vec![g.centre(s)];
|
||
let (mut prev, mut cur) = (usize::MAX, s);
|
||
let mut closed = false;
|
||
while let Some(next) = g.neighbours(cur).find(|&n| n != prev && !used.contains(&edge(cur, n))) {
|
||
used.insert(edge(cur, next));
|
||
if next == s {
|
||
closed = true;
|
||
break;
|
||
}
|
||
pts.push(g.centre(next));
|
||
(prev, cur) = (cur, next);
|
||
}
|
||
runs.push(Run { pts, ends: [None, None], closed });
|
||
}
|
||
// The line's mean width: its pixels over its length (the skeleton stops about half a width
|
||
// short of each end).
|
||
let width = |runs: &[Run]| {
|
||
let length: f64 = runs.iter().map(Run::length).sum();
|
||
let px = comp.len() as f64;
|
||
((-length + (length * length + 4.0 * px).sqrt()) / 2.0).clamp(1.0, max_width.max(1.0))
|
||
};
|
||
let spur = width(&runs);
|
||
let runs = join(prune(runs, centres.len(), spur), centres.len());
|
||
let mut runs = runs;
|
||
let coarse = width(&runs);
|
||
let ink = Ink { img, color: ink, reach: (max_width / 2.0).ceil().clamp(1.0, 64.0) as i64 + 2 };
|
||
// How many runs end at each node: one is an end of the line.
|
||
let mut meet = vec![0usize; centres.len()];
|
||
for e in runs.iter().flat_map(|r| r.ends.iter().flatten()) {
|
||
meet[*e] += 1;
|
||
}
|
||
let (mut widths, mut soft, mut read) = (vec![], 0usize, 0usize);
|
||
for r in &mut runs {
|
||
let n = r.pts.len();
|
||
let old = r.pts.clone();
|
||
for i in 0..n {
|
||
// A junction stays where its pixels put it.
|
||
let end = if i == 0 {
|
||
r.ends[0]
|
||
} else if i + 1 == n {
|
||
r.ends[1]
|
||
} else {
|
||
None
|
||
};
|
||
if end.is_some_and(|e| meet[e] > 1) {
|
||
continue;
|
||
}
|
||
let (a, b) = if r.closed { (old[(i + n - 2 % n) % n], old[(i + 2) % n]) } else { (old[i.saturating_sub(2)], old[(i + 2).min(n - 1)]) };
|
||
let d = b - a;
|
||
if d.hypot() < 1e-9 {
|
||
continue;
|
||
}
|
||
let upright = d.x.abs() >= d.y.abs();
|
||
let Some(across) = ink.across(old[i], upright) else { continue };
|
||
r.pts[i] = old[i] + if upright { kurbo::Vec2::new(0.0, across.middle) } else { kurbo::Vec2::new(across.middle, 0.0) };
|
||
widths.push(across.width * if upright { d.x.abs() } else { d.y.abs() } / d.hypot());
|
||
read += 1;
|
||
soft += usize::from(across.soft);
|
||
}
|
||
}
|
||
// The stroke is as wide as the ink at the line's middle point (where no ink could be read, as
|
||
// wide as its pixels make it).
|
||
widths.sort_by(f64::total_cmp);
|
||
let width = widths.get(widths.len() / 2).copied().unwrap_or(coarse).clamp(0.25, max_width.max(1.0));
|
||
// Each end goes where the ink ends, less the half width its cap adds. The cap is square when
|
||
// the ends wide enough to tell are, taken together: the ink reaches as far a third of the width to either side
|
||
// of the line's middle as along it, where a round end stops short by a seventh of the width.
|
||
let (mut short_by, mut told) = (0.0, 0);
|
||
for r in runs.iter_mut().filter(|r| !r.closed && r.pts.len() >= 2) {
|
||
for k in 0..2 {
|
||
if r.ends[k].is_some_and(|e| meet[e] > 1) {
|
||
continue;
|
||
}
|
||
let n = r.pts.len();
|
||
let at = |i: usize| if k == 0 { i.min(n - 1) } else { n - 1 - i.min(n - 1) };
|
||
// The last points of a wide line's skeleton turn towards a corner of its end: the end
|
||
// lies where the line was going a width back, and they are put on that line.
|
||
let back = (width.round() as usize + 2).max(3);
|
||
let (tip, inner) = (r.pts[at(0)], r.pts[at(back)]);
|
||
// (Only an end that has left that line: over so few pixels the line's direction is known
|
||
// to a few degrees at best, less without anti-aliasing, and an end already on it is
|
||
// better left where it is.)
|
||
let before = inner - r.pts[at(2 * back)];
|
||
let wide = width >= 3.0 && n > 2 * back && before.cross(tip - inner).abs() > (0.25 * width).max(1.0) * before.hypot();
|
||
let d = if wide { before } else { tip - inner };
|
||
if d.hypot() < 1e-9 {
|
||
continue;
|
||
}
|
||
let along = d / d.hypot();
|
||
let on_line = |p: Point| inner + along * (p - inner).dot(along).max(0.0);
|
||
if wide {
|
||
for i in 0..back {
|
||
r.pts[at(i)] = on_line(r.pts[at(i)]);
|
||
}
|
||
}
|
||
let tip = on_line(tip);
|
||
let reach = ink.beyond(tip, along);
|
||
if width >= 3.0 {
|
||
let aside = kurbo::Vec2::new(-along.y, along.x) * (0.35 * width);
|
||
let short = reach - (ink.beyond(tip + aside, along) + ink.beyond(tip - aside, along)) / 2.0;
|
||
short_by += short / width;
|
||
told += 1;
|
||
}
|
||
let more = reach - width / 2.0;
|
||
if more.abs() > 0.05 && more > 1.0 - (tip - inner).hypot() {
|
||
r.pts[at(0)] = tip + along * more;
|
||
}
|
||
}
|
||
}
|
||
let mut style = *style;
|
||
if read > 0 && soft * 4 < read {
|
||
// Hard pixels: the middle is only known to about half a pixel (as for outlines).
|
||
style.tol *= 2.5;
|
||
style.line_tol *= 2.5;
|
||
style.sigma = 1.5;
|
||
}
|
||
let subs: Vec<_> = runs
|
||
.iter()
|
||
.filter_map(|r| {
|
||
if r.closed { fit::subpath(&fit::fit_closed(&r.pts, &style)?, true) } else { fit::subpath(&fit::fit_piece(&r.pts, &style, false), false) }
|
||
})
|
||
.collect();
|
||
if subs.is_empty() {
|
||
return None;
|
||
}
|
||
Some(Line { path: PathData::new(subs), width, pixels: comp.len(), square: told > 0 && short_by / f64::from(told) < 0.07 })
|
||
}
|
||
|
||
/// What the image says of a line's ink.
|
||
struct Ink<'a> {
|
||
img: &'a Raster,
|
||
/// The line's colour.
|
||
color: [f64; 3],
|
||
/// How far (pixels) from the skeleton the ink is looked for.
|
||
reach: i64,
|
||
}
|
||
|
||
/// The ink across a line at one point: where its middle lies from the point, how wide it is, and
|
||
/// whether its edges are soft (some pixel is part ink, part paper).
|
||
struct Across {
|
||
middle: f64,
|
||
width: f64,
|
||
soft: bool,
|
||
}
|
||
|
||
impl Ink<'_> {
|
||
/// How far the colour of pixel `(x, y)` is from the line's (`None` off the image).
|
||
fn far(&self, x: i64, y: i64) -> Option<f64> {
|
||
let (w, h) = (i64::from(self.img.width), i64::from(self.img.height));
|
||
if x < 0 || y < 0 || x >= w || y >= h {
|
||
return None;
|
||
}
|
||
let at = (y * w + x) as usize * 4;
|
||
let p = self.img.rgba.get(at..at + 3)?;
|
||
Some((0..3).map(|k| (f64::from(p[k]) - self.color[k]).powi(2)).sum::<f64>().sqrt())
|
||
}
|
||
/// How far the paper's colour is from the line's around pixel `(x, y)`: the farthest colour
|
||
/// within reach, along its row and its column.
|
||
fn paper(&self, x: i64, y: i64) -> f64 {
|
||
(-self.reach..=self.reach).flat_map(|k| [self.far(x + k, y), self.far(x, y + k)]).flatten().fold(0.0, f64::max)
|
||
}
|
||
/// The ink across the line through the pixel `p` lies in: along its column (`upright`) or row.
|
||
fn across(&self, p: Point, upright: bool) -> Option<Across> {
|
||
let (x, y) = (p.x.floor() as i64, p.y.floor() as i64);
|
||
let paper = self.paper(x, y);
|
||
// Paper too like the ink says nothing.
|
||
if paper < 24.0 {
|
||
return None;
|
||
}
|
||
let tone = |k: i64| (if upright { self.far(x, y + k) } else { self.far(x + k, y) }).map_or(0.0, |d| (1.0 - d / paper).clamp(0.0, 1.0));
|
||
// From the pixel with most ink near the skeleton's (thinning leaves it up to a pixel or two
|
||
// off the line's darkest row) out to where the ink ends, or rises again (another line).
|
||
let peak = [0, -1, 1, -2, 2].into_iter().fold(0, |best, k| if tone(k) > tone(best) + 1e-9 { k } else { best });
|
||
let here = tone(peak);
|
||
let (mut sum, mut moment, mut soft) = (here, here * peak as f64, here > 0.1 && here < 0.9);
|
||
for side in [-1, 1] {
|
||
let mut last = here;
|
||
for k in 1..=self.reach {
|
||
let t = tone(peak + side * k);
|
||
if t < 0.04 || t > last + 0.1 {
|
||
break;
|
||
}
|
||
sum += t;
|
||
moment += t * (peak + side * k) as f64;
|
||
soft |= t > 0.1 && t < 0.9;
|
||
last = t;
|
||
}
|
||
}
|
||
// The pixel's centre is at `p`'s own half-pixel; the middle is measured from `p`.
|
||
let centre = if upright { y as f64 + 0.5 - p.y } else { x as f64 + 0.5 - p.x };
|
||
(sum > 0.0).then(|| Across { middle: centre + moment / sum, width: sum, soft })
|
||
}
|
||
/// How much ink (pixels) lies beyond `tip`, going `dir` (a unit vector).
|
||
fn beyond(&self, tip: Point, dir: kurbo::Vec2) -> f64 {
|
||
let paper = self.paper(tip.x.floor() as i64, tip.y.floor() as i64);
|
||
if paper < 24.0 {
|
||
return 0.0;
|
||
}
|
||
let mut sum = 0.0;
|
||
for step in 1..=4 * (self.reach + 2) {
|
||
let q = tip + dir * (step as f64 * 0.25);
|
||
let t = self.far(q.x.floor() as i64, q.y.floor() as i64).map_or(0.0, |d| (1.0 - d / paper).clamp(0.0, 1.0));
|
||
if t < 0.04 {
|
||
break;
|
||
}
|
||
sum += 0.25 * t;
|
||
}
|
||
sum
|
||
}
|
||
}
|
||
|
||
/// The runs without the spurs thinning leaves: runs shorter than the line's `width` from a line end
|
||
/// to a junction that keeps at least two other runs.
|
||
fn prune(runs: Vec<Run>, nodes: usize, width: f64) -> Vec<Run> {
|
||
let mut degree = vec![0usize; nodes];
|
||
for e in runs.iter().flat_map(|r| r.ends.iter().flatten()) {
|
||
degree[*e] += 1;
|
||
}
|
||
let mut order: Vec<usize> = (0..runs.len()).collect();
|
||
order.sort_by(|a, b| runs[*a].length().total_cmp(&runs[*b].length()));
|
||
let mut keep = vec![true; runs.len()];
|
||
for i in order {
|
||
let r = &runs[i];
|
||
let [Some(a), Some(b)] = r.ends else { continue };
|
||
let (tip, fork) = if degree[a] == 1 { (a, b) } else { (b, a) };
|
||
if degree[tip] == 1 && degree[fork] >= 3 && r.length() < width.max(2.0) {
|
||
keep[i] = false;
|
||
degree[tip] -= 1;
|
||
degree[fork] -= 1;
|
||
}
|
||
}
|
||
runs.into_iter().zip(keep).filter_map(|(r, k)| k.then_some(r)).collect()
|
||
}
|
||
|
||
/// The runs joined through every node exactly two of them meet at (a run meeting itself closes).
|
||
fn join(runs: Vec<Run>, nodes: usize) -> Vec<Run> {
|
||
let mut runs: Vec<Option<Run>> = runs.into_iter().map(Some).collect();
|
||
for n in 0..nodes {
|
||
let at: Vec<(usize, usize)> = runs
|
||
.iter()
|
||
.enumerate()
|
||
.flat_map(|(i, r)| r.iter().flat_map(move |r| (0..2).filter(move |&k| r.ends[k] == Some(n)).map(move |k| (i, k))))
|
||
.collect();
|
||
let [(i, ki), (j, kj)] = at[..] else { continue };
|
||
if i == j {
|
||
if let Some(r) = runs[i].as_mut() {
|
||
r.pts.pop();
|
||
r.ends = [None, None];
|
||
r.closed = r.pts.len() >= 3;
|
||
}
|
||
continue;
|
||
}
|
||
let (Some(mut a), Some(mut b)) = (runs[i].take(), runs[j].take()) else { continue };
|
||
// a ends at n, b starts at n.
|
||
if ki == 0 {
|
||
a.pts.reverse();
|
||
a.ends.reverse();
|
||
}
|
||
if kj == 1 {
|
||
b.pts.reverse();
|
||
b.ends.reverse();
|
||
}
|
||
a.pts.extend(b.pts.into_iter().skip(1));
|
||
a.ends[1] = b.ends[1];
|
||
runs[i] = Some(a);
|
||
}
|
||
runs.into_iter().flatten().collect()
|
||
}
|