From ab18f6b340f57ffe65eb16c17af996461e35a28c Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Tue, 8 Sep 2026 17:20:32 -0400 Subject: [PATCH] feat(fourier): epicycle cap 50,000 via FFT + scaled sampling; default speed 120 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The term count is bounded by the sample count (K ≤ M−1), so raising the cap alone would have done nothing. The lab now requests a power-of-two sample count that grows with the requested epicycles (2048 → 65536), and the rule takes a radix-2 FFT path for power-of-two inputs (O(M log M); the naive O(M²) DFT remains for other sizes, with an equivalence test). The trace step count is decoupled from the sample count (2000 steps), so a full trace still takes ~17 s at the new default speed of 120. The viz skips arms whose ring is sub-pixel — with 50k terms nearly all are — so per-frame geometry stays proportional to what's visible. Schema/input/URL cap: 2000 → 50,000. Test mocks of textPath keep its pure helpers real (partial mock). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../viz-core/src/rules/fourier_epicycles.rs | 134 +++++++++++++++--- .../src/visualizations/fourier_epicycles.rs | 7 +- .../components/__tests__/FourierLab.test.ts | 15 +- .../lib/components/__tests__/LabShell.test.ts | 4 +- web/src/lib/components/labs/FourierLab.svelte | 13 +- .../lib/fourier/__tests__/textPath.test.ts | 18 ++- web/src/lib/fourier/textPath.ts | 14 ++ 7 files changed, 177 insertions(+), 28 deletions(-) diff --git a/crates/viz-core/src/rules/fourier_epicycles.rs b/crates/viz-core/src/rules/fourier_epicycles.rs index 200be98..8cb7f02 100644 --- a/crates/viz-core/src/rules/fourier_epicycles.rs +++ b/crates/viz-core/src/rules/fourier_epicycles.rs @@ -28,7 +28,8 @@ pub struct PathPoint { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FourierConfig { - /// Closed path to trace, ~2000 samples produced by the UI. + /// Closed path to trace. The UI sends a power-of-two sample count + /// (2048–65536, growing with `epicycles`) so the DFT takes the FFT path. pub path: Vec, /// Number of DFT terms kept (K), largest amplitudes first. pub epicycles: u32, @@ -71,7 +72,7 @@ impl ConfigSchema for FourierConfig { label: "Epicycles", default: 2000.0, min: 1.0, - max: 2000.0, + max: 50_000.0, step: 1.0, integer: true, cosmetic: false, @@ -157,27 +158,20 @@ pub fn dft(path: &[PathPoint]) -> ([f32; 2], Vec) { return ([0.0, 0.0], Vec::new()); } - // w[j] = e^{-2πi·j/M} - let twiddle: Vec<(f64, f64)> = (0..m) - .map(|j| { - let a = -std::f64::consts::TAU * j as f64 / m as f64; - (a.cos(), a.sin()) - }) - .collect(); let z: Vec<(f64, f64)> = path.iter().map(|p| (p.x as f64, p.y as f64)).collect(); + // O(M log M) when M is a power of two (the UI always sends one), else O(M²). + let coeffs = if m.is_power_of_two() { + fft_radix2(&z) + } else { + naive_dft(&z) + }; let inv_m = 1.0 / m as f64; let mut origin = [0.0f32; 2]; let mut eps = Vec::with_capacity(m - 1); - for k_idx in 0..m { - let (mut re, mut im) = (0.0f64, 0.0f64); - for (j, &(zr, zi)) in z.iter().enumerate() { - let (wr, wi) = twiddle[(j * k_idx) % m]; - re += zr * wr - zi * wi; - im += zr * wi + zi * wr; - } - re *= inv_m; - im *= inv_m; + for (k_idx, &(re0, im0)) in coeffs.iter().enumerate() { + let re = re0 * inv_m; + let im = im0 * inv_m; let k = if 2 * k_idx < m { k_idx as i32 @@ -198,6 +192,70 @@ pub fn dft(path: &[PathPoint]) -> ([f32; 2], Vec) { (origin, eps) } +/// Unnormalized forward DFT, `X[k] = Σ_j x[j]·e^{-2πi·jk/N}`, via a twiddle +/// table so the inner loop is a complex multiply-add. O(N²); used when N is +/// not a power of two. +fn naive_dft(x: &[(f64, f64)]) -> Vec<(f64, f64)> { + let n = x.len(); + let twiddle: Vec<(f64, f64)> = (0..n) + .map(|j| { + let a = -std::f64::consts::TAU * j as f64 / n as f64; + (a.cos(), a.sin()) + }) + .collect(); + (0..n) + .map(|k| { + let (mut re, mut im) = (0.0f64, 0.0f64); + for (j, &(zr, zi)) in x.iter().enumerate() { + let (wr, wi) = twiddle[(j * k) % n]; + re += zr * wr - zi * wi; + im += zr * wi + zi * wr; + } + (re, im) + }) + .collect() +} + +/// Unnormalized forward FFT (same contract as `naive_dft`) — iterative +/// radix-2 Cooley–Tukey in f64. `x.len()` must be a power of two. +fn fft_radix2(x: &[(f64, f64)]) -> Vec<(f64, f64)> { + let n = x.len(); + debug_assert!(n.is_power_of_two()); + let mut a = x.to_vec(); + let bits = n.trailing_zeros(); + for i in 0..n { + let j = if bits == 0 { + 0 + } else { + i.reverse_bits() >> (usize::BITS - bits) + }; + if j > i { + a.swap(i, j); + } + } + let mut len = 2; + while len <= n { + let half = len / 2; + let ang = -std::f64::consts::TAU / len as f64; + let (wr, wi) = (ang.cos(), ang.sin()); + for start in (0..n).step_by(len) { + let (mut cr, mut ci) = (1.0f64, 0.0f64); + for k in 0..half { + let (ur, ui) = a[start + k]; + let (vr0, vi0) = a[start + k + half]; + let (vr, vi) = (vr0 * cr - vi0 * ci, vr0 * ci + vi0 * cr); + a[start + k] = (ur + vr, ui + vi); + a[start + k + half] = (ur - vr, ui - vi); + let ncr = cr * wr - ci * wi; + ci = cr * wi + ci * wr; + cr = ncr; + } + } + len <<= 1; + } + a +} + /// Rotation angle of one epicycle at time `t`. `rem_euclid` keeps the angle /// small so f64→f32 precision doesn't jitter at |freq| ≈ 1500, t ≈ 1. #[inline] @@ -355,6 +413,46 @@ impl Rule for FourierEpicycles { #[cfg(test)] mod tests { use super::*; + + #[test] + fn fft_matches_naive_dft_on_a_power_of_two_signal() { + let z: Vec<(f64, f64)> = (0..64) + .map(|j| { + let t = j as f64 * 0.37; + (t.sin() + 0.3 * (3.0 * t).cos(), (2.0 * t).cos() - 0.2 * t) + }) + .collect(); + let a = fft_radix2(&z); + let b = naive_dft(&z); + assert_eq!(a.len(), b.len()); + for (k, (p, q)) in a.iter().zip(&b).enumerate() { + assert!( + (p.0 - q.0).abs() < 1e-9 && (p.1 - q.1).abs() < 1e-9, + "bin {k}: {p:?} vs {q:?}" + ); + } + } + + #[test] + fn dft_of_a_non_power_of_two_circle_is_one_dominant_term() { + // M = 30 is not a power of two → exercises the naive path end-to-end. + let m = 30; + let path: Vec = (0..m) + .map(|j| { + let a = std::f64::consts::TAU * j as f64 / m as f64; + PathPoint { + x: a.cos() as f32, + y: a.sin() as f32, + pen: true, + } + }) + .collect(); + let (origin, eps) = dft(&path); + assert!(origin[0].abs() < 1e-5 && origin[1].abs() < 1e-5); + assert_eq!(eps[0].freq.abs(), 1); + assert!((eps[0].amp - 1.0).abs() < 1e-5); + assert!(eps[1].amp < 1e-5); + } use crate::config::ConfigSchema; use crate::traits::{Rule, SceneState}; diff --git a/crates/viz-core/src/visualizations/fourier_epicycles.rs b/crates/viz-core/src/visualizations/fourier_epicycles.rs index 34e92df..01354e1 100644 --- a/crates/viz-core/src/visualizations/fourier_epicycles.rs +++ b/crates/viz-core/src/visualizations/fourier_epicycles.rs @@ -286,7 +286,12 @@ impl Visualization for FourierEpicyclesViz { let chain = &state.chain; line_scratch.clear(); line_scratch.reserve(chain.len().saturating_sub(1) * 2); - for w in chain.windows(2) { + // Arm i has length amp_i, so a sub-pixel ring implies a sub-pixel arm: + // apply the same cull (with 50k terms, nearly all are sub-pixel). + for (e, w) in state.epicycles.iter().zip(chain.windows(2)) { + if e.amp * 2.0 / world_per_px < cfg.min_circle_px { + continue; + } push_seg(line_scratch, w[0], w[1], cfg.arm_color); } lines.upload(gl, line_scratch); diff --git a/web/src/lib/components/__tests__/FourierLab.test.ts b/web/src/lib/components/__tests__/FourierLab.test.ts index 2fcfd32..67b921d 100644 --- a/web/src/lib/components/__tests__/FourierLab.test.ts +++ b/web/src/lib/components/__tests__/FourierLab.test.ts @@ -15,7 +15,9 @@ vi.mock('../../wasm/loader', async () => { // Mock the font-backed text→path pipeline: three points for any non-blank // text, `[]` for blank (mirroring the real contract). -vi.mock('../../fourier/textPath', () => ({ +vi.mock('../../fourier/textPath', async (importOriginal) => ({ + ...(await importOriginal()), // keep pure helpers (samplesFor) real + textToPath: vi.fn(async (t: string) => t.trim() ? [ @@ -28,6 +30,7 @@ vi.mock('../../fourier/textPath', () => ({ })); import App from '../../../App.svelte'; +import { textToPath } from '../../fourier/textPath'; describe('FourierLab.svelte', () => { beforeEach(() => { @@ -121,6 +124,16 @@ describe('FourierLab.svelte — shareable link params', () => { expect((getByLabelText('Text to trace') as HTMLInputElement).value).toBe('HELLO'); }); + it('accepts up to 50,000 epicycles from the link and requests a matching sample count', async () => { + navigate('fourier', 'n=50000'); + render(App); + await vi.waitFor(() => expect(updateRuleConfigSpy).toHaveBeenCalled()); + const cfg = updateRuleConfigSpy.mock.calls[0][0] as { epicycles: number; max_iterations: number }; + expect(cfg.epicycles).toBe(50_000); + expect(cfg.max_iterations).toBe(3); // min(path.length, TRACE_STEPS) with the 3-point mock + expect(textToPath).toHaveBeenLastCalledWith(expect.any(String), expect.objectContaining({ samples: 65_536 })); + }); + it('keeps the URL in sync as the text changes and offers a copy-link button', async () => { navigate('fourier'); const { getByLabelText, getByTitle } = render(App); diff --git a/web/src/lib/components/__tests__/LabShell.test.ts b/web/src/lib/components/__tests__/LabShell.test.ts index 330990b..62e0a5d 100644 --- a/web/src/lib/components/__tests__/LabShell.test.ts +++ b/web/src/lib/components/__tests__/LabShell.test.ts @@ -12,7 +12,9 @@ vi.mock('../../wasm/loader', async () => { }); // The Fourier lab fetches a font on mount; jsdom has no origin to fetch from. -vi.mock('../../fourier/textPath', () => ({ textToPath: vi.fn(async () => []) })); +vi.mock('../../fourier/textPath', async (importOriginal) => ({ + ...(await importOriginal()), // keep pure helpers (samplesFor) real + textToPath: vi.fn(async () => []) })); import App from '../../../App.svelte'; diff --git a/web/src/lib/components/labs/FourierLab.svelte b/web/src/lib/components/labs/FourierLab.svelte index da5fd9e..9e29cbe 100644 --- a/web/src/lib/components/labs/FourierLab.svelte +++ b/web/src/lib/components/labs/FourierLab.svelte @@ -4,14 +4,15 @@ import FormulaPanel from '../FormulaPanel.svelte'; import type { LabApi } from '../labApi.svelte'; import { cmd } from '../../playback/commands'; - import { textToPath } from '../../fourier/textPath'; + import { textToPath, samplesFor } from '../../fourier/textPath'; import { readSummary, type FourierSummary } from '../../fourier/summary'; import { route, replaceQuery } from '../../router.svelte'; import { buildQuery } from '../../router'; const DEFAULT_TEXT = 'POIETIC TECH'; - const SAMPLES = 2000; - const MAX_EPICYCLES = 2000; + /** Pen steps per full trace — the ink resolution, independent of the DFT sample count. */ + const TRACE_STEPS = 2000; + const MAX_EPICYCLES = 50_000; const DEBOUNCE_MS = 150; const DEFAULT_EPICYCLES = 2000; const MAX_TEXT = 40; @@ -91,7 +92,7 @@ const my = ++gen; let path: Awaited>; try { - path = await textToPath(text, { samples: SAMPLES }); + path = await textToPath(text, { samples: samplesFor(epicycles) }); } catch (err) { console.warn('textToPath failed:', err); return; @@ -103,7 +104,7 @@ summary = null; return; } - api.setRuleConfig({ path, epicycles, max_iterations: path.length }); + api.setRuleConfig({ path, epicycles, max_iterations: Math.min(path.length, TRACE_STEPS) }); summary = readEngineSummary(api); api.dispatch(cmd.play()); } @@ -144,7 +145,7 @@ }); - + {#snippet info()}

Fourier Epicycles

diff --git a/web/src/lib/fourier/__tests__/textPath.test.ts b/web/src/lib/fourier/__tests__/textPath.test.ts index 55d1930..3c7baf7 100644 --- a/web/src/lib/fourier/__tests__/textPath.test.ts +++ b/web/src/lib/fourier/__tests__/textPath.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import * as opentype from 'opentype.js'; -import { textToPath, glyphOutlineCommands } from '../textPath'; +import { textToPath, glyphOutlineCommands, samplesFor, MIN_SAMPLES, MAX_SAMPLES } from '../textPath'; import type { PathCommand } from '../geometry'; // A 2-glyph fake font: each glyph is a unit square outline, advance 1000 units/em. @@ -36,6 +36,22 @@ describe('textToPath (fake font)', () => { }); }); +describe('samplesFor', () => { + it('is a power of two, at least epicycles + 1, clamped to [MIN, MAX]', () => { + expect(samplesFor(1)).toBe(MIN_SAMPLES); + expect(samplesFor(2000)).toBe(2048); + expect(samplesFor(2047)).toBe(2048); + expect(samplesFor(2048)).toBe(4096); + expect(samplesFor(50_000)).toBe(65_536); + expect(samplesFor(1_000_000)).toBe(MAX_SAMPLES); + for (const n of [3, 500, 2048, 9_999, 50_000]) { + const s = samplesFor(n); + expect(Number.isInteger(Math.log2(s))).toBe(true); + expect(s).toBeGreaterThanOrEqual(Math.min(n + 1, MAX_SAMPLES)); + } + }); +}); + describe('textToPath (real bundled font)', () => { // Guards against opentype.js shaping limitations: font.getPath() throws on // Space Mono's GSUB lookups, so we must build outlines per glyph. diff --git a/web/src/lib/fourier/textPath.ts b/web/src/lib/fourier/textPath.ts index 9efbac6..6df5d16 100644 --- a/web/src/lib/fourier/textPath.ts +++ b/web/src/lib/fourier/textPath.ts @@ -9,6 +9,20 @@ import type * as opentype from 'opentype.js'; import { loadFont } from './font'; import { buildLoop, flattenCommands, normalize, resampleClosed, type PathCommand, type PathPoint } from './geometry'; +/** Sample-count bounds for the Fourier lab: powers of two so the Rust side takes the FFT path. */ +export const MIN_SAMPLES = 2048; +export const MAX_SAMPLES = 65536; + +/** + * Samples to request for a given epicycle count: the DFT of an M-sample loop + * has only M−1 usable terms, so the loop must be sampled at least `epicycles + 1` + * times. Rounded up to a power of two within [MIN_SAMPLES, MAX_SAMPLES]. + */ +export function samplesFor(epicycles: number): number { + const want = Math.max(MIN_SAMPLES, Math.min(MAX_SAMPLES, Math.floor(epicycles) + 1)); + return 2 ** Math.ceil(Math.log2(want)); +} + export type TextToPathOptions = { /** Total samples in the closed loop (uniform arc length). Default 2000. */ samples?: number;