From 57f861510e9b08cdc32edc358569d1654b41bf8d Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 14 Oct 2026 16:29:17 +0200 Subject: [PATCH] feat(fourier): add dog head preset Build the dog outline from one mirrored right half. Smooth it with Chaikin corner cutting (3 passes). Also add a ROADMAP note for the circles slider thumb. --- ROADMAP.md | 1 + .../algorithms/fourier/fourier.component.html | 3 ++ .../algorithms/fourier/fourier.models.ts | 2 +- .../fourier/service/fourier.service.spec.ts | 26 ++++++++- .../fourier/service/fourier.service.ts | 53 +++++++++++++++++++ src/assets/i18n/de.json | 1 + src/assets/i18n/en.json | 1 + 7 files changed, 85 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index af8eb54..c5c02fb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,6 +5,7 @@ Open ideas for the playground frontend. Tick a box when done. ## Quick wins - [ ] **Labyrinth grid size crash**: `labyrinth.component.html` still binds `[gridRows]`; typed sizes above the max crash the page. Same fix as pathfinding (clamp in code, drop the binding). +- [ ] **Fourier circles slider thumb**: at "Circles: 256" the thumb sits near the left end, like at 30. Check `max`/value binding of the circles `mat-slider` (seen 2026-10-14, not verified). - [ ] **Lighthouse gate**: add an `assert` block to `lighthouserc.json` (e.g. performance, accessibility, SEO >= 0.9). Today CI runs the audit but never fails. Note: the `@lhci/cli` → lighthouse 13 override in `package.json` is only safe without `assert`. Check it again before you add one. - [ ] **robots.txt + sitemap.xml**: static files in `public/`, listing all routes from `app.routes.ts`. diff --git a/src/app/pages/algorithms/fourier/fourier.component.html b/src/app/pages/algorithms/fourier/fourier.component.html index 9d573a1..0588f3e 100644 --- a/src/app/pages/algorithms/fourier/fourier.component.html +++ b/src/app/pages/algorithms/fourier/fourier.component.html @@ -17,6 +17,9 @@ + diff --git a/src/app/pages/algorithms/fourier/fourier.models.ts b/src/app/pages/algorithms/fourier/fourier.models.ts index 6ebb7cf..9d968b3 100644 --- a/src/app/pages/algorithms/fourier/fourier.models.ts +++ b/src/app/pages/algorithms/fourier/fourier.models.ts @@ -3,7 +3,7 @@ export interface Point { x: number; y: number; } /** One rotating circle. frequency is signed (turns per period), phase in radians. */ export interface EpicycleTerm { frequency: number; amplitude: number; phase: number; } -export enum Preset { HEART = 0, STAR = 1, SQUARE = 2 } +export enum Preset { HEART = 0, STAR = 1, SQUARE = 2, DOG = 3 } export const SAMPLE_COUNT = 256; export const MIN_DRAW_POINTS = 3; diff --git a/src/app/pages/algorithms/fourier/service/fourier.service.spec.ts b/src/app/pages/algorithms/fourier/service/fourier.service.spec.ts index 7d78beb..60be5c1 100644 --- a/src/app/pages/algorithms/fourier/service/fourier.service.spec.ts +++ b/src/app/pages/algorithms/fourier/service/fourier.service.spec.ts @@ -58,7 +58,7 @@ describe('FourierService', () => { expect(service.epicyclePositions(terms, 0, 0)).toEqual([{x: 0, y: 0}]); }); - it.each([Preset.HEART, Preset.STAR, Preset.SQUARE])('keeps preset %s inside the canvas', preset => { + it.each([Preset.HEART, Preset.STAR, Preset.SQUARE, Preset.DOG])('keeps preset %s inside the canvas', preset => { const points = service.presetPoints(preset); expect(points.length).toBeGreaterThanOrEqual(3); @@ -68,6 +68,30 @@ describe('FourierService', () => { }); }); + it('mirrors the dog preset left-right', () => { + const points = service.presetPoints(Preset.DOG); + + points.forEach((p, i) => { + expect(points.some(q => Math.abs(q.x + p.x) < 1e-9 && Math.abs(q.y - p.y) < 1e-9)).toBe(true); + const next = points[(i + 1) % points.length]; + expect(Math.hypot(next.x - p.x, next.y - p.y)).toBeGreaterThan(0); + }); + }); + + it('cuts the corners of a square with one chaikin pass', () => { + const square = service.presetPoints(Preset.SQUARE); + const r = square[2].x; + + const cut = service.chaikin(square, 1); + + expect(cut.length).toBe(8); + cut.forEach(p => { + expect(Math.abs(p.x)).toBeLessThanOrEqual(r); + expect(Math.abs(p.y)).toBeLessThanOrEqual(r); + expect(Math.abs(p.x) === r && Math.abs(p.y) === r).toBe(false); + }); + }); + it('samples the heart preset SAMPLE_COUNT times', () => { expect(service.presetPoints(Preset.HEART).length).toBe(SAMPLE_COUNT); }); diff --git a/src/app/pages/algorithms/fourier/service/fourier.service.ts b/src/app/pages/algorithms/fourier/service/fourier.service.ts index ea693e9..05dc707 100644 --- a/src/app/pages/algorithms/fourier/service/fourier.service.ts +++ b/src/app/pages/algorithms/fourier/service/fourier.service.ts @@ -1,6 +1,38 @@ import {Injectable} from '@angular/core'; import {EpicycleTerm, Point, PRESET_RADIUS, Preset, SAMPLE_COUNT} from '../fourier.models'; +/** Right half of the dog head in a [-1, 1] box, clockwise from top center to chin center. */ +const DOG_RIGHT_HALF: Point[] = [ + {x: 0, y: -0.8}, + {x: 0.14, y: -0.79}, + {x: 0.26, y: -0.74}, + {x: 0.34, y: -0.64}, + {x: 0.4, y: -0.54}, + {x: 0.52, y: -0.58}, + {x: 0.68, y: -0.5}, + {x: 0.8, y: -0.36}, + {x: 0.88, y: -0.12}, + {x: 0.9, y: 0.18}, + {x: 0.86, y: 0.48}, + {x: 0.78, y: 0.72}, + {x: 0.66, y: 0.72}, + {x: 0.58, y: 0.5}, + {x: 0.55, y: 0.2}, + {x: 0.5, y: -0.1}, + {x: 0.46, y: -0.46}, + {x: 0.39, y: -0.1}, + {x: 0.4, y: 0.15}, + {x: 0.38, y: 0.36}, + {x: 0.31, y: 0.5}, + {x: 0.23, y: 0.58}, + {x: 0.21, y: 0.68}, + {x: 0.15, y: 0.72}, + {x: 0.13, y: 0.78}, + {x: 0.09, y: 0.9}, + {x: 0, y: 0.92}, +]; +const DOG_SMOOTHING_PASSES = 3; + @Injectable({providedIn: 'root'}) export class FourierService { /** Treats points as a closed polygon and returns count points at equal arc-length spacing. */ @@ -72,6 +104,22 @@ export class FourierService { return positions; } + /** Corner cutting on a closed polygon: each edge P->Q becomes 0.75P+0.25Q and 0.25P+0.75Q. */ + chaikin(points: Point[], passes: number): Point[] { + let result = points; + for (let pass = 0; pass < passes; pass++) { + const previous = result; + result = previous.flatMap((p, i) => { + const q = previous[(i + 1) % previous.length]; + return [ + {x: 0.75 * p.x + 0.25 * q.x, y: 0.75 * p.y + 0.25 * q.y}, + {x: 0.25 * p.x + 0.75 * q.x, y: 0.25 * p.y + 0.75 * q.y}, + ]; + }); + } + return result; + } + presetPoints(preset: Preset): Point[] { switch (preset) { case Preset.HEART: @@ -92,6 +140,11 @@ export class FourierService { const r = PRESET_RADIUS * 0.85; return [{x: -r, y: -r}, {x: r, y: -r}, {x: r, y: r}, {x: -r, y: r}]; } + case Preset.DOG: { + const left = DOG_RIGHT_HALF.slice(1, -1).reverse().map(p => ({x: -p.x, y: p.y})); + const outline = this.chaikin([...DOG_RIGHT_HALF, ...left], DOG_SMOOTHING_PASSES); + return outline.map(p => ({x: p.x * PRESET_RADIUS, y: p.y * PRESET_RADIUS})); + } } } } diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index c8c8c96..5da8d4c 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -679,6 +679,7 @@ "PRESET_HEART": "Herz", "PRESET_STAR": "Stern", "PRESET_SQUARE": "Quadrat", + "PRESET_DOG": "Hund", "CIRCLES": "Kreise", "SPEED": "Geschwindigkeit", "SHOW_CIRCLES": "Kreise anzeigen", diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index bdb51f6..de4cdc6 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -679,6 +679,7 @@ "PRESET_HEART": "Heart", "PRESET_STAR": "Star", "PRESET_SQUARE": "Square", + "PRESET_DOG": "Dog", "CIRCLES": "Circles", "SPEED": "Speed", "SHOW_CIRCLES": "Show circles",