diff --git a/ROADMAP.md b/ROADMAP.md index 9067872..d00ea8a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,13 +19,12 @@ Open ideas for the playground frontend. Tick a box when done. ## Features -- [ ] Lenia: display zoom so small creatures (e.g. Orbium bicaudatus tails) are visible - [ ] **Shareable demo state**: keep demo settings in query params (fractal zoom/center, sorting algorithm, seeds). Opening the link restores the view. - [ ] **New algorithm demos** (pattern: route + `RouterConstants` + `AlgorithmsService` entry + i18n + information panel): - [x] Boids / flocking (GPU path like cloth) - [x] **Physarum** (slime mold transport networks, trail map on GPU compute, CPU fallback) - [ ] Boids 3D (later, 2D done) - - [ ] Voronoi / Delaunay + - [x] **Voronoi / Delaunay** (Bowyer-Watson, CPU + Canvas 2D) - [x] A* vs Dijkstra side by side (reuse pathfinding) - [x] Fourier epicycles - [x] Reaction-diffusion (Gray-Scott) shader (reuse the fractal shader setup) diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 7e7ad3e..e1e3d94 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -22,6 +22,7 @@ export const routes: Routes = [ { path: RouterConstants.LENIA.PATH, loadComponent: () => import('./pages/algorithms/lenia/lenia.component').then(m => m.LeniaComponent), data: { seo: 'ALGORITHMS' } }, { path: RouterConstants.FLUID.PATH, loadComponent: () => import('./pages/algorithms/fluid/fluid.component').then(m => m.FluidComponent), data: { seo: 'ALGORITHMS' } }, { path: RouterConstants.ANT_COLONY.PATH, loadComponent: () => import('./pages/algorithms/ant-colony/ant-colony.component').then(m => m.AntColonyComponent), data: { seo: 'ALGORITHMS' } }, + { path: RouterConstants.VORONOI.PATH, loadComponent: () => import('./pages/algorithms/voronoi/voronoi.component').then(m => m.VoronoiComponent), data: { seo: 'ALGORITHMS' } }, { path: RouterConstants.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent), data: { seo: 'ALGORITHMS' } }, { path: RouterConstants.FOURIER.PATH, loadComponent: () => import('./pages/algorithms/fourier/fourier.component').then(m => m.FourierComponent), data: { seo: 'FOURIER' } }, { path: RouterConstants.STOPWATCH.PATH, loadComponent: () => import('./pages/stopwatch/stopwatch.component').then(m => m.StopwatchComponent), data: { seo: 'STOPWATCH' } }, diff --git a/src/app/constants/RouterConstants.ts b/src/app/constants/RouterConstants.ts index e3dfa5d..7c7812f 100644 --- a/src/app/constants/RouterConstants.ts +++ b/src/app/constants/RouterConstants.ts @@ -85,6 +85,11 @@ LINK: '/algorithms/ant-colony', }; + static readonly VORONOI = { + PATH: 'algorithms/voronoi', + LINK: '/algorithms/voronoi', + }; + static readonly FOUR_COLOR = { PATH: 'algorithms/four_color', LINK: '/algorithms/four_color', diff --git a/src/app/constants/UrlConstants.ts b/src/app/constants/UrlConstants.ts index 8306324..6d6f369 100644 --- a/src/app/constants/UrlConstants.ts +++ b/src/app/constants/UrlConstants.ts @@ -33,6 +33,9 @@ static readonly GPU_GEMS_FLUIDS = 'https://developer.nvidia.com/gpugems/gpugems/part-vi-beyond-triangles/chapter-38-fast-fluid-dynamics-simulation-gpu' static readonly ANT_COLONY_WIKI = 'https://de.wikipedia.org/wiki/Ameisenalgorithmus' static readonly TSP_WIKI = 'https://de.wikipedia.org/wiki/Problem_des_Handlungsreisenden' + static readonly DELAUNAY_WIKI = 'https://de.wikipedia.org/wiki/Delaunay-Triangulierung' + static readonly VORONOI_WIKI = 'https://de.wikipedia.org/wiki/Voronoi-Diagramm' + static readonly BOWYER_WATSON_WIKI = 'https://en.wikipedia.org/wiki/Bowyer%E2%80%93Watson_algorithm' static readonly GPU_COMPUTING_WIKI = 'https://en.wikipedia.org/wiki/General-purpose_computing_on_graphics_processing_units' static readonly DATA_STRUCTURE_WIKI = 'https://de.wikipedia.org/wiki/Datenstruktur' static readonly FOUR_COLOR_THEOREM = 'https://de.wikipedia.org/wiki/Vier-Farben-Satz' diff --git a/src/app/pages/algorithms/algorithms.service.ts b/src/app/pages/algorithms/algorithms.service.ts index cd9df1c..3effdd4 100644 --- a/src/app/pages/algorithms/algorithms.service.ts +++ b/src/app/pages/algorithms/algorithms.service.ts @@ -50,6 +50,13 @@ export class AlgorithmsService { routerLink: RouterConstants.ANT_COLONY.LINK, icon: 'pest_control' }, + { + id: 'voronoi', + title: 'ALGORITHM.VORONOI.TITLE', + description: 'ALGORITHM.VORONOI.DESCRIPTION', + routerLink: RouterConstants.VORONOI.LINK, + icon: 'hexagon' + }, { id: 'fractal3d', title: 'ALGORITHM.FRACTAL3D.TITLE', diff --git a/src/app/pages/algorithms/information/source-paths.ts b/src/app/pages/algorithms/information/source-paths.ts index 485d4d3..53d8730 100644 --- a/src/app/pages/algorithms/information/source-paths.ts +++ b/src/app/pages/algorithms/information/source-paths.ts @@ -16,4 +16,5 @@ export const SOURCE_PATHS = { LENIA: ['src/app/pages/algorithms/lenia/lenia.shader.ts', 'src/app/pages/algorithms/lenia/lenia-simulation.ts', 'src/app/pages/algorithms/lenia/lenia-seed.ts'], FLUID: ['src/app/pages/algorithms/fluid/fluid.shader.ts', 'src/app/pages/algorithms/fluid/fluid-simulation.ts', 'src/app/pages/algorithms/fluid/fluid.model.ts'], ANT_COLONY: ['src/app/pages/algorithms/ant-colony/ant-colony-simulation.ts', 'src/app/pages/algorithms/ant-colony/ant-colony.model.ts'], + VORONOI: ['src/app/pages/algorithms/voronoi/delaunay.ts', 'src/app/pages/algorithms/voronoi/voronoi.ts'], } as const satisfies Record; diff --git a/src/app/pages/algorithms/voronoi/delaunay.spec.ts b/src/app/pages/algorithms/voronoi/delaunay.spec.ts new file mode 100644 index 0000000..f6e6524 --- /dev/null +++ b/src/app/pages/algorithms/voronoi/delaunay.spec.ts @@ -0,0 +1,132 @@ +import {DelaunayBuilder, Triangle, triangulate} from './delaunay'; +import {Point, randomPoints} from './voronoi.model'; + +function seededRandom(seed: number): () => number { + let s = seed; + return () => { + s = (s * 1664525 + 1013904223) % 4294967296; + return s / 4294967296; + }; +} + +function area(points: readonly Point[], t: Triangle): number { + const a = points[t.a]; + const b = points[t.b]; + const c = points[t.c]; + return Math.abs((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) / 2; +} + +function canonical(triangles: readonly Triangle[]): string[] { + return triangles.map(t => [t.a, t.b, t.c].sort((x, y) => x - y).join(',')).sort(); +} + +function hullSize(points: readonly Point[]): number { + const sorted = [...points].sort((p, q) => p.x - q.x || p.y - q.y); + const cross = (o: Point, a: Point, b: Point) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); + const half = (input: Point[]): Point[] => { + const out: Point[] = []; + for (const p of input) { + while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], p) <= 0) { + out.pop(); + } + out.push(p); + } + out.pop(); + return out; + }; + return half(sorted).length + half([...sorted].reverse()).length; +} + +describe('Delaunay (Bowyer-Watson)', () => { + it('every result triangle has an empty circumcircle', () => { + const points = randomPoints(200, seededRandom(1)); + const builder = triangulate(points); + for (const t of builder.result()) { + for (let i = 0; i < points.length; i++) { + if (i !== t.a && i !== t.b && i !== t.c) { + const d2 = (points[i].x - t.cx) ** 2 + (points[i].y - t.cy) ** 2; + expect(d2).toBeGreaterThanOrEqual(t.r2 * (1 - 1e-9)); + } + } + } + }); + + it('has 2n - 2 - h triangles for random points', () => { + for (const seed of [2, 3, 4]) { + const points = randomPoints(120, seededRandom(seed)); + expect(triangulate(points).result()).toHaveLength(2 * points.length - 2 - hullSize(points)); + } + }); + + it('gives no triangle for 0, 1 and 2 points', () => { + for (const count of [0, 1, 2]) { + const points = randomPoints(count, seededRandom(5)); + expect(triangulate(points).result()).toEqual([]); + } + }); + + it('gives no triangle for 3 collinear points', () => { + const points: Point[] = [{x: 0, y: 0}, {x: 10, y: 10}, {x: 20, y: 20}]; + expect(triangulate(points).result()).toEqual([]); + }); + + it('triangulates 10 collinear points plus one off the line', () => { + const points: Point[] = Array.from({length: 10}, (_, i) => ({x: i * 10, y: 0})); + points.push({x: 45, y: 30}); + const result = triangulate(points).result(); + expect(result).toHaveLength(9); + for (const t of result) { + expect(area(points, t)).toBeGreaterThan(1e-6); + } + }); + + it('skips a duplicate point and gives the same result as without it', () => { + const points = randomPoints(30, seededRandom(6)); + const withDuplicate = [...points, {...points[7]}]; + const builder = new DelaunayBuilder(withDuplicate); + const steps = withDuplicate.map((_, i) => builder.insert(i)); + expect(steps[30].kind).toBe('skip'); + expect(canonical(builder.result())).toEqual(canonical(triangulate(points).result())); + }); + + it('triangulates a 3x3 grid of cocircular points', () => { + const points: Point[] = []; + for (let y = 0; y < 3; y++) { + for (let x = 0; x < 3; x++) { + points.push({x: x * 100, y: y * 100}); + } + } + const result = triangulate(points).result(); + expect(result).toHaveLength(8); + for (const t of result) { + expect(area(points, t)).toBeGreaterThan(1e-6); + } + }); + + it('step by step insertion ends in the same result as instant mode', () => { + const points = randomPoints(60, seededRandom(7)); + const builder = new DelaunayBuilder(points); + for (let i = 0; i < points.length; i++) { + const step = builder.insert(i); + expect(step.kind).toBe('insert'); + if (step.kind === 'insert') { + expect(step.bad.length).toBeGreaterThan(0); + expect(step.created).toHaveLength(step.cavity.length); + } + } + expect(canonical(builder.result())).toEqual(canonical(triangulate(points).result())); + }); +}); + +describe('Delaunay review findings', () => { + it('collinearBorderPointsGiveNoDegenerateTriangle', () => { + const points: Point[] = [ + {x: 921.3887667688259, y: 1000}, {x: 879.0779328384477, y: 1000}, {x: 125.68431207449416, y: 1000}, + {x: 878.1822236030316, y: 1000}, {x: 894.6164260177268, y: 1000} + ]; + const builder = triangulate(points); + for (const t of builder.triangles) { + expect(Number.isFinite(t.r2)).toBe(true); + } + }); +}); diff --git a/src/app/pages/algorithms/voronoi/delaunay.ts b/src/app/pages/algorithms/voronoi/delaunay.ts new file mode 100644 index 0000000..262b7f9 --- /dev/null +++ b/src/app/pages/algorithms/voronoi/delaunay.ts @@ -0,0 +1,153 @@ +import {Point, WORLD_SIZE} from './voronoi.model'; + +export interface Triangle { + /** Vertex indices, counter-clockwise. */ + a: number; + b: number; + c: number; + /** Circumcircle: center and squared radius. */ + cx: number; + cy: number; + r2: number; +} + +export type Edge = readonly [number, number]; + +export type InsertionStep = + | {kind: 'insert'; point: number; bad: Triangle[]; cavity: Edge[]; created: Triangle[]} + | {kind: 'skip'; point: number}; + +/** The super triangle is this many times larger than the bounding box, so hull edges stay inside the world rect. */ +const SUPER_SCALE = 1000; +/** Relative margin for the in-circle and orientation tests. Cocircular points count as outside. */ +const IN_CIRCLE_EPS = 1e-12; +const DUPLICATE_EPS = 1e-6; + +/** Bowyer-Watson. Vertices are the input points, then 3 super triangle vertices at n, n + 1, n + 2. */ +export class DelaunayBuilder { + readonly vertices: Point[]; + triangles: Triangle[]; + private readonly pointCount: number; + private readonly inserted: number[] = []; + + constructor(points: readonly Point[]) { + this.pointCount = points.length; + this.vertices = [...points]; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const p of points) { + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + } + if (points.length === 0) { + minX = minY = maxX = maxY = 0; + } + // The floor keeps the bisectors to the super vertices far away for few or clustered points. + const size = Math.max(maxX - minX, maxY - minY, WORLD_SIZE) * SUPER_SCALE; + const mx = (minX + maxX) / 2; + const my = (minY + maxY) / 2; + const n = this.pointCount; + this.vertices.push({x: mx - size, y: my - size}, {x: mx + size, y: my - size}, {x: mx, y: my + size}); + this.triangles = [makeTriangle(this.vertices, n, n + 1, n + 2)]; + } + + isSuper(vertex: number): boolean { + return vertex >= this.pointCount; + } + + /** One insertion. Returns what it did, for step mode. */ + insert(index: number): InsertionStep { + const p = this.vertices[index]; + for (const other of this.inserted) { + if (Math.hypot(this.vertices[other].x - p.x, this.vertices[other].y - p.y) < DUPLICATE_EPS) { + return {kind: 'skip', point: index}; + } + } + const bad: Triangle[] = []; + const good: Triangle[] = []; + for (const t of this.triangles) { + (inCircle(this.vertices, t, p) ? bad : good).push(t); + } + if (bad.length === 0) { + return {kind: 'skip', point: index}; + } + const edgeKeys = new Set(); + const stride = this.vertices.length; + for (const t of bad) { + edgeKeys.add(t.a * stride + t.b).add(t.b * stride + t.c).add(t.c * stride + t.a); + } + const cavity: Edge[] = []; + for (const t of bad) { + for (const [u, v] of [[t.a, t.b], [t.b, t.c], [t.c, t.a]] as const) { + if (!edgeKeys.has(v * stride + u)) { + cavity.push([u, v]); + } + } + } + if (cavity.some(([u, v]) => isDegenerate(this.vertices, u, v, index))) { + return {kind: 'skip', point: index}; + } + const created = cavity.map(([u, v]) => makeTriangle(this.vertices, u, v, index)); + this.triangles = [...good, ...created]; + this.inserted.push(index); + return {kind: 'insert', point: index, bad, cavity, created}; + } + + /** Triangles that touch no super vertex. */ + result(): Triangle[] { + return this.triangles.filter(t => !this.isSuper(t.a) && !this.isSuper(t.b) && !this.isSuper(t.c)); + } +} + +export function triangulate(points: readonly Point[]): DelaunayBuilder { + const builder = new DelaunayBuilder(points); + for (let i = 0; i < points.length; i++) { + builder.insert(i); + } + return builder; +} + +function makeTriangle(vertices: readonly Point[], a: number, b: number, c: number): Triangle { + const pa = vertices[a]; + const cross = (vertices[b].x - pa.x) * (vertices[c].y - pa.y) - (vertices[b].y - pa.y) * (vertices[c].x - pa.x); + if (cross < 0) { + [b, c] = [c, b]; + } + // Circumcenter relative to pa, which keeps the numbers small. + const bx = vertices[b].x - pa.x; + const by = vertices[b].y - pa.y; + const cx = vertices[c].x - pa.x; + const cy = vertices[c].y - pa.y; + const d = 2 * (bx * cy - by * cx); + const ux = (cy * (bx * bx + by * by) - by * (cx * cx + cy * cy)) / d; + const uy = (bx * (cx * cx + cy * cy) - cx * (bx * bx + by * by)) / d; + return {a, b, c, cx: pa.x + ux, cy: pa.y + uy, r2: ux * ux + uy * uy}; +} + +function isDegenerate(vertices: readonly Point[], a: number, b: number, c: number): boolean { + const pa = vertices[a]; + const t1 = (vertices[b].x - pa.x) * (vertices[c].y - pa.y); + const t2 = (vertices[b].y - pa.y) * (vertices[c].x - pa.x); + return Math.abs(t1 - t2) <= IN_CIRCLE_EPS * (Math.abs(t1) + Math.abs(t2)); +} + +/** In-circle determinant relative to p, so it does not depend on the stored circumcenter. t is counter-clockwise. */ +function inCircle(vertices: readonly Point[], t: Triangle, p: Point): boolean { + const adx = vertices[t.a].x - p.x; + const ady = vertices[t.a].y - p.y; + const bdx = vertices[t.b].x - p.x; + const bdy = vertices[t.b].y - p.y; + const cdx = vertices[t.c].x - p.x; + const cdy = vertices[t.c].y - p.y; + const al = adx * adx + ady * ady; + const bl = bdx * bdx + bdy * bdy; + const cl = cdx * cdx + cdy * cdy; + const x = al * (bdx * cdy - bdy * cdx); + const y = bl * (cdx * ady - cdy * adx); + const z = cl * (adx * bdy - ady * bdx); + return x + y + z > IN_CIRCLE_EPS * (Math.abs(x) + Math.abs(y) + Math.abs(z)); +} diff --git a/src/app/pages/algorithms/voronoi/voronoi.component.html b/src/app/pages/algorithms/voronoi/voronoi.component.html new file mode 100644 index 0000000..17b7e28 --- /dev/null +++ b/src/app/pages/algorithms/voronoi/voronoi.component.html @@ -0,0 +1,40 @@ + + + {{ 'VORONOI.TITLE' | translate }} + + + +
+
+ + + + {{ 'VORONOI.CELLS' | translate }} + {{ 'VORONOI.EDGES' | translate }} + {{ 'VORONOI.CIRCLES' | translate }} + +
+
+ + + +
+ + +

{{ 'VORONOI.HINT' | translate }}

+
+ +
+ +
+
+
diff --git a/src/app/pages/algorithms/voronoi/voronoi.component.spec.ts b/src/app/pages/algorithms/voronoi/voronoi.component.spec.ts new file mode 100644 index 0000000..498f169 --- /dev/null +++ b/src/app/pages/algorithms/voronoi/voronoi.component.spec.ts @@ -0,0 +1,50 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {provideZonelessChangeDetection} from '@angular/core'; +import {provideNoopAnimations} from '@angular/platform-browser/animations'; +import {provideTranslateService} from '@ngx-translate/core'; +import {VoronoiComponent} from './voronoi.component'; +import {THEME_COLORS} from './voronoi.model'; + +describe('VoronoiComponent', () => { + let fixture: ComponentFixture; + let component: VoronoiComponent; + const originalGetContext = HTMLCanvasElement.prototype.getContext; + let fills: string[]; + + beforeEach(async () => { + fills = []; + // jsdom has no 2D context; record every fillStyle so the test sees what was filled. + const fakeContext: Record = { + clearRect: vi.fn(), beginPath: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), stroke: vi.fn(), fill: vi.fn(), arc: vi.fn(), + closePath: vi.fn(), setTransform: vi.fn(), strokeStyle: '', lineWidth: 1 + }; + Object.defineProperty(fakeContext, 'fillStyle', {set: (value: string) => fills.push(value), get: () => ''}); + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {configurable: true, value: () => fakeContext}); + vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1)); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + + await TestBed.configureTestingModule({ + imports: [VoronoiComponent], + providers: [provideZonelessChangeDetection(), provideNoopAnimations(), provideTranslateService({lang: 'en', fallbackLang: 'en'})] + }).compileComponents(); + + fixture = TestBed.createComponent(VoronoiComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {configurable: true, value: originalGetContext}); + }); + + it('firstStepShowsBadTriangles', () => { + const badColors = [THEME_COLORS.light.bad, THEME_COLORS.dark.bad]; + component.toggleStepMode(); + component.togglePlay(); + component.singleStep(); + fills = []; + (component as unknown as {frame: (now: number) => void}).frame(16); + expect(fills.some(fill => badColors.includes(fill))).toBe(true); + }); +}); diff --git a/src/app/pages/algorithms/voronoi/voronoi.component.ts b/src/app/pages/algorithms/voronoi/voronoi.component.ts new file mode 100644 index 0000000..eadc18e --- /dev/null +++ b/src/app/pages/algorithms/voronoi/voronoi.component.ts @@ -0,0 +1,446 @@ +import {AfterViewInit, Component, computed, ElementRef, HostListener, inject, NgZone, OnDestroy, PLATFORM_ID, signal, ViewChild} from '@angular/core'; +import {isPlatformBrowser} from '@angular/common'; +import {MatButton} from '@angular/material/button'; +import {MatButtonToggleModule} from '@angular/material/button-toggle'; +import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; +import {MatIcon} from '@angular/material/icon'; +import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field'; +import {TranslatePipe} from '@ngx-translate/core'; +import {Information} from '../information/information'; +import {AlgorithmInformation} from '../information/information.models'; +import {SOURCE_PATHS} from '../information/source-paths'; +import {UrlConstants} from '../../../constants/UrlConstants'; +import {ThemeService} from '../../../service/theme.service'; +import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics'; +import {FpsCounter} from '../../../shared/fps-counter'; +import {DelaunayBuilder, Edge, InsertionStep, Triangle, triangulate} from './delaunay'; +import {voronoiCells} from './voronoi'; +import { + clampPointCount, clampToWorld, CLICK_MOVE_PX, DEFAULT_POINTS, DEFAULT_STEP_SPEED, hitTest, HIT_RADIUS_PX, MAX_CANVAS_PX, MAX_POINTS, + MAX_STEP_SPEED, MIN_POINT_DISTANCE, MIN_POINTS, MIN_STEP_SPEED, Point, randomPoints, THEME_COLORS, TOUCH_HIT_RADIUS_PX, VoronoiColors, WORLD_RECT, + WORLD_SIZE +} from './voronoi.model'; + +type InsertStep = Extract; + +@Component({ + selector: 'app-voronoi', + imports: [ + MatCard, + MatCardContent, + MatCardHeader, + MatCardTitle, + TranslatePipe, + Information, + MatButton, + MatButtonToggleModule, + MatIcon, + SliderFieldComponent, + MetricsComponent + ], + templateUrl: './voronoi.component.html' +}) +export class VoronoiComponent implements AfterViewInit, OnDestroy { + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + private readonly themeService = inject(ThemeService); + private readonly ngZone = inject(NgZone); + private readonly fpsCounter = new FpsCounter(); + + @ViewChild('voronoiCanvas', {static: true}) canvasRef!: ElementRef; + private ctx!: CanvasRenderingContext2D; + private animationFrameId = 0; + private lastFrameTime = 0; + private cssSize = MAX_CANVAS_PX; + private pressed: {pointerId: number; x: number; y: number; dragIndex: number} | null = null; + + private points: Point[] = []; + private builder: DelaunayBuilder = triangulate([]); + private cells: Point[][] = []; + private dirty = true; + private next = 0; + private stepPhase = 0; + private stepAccumulator = 0; + private lastStep: InsertStep | null = null; + + protected readonly MIN_POINTS = MIN_POINTS; + protected readonly MAX_POINTS = MAX_POINTS; + protected readonly MIN_STEP_SPEED = MIN_STEP_SPEED; + protected readonly MAX_STEP_SPEED = MAX_STEP_SPEED; + + readonly pointCount = signal(DEFAULT_POINTS); + readonly showCells = signal(true); + readonly showEdges = signal(true); + readonly showCircles = signal(false); + readonly stepMode = signal(false); + readonly playing = signal(true); + readonly stepSpeed = signal(DEFAULT_STEP_SPEED); + readonly triangleCount = signal(0); + readonly buildMs = signal(0); + + readonly layers = computed(() => [ + ...(this.showCells() ? ['cells'] : []), + ...(this.showEdges() ? ['edges'] : []), + ...(this.showCircles() ? ['circles'] : []) + ]); + + readonly metrics = computed(() => [ + {label: 'METRICS.FPS', value: this.fpsCounter.fps()}, + {label: 'VORONOI.POINTS', value: this.pointCount()}, + {label: 'VORONOI.TRIANGLES', value: this.triangleCount()}, + {label: 'VORONOI.BUILD_MS', value: this.buildMs(), unit: 'ms', fractionDigits: 2} + ]); + + readonly algoInformation: AlgorithmInformation = { + title: 'VORONOI.EXPLANATION.TITLE', + entries: [ + { + name: 'VORONOI.EXPLANATION.DELAUNAY_TITLE', + description: 'VORONOI.EXPLANATION.DELAUNAY', + link: UrlConstants.DELAUNAY_WIKI, + translateName: true + }, + { + name: 'VORONOI.EXPLANATION.VORONOI_TITLE', + description: 'VORONOI.EXPLANATION.VORONOI', + link: UrlConstants.VORONOI_WIKI, + translateName: true + }, + { + name: 'VORONOI.EXPLANATION.BOWYER_WATSON_TITLE', + description: 'VORONOI.EXPLANATION.BOWYER_WATSON', + link: UrlConstants.BOWYER_WATSON_WIKI, + translateName: true + } + ], + disclaimer: 'VORONOI.EXPLANATION.DISCLAIMER', + disclaimerBottom: '', + disclaimerListEntry: ['VORONOI.EXPLANATION.DISCLAIMER_1', 'VORONOI.EXPLANATION.DISCLAIMER_2', 'VORONOI.EXPLANATION.DISCLAIMER_3'], + howItWorks: { + intro: 'VORONOI.HOW_IT_WORKS.INTRO', + items: ['VORONOI.HOW_IT_WORKS.ITEM_1', 'VORONOI.HOW_IT_WORKS.ITEM_2', 'VORONOI.HOW_IT_WORKS.ITEM_3', 'VORONOI.HOW_IT_WORKS.ITEM_4'] + }, + sources: SOURCE_PATHS.VORONOI + }; + + ngAfterViewInit(): void { + if (!this.isBrowser) { + return; + } + this.ctx = this.canvasRef.nativeElement.getContext('2d')!; + this.resizeCanvas(); + this.randomize(); + this.ngZone.runOutsideAngular(() => { + this.animationFrameId = requestAnimationFrame(this.frame); + }); + } + + ngOnDestroy(): void { + if (this.isBrowser) { + cancelAnimationFrame(this.animationFrameId); + } + } + + /** Changes only the canvas size. The points live in world units. */ + @HostListener('window:resize') + resizeCanvas(): void { + const canvas = this.canvasRef.nativeElement; + const dpr = window.devicePixelRatio || 1; + this.cssSize = Math.min(canvas.parentElement!.clientWidth, MAX_CANVAS_PX); + canvas.width = canvas.height = Math.round(this.cssSize * dpr); + canvas.style.width = canvas.style.height = `${this.cssSize}px`; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + + randomize(): void { + this.points = randomPoints(clampPointCount(this.pointCount())); + this.pointCount.set(this.points.length); + this.edit(); + } + + clear(): void { + this.points = []; + this.pointCount.set(0); + this.edit(); + } + + setLayers(layers: string[]): void { + this.showCells.set(layers.includes('cells')); + this.showEdges.set(layers.includes('edges')); + this.showCircles.set(layers.includes('circles')); + } + + toggleStepMode(): void { + if (this.stepMode()) { + this.edit(); + } else { + this.startSteps(); + } + } + + togglePlay(): void { + this.playing.update(v => !v); + } + + singleStep(): void { + if (this.stepMode()) { + this.tickStep(); + } + } + + onPointerDown(event: PointerEvent): void { + if (!event.isPrimary) { + return; + } + this.canvasRef.nativeElement.setPointerCapture(event.pointerId); + const px = event.pointerType === 'touch' ? TOUCH_HIT_RADIUS_PX : HIT_RADIUS_PX; + const dragIndex = hitTest(this.points, this.toWorld(event), px * WORLD_SIZE / this.cssSize); + this.pressed = {pointerId: event.pointerId, x: event.clientX, y: event.clientY, dragIndex}; + } + + onPointerMove(event: PointerEvent): void { + const down = this.pressed; + if (!down || down.pointerId !== event.pointerId || down.dragIndex < 0) { + return; + } + this.points[down.dragIndex] = clampToWorld(this.toWorld(event)); + this.edit(); + } + + onPointerUp(event: PointerEvent): void { + const down = this.pressed; + this.pressed = null; + if (!down || down.pointerId !== event.pointerId || down.dragIndex >= 0) { + return; + } + if (Math.hypot(event.clientX - down.x, event.clientY - down.y) >= CLICK_MOVE_PX) { + return; + } + const world = this.toWorld(event); + if (this.points.length < MAX_POINTS && hitTest(this.points, world, MIN_POINT_DISTANCE) < 0) { + this.points.push(clampToWorld(world)); + this.pointCount.set(this.points.length); + this.edit(); + } + } + + onPointerLeave(): void { + this.pressed = null; + } + + /** Any change of the points ends step mode and asks for an instant rebuild. */ + private edit(): void { + this.stepMode.set(false); + this.lastStep = null; + this.dirty = true; + } + + private startSteps(): void { + this.builder = new DelaunayBuilder(this.points); + this.cells = []; + this.next = 0; + this.stepPhase = 0; + this.stepAccumulator = 0; + this.lastStep = null; + this.dirty = false; + this.playing.set(true); + this.stepMode.set(true); + this.triangleCount.set(0); + } + + /** Each tick shows one phase: 0 the bad triangles of a new point (inserted now), 1 the cavity, 2 the new triangles. */ + private tickStep(): void { + if (this.lastStep && this.stepPhase < 2) { + this.stepPhase++; + } else { + this.lastStep = null; + this.stepPhase = 0; + let step: InsertStep | null = null; + while (!step && this.next < this.points.length) { + const result = this.builder.insert(this.next++); + if (result.kind === 'insert') { + step = result; + } + } + if (!step) { + this.finishSteps(); + return; + } + this.lastStep = step; + } + this.triangleCount.set(this.builder.result().length); + } + + private finishSteps(): void { + this.cells = voronoiCells(this.builder, this.points.length, WORLD_RECT); + this.triangleCount.set(this.builder.result().length); + this.lastStep = null; + this.stepPhase = 0; + this.stepMode.set(false); + } + + private rebuild(): void { + const start = performance.now(); + this.builder = triangulate(this.points); + this.cells = voronoiCells(this.builder, this.points.length, WORLD_RECT); + this.buildMs.set(performance.now() - start); + this.triangleCount.set(this.builder.result().length); + this.dirty = false; + } + + private toWorld(event: PointerEvent): Point { + const rect = this.canvasRef.nativeElement.getBoundingClientRect(); + return { + x: (event.clientX - rect.left) / rect.width * WORLD_SIZE, + y: (event.clientY - rect.top) / rect.height * WORLD_SIZE + }; + } + + private readonly frame = (now: number): void => { + this.fpsCounter.tick(now); + const dt = this.lastFrameTime ? Math.min((now - this.lastFrameTime) / 1000, 0.25) : 0; + this.lastFrameTime = now; + if (this.stepMode()) { + this.advanceSteps(dt); + } else if (this.dirty) { + this.rebuild(); + } + this.draw(); + this.animationFrameId = requestAnimationFrame(this.frame); + }; + + private advanceSteps(dt: number): void { + if (!this.playing()) { + return; + } + this.stepAccumulator += dt * this.stepSpeed(); + while (this.stepAccumulator >= 1 && this.stepMode()) { + this.stepAccumulator--; + this.tickStep(); + } + } + + private draw(): void { + const colors = THEME_COLORS[this.themeService.theme()]; + const stepMode = this.stepMode(); + this.ctx.clearRect(0, 0, this.cssSize, this.cssSize); + if (stepMode) { + this.drawStep(colors); + } else { + if (this.showCells()) { + this.drawCells(colors); + } + if (this.showEdges()) { + this.strokeTriangles(this.builder.result(), colors.edge, 1.5); + } + if (this.showCircles()) { + this.drawCircles(this.builder.result(), colors); + } + } + this.drawPoints(colors, stepMode); + } + + private drawCells(colors: VoronoiColors): void { + const ctx = this.ctx; + const scale = this.cssSize / WORLD_SIZE; + this.cells.forEach((cell, i) => { + if (cell.length < 3) { + return; + } + ctx.fillStyle = colors.cellFills[i % colors.cellFills.length]; + ctx.strokeStyle = colors.circle; + ctx.lineWidth = 1; + ctx.beginPath(); + cell.forEach((p, k) => (k === 0 ? ctx.moveTo(p.x * scale, p.y * scale) : ctx.lineTo(p.x * scale, p.y * scale))); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + }); + } + + private drawCircles(triangles: readonly Triangle[], colors: VoronoiColors): void { + const ctx = this.ctx; + const scale = this.cssSize / WORLD_SIZE; + ctx.strokeStyle = colors.circle; + ctx.lineWidth = 1; + for (const t of triangles) { + ctx.beginPath(); + ctx.arc(t.cx * scale, t.cy * scale, Math.sqrt(t.r2) * scale, 0, 2 * Math.PI); + ctx.stroke(); + } + } + + /** The current triangulation with the super triangle, so lines leave the canvas. */ + private drawStep(colors: VoronoiColors): void { + const step = this.lastStep; + let triangles = this.builder.triangles; + if (step && this.stepPhase < 2) { + const created = new Set(step.created); + triangles = triangles.filter(t => !created.has(t)); + if (this.stepPhase === 0) { + this.fillTriangles(step.bad, colors.bad); + triangles = [...triangles, ...step.bad]; + } + } + this.strokeTriangles(triangles, colors.edge, 1.5); + if (!step) { + return; + } + if (this.stepPhase === 1) { + this.strokeEdges(step.cavity, colors.cavity, 3); + } else if (this.stepPhase === 2) { + this.fillTriangles(step.created, colors.created); + } + } + + private strokeTriangles(triangles: readonly Triangle[], color: string, width: number): void { + this.ctx.strokeStyle = color; + this.ctx.lineWidth = width; + this.traceTriangles(triangles); + this.ctx.stroke(); + } + + private fillTriangles(triangles: readonly Triangle[], color: string): void { + this.ctx.fillStyle = color; + this.traceTriangles(triangles); + this.ctx.fill(); + } + + private traceTriangles(triangles: readonly Triangle[]): void { + const ctx = this.ctx; + const scale = this.cssSize / WORLD_SIZE; + const v = this.builder.vertices; + ctx.beginPath(); + for (const t of triangles) { + ctx.moveTo(v[t.a].x * scale, v[t.a].y * scale); + ctx.lineTo(v[t.b].x * scale, v[t.b].y * scale); + ctx.lineTo(v[t.c].x * scale, v[t.c].y * scale); + ctx.closePath(); + } + } + + private strokeEdges(edges: readonly Edge[], color: string, width: number): void { + const ctx = this.ctx; + const scale = this.cssSize / WORLD_SIZE; + const v = this.builder.vertices; + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.beginPath(); + for (const [a, b] of edges) { + ctx.moveTo(v[a].x * scale, v[a].y * scale); + ctx.lineTo(v[b].x * scale, v[b].y * scale); + } + ctx.stroke(); + } + + private drawPoints(colors: VoronoiColors, stepMode: boolean): void { + const ctx = this.ctx; + const scale = this.cssSize / WORLD_SIZE; + const highlighted = stepMode ? this.lastStep?.point ?? -1 : this.pressed?.dragIndex ?? -1; + this.points.forEach((p, i) => { + ctx.fillStyle = i === highlighted ? colors.selected : colors.point; + ctx.beginPath(); + ctx.arc(p.x * scale, p.y * scale, i === highlighted ? 6 : 4, 0, 2 * Math.PI); + ctx.fill(); + }); + } +} diff --git a/src/app/pages/algorithms/voronoi/voronoi.model.spec.ts b/src/app/pages/algorithms/voronoi/voronoi.model.spec.ts new file mode 100644 index 0000000..e9d6167 --- /dev/null +++ b/src/app/pages/algorithms/voronoi/voronoi.model.spec.ts @@ -0,0 +1,40 @@ +import { + clampPointCount, clampToWorld, DEFAULT_POINTS, hitTest, MAX_POINTS, POINT_MARGIN, Point, randomPoints, WORLD_SIZE +} from './voronoi.model'; + +describe('voronoi model', () => { + const points: Point[] = [{x: 100, y: 100}, {x: 110, y: 100}, {x: 500, y: 500}]; + + it('hitTest returns the nearest point within the radius', () => { + expect(hitTest(points, {x: 108, y: 100}, 10)).toBe(1); + expect(hitTest(points, {x: 102, y: 100}, 10)).toBe(0); + }); + + it('hitTest returns -1 outside the radius and for no points', () => { + expect(hitTest(points, {x: 300, y: 300}, 10)).toBe(-1); + expect(hitTest([], {x: 0, y: 0}, 10)).toBe(-1); + }); + + it('clampPointCount handles NaN, rounding and the limits', () => { + expect(clampPointCount(NaN)).toBe(DEFAULT_POINTS); + expect(clampPointCount(MAX_POINTS + 50)).toBe(MAX_POINTS); + expect(clampPointCount(-3)).toBe(0); + expect(clampPointCount(12.6)).toBe(13); + }); + + it('randomPoints stay inside the margin', () => { + const generated = randomPoints(500); + expect(generated).toHaveLength(500); + for (const p of generated) { + expect(p.x).toBeGreaterThanOrEqual(POINT_MARGIN); + expect(p.x).toBeLessThanOrEqual(WORLD_SIZE - POINT_MARGIN); + expect(p.y).toBeGreaterThanOrEqual(POINT_MARGIN); + expect(p.y).toBeLessThanOrEqual(WORLD_SIZE - POINT_MARGIN); + } + }); + + it('clampToWorld keeps a point in the world rect', () => { + expect(clampToWorld({x: -5, y: WORLD_SIZE + 9})).toEqual({x: 0, y: WORLD_SIZE}); + expect(clampToWorld({x: 12, y: 34})).toEqual({x: 12, y: 34}); + }); +}); diff --git a/src/app/pages/algorithms/voronoi/voronoi.model.ts b/src/app/pages/algorithms/voronoi/voronoi.model.ts new file mode 100644 index 0000000..169ff75 --- /dev/null +++ b/src/app/pages/algorithms/voronoi/voronoi.model.ts @@ -0,0 +1,99 @@ +export interface Point { + x: number; + y: number; +} + +export interface Rect { + x0: number; + y0: number; + x1: number; + y1: number; +} + +export interface VoronoiColors { + point: string; + selected: string; + edge: string; + circle: string; + bad: string; + cavity: string; + created: string; + cellFills: string[]; +} + +/** Points and cells live in these world units, not in pixels. */ +export const WORLD_SIZE = 1000; +export const WORLD_RECT: Rect = {x0: 0, y0: 0, x1: WORLD_SIZE, y1: WORLD_SIZE}; +export const POINT_MARGIN = 20; +export const MIN_POINTS = 0; +export const MAX_POINTS = 400; +export const DEFAULT_POINTS = 40; +/** Duplicate click guard, world units. */ +export const MIN_POINT_DISTANCE = 6; +export const HIT_RADIUS_PX = 12; +export const TOUCH_HIT_RADIUS_PX = 24; +export const CLICK_MOVE_PX = 8; +export const MAX_CANVAS_PX = 800; +/** Phases per second in step mode. */ +export const MIN_STEP_SPEED = 1; +export const MAX_STEP_SPEED = 30; +export const DEFAULT_STEP_SPEED = 4; + +export const THEME_COLORS: Record<'light' | 'dark', VoronoiColors> = { + light: { + point: '#222', + selected: '#d32f2f', + edge: 'rgba(15, 79, 138, 0.9)', + circle: 'rgba(15, 79, 138, 0.35)', + bad: 'rgba(211, 47, 47, 0.25)', + cavity: '#d32f2f', + created: 'rgba(46, 125, 50, 0.3)', + cellFills: [ + 'rgba(244, 143, 177, 0.3)', 'rgba(144, 202, 249, 0.3)', 'rgba(165, 214, 167, 0.3)', 'rgba(255, 224, 130, 0.3)', + 'rgba(206, 147, 216, 0.3)', 'rgba(128, 222, 234, 0.3)', 'rgba(255, 171, 145, 0.3)', 'rgba(197, 225, 165, 0.3)' + ] + }, + dark: { + point: '#eee', + selected: '#ff7961', + edge: 'rgba(109, 188, 255, 0.9)', + circle: 'rgba(109, 188, 255, 0.35)', + bad: 'rgba(255, 121, 97, 0.3)', + cavity: '#ff7961', + created: 'rgba(129, 199, 132, 0.3)', + cellFills: [ + 'rgba(240, 98, 146, 0.25)', 'rgba(66, 165, 245, 0.25)', 'rgba(102, 187, 106, 0.25)', 'rgba(255, 202, 40, 0.25)', + 'rgba(171, 71, 188, 0.25)', 'rgba(38, 198, 218, 0.25)', 'rgba(255, 112, 67, 0.25)', 'rgba(156, 204, 101, 0.25)' + ] + } +}; + +export function clampPointCount(count: number): number { + if (!Number.isFinite(count)) { + return DEFAULT_POINTS; + } + return Math.min(MAX_POINTS, Math.max(MIN_POINTS, Math.round(count))); +} + +export function randomPoints(count: number, random: () => number = Math.random): Point[] { + const span = WORLD_SIZE - 2 * POINT_MARGIN; + return Array.from({length: count}, () => ({x: POINT_MARGIN + random() * span, y: POINT_MARGIN + random() * span})); +} + +export function clampToWorld(p: Point): Point { + return {x: Math.min(WORLD_SIZE, Math.max(0, p.x)), y: Math.min(WORLD_SIZE, Math.max(0, p.y))}; +} + +/** Index of the nearest point within `radius`, else -1. */ +export function hitTest(points: readonly Point[], p: Point, radius: number): number { + let best = -1; + let bestDistance = radius * radius; + for (let i = 0; i < points.length; i++) { + const d = (points[i].x - p.x) ** 2 + (points[i].y - p.y) ** 2; + if (d <= bestDistance) { + bestDistance = d; + best = i; + } + } + return best; +} diff --git a/src/app/pages/algorithms/voronoi/voronoi.spec.ts b/src/app/pages/algorithms/voronoi/voronoi.spec.ts new file mode 100644 index 0000000..9378a00 --- /dev/null +++ b/src/app/pages/algorithms/voronoi/voronoi.spec.ts @@ -0,0 +1,106 @@ +import {triangulate} from './delaunay'; +import {clipPolygon, voronoiCells} from './voronoi'; +import {Point, randomPoints, WORLD_RECT, WORLD_SIZE} from './voronoi.model'; + +function seededRandom(seed: number): () => number { + let s = seed; + return () => { + s = (s * 1664525 + 1013904223) % 4294967296; + return s / 4294967296; + }; +} + +function polygonArea(polygon: readonly Point[]): number { + let sum = 0; + for (let i = 0; i < polygon.length; i++) { + const a = polygon[i]; + const b = polygon[(i + 1) % polygon.length]; + sum += a.x * b.y - b.x * a.y; + } + return Math.abs(sum) / 2; +} + +function inside(polygon: readonly Point[], p: Point): boolean { + let sign = 0; + for (let i = 0; i < polygon.length; i++) { + const a = polygon[i]; + const b = polygon[(i + 1) % polygon.length]; + const cross = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x); + if (Math.abs(cross) > 1e-6) { + const s = Math.sign(cross); + if (sign !== 0 && s !== sign) { + return false; + } + sign = s; + } + } + return polygon.length > 0; +} + +function cellsOf(points: Point[]): Point[][] { + return voronoiCells(triangulate(points), points.length, WORLD_RECT); +} + +function totalArea(cells: Point[][]): number { + return cells.reduce((sum, cell) => sum + polygonArea(cell), 0); +} + +const RECT_AREA = WORLD_SIZE * WORLD_SIZE; + +describe('voronoi cells', () => { + it('one point owns the whole rect', () => { + const cells = cellsOf([{x: 300, y: 400}]); + expect(polygonArea(cells[0])).toBeCloseTo(RECT_AREA, 3); + }); + + it('two symmetric points split the rect in half', () => { + const cells = cellsOf([{x: 250, y: 500}, {x: 750, y: 500}]); + expect(polygonArea(cells[0])).toBeCloseTo(RECT_AREA / 2, 3); + expect(polygonArea(cells[1])).toBeCloseTo(RECT_AREA / 2, 3); + }); + + it('cells tile the rect and each sample lies in the cell of its nearest site', () => { + const random = seededRandom(11); + const points = [ + ...randomPoints(100, random), + {x: 0, y: 300}, {x: 0, y: 700}, {x: 400, y: WORLD_SIZE}, {x: 800, y: WORLD_SIZE}, {x: 0, y: 0}, {x: WORLD_SIZE, y: WORLD_SIZE} + ]; + const cells = cellsOf(points); + expect(Math.abs(totalArea(cells) - RECT_AREA) / RECT_AREA).toBeLessThan(1e-9); + for (let k = 0; k < 500; k++) { + const sample = {x: random() * WORLD_SIZE, y: random() * WORLD_SIZE}; + let nearest = 0; + for (let i = 1; i < points.length; i++) { + if (Math.hypot(points[i].x - sample.x, points[i].y - sample.y) < Math.hypot(points[nearest].x - sample.x, points[nearest].y - sample.y)) { + nearest = i; + } + } + expect(inside(cells[nearest], sample)).toBe(true); + const owners = cells.filter(cell => inside(cell, sample)).length; + expect(owners).toBe(1); + } + }); + + it('collinear points still tile the rect', () => { + const points: Point[] = Array.from({length: 8}, (_, i) => ({x: 100 + i * 100, y: 500})); + expect(totalArea(cellsOf(points))).toBeCloseTo(RECT_AREA, 3); + }); + + it('a duplicate point gets an empty cell', () => { + const cells = cellsOf([{x: 200, y: 200}, {x: 800, y: 800}, {x: 200, y: 200}]); + expect(cells[2]).toEqual([]); + expect(totalArea(cells)).toBeCloseTo(RECT_AREA, 3); + }); +}); + +describe('clipPolygon', () => { + it('clips a square over the rect corner to the overlap', () => { + const square = [{x: -100, y: -100}, {x: 100, y: -100}, {x: 100, y: 100}, {x: -100, y: 100}]; + expect(polygonArea(clipPolygon(square, WORLD_RECT))).toBeCloseTo(100 * 100); + }); + + it('gives [] for a polygon fully outside', () => { + const outside = [{x: -50, y: -50}, {x: -10, y: -50}, {x: -10, y: -10}]; + expect(clipPolygon(outside, WORLD_RECT)).toEqual([]); + }); +}); diff --git a/src/app/pages/algorithms/voronoi/voronoi.ts b/src/app/pages/algorithms/voronoi/voronoi.ts new file mode 100644 index 0000000..65c104a --- /dev/null +++ b/src/app/pages/algorithms/voronoi/voronoi.ts @@ -0,0 +1,60 @@ +import {DelaunayBuilder} from './delaunay'; +import {Point, Rect} from './voronoi.model'; + +/** + * One clipped cell per input point, `[]` for a skipped duplicate. + * Uses the full triangulation: hull cells are closed by the far super vertices. + */ +export function voronoiCells(builder: DelaunayBuilder, pointCount: number, rect: Rect): Point[][] { + const centers: Point[][] = Array.from({length: pointCount}, () => []); + for (const t of builder.triangles) { + const center = {x: t.cx, y: t.cy}; + for (const v of [t.a, t.b, t.c]) { + if (v < pointCount) { + centers[v].push(center); + } + } + } + return centers.map((polygon, i) => { + const site = builder.vertices[i]; + const angles = new Map(polygon.map(c => [c, Math.atan2(c.y - site.y, c.x - site.x)])); + polygon.sort((p, q) => angles.get(p)! - angles.get(q)!); + return clipPolygon(polygon, rect); + }); +} + +/** Sutherland-Hodgman against the 4 edges of the rect. */ +export function clipPolygon(polygon: readonly Point[], rect: Rect): Point[] { + const edges: {inside: (p: Point) => boolean; cut: (a: Point, b: Point) => Point}[] = [ + {inside: p => p.x >= rect.x0, cut: (a, b) => atX(a, b, rect.x0)}, + {inside: p => p.x <= rect.x1, cut: (a, b) => atX(a, b, rect.x1)}, + {inside: p => p.y >= rect.y0, cut: (a, b) => atY(a, b, rect.y0)}, + {inside: p => p.y <= rect.y1, cut: (a, b) => atY(a, b, rect.y1)} + ]; + let output = [...polygon]; + for (const {inside, cut} of edges) { + const input = output; + output = []; + for (let i = 0; i < input.length; i++) { + const current = input[i]; + const previous = input[(i + input.length - 1) % input.length]; + if (inside(current)) { + if (!inside(previous)) { + output.push(cut(previous, current)); + } + output.push(current); + } else if (inside(previous)) { + output.push(cut(previous, current)); + } + } + } + return output; +} + +function atX(a: Point, b: Point, x: number): Point { + return {x, y: a.y + (b.y - a.y) * (x - a.x) / (b.x - a.x)}; +} + +function atY(a: Point, b: Point, y: number): Point { + return {x: a.x + (b.x - a.x) * (y - a.y) / (b.y - a.y), y}; +} diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index 9121e65..b0bf539 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -968,6 +968,10 @@ "TITLE": "Ameisenkolonie (Handlungsreisender)", "DESCRIPTION": "Ameisen finden mit Pheromonspuren eine kurze Rundreise durch Städte. Ein Klick auf die Fläche setzt eine neue Stadt." }, + "VORONOI": { + "TITLE": "Voronoi und Delaunay", + "DESCRIPTION": "Setze Punkte und sieh, wie die Delaunay-Triangulation und ihre Voronoi-Zellen folgen. Klicken fügt hinzu, Ziehen verschiebt. Der Schrittmodus zeigt jede Einfügung." + }, "NOTE": "HINWEIS", "GRID_HEIGHT": "Höhe", "GRID_WIDTH": "Breite", @@ -1078,6 +1082,46 @@ "ITEM_5": "Gezeichnet wird mit Canvas 2D und requestAnimationFrame außerhalb der Angular-Zone. Die Deckkraft der Linien zeigt die Spurstärke, die rote Linie ist die beste Tour." } }, + "VORONOI": { + "TITLE": "Voronoi und Delaunay", + "RANDOM": "Zufällige Punkte", + "CLEAR": "Leeren", + "LAYERS": "Ebenen", + "CELLS": "Voronoi-Zellen", + "EDGES": "Delaunay-Kanten", + "CIRCLES": "Umkreise", + "STEP_MODE_ON": "Schrittmodus", + "STEP_MODE_OFF": "Schrittmodus beenden", + "PLAY": "Start", + "PAUSE": "Pause", + "SINGLE_STEP": "Schritt", + "POINT_COUNT": "Punkte", + "STEP_SPEED": "Phasen/s", + "HINT": "Klicke oder tippe auf die Fläche, um einen Punkt zu setzen. Ziehe einen Punkt, um ihn zu verschieben. Der Schrittmodus blendet die Voronoi-Zellen aus, bis der letzte Punkt eingefügt ist.", + "POINTS": "Punkte", + "TRIANGLES": "Dreiecke", + "BUILD_MS": "Letzter Aufbau", + "EXPLANATION": { + "TITLE": "Dreiecke und Zellen aus Punkten", + "DELAUNAY_TITLE": "Delaunay-Triangulation", + "DELAUNAY": "Verbinde die Punkte so mit Dreiecken, dass der Kreis durch die drei Ecken jedes Dreiecks keinen anderen Punkt enthält. So entstehen, soweit die Punkte es erlauben, keine langen, dünnen Dreiecke.", + "VORONOI_TITLE": "Voronoi-Diagramm", + "VORONOI": "Jeder Punkt besitzt den Teil der Ebene, der ihm näher liegt als jedem anderen Punkt. Die Zellgrenzen sind das Duale der Delaunay-Dreiecke: Jede Zellecke ist der Kreismittelpunkt eines Dreiecks.", + "BOWYER_WATSON_TITLE": "Bowyer-Watson", + "BOWYER_WATSON": "Beginne mit einem riesigen Dreieck um alle Punkte. Füge die Punkte nacheinander ein. Entferne jedes Dreieck, dessen Kreis den neuen Punkt enthält, und verbinde den neuen Punkt mit dem Rand des Lochs.", + "DISCLAIMER": "Vereinfachungen in dieser Demo:", + "DISCLAIMER_1": "Die Triangulation nutzt normale Gleitkommazahlen mit einer kleinen Toleranz. Bei fast entarteten Eingaben ist sie nicht exakt.", + "DISCLAIMER_2": "Die Suche nach den zu entfernenden Dreiecken prüft alle Dreiecke. Die Aufbauzeit wächst daher mit dem Quadrat der Punktzahl.", + "DISCLAIMER_3": "Ein Punkt auf einem anderen Punkt wird übersprungen. Die Zellen werden auf die Fläche von 1000 x 1000 Welteinheiten begrenzt." + }, + "HOW_IT_WORKS": { + "INTRO": "Drei weit entfernte Eckpunkte bilden das erste Dreieck. Jede Einfügung sucht die Dreiecke, deren Umkreis den neuen Punkt enthält (die schlechten Dreiecke), nimmt den Rand ihrer Vereinigung (die Kavität) und füllt die Kavität mit neuen Dreiecken. Der Schrittmodus zeigt diese drei Phasen.", + "ITEM_1": "Die Dreiecke bleiben gegen den Uhrzeigersinn orientiert und speichern ihren Umkreis, der Test ist also ein einziger Abstandsvergleich.", + "ITEM_2": "Die Kavitätskanten sind die Kanten schlechter Dreiecke, deren Gegenkante in keinem schlechten Dreieck liegt.", + "ITEM_3": "Die Voronoi-Zelle eines Punktes ist das Polygon aus den Umkreismittelpunkten seiner Dreiecke. Die Eckpunkte halten die äußeren Zellen geschlossen, danach werden die Polygone auf die Fläche begrenzt.", + "ITEM_4": "Gezeichnet wird mit Canvas 2D und requestAnimationFrame außerhalb der Angular-Zone. Ein Ziehen markiert das Diagramm nur als veraltet, und jedes Bild baut es höchstens einmal neu auf." + } + }, "STOPWATCH": { "TITLE": "Stoppuhr", "START": "Start", diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 477c905..ed96e38 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -968,6 +968,10 @@ "TITLE": "Ant colony (travelling salesman)", "DESCRIPTION": "Ants find a short round trip through cities with pheromone trails. Click the canvas to add a city." }, + "VORONOI": { + "TITLE": "Voronoi and Delaunay", + "DESCRIPTION": "Drop points and watch the Delaunay triangulation and its Voronoi cells follow. Click to add, drag to move. Step mode shows each insertion." + }, "NOTE": "Note", "GRID_HEIGHT": "Height", "GRID_WIDTH": "Width", @@ -1078,6 +1082,46 @@ "ITEM_5": "Drawing uses Canvas 2D with requestAnimationFrame outside the Angular zone. Line opacity shows the trail strength, the red line is the best tour." } }, + "VORONOI": { + "TITLE": "Voronoi and Delaunay", + "RANDOM": "Random points", + "CLEAR": "Clear", + "LAYERS": "Layers", + "CELLS": "Voronoi cells", + "EDGES": "Delaunay edges", + "CIRCLES": "Circumcircles", + "STEP_MODE_ON": "Step mode", + "STEP_MODE_OFF": "End step mode", + "PLAY": "Play", + "PAUSE": "Pause", + "SINGLE_STEP": "Step", + "POINT_COUNT": "Points", + "STEP_SPEED": "Phases/s", + "HINT": "Click or tap the canvas to add a point. Drag a point to move it. Step mode hides the Voronoi cells until the last point is in.", + "POINTS": "Points", + "TRIANGLES": "Triangles", + "BUILD_MS": "Last build", + "EXPLANATION": { + "TITLE": "Triangles and cells from points", + "DELAUNAY_TITLE": "Delaunay triangulation", + "DELAUNAY": "Connect the points with triangles so that the circle through the three corners of every triangle holds no other point. This avoids long thin triangles as far as the points allow.", + "VORONOI_TITLE": "Voronoi diagram", + "VORONOI": "Every point owns the part of the plane that is closer to it than to any other point. The borders of these cells are the dual of the Delaunay triangles: each cell corner is the circle center of one triangle.", + "BOWYER_WATSON_TITLE": "Bowyer-Watson", + "BOWYER_WATSON": "Start with one huge triangle around all points. Insert the points one by one. Remove every triangle whose circle holds the new point, then connect the new point to the border of the hole.", + "DISCLAIMER": "Simplifications in this demo:", + "DISCLAIMER_1": "The triangulation uses plain floating point numbers with a small tolerance. It is not exact for nearly degenerate input.", + "DISCLAIMER_2": "The search for triangles to remove checks all triangles, so the build time grows with the square of the point count.", + "DISCLAIMER_3": "A point on top of another point is skipped. Cells are clipped to the canvas, which is 1000 x 1000 world units." + }, + "HOW_IT_WORKS": { + "INTRO": "Three far away corner points form the first triangle. Each insertion finds the triangles whose circumcircle holds the new point (the bad triangles), takes the border of their union (the cavity) and fills the cavity with new triangles. Step mode shows these three phases.", + "ITEM_1": "Triangles are kept counter-clockwise and cache their circumcircle, so the test is one distance compare.", + "ITEM_2": "The cavity edges are the edges of bad triangles whose opposite edge is not in a bad triangle.", + "ITEM_3": "The Voronoi cell of a point is the polygon of the circle centers of its triangles. The corner points keep the outer cells closed, then the polygons are clipped to the canvas.", + "ITEM_4": "Drawing uses Canvas 2D with requestAnimationFrame outside the Angular zone. A drag only marks the diagram as dirty, and each frame rebuilds it at most once." + } + }, "STOPWATCH": { "TITLE": "Stopwatch", "START": "Start",