Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 116 additions & 18 deletions crates/viz-core/src/rules/fourier_epicycles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathPoint>,
/// Number of DFT terms kept (K), largest amplitudes first.
pub epicycles: u32,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -157,27 +158,20 @@ pub fn dft(path: &[PathPoint]) -> ([f32; 2], Vec<Epicycle>) {
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
Expand All @@ -198,6 +192,70 @@ pub fn dft(path: &[PathPoint]) -> ([f32; 2], Vec<Epicycle>) {
(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]
Expand Down Expand Up @@ -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<PathPoint> = (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};

Expand Down
7 changes: 6 additions & 1 deletion crates/viz-core/src/visualizations/fourier_epicycles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 14 additions & 1 deletion web/src/lib/components/__tests__/FourierLab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('../../fourier/textPath')>()), // keep pure helpers (samplesFor) real

textToPath: vi.fn(async (t: string) =>
t.trim()
? [
Expand All @@ -28,6 +30,7 @@ vi.mock('../../fourier/textPath', () => ({
}));

import App from '../../../App.svelte';
import { textToPath } from '../../fourier/textPath';

describe('FourierLab.svelte', () => {
beforeEach(() => {
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion web/src/lib/components/__tests__/LabShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('../../fourier/textPath')>()), // keep pure helpers (samplesFor) real
textToPath: vi.fn(async () => []) }));

import App from '../../../App.svelte';

Expand Down
13 changes: 7 additions & 6 deletions web/src/lib/components/labs/FourierLab.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -91,7 +92,7 @@
const my = ++gen;
let path: Awaited<ReturnType<typeof textToPath>>;
try {
path = await textToPath(text, { samples: SAMPLES });
path = await textToPath(text, { samples: samplesFor(epicycles) });
} catch (err) {
console.warn('textToPath failed:', err);
return;
Expand All @@ -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());
}
Expand Down Expand Up @@ -144,7 +145,7 @@
});
</script>

<LabShell labId="fourier" initialSpeed={360} {onReady}>
<LabShell labId="fourier" initialSpeed={120} {onReady}>
{#snippet info()}
<h2>Fourier Epicycles</h2>
<p>
Expand Down
18 changes: 17 additions & 1 deletion web/src/lib/fourier/__tests__/textPath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions web/src/lib/fourier/textPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading