diff --git a/demo/distance.html b/demo/distance.html
new file mode 100644
index 0000000..0ef02e1
--- /dev/null
+++ b/demo/distance.html
@@ -0,0 +1,61 @@
+
+
+
+
+
+ Distance Field Demo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/arrows/abstract-arrow.ts b/src/arrows/abstract-arrow.ts
index 40c841d..c3dcabb 100644
--- a/src/arrows/abstract-arrow.ts
+++ b/src/arrows/abstract-arrow.ts
@@ -1,5 +1,5 @@
-import { FolkGeometry } from '../canvas/fc-geometry';
-import { parseVertex } from './utils';
+import { FolkGeometry } from '../canvas/fc-geometry.ts';
+import { parseVertex } from './utils.ts';
import { ClientRectObserverEntry, ClientRectObserverManager } from '../client-rect-observer.ts';
const clientRectObserver = new ClientRectObserverManager();
diff --git a/src/distanceField/cellRenderer.ts b/src/distanceField/cellRenderer.ts
new file mode 100644
index 0000000..60dc0b7
--- /dev/null
+++ b/src/distanceField/cellRenderer.ts
@@ -0,0 +1,174 @@
+import type { FolkGeometry } from '../canvas/fc-geometry.ts';
+import type { Vector2 } from '../utils/Vector2.ts';
+import { Fields } from './fields.ts';
+
+export class CellRenderer extends HTMLElement {
+ private canvas: HTMLCanvasElement;
+ private ctx: CanvasRenderingContext2D;
+ private offscreenCtx: CanvasRenderingContext2D;
+ private fields: Fields;
+ private resolution: number;
+ private imageSmoothing: boolean;
+ static tagName = 'cell-renderer';
+
+ constructor() {
+ super();
+ this.resolution = 2000; // default resolution
+ this.imageSmoothing = true;
+ this.fields = new Fields(this.resolution);
+
+ const { ctx, offscreenCtx } = this.createCanvas(
+ window.innerWidth,
+ window.innerHeight,
+ this.resolution,
+ this.resolution
+ );
+
+ this.ctx = ctx;
+ this.offscreenCtx = offscreenCtx;
+
+ this.renderDistanceField();
+ }
+
+ static define() {
+ customElements.define(this.tagName, this);
+ }
+
+ static get observedAttributes() {
+ return ['resolution', 'image-smoothing'];
+ }
+
+ attributeChangedCallback(name: string, _oldValue: string, newValue: string) {
+ if (name === 'resolution') {
+ this.resolution = parseInt(newValue, 10);
+ this.fields = new Fields(this.resolution);
+ } else if (name === 'image-smoothing') {
+ this.imageSmoothing = newValue === 'true';
+ if (this.ctx) {
+ this.ctx.imageSmoothingEnabled = this.imageSmoothing;
+ }
+ }
+ }
+
+ private renderDistanceField() {
+ const imageData = this.offscreenCtx.getImageData(0, 0, this.resolution, this.resolution);
+
+ for (let row = 0; row < this.resolution; row++) {
+ for (let col = 0; col < this.resolution; col++) {
+ const index = (col * this.resolution + row) * 4;
+ const distance = this.fields.getDistance(row, col);
+ const color = this.fields.getColor(row, col);
+
+ const maxDistance = 10;
+ const normalizedDistance = Math.sqrt(distance) / maxDistance;
+ const baseColor = {
+ r: (color * 7) % 256,
+ g: (color * 13) % 256,
+ b: (color * 19) % 256,
+ };
+
+ imageData.data[index] = baseColor.r * (1 - normalizedDistance);
+ imageData.data[index + 1] = baseColor.g * (1 - normalizedDistance);
+ imageData.data[index + 2] = baseColor.b * (1 - normalizedDistance);
+ imageData.data[index + 3] = 255;
+ }
+ }
+
+ this.offscreenCtx.putImageData(imageData, 0, 0);
+
+ // Draw scaled version to main canvas
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
+ this.ctx.drawImage(
+ this.offscreenCtx.canvas,
+ 0,
+ 0,
+ this.resolution,
+ this.resolution,
+ 0,
+ 0,
+ this.canvas.width,
+ this.canvas.height
+ );
+ }
+
+ // Public methods
+ reset() {
+ this.fields = new Fields(this.resolution);
+ }
+
+ private transformToFieldCoordinates(point: Vector2): Vector2 {
+ // Transform from screen coordinates to field coordinates (0 to resolution)
+ return {
+ x: (point.x / this.canvas.width) * this.resolution,
+ y: (point.y / this.canvas.height) * this.resolution,
+ };
+ }
+
+ addShape(points: Vector2[], isClosed = true) {
+ // Transform each point from screen coordinates to field coordinates
+ const transformedPoints = points.map((point) => this.transformToFieldCoordinates(point));
+ this.fields.addShape(transformedPoints, isClosed);
+ this.renderDistanceField();
+ }
+
+ removeShape(index: number) {
+ this.fields.removeShape(index);
+ this.renderDistanceField();
+ }
+
+ private createCanvas(width: number, height: number, offScreenWidth: number, offScreenHeight: number) {
+ this.canvas = document.createElement('canvas');
+ const offscreenCanvas = document.createElement('canvas');
+
+ // Set canvas styles to ensure it stays behind other elements
+ this.canvas.style.position = 'absolute';
+ this.canvas.style.top = '0';
+ this.canvas.style.left = '0';
+ this.canvas.style.width = '100%';
+ this.canvas.style.height = '100%';
+ this.canvas.style.zIndex = '-1';
+
+ offscreenCanvas.width = offScreenWidth;
+ offscreenCanvas.height = offScreenHeight;
+ this.canvas.width = width;
+ this.canvas.height = height;
+
+ const offscreenCtx = offscreenCanvas.getContext('2d', {
+ willReadFrequently: true,
+ });
+ const ctx = this.canvas.getContext('2d');
+
+ if (!ctx || !offscreenCtx) throw new Error('Could not get context');
+ ctx.imageSmoothingEnabled = this.imageSmoothing;
+
+ this.appendChild(this.canvas);
+ return { ctx, offscreenCtx };
+ }
+
+ handleGeometryUpdate(event: Event) {
+ const geometry = event.target as HTMLElement;
+ const index = Array.from(document.querySelectorAll('fc-geometry')).indexOf(geometry as FolkGeometry);
+ if (index === -1) return;
+
+ const rect = geometry.getBoundingClientRect();
+ const points = [
+ { x: rect.x, y: rect.y },
+ { x: rect.x + rect.width, y: rect.y },
+ { x: rect.x + rect.width, y: rect.y + rect.height },
+ { x: rect.x, y: rect.y + rect.height },
+ ];
+
+ if (index < this.fields.shapes.length) {
+ this.updateShape(index, points, true);
+ } else {
+ this.addShape(points, true);
+ }
+ }
+
+ updateShape(index: number, points: Vector2[], isClosed = true) {
+ // Transform each point from screen coordinates to field coordinates
+ const transformedPoints = points.map((point) => this.transformToFieldCoordinates(point));
+ this.fields.updateShape(index, transformedPoints, isClosed);
+ this.renderDistanceField();
+ }
+}
diff --git a/src/distanceField/cpt.ts b/src/distanceField/cpt.ts
new file mode 100644
index 0000000..0c52980
--- /dev/null
+++ b/src/distanceField/cpt.ts
@@ -0,0 +1,51 @@
+import type { Vector2 } from '../utils/Vector2.ts';
+import { findHullParabolas, transpose } from './utils.ts';
+
+export function computeCPT(
+ sedt: Float32Array[],
+ cpt: Vector2[][],
+ xcoords: Float32Array[],
+ ycoords: Float32Array[]
+): Vector2[][] {
+ const length = sedt.length;
+
+ for (let row = 0; row < length; row++) {
+ horizontalPass(sedt[row], xcoords[row]);
+ }
+
+ transpose(sedt);
+
+ for (let row = 0; row < length; row++) {
+ horizontalPass(sedt[row], ycoords[row]);
+ }
+
+ for (let col = 0; col < length; col++) {
+ for (let row = 0; row < length; row++) {
+ const y = ycoords[col][row];
+ const x = xcoords[y][col];
+ cpt[row][col] = { x, y };
+ }
+ }
+
+ return cpt;
+}
+
+function horizontalPass(singleRow: Float32Array, indices: Float32Array) {
+ const hullVertices: Vector2[] = [];
+ const hullIntersections: Vector2[] = [];
+ findHullParabolas(singleRow, hullVertices, hullIntersections);
+ marchParabolas(singleRow, hullVertices, hullIntersections, indices);
+}
+
+function marchParabolas(row: Float32Array, verts: Vector2[], intersections: Vector2[], indices: Float32Array) {
+ let k = 0;
+
+ for (let i = 0; i < row.length; i++) {
+ while (intersections[k + 1].x < i) {
+ k++;
+ }
+ const dx = i - verts[k].x;
+ row[i] = dx * dx + verts[k].y;
+ indices[i] = verts[k].x;
+ }
+}
diff --git a/src/distanceField/edt.ts b/src/distanceField/edt.ts
new file mode 100644
index 0000000..0875616
--- /dev/null
+++ b/src/distanceField/edt.ts
@@ -0,0 +1,36 @@
+import { findHullParabolas, transpose } from './utils.ts';
+import type { Vector2 } from '../utils/Vector2.ts';
+
+// TODO: test performance of non-square sedt
+export function computeEDT(sedt: Float32Array[]): Float32Array[] {
+ for (let row = 0; row < sedt.length; row++) {
+ horizontalPass(sedt[row]);
+ }
+ transpose(sedt);
+
+ for (let row = 0; row < sedt.length; row++) {
+ horizontalPass(sedt[row]);
+ }
+ transpose(sedt);
+
+ return sedt.map((row) => row.map(Math.sqrt));
+}
+
+function horizontalPass(singleRow: Float32Array) {
+ const hullVertices: Vector2[] = [];
+ const hullIntersections: Vector2[] = [];
+ findHullParabolas(singleRow, hullVertices, hullIntersections);
+ marchParabolas(singleRow, hullVertices, hullIntersections);
+}
+
+function marchParabolas(row: Float32Array, verts: Vector2[], intersections: Vector2[]) {
+ let k = 0;
+
+ for (let i = 0; i < row.length; i++) {
+ while (intersections[k + 1].x < i) {
+ k++;
+ }
+ const dx = i - verts[k].x;
+ row[i] = dx * dx + verts[k].y;
+ }
+}
diff --git a/src/distanceField/fields.ts b/src/distanceField/fields.ts
new file mode 100644
index 0000000..f229368
--- /dev/null
+++ b/src/distanceField/fields.ts
@@ -0,0 +1,250 @@
+import type { Vector2 } from '../utils/Vector2.ts';
+import { computeCPT } from './cpt.ts';
+
+export class Fields {
+ private edt: Float32Array[] = [];
+ private cpt: Vector2[][] = [];
+ private colorField: Float32Array[] = [];
+ private xcoords: Float32Array[] = [];
+ private ycoords: Float32Array[] = [];
+ private resolution: number;
+ shapes: Array<{
+ points: Vector2[];
+ color: number;
+ isClosed: boolean;
+ }> = [];
+
+ constructor(resolution: number) {
+ this.resolution = resolution + 1;
+ this.initializeArrays();
+ this.updateFields();
+ }
+
+ private initializeArrays() {
+ this.edt = new Array(this.resolution).fill(Infinity).map(() => new Float32Array(this.resolution).fill(Infinity));
+ this.colorField = new Array(this.resolution).fill(0).map(() => new Float32Array(this.resolution).fill(0));
+ this.xcoords = Array.from({ length: this.resolution }, () => new Float32Array(this.resolution).fill(0));
+ this.ycoords = Array.from({ length: this.resolution }, () => new Float32Array(this.resolution).fill(0));
+ this.cpt = Array.from({ length: this.resolution }, () =>
+ Array.from({ length: this.resolution }, () => ({ x: 0, y: 0 }))
+ );
+ }
+
+ // Public getters for field data
+ getDistance(row: number, col: number): number {
+ return this.edt[row][col];
+ }
+
+ getColor(row: number, col: number): number {
+ const { x, y } = this.cpt[row][col];
+ return this.colorField[x][y];
+ }
+
+ addShape(points: Vector2[], isClosed: boolean = true) {
+ const color = Math.floor(Math.random() * 255);
+ this.shapes.push({ points, color, isClosed });
+ this.updateFields();
+ console.log(this.shapes);
+ }
+
+ removeShape(index: number) {
+ this.shapes.splice(index, 1);
+ this.updateFields();
+ }
+
+ updateFields() {
+ this.boolifyFields(this.edt, this.colorField);
+ this.cpt = computeCPT(this.edt, this.cpt, this.xcoords, this.ycoords);
+ this.deriveEDTfromCPT();
+ }
+
+ deriveEDTfromCPT() {
+ for (let x = 0; x < this.resolution; x++) {
+ for (let y = 0; y < this.resolution; y++) {
+ const { x: closestX, y: closestY } = this.cpt[y][x];
+ const distance = Math.sqrt((x - closestX) ** 2 + (y - closestY) ** 2);
+ this.edt[y][x] = distance;
+ }
+ }
+ }
+
+ boolifyFields(distanceField: Float32Array[], colorField: Float32Array[]): void {
+ const LARGE_NUMBER = 1000000000000;
+ const size = distanceField.length;
+ const cellSize = 1;
+
+ for (let x = 0; x < size; x++) {
+ for (let y = 0; y < size; y++) {
+ distanceField[x][y] = LARGE_NUMBER;
+ colorField[y][x] = 0;
+ }
+ }
+
+ const drawLine = (start: Vector2, end: Vector2, color: number) => {
+ const startCell = {
+ x: Math.floor(start.x / cellSize),
+ y: Math.floor(start.y / cellSize),
+ };
+ const endCell = {
+ x: Math.floor(end.x / cellSize),
+ y: Math.floor(end.y / cellSize),
+ };
+ if (startCell.x < 0 || startCell.x >= size || startCell.y < 0 || startCell.y >= size) {
+ return;
+ }
+ if (endCell.x < 0 || endCell.x >= size || endCell.y < 0 || endCell.y >= size) {
+ return;
+ }
+ if (startCell.x === endCell.x && startCell.y === endCell.y) {
+ distanceField[startCell.x][startCell.y] = 0;
+ colorField[startCell.y][startCell.x] = color;
+ return;
+ }
+
+ let x0 = startCell.x;
+ let y0 = startCell.y;
+ const x1 = endCell.x;
+ const y1 = endCell.y;
+
+ const dx = Math.abs(x1 - x0);
+ const dy = Math.abs(y1 - y0);
+ const sx = x0 < x1 ? 1 : -1;
+ const sy = y0 < y1 ? 1 : -1;
+ let err = dx - dy;
+
+ while (true) {
+ distanceField[x0][y0] = 0;
+ colorField[y0][x0] = color;
+ if (x0 === x1 && y0 === y1) break;
+ const e2 = 2 * err;
+ if (e2 > -dy) {
+ err -= dy;
+ x0 += sx;
+ }
+ if (e2 < dx) {
+ err += dx;
+ y0 += sy;
+ }
+ }
+ };
+
+ for (const shape of this.shapes) {
+ const { points, color, isClosed } = shape;
+ const length = isClosed ? points.length : points.length - 1;
+
+ for (let i = 0; i < length; i++) {
+ const start = points[i];
+ const end = points[(i + 1) % points.length];
+ drawLine(start, end, color);
+ }
+ }
+ }
+
+ Color = {
+ simpleColorFunc: (d: number) => {
+ return { r: 250 - d * 2, g: 250 - d * 5, b: 250 - d * 3 };
+ },
+ simpleModuloColorFunc: (d: number) => {
+ const period = 18;
+ const modulo = d % period;
+ return { r: modulo * period, g: (modulo * period) / 3, b: (modulo * period) / 2 };
+ },
+ moduloColorFunc: (d: number) => {
+ const dPeriod = d % 15;
+ return { r: dPeriod * 10, g: dPeriod * 20, b: dPeriod * 30 };
+ },
+ grayscaleColorFunc: (d: number) => {
+ const value = 255 - Math.abs(d) * 10;
+ return { r: value, g: value, b: value };
+ },
+ heatmapColorFunc: (d: number) => {
+ const value = Math.min(255, Math.max(0, 255 - Math.abs(d) * 10));
+ return { r: value, g: 0, b: 255 - value };
+ },
+ invertedColorFunc: (d: number) => {
+ const value = Math.abs(d) % 255;
+ return { r: 255 - value, g: 255 - value, b: 255 - value };
+ },
+ rainbowColorFunc: (d: number) => {
+ const value = Math.abs(d) % 255;
+ return { r: (value * 5) % 255, g: (value * 3) % 255, b: (value * 7) % 255 };
+ },
+ };
+
+ public renderEDT(canvas: CanvasRenderingContext2D): void {
+ const imageData = canvas.getImageData(0, 0, this.resolution, this.resolution);
+ for (let row = 0; row < this.resolution; row++) {
+ for (let col = 0; col < this.resolution; col++) {
+ const index = (col * this.resolution + row) * 4;
+ const distance = this.edt[row][col];
+ const color = this.Color.simpleColorFunc(distance);
+ imageData.data[index] = color.r;
+ imageData.data[index + 1] = color.g;
+ imageData.data[index + 2] = color.b;
+ imageData.data[index + 3] = 255;
+ }
+ }
+ canvas.putImageData(imageData, 0, 0);
+ }
+ public renderCPT(canvas: CanvasRenderingContext2D): void {
+ const imageData = canvas.getImageData(0, 0, this.resolution, this.resolution);
+
+ for (let row = 0; row < this.resolution; row++) {
+ for (let col = 0; col < this.resolution; col++) {
+ const { x, y } = this.cpt[row][col];
+ const shapeColor = this.colorField[x][y];
+ const color = {
+ r: (shapeColor * 7) % 150,
+ g: (shapeColor * 13) % 200,
+ b: (shapeColor * 19) % 250,
+ };
+ const index = (col * this.resolution + row) * 4;
+ imageData.data[index] = color.r;
+ imageData.data[index + 1] = color.g;
+ imageData.data[index + 2] = color.b;
+ imageData.data[index + 3] = 255;
+ }
+ }
+ canvas.putImageData(imageData, 0, 0);
+ }
+
+ public renderBoth(canvas: CanvasRenderingContext2D): void {
+ const imageData = canvas.getImageData(0, 0, this.resolution, this.resolution);
+
+ for (let row = 0; row < this.resolution; row++) {
+ for (let col = 0; col < this.resolution; col++) {
+ const index = (col * this.resolution + row) * 4;
+ const distance = this.edt[row][col];
+ const { x, y } = this.cpt[row][col];
+ const shapeColor = this.colorField[x][y] % 200;
+
+ const maxDistance = 10;
+ const normalizedDistance = Math.sqrt(distance) / maxDistance;
+ const baseColor = {
+ r: (shapeColor * 7) % 256,
+ g: (shapeColor * 13) % 256,
+ b: (shapeColor * 19) % 256,
+ };
+ const color = {
+ r: baseColor.r * (1 - normalizedDistance),
+ g: baseColor.g * (1 - normalizedDistance),
+ b: baseColor.b * (1 - normalizedDistance),
+ };
+
+ imageData.data[index] = color.r;
+ imageData.data[index + 1] = color.g;
+ imageData.data[index + 2] = color.b;
+ imageData.data[index + 3] = 255;
+ }
+ }
+ canvas.putImageData(imageData, 0, 0);
+ }
+
+ updateShape(index: number, points: Vector2[], isClosed: boolean = true) {
+ if (index >= 0 && index < this.shapes.length) {
+ const existingColor = this.shapes[index].color;
+ this.shapes[index] = { points, color: existingColor, isClosed };
+ this.updateFields();
+ }
+ }
+}
diff --git a/src/distanceField/utils.ts b/src/distanceField/utils.ts
new file mode 100644
index 0000000..aca089f
--- /dev/null
+++ b/src/distanceField/utils.ts
@@ -0,0 +1,41 @@
+import type { Vector2 } from '../utils/Vector2.ts';
+
+export function intersectParabolas(p: Vector2, q: Vector2): Vector2 {
+ const x = (q.y + q.x * q.x - (p.y + p.x * p.x)) / (2 * q.x - 2 * p.x);
+ return { x, y: 0 };
+}
+
+export function transpose(matrix: Float32Array[]) {
+ for (let i = 0; i < matrix.length; i++) {
+ for (let j = i + 1; j < matrix[i].length; j++) {
+ const temp = matrix[i][j];
+ matrix[i][j] = matrix[j][i];
+ matrix[j][i] = temp;
+ }
+ }
+}
+
+export function findHullParabolas(row: Float32Array, verts: Vector2[], intersections: Vector2[]) {
+ let k = 0;
+
+ verts[0] = { x: 0, y: row[0] };
+ intersections[0] = { x: -Infinity, y: 0 };
+ intersections[1] = { x: Infinity, y: 0 };
+
+ for (let i = 1; i < row.length; i++) {
+ const q: Vector2 = { x: i, y: row[i] };
+ let p = verts[k];
+ let s = intersectParabolas(p, q);
+
+ while (s.x <= intersections[k].x) {
+ k--;
+ p = verts[k];
+ s = intersectParabolas(p, q);
+ }
+
+ k++;
+ verts[k] = q;
+ intersections[k] = s;
+ intersections[k + 1] = { x: Infinity, y: 0 };
+ }
+}