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

This reverts commit c7c1148989.
This commit is contained in:
amosblomqvist
2026-09-13 16:23:21 +03:00
parent c7c1148989
commit 0af63113f5
4 changed files with 3 additions and 507 deletions
+3 -110
View File
@@ -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) {
-6
View File
@@ -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);
-223
View File
@@ -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;
}
-168
View File
@@ -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);
});
});