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.
This commit is contained in:
1 parent
52375e5ab1
commit
57f861510e
7 files changed
+85
-2
No files matched your search
@@ -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}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user