Revert "Region boundaries: iso-line contours of the blurred region field"
This reverts commit c7c1148989.
This commit is contained in:
+3
-110
@@ -54,7 +54,6 @@ 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,
|
||||
@@ -160,15 +159,6 @@ 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
|
||||
@@ -188,10 +178,6 @@ 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 = {
|
||||
@@ -199,7 +185,6 @@ 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). */
|
||||
@@ -212,7 +197,6 @@ 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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -587,19 +571,11 @@ 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 = "";
|
||||
@@ -1162,7 +1138,6 @@ export class GraphCanvasRenderer {
|
||||
this.regionCanvas = null;
|
||||
this.regionRect = null;
|
||||
this.regionMeta = [];
|
||||
this.regionBoundaries = [];
|
||||
return;
|
||||
}
|
||||
const visible = this.visible;
|
||||
@@ -1172,32 +1147,23 @@ export class GraphCanvasRenderer {
|
||||
const s = 1 / this.kFit;
|
||||
|
||||
// World rect over VISIBLE cluster members (regions cover members
|
||||
// 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).
|
||||
// and their intra-cluster edges — nothing else).
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
const boxes = data.clusters.map((c) => {
|
||||
let a = Infinity, b = Infinity, e = -Infinity, f = -Infinity;
|
||||
for (const c of data.clusters) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1300,7 +1266,6 @@ 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;
|
||||
@@ -1334,65 +1299,6 @@ 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 };
|
||||
}
|
||||
@@ -1420,7 +1326,6 @@ export class GraphCanvasRenderer {
|
||||
this.regionBuiltVersion !== this.geoVersion &&
|
||||
(!this.simActive || this.frameCount % REGION_EVERY === 0)
|
||||
) {
|
||||
this.regionRebuildCount++;
|
||||
this.buildRegions();
|
||||
this.regionBuiltVersion = this.geoVersion;
|
||||
}
|
||||
@@ -1446,18 +1351,6 @@ 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,12 +656,6 @@ 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);
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
/**
|
||||
* 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