Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
57 changes: 57 additions & 0 deletions docs/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
};
};

Expand Down Expand Up @@ -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<ReadonlyArray<number>>;
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<ReadonlyArray<number>>;
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
// ============================================================================
Expand Down
12 changes: 12 additions & 0 deletions examples/marchingSquares.ts
Original file line number Diff line number Diff line change
@@ -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);
14 changes: 14 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
// ============================================================================
Expand Down
180 changes: 180 additions & 0 deletions src/visual/marchingSquares.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
export interface ScalarField {
data: ReadonlyArray<ReadonlyArray<number>>;
cellSize?: number;
}

export interface MarchingSquaresOptions {
field: ScalarField | ReadonlyArray<ReadonlyArray<number>>;
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<ReadonlyArray<[number, number]>> = [
[],
[[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<ReadonlyArray<number>>;

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.');
}
}
}
2 changes: 2 additions & 0 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -184,6 +185,7 @@ describe('package entry point', () => {
| 'hslToRgb'
| 'mixRgbColors'
| 'computeForceDirectedLayout'
| 'computeMarchingSquares'
>();
});
});
61 changes: 61 additions & 0 deletions tests/marchingSquares.test.ts
Original file line number Diff line number Diff line change
@@ -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.'
);
});
});