Merge pull request 'tools/sprite-silhouette: sprite alpha to CSS shape-outside polygon' (#8) from libby/sprite-silhouette into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
This commit is contained in:
commit
e57fbaee90
5 changed files with 453 additions and 0 deletions
68
tools/sprite-silhouette/README.md
Normal file
68
tools/sprite-silhouette/README.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# sprite-silhouette
|
||||
|
||||
Turns a sprite's alpha channel into a simplified polygon in percent coordinates, for CSS
|
||||
`shape-outside: polygon()`, so text wraps around the figure instead of its rectangle. Zero
|
||||
dependencies: Node (tested on 24; `node:zlib` does the PNG inflate). Entry point: `silhouette.mjs`.
|
||||
Live and tested; there is no packaging or release for it, it is a repo tool.
|
||||
|
||||
## First call
|
||||
|
||||
```
|
||||
node tools/sprite-silhouette/silhouette.mjs /opt/commons/cast-art/poses --out silhouettes.json
|
||||
node --test tools/sprite-silhouette/silhouette.test.mjs
|
||||
```
|
||||
|
||||
The first argument is a poses directory holding `catalogue.json` (the cast-art catalogue); the
|
||||
tool reads it and every PNG it names, and writes nothing there. Output is a JSON object keyed by
|
||||
pose id, `mind/label` in catalogue order (`void/neutral`, `eureka/probe`, ...):
|
||||
|
||||
```json
|
||||
{ "void/neutral": { "width": 605, "height": 461,
|
||||
"points": [[42.98, 6.72], [49.59, 8.24], "..."],
|
||||
"css": "polygon(42.98% 6.72%, 49.59% 8.24%, ...)" } }
|
||||
```
|
||||
|
||||
Use the `css` string as the value of `shape-outside` on a floated image of the same aspect ratio
|
||||
(percentages are of the image's box):
|
||||
|
||||
```css
|
||||
img.pose { float: left; shape-outside: polygon(42.98% 6.72%, 49.59% 8.24%, ...); shape-margin: 0.5em; }
|
||||
```
|
||||
|
||||
An empty sprite (nothing at or above the threshold) gets `points: []` and `css: "none"`.
|
||||
|
||||
From code: `silhouette(pngBuffer, { threshold, tolerance })` in `silhouette.mjs`.
|
||||
|
||||
## Options
|
||||
|
||||
- `--threshold 0.35`: a pixel is inside when `alpha/255 >= threshold` (0.35 is the Void mock-up's value).
|
||||
- `--tolerance 1.5`: Douglas-Peucker distance in source pixels; the simplified polygon stays within
|
||||
this of the traced edge in both directions (tested). 0 keeps every corner.
|
||||
- `--out file`: write there instead of stdout.
|
||||
|
||||
## What it does and does not trace
|
||||
|
||||
Threshold, then the outer boundary of the largest 8-connected shape, by walking the pixel edges
|
||||
(so vertices sit on pixel corners and the walk is exact), then simplification of that closed ring.
|
||||
Holes are ignored (wrapping text goes around a figure, not through its gaps), and smaller
|
||||
disconnected islands (a floating hand, a stray sparkle) are dropped: `shape-outside` takes one
|
||||
polygon. The contour is a staircase of pixel edges, so below about 1 px the tolerance only
|
||||
reproduces the staircase; there is no sub-pixel interpolation of the alpha ramp.
|
||||
|
||||
Input: 8-bit non-interlaced PNG (RGBA, grey+alpha, palette with tRNS, or opaque RGB/grey, which is
|
||||
a rectangle). Anything else is refused with its name.
|
||||
|
||||
## Prior art
|
||||
|
||||
- **Marching squares** (Lorensen and Cline's cubes, 2D case) interpolates along the alpha ramp for
|
||||
sub-pixel contours and one pass emits every contour, holes included. Not used: the pose art is
|
||||
anti-aliased over a pixel or two, a 1.5 px tolerance swallows that, and we want exactly one outer ring.
|
||||
- **Potrace** (Selinger, 2003) fits optimal polygons and then Bezier curves to a bitmap. CSS
|
||||
`polygon()` has no curves, so only its idea of an optimal polygon applies; this tool keeps the
|
||||
simpler greedy Douglas-Peucker and measures its error directly.
|
||||
- **Douglas-Peucker** (1973; Ramer 1972) is the simplifier: keep the vertex farthest from the chord
|
||||
if it is beyond the tolerance, recurse. Its guarantee (every dropped vertex within the tolerance
|
||||
of the result) is what the tests assert. It is not topology-preserving; on all 99 current poses at
|
||||
the defaults no output self-intersects (checked when this was written, not asserted by the suite).
|
||||
- **Boundary following** (Moore-neighbour / square tracing) is the contour walk, with the
|
||||
8-connected rule so diagonally touching pixels stay one shape.
|
||||
82
tools/sprite-silhouette/png.mjs
Normal file
82
tools/sprite-silhouette/png.mjs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// Minimal PNG reader: the alpha plane only. 8-bit, non-interlaced (every pose in cast-art is RGBA8).
|
||||
// Anything else is refused by name, never guessed at.
|
||||
import { inflateSync } from "node:zlib";
|
||||
|
||||
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
|
||||
|
||||
/** @returns {{ width: number, height: number, alpha: Uint8Array }} alpha is row-major, 0..255. */
|
||||
export function decodeAlpha(buffer) {
|
||||
if (buffer.length < 8 || !buffer.subarray(0, 8).equals(SIGNATURE)) throw new Error("not a PNG");
|
||||
let header = null;
|
||||
let palette = null;
|
||||
let trns = null;
|
||||
const idat = [];
|
||||
for (let at = 8; at + 8 <= buffer.length; ) {
|
||||
const length = buffer.readUInt32BE(at);
|
||||
const type = buffer.toString("latin1", at + 4, at + 8);
|
||||
const body = buffer.subarray(at + 8, at + 8 + length);
|
||||
if (body.length !== length) throw new Error(`truncated ${type} chunk`);
|
||||
at += 12 + length;
|
||||
if (type === "IHDR") {
|
||||
header = { width: body.readUInt32BE(0), height: body.readUInt32BE(4), depth: body[8], color: body[9], interlace: body[12] };
|
||||
} else if (type === "PLTE") palette = body;
|
||||
else if (type === "tRNS") trns = body;
|
||||
else if (type === "IDAT") idat.push(body);
|
||||
else if (type === "IEND") break;
|
||||
}
|
||||
if (!header) throw new Error("PNG has no IHDR");
|
||||
const { width, height, depth, color, interlace } = header;
|
||||
if (depth !== 8) throw new Error(`unsupported PNG bit depth ${depth} (8 only)`);
|
||||
if (interlace !== 0) throw new Error("unsupported PNG: interlaced");
|
||||
const channels = CHANNELS[color];
|
||||
if (!channels) throw new Error(`unsupported PNG color type ${color}`);
|
||||
if (color === 3 && !palette) throw new Error("indexed PNG has no PLTE");
|
||||
|
||||
const raw = inflateSync(Buffer.concat(idat));
|
||||
const stride = width * channels;
|
||||
if (raw.length !== (stride + 1) * height) throw new Error("PNG pixel data has the wrong length");
|
||||
|
||||
const alpha = new Uint8Array(width * height);
|
||||
let prev = new Uint8Array(stride);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const filter = raw[y * (stride + 1)];
|
||||
const line = Uint8Array.from(raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)));
|
||||
unfilter(filter, line, prev, channels);
|
||||
for (let x = 0; x < width; x++) {
|
||||
const p = x * channels;
|
||||
let a = 255;
|
||||
if (color === 6) a = line[p + 3];
|
||||
else if (color === 4) a = line[p + 1];
|
||||
else if (color === 3) a = trns && line[p] < trns.length ? trns[line[p]] : 255;
|
||||
else if (trns && color === 0 && line[p] === trns.readUInt16BE(0)) a = 0;
|
||||
else if (trns && color === 2 && line[p] === trns.readUInt16BE(0) && line[p + 1] === trns.readUInt16BE(2) && line[p + 2] === trns.readUInt16BE(4)) a = 0;
|
||||
alpha[y * width + x] = a;
|
||||
}
|
||||
prev = line;
|
||||
}
|
||||
return { width, height, alpha };
|
||||
}
|
||||
|
||||
function unfilter(filter, line, prev, bpp) {
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const a = i >= bpp ? line[i - bpp] : 0;
|
||||
const b = prev[i];
|
||||
const c = i >= bpp ? prev[i - bpp] : 0;
|
||||
let add;
|
||||
switch (filter) {
|
||||
case 0: add = 0; break;
|
||||
case 1: add = a; break;
|
||||
case 2: add = b; break;
|
||||
case 3: add = (a + b) >> 1; break;
|
||||
case 4: {
|
||||
const p = a + b - c;
|
||||
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
||||
add = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
||||
break;
|
||||
}
|
||||
default: throw new Error(`bad PNG filter ${filter}`);
|
||||
}
|
||||
line[i] = (line[i] + add) & 255;
|
||||
}
|
||||
}
|
||||
61
tools/sprite-silhouette/silhouette.mjs
Normal file
61
tools/sprite-silhouette/silhouette.mjs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
#!/usr/bin/env node
|
||||
// Sprite alpha silhouette -> simplified polygon in percent coordinates for CSS shape-outside: polygon().
|
||||
// First call: node tools/sprite-silhouette/silhouette.mjs /opt/commons/cast-art/poses > silhouettes.json
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { decodeAlpha } from "./png.mjs";
|
||||
import { largestContour, mask, simplifyRing } from "./trace.mjs";
|
||||
|
||||
export const DEFAULTS = { threshold: 0.35, tolerance: 1.5 };
|
||||
|
||||
const pct = (v, total) => Math.round((v / total) * 10000) / 100;
|
||||
|
||||
/** @returns {{ width: number, height: number, points: number[][], css: string }} points are [x%, y%]; css is "none" for an empty sprite. */
|
||||
export function silhouette(png, { threshold = DEFAULTS.threshold, tolerance = DEFAULTS.tolerance } = {}) {
|
||||
const { width, height, alpha } = decodeAlpha(png);
|
||||
const ring = largestContour(mask(alpha, threshold), width, height);
|
||||
if (!ring) return { width, height, points: [], css: "none" };
|
||||
const points = simplifyRing(ring, tolerance).map(([x, y]) => [pct(x, width), pct(y, height)]);
|
||||
const css = `polygon(${points.map(([x, y]) => `${x}% ${y}%`).join(", ")})`;
|
||||
return { width, height, points, css };
|
||||
}
|
||||
|
||||
/** Pose ids are `mind/label`, in catalogue order; the value is the path under the poses root. */
|
||||
export function poseFiles(catalogue) {
|
||||
const files = {};
|
||||
for (const [mind, entry] of Object.entries(catalogue.minds)) {
|
||||
for (const group of [entry.shared, entry.signatures]) {
|
||||
for (const [label, path] of Object.entries(group ?? {})) files[`${mind}/${label}`] = path;
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function silhouettes(root, options) {
|
||||
const catalogue = JSON.parse(readFileSync(join(root, "catalogue.json"), "utf8"));
|
||||
const out = {};
|
||||
for (const [id, path] of Object.entries(poseFiles(catalogue))) out[id] = silhouette(readFileSync(join(root, path)), options);
|
||||
return out;
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const options = { ...DEFAULTS };
|
||||
let out = null, root = null;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === "--threshold") options.threshold = Number(argv[++i]);
|
||||
else if (argv[i] === "--tolerance") options.tolerance = Number(argv[++i]);
|
||||
else if (argv[i] === "--out") out = argv[++i];
|
||||
else if (argv[i].startsWith("--")) throw new Error(`unknown option ${argv[i]}`);
|
||||
else root = argv[i];
|
||||
}
|
||||
if (!root || !(options.threshold > 0 && options.threshold <= 1) || !(options.tolerance >= 0)) {
|
||||
console.error("usage: silhouette.mjs <poses-dir> [--threshold 0.35] [--tolerance 1.5] [--out file.json]");
|
||||
process.exit(2);
|
||||
}
|
||||
const text = JSON.stringify(silhouettes(root, options), null, 1) + "\n";
|
||||
if (out) writeFileSync(out, text);
|
||||
else process.stdout.write(text);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main(process.argv.slice(2));
|
||||
133
tools/sprite-silhouette/silhouette.test.mjs
Normal file
133
tools/sprite-silhouette/silhouette.test.mjs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { existsSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { decodeAlpha } from "./png.mjs";
|
||||
import { silhouette, silhouettes } from "./silhouette.mjs";
|
||||
import { largestContour, mask, maxDistanceToRing, simplifyRing } from "./trace.mjs";
|
||||
|
||||
// An RGBA8 PNG built from an alpha function, every row encoded with the given PNG filter.
|
||||
function crc32(buf) {
|
||||
let c, crc = ~0;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
c = (crc ^ buf[i]) & 255;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xedb88320 : c >>> 1;
|
||||
crc = (crc >>> 8) ^ c;
|
||||
}
|
||||
return ~crc >>> 0;
|
||||
}
|
||||
function chunk(type, body) {
|
||||
const head = Buffer.alloc(8);
|
||||
head.writeUInt32BE(body.length, 0);
|
||||
head.write(type, 4, "latin1");
|
||||
const tail = Buffer.alloc(4);
|
||||
tail.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), body])), 0);
|
||||
return Buffer.concat([head, body, tail]);
|
||||
}
|
||||
function png(width, height, alphaAt, { filter = 0 } = {}) {
|
||||
const rows = [];
|
||||
let prev = Buffer.alloc(width * 4);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const line = Buffer.alloc(width * 4);
|
||||
for (let x = 0; x < width; x++) line.set([200, 10 * (x % 20), 30, alphaAt(x, y)], x * 4);
|
||||
const out = Buffer.alloc(width * 4 + 1);
|
||||
out[0] = filter;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const a = i >= 4 ? line[i - 4] : 0, b = prev[i], c = i >= 4 ? prev[i - 4] : 0;
|
||||
const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
||||
const pred = [0, a, b, (a + b) >> 1, pa <= pb && pa <= pc ? a : pb <= pc ? b : c][filter];
|
||||
out[i + 1] = (line[i] - pred) & 255;
|
||||
}
|
||||
rows.push(out);
|
||||
prev = line;
|
||||
}
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; ihdr[9] = 6;
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk("IHDR", ihdr), chunk("IDAT", deflateSync(Buffer.concat(rows))), chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
const disc = (cx, cy, r) => (x, y) => ((x + 0.5 - cx) ** 2 + (y + 0.5 - cy) ** 2 <= r * r ? 255 : 0);
|
||||
|
||||
test("decodes alpha through every PNG row filter", () => {
|
||||
const f = (x, y) => (x * 7 + y * 13) % 256;
|
||||
for (const filter of [0, 1, 2, 3, 4]) {
|
||||
const { width, height, alpha } = decodeAlpha(png(9, 7, f, { filter }));
|
||||
assert.deepEqual([width, height], [9, 7]);
|
||||
for (let y = 0; y < 7; y++) for (let x = 0; x < 9; x++) assert.equal(alpha[y * 9 + x], f(x, y), `filter ${filter} at ${x},${y}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("refuses what it cannot read", () => {
|
||||
assert.throws(() => decodeAlpha(Buffer.from("nope")), /not a PNG/);
|
||||
const bad = png(2, 2, () => 255);
|
||||
bad[24] = 16; // IHDR bit depth
|
||||
assert.throws(() => decodeAlpha(bad), /bit depth 16/);
|
||||
});
|
||||
|
||||
test("a fully transparent sprite has no shape", () => {
|
||||
const s = silhouette(png(8, 8, () => 0));
|
||||
assert.deepEqual(s.points, []);
|
||||
assert.equal(s.css, "none");
|
||||
});
|
||||
|
||||
test("a fully opaque sprite is its own bounding box", () => {
|
||||
const s = silhouette(png(8, 4, () => 255));
|
||||
assert.deepEqual(s.points, [[0, 0], [100, 0], [100, 100], [0, 100]]);
|
||||
assert.equal(s.css, "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)");
|
||||
});
|
||||
|
||||
test("threshold is alpha/255 >= 0.35: 89 is out, 90 is in", () => {
|
||||
assert.deepEqual(silhouette(png(4, 4, () => 89)).points, []);
|
||||
assert.equal(silhouette(png(4, 4, () => 90)).points.length, 4);
|
||||
assert.deepEqual(silhouette(png(4, 4, () => 89), { threshold: 0.3 }).points.length, 4);
|
||||
});
|
||||
|
||||
test("simplified shape stays within tolerance of the traced edge, both ways, and is smaller", () => {
|
||||
const { width, height, alpha } = decodeAlpha(png(120, 100, disc(60, 50, 40)));
|
||||
const ring = largestContour(mask(alpha, 0.35), width, height);
|
||||
for (const tolerance of [0.5, 1.5, 4]) {
|
||||
const simple = simplifyRing(ring, tolerance);
|
||||
assert.ok(simple.length < ring.length && simple.length >= 4);
|
||||
assert.ok(maxDistanceToRing(ring, simple) <= tolerance + 1e-9, `ring to simplified at ${tolerance}`);
|
||||
assert.ok(maxDistanceToRing(simple, ring) <= tolerance + 1e-9, `simplified to ring at ${tolerance}`);
|
||||
}
|
||||
// And the traced edge is the true edge to within a pixel: every ring vertex sits within 1px of the disc's circle.
|
||||
for (const [x, y] of ring) assert.ok(Math.abs(Math.hypot(x - 60, y - 50) - 40) <= 1.0, `${x},${y}`);
|
||||
});
|
||||
|
||||
test("only the outside is traced: a hole is ignored", () => {
|
||||
const ring = (x, y) => (Math.max(Math.abs(x - 10), Math.abs(y - 10)) <= 8 && Math.max(Math.abs(x - 10), Math.abs(y - 10)) > 3 ? 255 : 0);
|
||||
const s = silhouette(png(20, 20, ring), { tolerance: 0 });
|
||||
const xs = s.points.map((p) => p[0]), ys = s.points.map((p) => p[1]);
|
||||
assert.deepEqual([Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)], [10, 95, 10, 95]);
|
||||
assert.equal(s.points.length, 4);
|
||||
});
|
||||
|
||||
test("pixels that only touch diagonally are one shape; a smaller island is dropped", () => {
|
||||
const dots = (x, y) => (x === y && x < 4 ? 255 : 0);
|
||||
const diag = silhouette(png(8, 8, dots), { tolerance: 0 });
|
||||
const xs = diag.points.map((p) => p[0]);
|
||||
assert.equal(Math.max(...xs), 50); // x 0..4 of 8
|
||||
const island = (x, y) => ((x < 5 && y < 5) || (x === 8 && y === 8) ? 255 : 0);
|
||||
const s = silhouette(png(10, 10, island));
|
||||
assert.deepEqual(s.points, [[0, 0], [50, 0], [50, 50], [0, 50]]);
|
||||
});
|
||||
|
||||
test("deterministic: same bytes, same polygon; coordinates are percent within the image", () => {
|
||||
const bytes = png(90, 70, disc(45, 35, 30));
|
||||
const a = silhouette(bytes), b = silhouette(Buffer.from(bytes));
|
||||
assert.deepEqual(a, b);
|
||||
for (const [x, y] of a.points) assert.ok(x >= 0 && x <= 100 && y >= 0 && y <= 100);
|
||||
assert.match(a.css, /^polygon\((\d+(\.\d+)?% \d+(\.\d+)?%)(, \d+(\.\d+)?% \d+(\.\d+)?%){3,}\)$/);
|
||||
});
|
||||
|
||||
const POSES = "/opt/commons/cast-art/poses";
|
||||
test("every pose in the shared cast-art catalogue gets a polygon", { skip: !existsSync(`${POSES}/catalogue.json`) }, () => {
|
||||
const all = silhouettes(POSES);
|
||||
const ids = Object.keys(all);
|
||||
assert.ok(ids.length > 90 && ids.includes("void/neutral"));
|
||||
for (const id of ids) assert.ok(all[id].points.length >= 4 && all[id].points.length < 400, id);
|
||||
assert.deepEqual(silhouettes(POSES), all);
|
||||
});
|
||||
109
tools/sprite-silhouette/trace.mjs
Normal file
109
tools/sprite-silhouette/trace.mjs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// Silhouette of an alpha plane: threshold -> outer contour of the largest 8-connected shape -> Douglas-Peucker.
|
||||
// Coordinates are pixel-corner lattice points: x in 0..width, y in 0..height.
|
||||
|
||||
/** A pixel is inside when alpha/255 >= threshold. */
|
||||
export function mask(alpha, threshold) {
|
||||
const m = new Uint8Array(alpha.length);
|
||||
for (let i = 0; i < alpha.length; i++) m[i] = alpha[i] / 255 >= threshold ? 1 : 0;
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Outer contour (lattice vertices at turns, clockwise on screen) of the 8-connected component with the most pixels, or null. */
|
||||
export function largestContour(m, width, height) {
|
||||
const seen = new Uint8Array(m.length);
|
||||
let best = null;
|
||||
for (let start = 0; start < m.length; start++) {
|
||||
if (!m[start] || seen[start]) continue;
|
||||
// Raster order: `start` is the component's first pixel, so the pixels above and up-left of it are empty
|
||||
// or belong to another component.
|
||||
let count = 0;
|
||||
const stack = [start];
|
||||
seen[start] = 1;
|
||||
while (stack.length) {
|
||||
const p = stack.pop();
|
||||
count++;
|
||||
const px = p % width, py = (p / width) | 0;
|
||||
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
||||
const x = px + dx, y = py + dy;
|
||||
if (x < 0 || y < 0 || x >= width || y >= height) continue;
|
||||
const q = y * width + x;
|
||||
if (m[q] && !seen[q]) { seen[q] = 1; stack.push(q); }
|
||||
}
|
||||
}
|
||||
if (!best || count > best.count) best = { count, start };
|
||||
}
|
||||
return best && traceOuter(m, width, height, best.start % width, (best.start / width) | 0);
|
||||
}
|
||||
|
||||
// Directions: 0 east, 1 south, 2 west, 3 north. The shape is kept on the walker's right, so an
|
||||
// outer boundary comes out clockwise; at each corner a filled left-ahead pixel turns left (which
|
||||
// joins pixels that only touch diagonally), else a filled right-ahead pixel goes straight, else right.
|
||||
const DX = [1, 0, -1, 0], DY = [0, 1, 0, -1];
|
||||
const AHEAD_LEFT = [[0, -1], [0, 0], [-1, 0], [-1, -1]];
|
||||
const AHEAD_RIGHT = [[0, 0], [-1, 0], [-1, -1], [0, -1]];
|
||||
|
||||
function traceOuter(m, width, height, px, py) {
|
||||
const at = (x, y) => x >= 0 && y >= 0 && x < width && y < height && m[y * width + x] === 1;
|
||||
const vertices = [];
|
||||
let x = px, y = py, dir = 3, first = true; // arrive at the start corner heading north, so it is counted as a turn
|
||||
for (;;) {
|
||||
const left = at(x + AHEAD_LEFT[dir][0], y + AHEAD_LEFT[dir][1]);
|
||||
const right = at(x + AHEAD_RIGHT[dir][0], y + AHEAD_RIGHT[dir][1]);
|
||||
const turn = left ? 3 : right ? 0 : 1;
|
||||
const next = (dir + turn) & 3;
|
||||
// Back at the start corner and about to leave east: the ring is closed.
|
||||
if (!first && x === px && y === py && next === 0) break;
|
||||
first = false;
|
||||
if (turn !== 0) vertices.push([x, y]);
|
||||
dir = next;
|
||||
x += DX[dir]; y += DY[dir];
|
||||
}
|
||||
return vertices;
|
||||
}
|
||||
|
||||
/** Douglas-Peucker on a closed ring: split at the vertex farthest from the first, simplify both halves. */
|
||||
export function simplifyRing(ring, tolerance) {
|
||||
if (ring.length <= 3 || tolerance <= 0) return ring.slice();
|
||||
let far = 0, best = -1;
|
||||
for (let i = 1; i < ring.length; i++) {
|
||||
const d = (ring[i][0] - ring[0][0]) ** 2 + (ring[i][1] - ring[0][1]) ** 2;
|
||||
if (d > best) { best = d; far = i; }
|
||||
}
|
||||
const a = dp(ring.slice(0, far + 1), tolerance);
|
||||
const b = dp(ring.slice(far).concat([ring[0]]), tolerance);
|
||||
return a.slice(0, -1).concat(b.slice(0, -1));
|
||||
}
|
||||
|
||||
function dp(points, tolerance) {
|
||||
const keep = new Uint8Array(points.length);
|
||||
keep[0] = keep[points.length - 1] = 1;
|
||||
const stack = [[0, points.length - 1]];
|
||||
while (stack.length) {
|
||||
const [lo, hi] = stack.pop();
|
||||
let worst = -1, at = -1;
|
||||
for (let i = lo + 1; i < hi; i++) {
|
||||
const d = distanceToSegment(points[i], points[lo], points[hi]);
|
||||
if (d > worst) { worst = d; at = i; }
|
||||
}
|
||||
if (worst > tolerance) { keep[at] = 1; stack.push([lo, at], [at, hi]); }
|
||||
}
|
||||
return points.filter((_, i) => keep[i]);
|
||||
}
|
||||
|
||||
export function distanceToSegment(p, a, b) {
|
||||
const vx = b[0] - a[0], vy = b[1] - a[1];
|
||||
const len2 = vx * vx + vy * vy;
|
||||
const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((p[0] - a[0]) * vx + (p[1] - a[1]) * vy) / len2));
|
||||
return Math.hypot(p[0] - (a[0] + t * vx), p[1] - (a[1] + t * vy));
|
||||
}
|
||||
|
||||
/** Largest distance from any point of ring `a`'s edges' vertices to ring `b`'s edges. */
|
||||
export function maxDistanceToRing(points, ring) {
|
||||
let worst = 0;
|
||||
for (const p of points) {
|
||||
let d = Infinity;
|
||||
for (let i = 0; i < ring.length; i++) d = Math.min(d, distanceToSegment(p, ring[i], ring[(i + 1) % ring.length]));
|
||||
worst = Math.max(worst, d);
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
Loading…
Reference in a new issue