Skip to content

Commit

Permalink
distance fields demo
Browse files Browse the repository at this point in the history
  • Loading branch information
OrionReed committed Dec 1, 2024
1 parent 3c61a41 commit c639101
Show file tree
Hide file tree
Showing 7 changed files with 615 additions and 2 deletions.
61 changes: 61 additions & 0 deletions demo/distance.html
Original file line number Diff line number Diff line change
@@ -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>
4 changes: 2 additions & 2 deletions src/arrows/abstract-arrow.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
174 changes: 174 additions & 0 deletions src/distanceField/cellRenderer.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 51 additions & 0 deletions src/distanceField/cpt.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 36 additions & 0 deletions src/distanceField/edt.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading

0 comments on commit c639101

Please sign in to comment.