Region boundaries: iso-line contours of the blurred region field

Marching squares over each cluster's own contribution to the shared
blurred bitmap, recovered by projecting premultiplied pixels onto the
cluster's paint color (blends split proportionally between overlapping
halos — the natural divider). Boundary level = fraction of the
cluster's peak field, user-adjustable in the settings panel; 0 = off.

One paint, one blur, one readback; contouring is cheap typed-array
math over small per-cluster crops (stride-2 sampling: measured 1.2ms
for all 26 clusters, vs 7.3ms naive). During the live sim boundaries
recompute every 2nd region rebuild and stale paths ride along — drag
cadence matches the pre-boundary baseline (avg 8.46ms vs 8.37ms).
This commit is contained in:
amosblomqvist
2026-09-13 16:00:28 +03:00
parent 652a25e382
commit c7c1148989
4 changed files with 507 additions and 3 deletions
+168
View File
@@ -0,0 +1,168 @@
/**
* Region boundary primitives (components/region-contour.ts): marching
* squares, segment joining, and the color projection that recovers each
* cluster's own field from the shared blurred bitmap. Pure typed-array
* math — exact, deterministic tests.
*/
import { strict as assert } from "node:assert";
import { describe, test } from "node:test";
import { hslToRgb, joinContourSegments, marchingSquares, projectCrop } from "../components/region-contour.ts";
describe("hslToRgb", () => {
test("matches the canvas hsla() conversion at the region paint points", () => {
// hsla(h, 65%, 60%) — the exact paint colors buildRegions uses.
assert.deepEqual(hslToRgb(0, 0.65, 0.6), [219, 87, 87]); // red-ish
assert.deepEqual(hslToRgb(120, 0.65, 0.6), [87, 219, 87]); // green-ish
assert.deepEqual(hslToRgb(240, 0.65, 0.6), [87, 87, 219]); // blue-ish
// Gray: zero saturation.
assert.deepEqual(hslToRgb(37, 0, 0.6), [153, 153, 153]);
// Hue wraps.
assert.deepEqual(hslToRgb(360, 0.65, 0.6), hslToRgb(0, 0.65, 0.6));
});
});
describe("marchingSquares", () => {
/** A smooth radial bump on a w×h grid. */
function bump(w: number, h: number, cx: number, cy: number, radius: number): Float32Array {
const f = new Float32Array(w * h);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const d = Math.hypot(x - cx, y - cy);
f[y * w + x] = Math.max(0, 1 - d / radius);
}
}
return f;
}
test("a single blob yields one closed loop", () => {
// Irrational center offsets: no grid sample sits EXACTLY on the
// iso-line (a tie makes a crossing land on a sample point, which
// can split the loop into two stroked halves — visually identical,
// but this test wants the clean case).
const w = 21;
const f = bump(w, 21, 10.4, 9.7, 8);
const segs = marchingSquares(f, w, 21, 0.5);
assert.ok(segs.length >= 16, "a circle has plenty of segments");
const lines = joinContourSegments(segs);
assert.equal(lines.length, 1, "exactly one loop");
const l = lines[0]!;
// Closed: first point == last point.
assert.equal(l[0], l[l.length - 2]);
assert.equal(l[1], l[l.length - 1]);
// Roughly the right radius: the iso-line of 1 - d/r = 0.5 is d = r/2 = 4.
const meanR =
l.filter((_, i) => i % 2 === 0 && i < l.length - 2).length === 0
? 0
: l.reduce((acc, _, i) => (i % 4 === 0 && i + 1 < l.length ? Math.hypot(l[i]! - 10, l[i + 1]! - 10) + acc : acc), 0) /
(l.filter((_, i) => i % 4 === 0 && i + 1 < l.length).length || 1);
assert.ok(Math.abs(meanR - 4) < 1.5, `iso-line radius ≈ 4 (got ${meanR.toFixed(2)})`);
});
test("two separated blobs yield two loops", () => {
const w = 40;
const f = bump(w, 21, 10, 10, 7);
const g = bump(w, 21, 30, 10, 7);
const both = f.map((v, i) => Math.max(v, g[i]!));
const lines = joinContourSegments(marchingSquares(both, w, 21, 0.5));
assert.equal(lines.length, 2);
});
test("a ramp clipped by the grid edge yields an OPEN polyline", () => {
const w = 16;
const f = new Float32Array(w * 16);
for (let y = 0; y < 16; y++) for (let x = 0; x < w; x++) f[y * w + x] = x / w;
const lines = joinContourSegments(marchingSquares(f, w, 16, 0.5));
assert.equal(lines.length, 1);
const l = lines[0]!;
assert.notEqual(l[1], l[l.length - 1], "open: first y != last y");
// The iso-line runs vertically at x = 8.
assert.ok(l.every((_, i) => i % 2 === 0 && Math.abs(l[i]! - 8) < 0.01 || i % 2 === 1));
});
test("level above the peak: no segments; level at 0 on an empty field: none", () => {
const f = bump(10, 10, 5, 5, 4);
assert.equal(marchingSquares(f, 10, 10, 1.5).length, 0);
assert.equal(marchingSquares(new Float32Array(100), 10, 10, 0.5).length, 0);
});
test("saddle cases produce consistent, joinable segments", () => {
// A 2x2 field with tl+br inside (case 10) — must not crash, must
// yield segments whose endpoints all pair up.
const f = new Float32Array([1, 0, 0, 1]);
const segs = marchingSquares(f, 2, 2, 0.5);
assert.ok(segs.length >= 8, "two segments (4 numbers each)");
const lines = joinContourSegments(segs);
for (const l of lines) assert.equal(l.length % 2, 0);
});
});
describe("joinContourSegments", () => {
test("chains collinear segments in order, closed loops close", () => {
// A unit square contour: 4 segments.
const segs = [0, 0, 1, 0, 1, 0, 1, 1, 1, 1, 0, 1, 0, 1, 0, 0];
const lines = joinContourSegments(segs);
assert.equal(lines.length, 1);
const l = lines[0]!;
assert.equal(l[0], l[l.length - 2]);
assert.equal(l[1], l[l.length - 1]);
// All four corners present.
for (const [cx, cy] of [[0, 0], [1, 0], [1, 1], [0, 1]] as const) {
assert.ok(l.some((_, i) => i % 2 === 0 && l[i] === cx && l[i + 1] === cy), `corner ${cx},${cy}`);
}
});
test("disconnected segments stay separate polylines", () => {
const lines = joinContourSegments([0, 0, 1, 1, 5, 5, 6, 6]);
assert.equal(lines.length, 2);
});
});
describe("projectCrop", () => {
test("pure-color pixels project to their alpha; blends split", () => {
// Paint color: pure red (255, 0, 0). Premultiplied bitmap (RGBA).
const pmW = 4;
const pm = new Float32Array(4 * 3 * 4); // 4x3 pixels
const set = (x: number, y: number, r: number, g: number, b: number, a: number): void => {
const i = (y * pmW + x) * 4;
pm[i] = r * a;
pm[i + 1] = g * a;
pm[i + 2] = b * a;
pm[i + 3] = a * 255;
};
set(0, 0, 255, 0, 0, 0.4); // pure red at alpha 0.4 → 0.4
set(1, 0, 255, 0, 0, 1.0); // opaque red → 1
set(2, 0, 0, 255, 0, 0.8); // pure green → 0 (orthogonal)
const { field, peak } = projectCrop(pm, pmW, { x: 0, y: 0, w: 3, h: 1 }, [255, 0, 0]);
assert.ok(Math.abs(field[0]! - 0.4) < 1e-6);
assert.ok(Math.abs(field[1]! - 1) < 1e-6);
assert.ok(Math.abs(field[2]!) < 1e-6);
assert.ok(Math.abs(peak - 1) < 1e-6);
// A 50/50 red/green blend at alpha 1 splits into 0.5 each.
set(3, 0, 128, 128, 0, 1);
const { field: f2 } = projectCrop(pm, pmW, { x: 3, y: 0, w: 1, h: 1 }, [255, 0, 0]);
assert.ok(Math.abs(f2[0]! - 128 / 255) < 0.01, `blend splits (got ${f2[0]!.toFixed(3)})`);
});
test("stride 2: samples every other pixel, same values, quarter size", () => {
// 8x1 crop of pure red pixels at varying alpha.
const pmW = 8;
const pm = new Float32Array(8 * 4);
for (let x = 0; x < 8; x++) {
const i = x * 4;
pm[i] = 255 * (x / 14); // premultiplied alpha fraction
pm[i + 3] = 255;
}
const { field, peak } = projectCrop(pm, pmW, { x: 0, y: 0, w: 8, h: 1 }, [255, 0, 0], 2);
assert.equal(field.length, 4); // ceil(8/2)
assert.ok(Math.abs(field[0]! - 0) < 1e-6);
assert.ok(Math.abs(field[1]! - 2 / 14) < 1e-6); // sample gx=1 → pixel x=2
assert.ok(Math.abs(field[2]! - 4 / 14) < 1e-6); // sample gx=2 → pixel x=4
assert.ok(Math.abs(peak - 6 / 14) < 1e-6); // max over SAMPLED pixels (x=0,2,4,6)
});
test("zero paint color: empty field, peak 0", () => {
const { field, peak } = projectCrop(new Float32Array(16), 2, { x: 0, y: 0, w: 2, h: 2 }, [0, 0, 0]);
assert.equal(field.length, 4);
assert.equal(peak, 0);
});
});