distance fields demo

This commit is contained in:
Orion Reed 2024-12-01 03:16:04 -05:00
parent 3c61a411cd
commit c639101942
7 changed files with 615 additions and 2 deletions

61
demo/distance.html Normal file
View File

@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Distance Field Demo</title>
<style>
html {
height: 100%;
}
body {
min-height: 100%;
position: relative;
margin: 0;
}
fc-geometry {
background: transparent;
position: absolute;
}
cell-renderer canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
}
</style>
</head>
<body>
<cell-renderer resolution="800" image-smoothing="true"></cell-renderer>
<fc-geometry x="100" y="100" width="50" height="50"></fc-geometry>
<fc-geometry x="100" y="200" width="50" height="50"></fc-geometry>
<fc-geometry x="100" y="300" width="50" height="50"></fc-geometry>
<fc-geometry x="300" y="150" width="80" height="40"></fc-geometry>
<fc-geometry x="400" y="250" width="60" height="90"></fc-geometry>
<fc-geometry x="200" y="400" width="100" height="100"></fc-geometry>
<fc-geometry x="500" y="100" width="30" height="70"></fc-geometry>
<script type="module">
import { FolkGeometry } from '../src/canvas/fc-geometry.ts';
import { CellRenderer } from '../src/distanceField/cellRenderer.ts';
FolkGeometry.define();
CellRenderer.define();
// Get all geometry elements and create points for the distance field
const geometries = document.querySelectorAll('fc-geometry');
const renderer = document.querySelector('cell-renderer');
// Update distance field when geometries move or resize
geometries.forEach((geometry) => {
geometry.addEventListener('move', (e) => renderer.handleGeometryUpdate(e));
geometry.addEventListener('resize', (e) => renderer.handleGeometryUpdate(e));
});
</script>
</body>
</html>

View File

@ -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();

View File

@ -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();
}
}

51
src/distanceField/cpt.ts Normal file
View File

@ -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;
}
}

36
src/distanceField/edt.ts Normal file
View File

@ -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;
}
}

250
src/distanceField/fields.ts Normal file
View File

@ -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();
}
}
}

View File

@ -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 };
}
}