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:
+110
-3
@@ -54,6 +54,7 @@ import { select } from "d3-selection";
|
||||
import { zoom, zoomIdentity, type ZoomBehavior, type ZoomTransform } from "d3-zoom";
|
||||
import type { Simulation, SimulationLinkDatum } from "d3-force";
|
||||
import { clusterHue, type Pt } from "./cluster-geometry.ts";
|
||||
import { hslToRgb, joinContourSegments, marchingSquares, projectCrop } from "./region-contour.ts";
|
||||
import {
|
||||
CHARGE_STRENGTH,
|
||||
CLUSTER_PULL_STRENGTH,
|
||||
@@ -159,6 +160,15 @@ const COMMIT_DEBOUNCE_MS = 250; // settle → ledger PUT delay (new drags cancel
|
||||
* offset polygons re-rasterized at DPR 2 (the old GPU coil-whine). */
|
||||
const REGION_EVERY = 3; // live-sim frames: rebuild every Nth, blit the rest
|
||||
const REGION_LABEL_SIZE = 14;
|
||||
/** Region REBUILDS between boundary recomputes during the live sim: the
|
||||
* boundary stage (projection + marching squares over ~26 crops) costs
|
||||
* about as much as the whole fill pipeline, so it runs on every 2nd
|
||||
* rebuild (~6th frame) and the stale paths ride along — at rest it
|
||||
* always syncs with the fill. */
|
||||
const BOUNDARY_EVERY = 2;
|
||||
/** Boundary stroke width, screen px at fit zoom (world units = this / kFit,
|
||||
* so it zooms with the graph like every other stroke). */
|
||||
const REGION_BOUNDARY_WIDTH = 1.5;
|
||||
|
||||
/** Dev-tunable region rendering (2026-09-13, user request): plain number
|
||||
* inputs in the settings panel — session-only, never persisted — so the
|
||||
@@ -178,6 +188,10 @@ export interface RegionTuning {
|
||||
bitmap: number;
|
||||
/** Per-stroke alpha; overlapping strokes stack toward the core. */
|
||||
edgeAlpha: number;
|
||||
/** Boundary iso-level as a FRACTION OF each cluster's peak field
|
||||
* value (0.3 = the line where the blurred halo has faded to 30% of
|
||||
* its densest point). 0 = no boundaries. */
|
||||
boundary: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_REGION_TUNING: RegionTuning = {
|
||||
@@ -185,6 +199,7 @@ export const DEFAULT_REGION_TUNING: RegionTuning = {
|
||||
blur: 10,
|
||||
bitmap: 384,
|
||||
edgeAlpha: 0.35,
|
||||
boundary: 0.3,
|
||||
};
|
||||
|
||||
/** Clamp anything into a usable tuning (number inputs can be empty/NaN). */
|
||||
@@ -197,6 +212,7 @@ export function sanitizeRegionTuning(input: Partial<RegionTuning> | null | undef
|
||||
blur: Math.min(Math.max(num(input?.blur, d.blur), 0), 40),
|
||||
bitmap: Math.round(Math.min(Math.max(num(input?.bitmap, d.bitmap), 128), 1024)),
|
||||
edgeAlpha: Math.min(Math.max(num(input?.edgeAlpha, d.edgeAlpha), 0.02), 1),
|
||||
boundary: Math.min(Math.max(num(input?.boundary, d.boundary), 0), 0.95),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -571,11 +587,19 @@ export class GraphCanvasRenderer {
|
||||
private regionRect: { minX: number; minY: number; w: number; h: number } | null = null;
|
||||
/** Region labels: cluster centroid + hue, computed at splat build. */
|
||||
private regionMeta: { label: string | null; cx: number; cy: number; hue: number }[] = [];
|
||||
/** Boundary paths (world-space Path2D per cluster), from the last
|
||||
* region rebuild — stroked every frame under the camera transform. */
|
||||
private regionBoundaries: { path: Path2D; hue: number }[] = [];
|
||||
private regionTuning: RegionTuning = DEFAULT_REGION_TUNING;
|
||||
private physicsTuning: PhysicsTuning = DEFAULT_PHYSICS_TUNING;
|
||||
/** Gravity center for the live sim (graph bbox centroid at setData). */
|
||||
private simCenter: Pt = { x: 0, y: 0 };
|
||||
private regionBuiltVersion = -1;
|
||||
/** geoVersion of the boundary paths (they lag one rebuild behind the
|
||||
* region fill during the live sim — see BOUNDARY_EVERY). */
|
||||
private boundaryBuiltVersion = -1;
|
||||
/** Rebuild counter for the boundary throttle (counts region rebuilds). */
|
||||
private regionRebuildCount = 0;
|
||||
private frameCount = 0; // draw counter for the live-sim splat throttle
|
||||
private labelLines = new Map<string, string[]>();
|
||||
private worldLabelFont = "";
|
||||
@@ -1138,6 +1162,7 @@ export class GraphCanvasRenderer {
|
||||
this.regionCanvas = null;
|
||||
this.regionRect = null;
|
||||
this.regionMeta = [];
|
||||
this.regionBoundaries = [];
|
||||
return;
|
||||
}
|
||||
const visible = this.visible;
|
||||
@@ -1147,23 +1172,32 @@ export class GraphCanvasRenderer {
|
||||
const s = 1 / this.kFit;
|
||||
|
||||
// World rect over VISIBLE cluster members (regions cover members
|
||||
// and their intra-cluster edges — nothing else).
|
||||
// and their intra-cluster edges — nothing else), plus each cluster's
|
||||
// OWN bbox for the boundary crops (the contour only needs to scan
|
||||
// where a cluster's halo can reach).
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const c of data.clusters) {
|
||||
const boxes = data.clusters.map((c) => {
|
||||
let a = Infinity, b = Infinity, e = -Infinity, f = -Infinity;
|
||||
for (const id of c.members) {
|
||||
if (!isVisible(id)) continue;
|
||||
const n = this.nodeById.get(id);
|
||||
if (!n) continue;
|
||||
if (n.x < a) a = n.x;
|
||||
if (n.x > e) e = n.x;
|
||||
if (n.y < b) b = n.y;
|
||||
if (n.y > f) f = n.y;
|
||||
if (n.x < minX) minX = n.x;
|
||||
if (n.x > maxX) maxX = n.x;
|
||||
if (n.y < minY) minY = n.y;
|
||||
if (n.y > maxY) maxY = n.y;
|
||||
}
|
||||
}
|
||||
return a > e ? null : { minX: a, minY: b, maxX: e, maxY: f };
|
||||
});
|
||||
if (minX > maxX) {
|
||||
this.regionCanvas = null;
|
||||
this.regionRect = null;
|
||||
this.regionMeta = [];
|
||||
this.regionBoundaries = [];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1266,6 +1300,7 @@ export class GraphCanvasRenderer {
|
||||
// THE blur: 3 box-blur rounds on the premultiplied bitmap. Bilinear
|
||||
// upscaling alone is no blur at all (tent ramps — the JPEG look);
|
||||
// this makes the field genuinely smooth before the blit.
|
||||
let blurred: Float32Array | null = null; // premultiplied field, for boundaries
|
||||
if (tuning.blur > 0) {
|
||||
const img = rctx.getImageData(0, 0, bw, bh);
|
||||
const n = bw * bh * 4;
|
||||
@@ -1299,6 +1334,65 @@ export class GraphCanvasRenderer {
|
||||
}
|
||||
}
|
||||
rctx.putImageData(img, 0, 0);
|
||||
blurred = b;
|
||||
}
|
||||
|
||||
// ── Boundaries (DECIDED 2026-09-13) ──────────────────────────────
|
||||
// Iso-lines of the blurred field at a FRACTION OF each cluster's
|
||||
// peak. Per-cluster fields are recovered from the ONE shared
|
||||
// bitmap by projecting crops of the blurred premultiplied array
|
||||
// onto each cluster's paint color (pure-i pixels → their alpha;
|
||||
// overlaps split proportionally — the natural divider between
|
||||
// neighboring halos). One paint, one blur, one readback — the
|
||||
// per-cluster work is cheap typed-array math over small crops.
|
||||
// During the live sim this doubles the rebuild cost, so it runs on
|
||||
// every BOUNDARY_EVERY-th region rebuild (at rest it always syncs;
|
||||
// the stale paths just ride along for ~3 frames).
|
||||
if (tuning.boundary <= 0 || !blurred) {
|
||||
// Off (or no blurred field): never keep stale paths around.
|
||||
this.regionBoundaries = [];
|
||||
} else if (!this.simActive || this.regionRebuildCount % BOUNDARY_EVERY === 0) {
|
||||
this.regionBoundaries = [];
|
||||
for (let ci = 0; ci < data.clusters.length; ci++) {
|
||||
const box = boxes[ci];
|
||||
if (!box) continue;
|
||||
const cluster = data.clusters[ci]!;
|
||||
// Crop in bitmap coords: the cluster bbox + the halo reach,
|
||||
// clamped to the bitmap.
|
||||
const cx0 = Math.max(0, Math.floor((box.minX - padWorld - minX) / cell));
|
||||
const cy0 = Math.max(0, Math.floor((box.minY - padWorld - minY) / cell));
|
||||
const cx1 = Math.min(bw - 1, Math.ceil((box.maxX + padWorld - minX) / cell));
|
||||
const cy1 = Math.min(bh - 1, Math.ceil((box.maxY + padWorld - minY) / cell));
|
||||
const cw = cx1 - cx0;
|
||||
const ch = cy1 - cy0;
|
||||
if (cw < 3 || ch < 3) continue;
|
||||
const hue = clusterHue(cluster.id);
|
||||
const rgb = hslToRgb(hue, 0.65, 0.6);
|
||||
// Stride 2: sample every other bitmap pixel — the blurred field
|
||||
// varies on a ~blur-radius scale, so the half-res grid loses
|
||||
// nothing and the contour work quarters (measured ~1ms total).
|
||||
const { field, peak } = projectCrop(blurred, bw, { x: cx0, y: cy0, w: cw, h: ch }, rgb, 2);
|
||||
const level = tuning.boundary * peak;
|
||||
if (peak <= 0 || level <= 0) continue;
|
||||
const fw = Math.ceil(cw / 2);
|
||||
const fh = Math.ceil(ch / 2);
|
||||
const segments = marchingSquares(field, fw, fh, level);
|
||||
if (segments.length === 0) continue;
|
||||
// Grid coords (stride-2 samples) → world coords → one stroked
|
||||
// path per cluster. Sample (gx, gy) is bitmap pixel
|
||||
// (cx0 + gx*2, cy0 + gy*2).
|
||||
const path = new Path2D();
|
||||
for (const line of joinContourSegments(segments)) {
|
||||
if (line.length < 4) continue;
|
||||
path.moveTo(minX + (cx0 + line[0]! * 2) * cell, minY + (cy0 + line[1]! * 2) * cell);
|
||||
for (let i = 2; i < line.length; i += 2) {
|
||||
path.lineTo(minX + (cx0 + line[i]! * 2) * cell, minY + (cy0 + line[i + 1]! * 2) * cell);
|
||||
}
|
||||
// Closed loop (first point == last point) → close the path.
|
||||
if (line[0] === line[line.length - 2] && line[1] === line[line.length - 1]) path.closePath();
|
||||
}
|
||||
this.regionBoundaries.push({ path, hue });
|
||||
}
|
||||
}
|
||||
this.regionRect = { minX, minY, w: spanX, h: spanY };
|
||||
}
|
||||
@@ -1326,6 +1420,7 @@ export class GraphCanvasRenderer {
|
||||
this.regionBuiltVersion !== this.geoVersion &&
|
||||
(!this.simActive || this.frameCount % REGION_EVERY === 0)
|
||||
) {
|
||||
this.regionRebuildCount++;
|
||||
this.buildRegions();
|
||||
this.regionBuiltVersion = this.geoVersion;
|
||||
}
|
||||
@@ -1351,6 +1446,18 @@ export class GraphCanvasRenderer {
|
||||
ctx.drawImage(this.regionCanvas, minX, minY, w, h);
|
||||
}
|
||||
|
||||
// 2. Region boundaries: the cached iso-line paths, one stroke per
|
||||
// cluster (hue-tinted, zoom-scaling width — see REGION_BOUNDARY_WIDTH).
|
||||
if (this.regionBoundaries.length > 0) {
|
||||
ctx.lineWidth = REGION_BOUNDARY_WIDTH * (1 / this.kFit);
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
for (const rb of this.regionBoundaries) {
|
||||
ctx.strokeStyle = `hsla(${rb.hue}, 65%, 68%, 0.55)`;
|
||||
ctx.stroke(rb.path);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Edges: shafts + arrowheads from cached paths; width constant on
|
||||
// screen (÷ k), geometry in world units (scales with the graph).
|
||||
if (settings.edgeWidth > 0) {
|
||||
|
||||
@@ -656,6 +656,12 @@ export function GraphView() {
|
||||
step={0.05}
|
||||
onChange={(v) => setRegionTuning((t) => ({ ...t, edgeAlpha: v }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Boundary level (fraction of peak, 0 = off)"
|
||||
value={regionTuning.boundary}
|
||||
step={0.05}
|
||||
onChange={(v) => setRegionTuning((t) => ({ ...t, boundary: v }))}
|
||||
/>
|
||||
</div>
|
||||
{/* Physics (user-facing): the live sim's forces. Every value feeds
|
||||
the SAME engine the server settle runs (createGraphSimulation);
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user