From 3ec0a60df812785619d65886b4683b97a53aa3bb Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 8 Oct 2025 17:59:13 +0900 Subject: [PATCH] feat: add marching squares contour extraction --- README.md | 2 +- ROADMAP.md | 2 +- docs/index.d.ts | 57 +++++++++++ examples/marchingSquares.ts | 12 +++ src/index.ts | 14 +++ src/visual/marchingSquares.ts | 180 ++++++++++++++++++++++++++++++++++ tests/index.test.ts | 2 + tests/marchingSquares.test.ts | 61 ++++++++++++ 8 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 examples/marchingSquares.ts create mode 100644 src/visual/marchingSquares.ts create mode 100644 tests/marchingSquares.test.ts diff --git a/README.md b/README.md index 6cc9413..5dfe508 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ CDN usage: | Search & text | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance`, `kmpSearch`, `rabinKarp`, `boyerMooreSearch`, `buildSuffixArray`, `longestCommonSubsequence`, `diffStrings` | `search/*.ts` | `examples/search.ts` | | Data & diff pipelines | `diff`, `deepClone`, `groupBy`, `diffJson`, `diffJsonAdvanced`, `applyJsonDiff`, `applyJsonDiffSelective`, `flatten`, `unflatten`, `diffTree`, `applyTreeDiff` | `data/*.ts` | `examples/jsonDiff.ts`, `examples/treeDiff.ts` | | Graph algorithms | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` | -| Visual & geometry | `convexHull`, `lineIntersection`, `pointInPolygon`, `bresenhamLine`, `easing`, `quadraticBezier`, `cubicBezier`, `hexToRgb`, `rgbToHex`, `rgbToHsl`, `hslToRgb`, `mixRgbColors`, `computeForceDirectedLayout` | `geometry/*.ts`, `visual/*.ts` | `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts`, `examples/color.ts`, `examples/forceDirected.ts` | +| Visual & geometry | `convexHull`, `lineIntersection`, `pointInPolygon`, `bresenhamLine`, `easing`, `quadraticBezier`, `cubicBezier`, `hexToRgb`, `rgbToHex`, `rgbToHsl`, `hslToRgb`, `mixRgbColors`, `computeForceDirectedLayout`, `computeMarchingSquares` | `geometry/*.ts`, `visual/*.ts` | `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts`, `examples/color.ts`, `examples/forceDirected.ts`, `examples/marchingSquares.ts` | ## Scripts ```bash diff --git a/ROADMAP.md b/ROADMAP.md index 3ca5888..07cf6a8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -89,7 +89,7 @@ - **Visual & simulation tools** - [x] Color manipulation helpers (RGB/HSL conversion, blending) - [x] Force-directed graph layout - - [ ] Marching squares contour extraction + - [x] Marching squares contour extraction - [ ] Marching cubes isosurface generation - **Graph algorithms** - [ ] Minimum spanning tree (Kruskal) diff --git a/docs/index.d.ts b/docs/index.d.ts index b86f48a..0c1f1e1 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -158,6 +158,7 @@ export const examples: { readonly hslToRgb: 'examples/color.ts'; readonly mixRgbColors: 'examples/color.ts'; readonly computeForceDirectedLayout: 'examples/forceDirected.ts'; + readonly computeMarchingSquares: 'examples/marchingSquares.ts'; }; }; @@ -3082,6 +3083,62 @@ export function computeForceDirectedLayout( options: ForceDirectedLayoutOptions ): ForceDirectedLayoutResult; +/** + * Scalar field for marching squares contour extraction. + * Use for: density maps, heatmaps, elevation grids. + * Import: visual/marchingSquares.ts + */ +export interface ScalarField { + data: ReadonlyArray>; + cellSize?: number; +} + +/** + * Options for marching squares contour extraction. + * Use for: generating isolines from scalar fields. + * Import: visual/marchingSquares.ts + */ +export interface MarchingSquaresOptions { + field: ScalarField | ReadonlyArray>; + threshold?: number; +} + +/** + * 2D point type for marching squares results. + * Use for: interoperating with rendering APIs. + * Import: visual/marchingSquares.ts + */ +export interface Point2D { + x: number; + y: number; +} + +/** + * Line segment returned by marching squares. + * Use for: drawing contour polylines. + * Import: visual/marchingSquares.ts + */ +export interface LineSegment { + start: Point2D; + end: Point2D; +} + +/** + * Marching squares result payload. + * Use for: feeding contour segments into renderers. + * Import: visual/marchingSquares.ts + */ +export interface MarchingSquaresResult { + segments: LineSegment[]; +} + +/** + * Computes contour segments using the marching squares algorithm. + * Use for: isolines, heatmap boundaries, scalar field visualisation. + * Import: visual/marchingSquares.ts + */ +export function computeMarchingSquares(options: MarchingSquaresOptions): MarchingSquaresResult; + // ============================================================================ // 🤖 STEERING BEHAVIOURS // ============================================================================ diff --git a/examples/marchingSquares.ts b/examples/marchingSquares.ts new file mode 100644 index 0000000..98dbd4b --- /dev/null +++ b/examples/marchingSquares.ts @@ -0,0 +1,12 @@ +import { computeMarchingSquares } from '../src/index.js'; + +const field = [ + [0, 0, 0, 0], + [0, 0.8, 0.6, 0], + [0, 0.4, 0.9, 0], + [0, 0, 0, 0], +]; + +const { segments } = computeMarchingSquares({ field, threshold: 0.5 }); + +console.log('Segments:', segments); diff --git a/src/index.ts b/src/index.ts index 2a58751..6b0f26d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -157,6 +157,7 @@ export const examples = { hslToRgb: 'examples/color.ts', mixRgbColors: 'examples/color.ts', computeForceDirectedLayout: 'examples/forceDirected.ts', + computeMarchingSquares: 'examples/marchingSquares.ts', }, } as const; @@ -1053,6 +1054,19 @@ export type { ForceDirectedNodeInput, } from './visual/forceDirected.js'; +/** + * Marching squares contour extraction. + */ +export { computeMarchingSquares } from './visual/marchingSquares.js'; + +export type { + MarchingSquaresOptions, + MarchingSquaresResult, + ScalarField, + LineSegment, + Point2D, +} from './visual/marchingSquares.js'; + // ============================================================================ // 🤖 AI & BEHAVIOUR // ============================================================================ diff --git a/src/visual/marchingSquares.ts b/src/visual/marchingSquares.ts new file mode 100644 index 0000000..12f2eab --- /dev/null +++ b/src/visual/marchingSquares.ts @@ -0,0 +1,180 @@ +export interface ScalarField { + data: ReadonlyArray>; + cellSize?: number; +} + +export interface MarchingSquaresOptions { + field: ScalarField | ReadonlyArray>; + threshold?: number; +} + +export interface Point2D { + x: number; + y: number; +} + +export interface LineSegment { + start: Point2D; + end: Point2D; +} + +export interface MarchingSquaresResult { + segments: LineSegment[]; +} + +const CASE_TABLE: ReadonlyArray> = [ + [], + [[3, 2]], + [[2, 1]], + [[3, 1]], + [[0, 1]], + [[0, 3], [2, 1]], + [[0, 2]], + [[3, 0]], + [[0, 3]], + [[0, 2]], + [[0, 1], [2, 3]], + [[0, 1]], + [[3, 1]], + [[2, 1]], + [[3, 2]], + [], +]; + +export function computeMarchingSquares(options: MarchingSquaresOptions): MarchingSquaresResult { + const { grid, cellSize } = normalizeField(options.field); + validateGrid(grid); + + const threshold = options.threshold ?? 0; + const rows = grid.length; + const cols = grid[0].length; + const segments: LineSegment[] = []; + + for (let y = 0; y < rows - 1; y += 1) { + for (let x = 0; x < cols - 1; x += 1) { + const tl = grid[y][x]; + const tr = grid[y][x + 1]; + const bl = grid[y + 1][x]; + const br = grid[y + 1][x + 1]; + + let caseIndex = 0; + if (tl >= threshold) { + caseIndex |= 8; + } + if (tr >= threshold) { + caseIndex |= 4; + } + if (br >= threshold) { + caseIndex |= 2; + } + if (bl >= threshold) { + caseIndex |= 1; + } + + const configurations = CASE_TABLE[caseIndex]; + if (configurations.length === 0) { + continue; + } + + for (const [edgeA, edgeB] of configurations) { + const start = interpolateEdge(x, y, edgeA, tl, tr, br, bl, threshold, cellSize); + const end = interpolateEdge(x, y, edgeB, tl, tr, br, bl, threshold, cellSize); + segments.push({ start, end }); + } + } + } + + return { segments }; +} + +function interpolateEdge( + cellX: number, + cellY: number, + edgeIndex: number, + tl: number, + tr: number, + br: number, + bl: number, + threshold: number, + cellSize: number +): Point2D { + const x0 = cellX * cellSize; + const x1 = (cellX + 1) * cellSize; + const y0 = cellY * cellSize; + const y1 = (cellY + 1) * cellSize; + + switch (edgeIndex) { + case 0: { + const t = interpolate(tl, tr, threshold); + return { x: lerp(x0, x1, t), y: y0 }; + } + case 1: { + const t = interpolate(tr, br, threshold); + return { x: x1, y: lerp(y0, y1, t) }; + } + case 2: { + const t = interpolate(br, bl, threshold); + return { x: lerp(x1, x0, t), y: y1 }; + } + case 3: { + const t = interpolate(bl, tl, threshold); + return { x: x0, y: lerp(y1, y0, t) }; + } + default: + throw new Error(`Unknown edge index: ${edgeIndex}`); + } +} + +function interpolate(v1: number, v2: number, threshold: number): number { + const denom = v2 - v1; + if (Math.abs(denom) < 1e-12) { + return 0.5; + } + return clamp((threshold - v1) / denom, 0, 1); +} + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +function clamp(value: number, min: number, max: number): number { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; +} + +type Grid = ReadonlyArray>; + +function normalizeField(field: ScalarField | Grid): { + grid: Grid; + cellSize: number; +} { + if (isGrid(field)) { + return { grid: field, cellSize: 1 }; + } + return { grid: field.data, cellSize: field.cellSize ?? 1 }; +} + +function isGrid(field: ScalarField | Grid): field is Grid { + return Array.isArray(field); +} + +function validateGrid(grid: Grid): void { + if (grid.length < 2) { + throw new Error('field must contain at least two rows.'); + } + const firstRow = grid[0]; + if (!Array.isArray(firstRow) || firstRow.length < 2) { + throw new Error('field must contain rows with at least two columns.'); + } + const width = firstRow.length; + for (let i = 1; i < grid.length; i += 1) { + if (grid[i].length !== width) { + throw new Error('field rows must all have the same length.'); + } + } +} diff --git a/tests/index.test.ts b/tests/index.test.ts index 44b5f69..793adea 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -33,6 +33,7 @@ describe('package entry point', () => { expect(examples.visual.hslToRgb).toBe('examples/color.ts'); expect(examples.visual.mixRgbColors).toBe('examples/color.ts'); expect(examples.visual.computeForceDirectedLayout).toBe('examples/forceDirected.ts'); + expect(examples.visual.computeMarchingSquares).toBe('examples/marchingSquares.ts'); expect(examples.gameplay.createDeltaTimeManager).toBe('examples/deltaTime.ts'); expect(examples.gameplay.createFixedTimestepLoop).toBe('examples/fixedTimestep.ts'); expect(examples.gameplay.createCamera2D).toBe('examples/camera2D.ts'); @@ -184,6 +185,7 @@ describe('package entry point', () => { | 'hslToRgb' | 'mixRgbColors' | 'computeForceDirectedLayout' + | 'computeMarchingSquares' >(); }); }); diff --git a/tests/marchingSquares.test.ts b/tests/marchingSquares.test.ts new file mode 100644 index 0000000..b7290d8 --- /dev/null +++ b/tests/marchingSquares.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { computeMarchingSquares } from '../src/index.js'; + +describe('computeMarchingSquares', () => { + it('extracts contour segments around a single peak', () => { + const field = [ + [0, 0, 0], + [0, 1, 0], + [0, 0, 0], + ]; + + const { segments } = computeMarchingSquares({ field, threshold: 0.5 }); + expect(segments).toHaveLength(4); + + const points = segments.flatMap((segment) => [segment.start, segment.end]); + for (const point of points) { + expect(point.x).toBeGreaterThan(0); + expect(point.x).toBeLessThan(2); + expect(point.y).toBeGreaterThan(0); + expect(point.y).toBeLessThan(2); + } + }); + + it('scales coordinates using cellSize', () => { + const field = [ + [0, 0], + [0, 1], + [0, 0], + ]; + + const { segments } = computeMarchingSquares({ field: { data: field, cellSize: 2 }, threshold: 0.5 }); + expect(segments).toHaveLength(2); + for (const { start, end } of segments) { + for (const point of [start, end]) { + expect(point.x).toBeGreaterThanOrEqual(0); + expect(point.x).toBeLessThanOrEqual(2); + expect(point.y).toBeGreaterThanOrEqual(0); + expect(point.y).toBeLessThanOrEqual(4); + } + } + }); + + it('handles ambiguous cases by emitting separate segments', () => { + const field = [ + [1, 0], + [0, 1], + [1, 0], + ]; + + const { segments } = computeMarchingSquares({ field, threshold: 0.5 }); + expect(segments.length).toBeGreaterThanOrEqual(2); + }); + + it('validates grid dimensions and rectangular shape', () => { + expect(() => computeMarchingSquares({ field: [[0]], threshold: 0 })).toThrow('field must contain at least two rows.'); + expect(() => computeMarchingSquares({ field: [[0, 0], [1]], threshold: 0 })).toThrow( + 'field rows must all have the same length.' + ); + }); +});