From 0af63113f59e48decfcca00e4a4bf779c375a542 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sun, 13 Sep 2026 16:23:21 +0300 Subject: [PATCH] Revert "Region boundaries: iso-line contours of the blurred region field" This reverts commit c7c1148989180b969ba9cab329c35878c14005d0. --- components/graph-canvas.ts | 113 +----------------- components/graph-view.tsx | 6 - components/region-contour.ts | 223 ----------------------------------- tests/region-contour.test.ts | 168 -------------------------- 4 files changed, 3 insertions(+), 507 deletions(-) delete mode 100644 components/region-contour.ts delete mode 100644 tests/region-contour.test.ts diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index 7df06c1..b9bab57 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -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 | 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(); 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) { diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 41a66cb..a139ddf 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -656,12 +656,6 @@ export function GraphView() { step={0.05} onChange={(v) => setRegionTuning((t) => ({ ...t, edgeAlpha: v }))} /> - setRegionTuning((t) => ({ ...t, boundary: v }))} - /> {/* Physics (user-facing): the live sim's forces. Every value feeds the SAME engine the server settle runs (createGraphSimulation); diff --git a/components/region-contour.ts b/components/region-contour.ts deleted file mode 100644 index 8294d3a..0000000 --- a/components/region-contour.ts +++ /dev/null @@ -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>(); - 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(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; -} diff --git a/tests/region-contour.test.ts b/tests/region-contour.test.ts deleted file mode 100644 index 13ee2de..0000000 --- a/tests/region-contour.test.ts +++ /dev/null @@ -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); - }); -});