feat(fourier): add dog head preset
Build, Test & Push Frontend / quality-check (push) Successful in 1m21s
Build, Test & Push Frontend / docker (push) Successful in 1m29s

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.
This commit is contained in:
lobo committed 2026-10-14 16:29:17 +02:00
1 parent 52375e5ab1
commit 57f861510e
7 files changed
+85 -2

No files matched your search

+1
View File
@@ -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`.
@@ -17,6 +17,9 @@
<button mat-raised-button (click)="applyPreset(Preset.SQUARE)">
<mat-icon>crop_square</mat-icon>{{ 'FOURIER.PRESET_SQUARE' | translate }}
</button>
<button mat-raised-button (click)="applyPreset(Preset.DOG)">
<mat-icon>pets</mat-icon>{{ 'FOURIER.PRESET_DOG' | translate }}
</button>
<button mat-raised-button (click)="toggleCircles()">
{{ (showCircles() ? 'FOURIER.HIDE_CIRCLES' : 'FOURIER.SHOW_CIRCLES') | translate }}
</button>
@@ -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;
@@ -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);
});
@@ -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}));
}
}
}
}
+1
View File
@@ -679,6 +679,7 @@
"PRESET_HEART": "Herz",
"PRESET_STAR": "Stern",
"PRESET_SQUARE": "Quadrat",
"PRESET_DOG": "Hund",
"CIRCLES": "Kreise",
"SPEED": "Geschwindigkeit",
"SHOW_CIRCLES": "Kreise anzeigen",
+1
View File
@@ -679,6 +679,7 @@
"PRESET_HEART": "Heart",
"PRESET_STAR": "Star",
"PRESET_SQUARE": "Square",
"PRESET_DOG": "Dog",
"CIRCLES": "Circles",
"SPEED": "Speed",
"SHOW_CIRCLES": "Show circles",