From c639101942f15a2f7334f082a661bcf028c85299 Mon Sep 17 00:00:00 2001 From: Orion Reed Date: Sun, 1 Dec 2024 03:16:04 -0500 Subject: [PATCH] distance fields demo --- demo/distance.html | 61 ++++++++ src/arrows/abstract-arrow.ts | 4 +- src/distanceField/cellRenderer.ts | 174 +++++++++++++++++++++ src/distanceField/cpt.ts | 51 ++++++ src/distanceField/edt.ts | 36 +++++ src/distanceField/fields.ts | 250 ++++++++++++++++++++++++++++++ src/distanceField/utils.ts | 41 +++++ 7 files changed, 615 insertions(+), 2 deletions(-) create mode 100644 demo/distance.html create mode 100644 src/distanceField/cellRenderer.ts create mode 100644 src/distanceField/cpt.ts create mode 100644 src/distanceField/edt.ts create mode 100644 src/distanceField/fields.ts create mode 100644 src/distanceField/utils.ts 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 }; + } +}