Files
learnharness/components/region-contour.ts
T
amosblomqvist c7c1148989 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).
2026-09-13 16:00:28 +03:00

224 lines
7.7 KiB
TypeScript

/**
* REGION BOUNDARIES (DECIDED 2026-09-13): iso-lines of the blurred
* region field — "draw a line along a constant luminance of the blurred
* background color". Each cluster's boundary is the contour of ITS OWN
* contribution to the field, recovered by projecting the blurred
* PREMULTIPLIED bitmap onto the cluster's paint color (a pixel painted
* only by cluster i projects to its alpha; a blend between two clusters
* splits proportionally — the projection is the natural divider where
* neighboring halos overlap). ONE paint pass, ONE blur, ONE readback;
* the per-cluster work is pure typed-array math over small crops.
*
* Pure module (no DOM, no Path2D) so it is unit-testable in node.
*/
/** HSL → RGB (0-255 each), the same conversion the canvas performs for
* hsla() paint colors — the projection must divide by exactly what was
* painted. h in degrees, s and l in 0-1. */
export function hslToRgb(h: number, s: number, l: number): [number, number, number] {
const c = (1 - Math.abs(2 * l - 1)) * s;
const hp = (((h % 360) + 360) % 360) / 60;
const x = c * (1 - Math.abs((hp % 2) - 1));
let r = 0;
let g = 0;
let b = 0;
if (hp < 1) [r, g, b] = [c, x, 0];
else if (hp < 2) [r, g, b] = [x, c, 0];
else if (hp < 3) [r, g, b] = [0, c, x];
else if (hp < 4) [r, g, b] = [0, x, c];
else if (hp < 5) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];
const m = l - c / 2;
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
}
/**
* Project a crop of the blurred premultiplied bitmap onto a paint color:
* field(p) = ⟨pm(p), rgb⟩ / |rgb|² — the fraction of pixel p's
* premultiplied color that lies along the cluster's paint direction.
* Pure-i pixels → their alpha fraction (0-1); blends split between the
* contributors. Returns the crop field and its peak (for the relative
* threshold).
*
* `stride` samples every Nth bitmap pixel (default 1): the blurred field
* varies on a ~blur-radius scale, so a stride-2 grid loses nothing and
* quarters the contour work (2026-09-13: stride-1 doubled the rebuild
* cost and broke the 120fps drag cadence every 6th frame; stride-2
* contouring is measured ~1ms for the whole boundary stage).
*/
export function projectCrop(
pm: Float32Array,
pmW: number,
crop: { x: number; y: number; w: number; h: number },
rgb: readonly [number, number, number],
stride = 1,
): { field: Float32Array; peak: number } {
const [r, g, b] = rgb;
const denom = r * r + g * g + b * b;
const fw = Math.ceil(crop.w / stride);
const fh = Math.ceil(crop.h / stride);
if (denom <= 0) return { field: new Float32Array(fw * fh), peak: 0 };
const field = new Float32Array(fw * fh);
let peak = 0;
for (let y = 0; y < fh; y++) {
const src = ((crop.y + y * stride) * pmW + crop.x) * 4;
const dst = y * fw;
for (let x = 0; x < fw; x++) {
const i = src + x * stride * 4;
const v = (pm[i]! * r + pm[i + 1]! * g + pm[i + 2]! * b) / denom;
field[dst + x] = v;
if (v > peak) peak = v;
}
}
return { field, peak };
}
/**
* Marching squares over a scalar field: emits iso-line SEGMENTS (grid
* coordinates, linear interpolation between samples) where the field
* crosses `level`. Flat output [x1,y1,x2,y2, ...] — join with
* joinContourSegments. Saddle cases (5, 10) are disambiguated by the
* cell-center average. Grid coords: sample (x, y) is field[y*w + x];
* a segment point may be fractional (between samples).
*/
export function marchingSquares(
field: Float32Array,
w: number,
h: number,
level: number,
): number[] {
// Hot loop (measured ~7ms over ~210k cells when the crossing helpers
// were per-cell closures — allocation dominated). All crossings are
// computed inline; zero allocations per empty cell.
const segments: number[] = [];
for (let y = 0; y < h - 1; y++) {
const row = y * w;
const rowBelow = row + w;
for (let x = 0; x < w - 1; x++) {
const tl = field[row + x]!;
const tr = field[row + x + 1]!;
const br = field[rowBelow + x + 1]!;
const bl = field[rowBelow + x]!;
const idx =
(tl >= level ? 8 : 0) | (tr >= level ? 4 : 0) | (br >= level ? 2 : 0) | (bl >= level ? 1 : 0);
if (idx === 0 || idx === 15) continue;
// Crossing points on the four cell edges (grid coords,
// linear interpolation between the two corner samples).
const topX = x + (level - tl) / (tr - tl);
const rightY = y + (level - tr) / (br - tr);
const bottomX = x + (level - bl) / (br - bl);
const leftY = y + (level - tl) / (bl - tl);
switch (idx) {
case 1:
case 14:
segments.push(x, leftY, bottomX, y + 1);
break;
case 2:
case 13:
segments.push(bottomX, y + 1, x + 1, rightY);
break;
case 3:
case 12:
segments.push(x, leftY, x + 1, rightY);
break;
case 4:
case 11:
segments.push(topX, y, x + 1, rightY);
break;
case 6:
case 9:
segments.push(topX, y, bottomX, y + 1);
break;
case 7:
case 8:
segments.push(topX, y, x, leftY);
break;
case 5:
// Saddle: tr+bl inside. Center decides connected vs split.
if ((tl + tr + br + bl) / 4 >= level) {
segments.push(topX, y, x, leftY, bottomX, y + 1, x + 1, rightY);
} else {
segments.push(topX, y, x + 1, rightY, x, leftY, bottomX, y + 1);
}
break;
case 10:
// Saddle: tl+br inside.
if ((tl + tr + br + bl) / 4 >= level) {
segments.push(topX, y, x + 1, rightY, x, leftY, bottomX, y + 1);
} else {
segments.push(topX, y, x, leftY, bottomX, y + 1, x + 1, rightY);
}
break;
}
}
}
return segments;
}
/**
* Join iso-line segments into POLYLINES: chains of connected segments,
* closed loops where the contour is closed (first point == last point).
* Endpoints are quantized — marching-squares crossings on a shared cell
* edge are bit-identical from both cells, so any deterministic
* quantization matches them exactly. Output: one flat [x,y,x,y,...] array
* per polyline.
*/
export function joinContourSegments(segments: readonly number[]): number[][] {
const key = (x: number, y: number): string => `${Math.round(x * 1024)},${Math.round(y * 1024)}`;
// point key -> [{seg index, which end}]
const at = new Map<string, Array<{ s: number; end: 0 | 1 }>>();
const n = segments.length / 4;
for (let s = 0; s < n; s++) {
const x1 = segments[s * 4]!;
const y1 = segments[s * 4 + 1]!;
const x2 = segments[s * 4 + 2]!;
const y2 = segments[s * 4 + 3]!;
const k1 = key(x1, y1);
const k2 = key(x2, y2);
let l1 = at.get(k1);
if (!l1) at.set(k1, (l1 = []));
l1.push({ s, end: 0 });
let l2 = at.get(k2);
if (!l2) at.set(k2, (l2 = []));
l2.push({ s, end: 1 });
}
const used = new Array<boolean>(n).fill(false);
const polylines: number[][] = [];
const takeNeighbor = (x: number, y: number, seg: number): { s: number; x: number; y: number } | null => {
const list = at.get(key(x, y));
if (!list) return null;
for (const e of list) {
if (used[e.s]) continue;
used[e.s] = true;
return {
s: e.s,
x: segments[e.s * 4 + (e.end === 0 ? 2 : 0)]!,
y: segments[e.s * 4 + (e.end === 0 ? 3 : 1)]!,
};
}
return null;
};
for (let s0 = 0; s0 < n; s0++) {
if (used[s0]) continue;
used[s0] = true;
const line = [segments[s0 * 4]!, segments[s0 * 4 + 1]!, segments[s0 * 4 + 2]!, segments[s0 * 4 + 3]!];
// Extend at the tail, then at the head.
for (let guard = 0; guard < 2 * n + 4; guard++) {
const tx = line[line.length - 2]!;
const ty = line[line.length - 1]!;
const nb = takeNeighbor(tx, ty, -1);
if (!nb) break;
line.push(nb.x, nb.y);
}
for (let guard = 0; guard < 2 * n + 4; guard++) {
const hx = line[0]!;
const hy = line[1]!;
const nb = takeNeighbor(hx, hy, -1);
if (!nb) break;
line.unshift(nb.x, nb.y);
}
polylines.push(line);
}
return polylines;
}