diff --git a/crates/viz-core/src/engine/erased.rs b/crates/viz-core/src/engine/erased.rs index 124099e..5cf47fe 100644 --- a/crates/viz-core/src/engine/erased.rs +++ b/crates/viz-core/src/engine/erased.rs @@ -27,6 +27,8 @@ use crate::traits::{Capabilities, InputEvent, Rule, Visualization}; pub enum ErasedError { StateDowncastFailed, ConfigParse(serde_json::Error), + /// The active rule's config has no path (`Rule::apply_path` returned false). + PathUnsupported, } impl std::fmt::Display for ErasedError { @@ -34,6 +36,7 @@ impl std::fmt::Display for ErasedError { match self { ErasedError::StateDowncastFailed => f.write_str("scene state has wrong concrete type"), ErasedError::ConfigParse(e) => write!(f, "config parse error: {e}"), + ErasedError::PathUnsupported => f.write_str("this rule does not accept a path"), } } } @@ -51,6 +54,18 @@ pub trait ErasedRule { /// half-applied. fn set_config(&mut self, cfg: &Value) -> Result<(), ErasedError>; + /// Parse the scalar fields from `cfg`, then install `xy`/`pen` as the + /// path via `Rule::apply_path`. Atomic: the old config survives any error. + fn set_config_with_path( + &mut self, + cfg: &Value, + xy: &[f32], + pen: &[u8], + ) -> Result<(), ErasedError>; + /// The current typed config, serialized on demand (large paths included — + /// callers that only need scalars should avoid calling this per frame). + fn config_json(&self) -> Value; + fn init(&self, seed: u64) -> Box; fn advance_to(&self, state: &mut dyn Any, seed: u64, n: u32) -> Result<(), ErasedError>; fn substep(&self, state: &mut dyn Any, seed: u64, n: u32, sub: f32) -> Result<(), ErasedError>; @@ -100,6 +115,25 @@ where Ok(()) } + fn set_config_with_path( + &mut self, + cfg: &Value, + xy: &[f32], + pen: &[u8], + ) -> Result<(), ErasedError> { + let mut typed: R::Config = + serde_json::from_value(cfg.clone()).map_err(ErasedError::ConfigParse)?; + if !self.rule.apply_path(&mut typed, xy, pen) { + return Err(ErasedError::PathUnsupported); + } + self.cfg = typed; + Ok(()) + } + + fn config_json(&self) -> Value { + serde_json::to_value(&self.cfg).unwrap_or(Value::Null) + } + fn init(&self, seed: u64) -> Box { Box::new(self.rule.init(&self.cfg, seed)) } @@ -295,3 +329,38 @@ mod tests { )); } } + +#[cfg(test)] +mod path_tests { + use super::*; + use crate::rules::fourier_epicycles::FourierEpicycles; + use crate::rules::sierpinski_chaos::SierpinskiChaos; + use serde_json::json; + + const XY: [f32; 8] = [1.0, 0.0, 0.0, 1.0, -1.0, 0.0, 0.0, -1.0]; + const PEN: [u8; 4] = [1, 1, 1, 0]; + + #[test] + fn typed_array_path_installs_into_a_path_rule() { + let mut r = TypedRule::new(FourierEpicycles); + r.set_config_with_path(&json!({"epicycles": 3, "max_iterations": 4}), &XY, &PEN) + .expect("fourier accepts a path"); + let cfg = r.config_json(); + assert_eq!(cfg["epicycles"], 3); + let path = cfg["path"].as_array().expect("path array"); + assert_eq!(path.len(), 4); + assert_eq!(path[3]["pen"], false); + assert_eq!(path[1]["y"], 1.0); + } + + #[test] + fn typed_array_path_is_rejected_by_rules_without_one() { + let mut r = TypedRule::new(SierpinskiChaos); + let before = r.config_json(); + let err = r + .set_config_with_path(&json!({"max_iterations": 10}), &XY, &PEN) + .expect_err("sierpinski has no path"); + assert!(matches!(err, ErasedError::PathUnsupported)); + assert_eq!(r.config_json(), before, "config untouched on error"); + } +} diff --git a/crates/viz-core/src/engine/mod.rs b/crates/viz-core/src/engine/mod.rs index 20106dd..0dc229f 100644 --- a/crates/viz-core/src/engine/mod.rs +++ b/crates/viz-core/src/engine/mod.rs @@ -180,8 +180,10 @@ impl Engine { to_js(&self.viz.schema()) } + /// The rule's live typed config, serialized on demand — so a path + /// installed via `update_rule_config_with_path` is reflected too. pub fn rule_config(&self) -> JsValue { - to_js(&self.rule_cfg) + to_js(&self.rule.config_json()) } /// Structured summary of the rule's current model — rule-specific, `null` @@ -214,6 +216,43 @@ impl Engine { let new_max = registry::max_iterations_of(&parsed, self.playback.max_iterations); self.rule_cfg = parsed; + self.reset_playback_after_rule_config(new_max); + Ok(()) + } + + /// Like `update_rule_config`, but the path arrives as typed arrays — + /// `xy` = [x0, y0, x1, y1, …] and `pen` = one 0/1 flag per sample — which + /// wasm-bindgen exposes as zero-copy views (JS passes a Float32Array and a + /// Uint8Array). A 65k-point path therefore skips ~3 MB of JSON. `cfg` + /// carries the remaining scalar fields. Errors if the shapes disagree or + /// the active rule has no path. + pub fn update_rule_config_with_path( + &mut self, + cfg: JsValue, + xy: &[f32], + pen: &[u8], + ) -> Result<(), JsValue> { + if xy.len() != pen.len() * 2 { + return Err(JsValue::from_str(&format!( + "path shape mismatch: {} coordinates for {} pen flags", + xy.len(), + pen.len() + ))); + } + let parsed: Value = serde_wasm_bindgen::from_value(cfg) + .map_err(|e| JsValue::from_str(&format!("bad rule config: {e}")))?; + self.rule + .set_config_with_path(&parsed, xy, pen) + .map_err(|e| JsValue::from_str(&format!("bad rule config: {e}")))?; + let new_max = registry::max_iterations_of(&parsed, self.playback.max_iterations); + self.rule_cfg = parsed; + self.reset_playback_after_rule_config(new_max); + Ok(()) + } + + /// Shared tail of the two rule-config setters: rewind, pause, adopt the + /// new step count, drop the stale frame timestamp, rebuild state. + fn reset_playback_after_rule_config(&mut self, new_max: u32) { self.playback.iteration = 0; self.playback.sub_progress = 0.0; self.playback.playing = false; @@ -221,9 +260,7 @@ impl Engine { // The user may have spent seconds in a config panel before committing; // the next frame() must not feed that gap as `dt` into viz.tick(). self.last_frame_ms = None; - self.state = self.rule.init(self.playback.seed); - Ok(()) } /// Replace the visualization config. Cosmetic-only edits don't reset diff --git a/crates/viz-core/src/rules/fourier_epicycles.rs b/crates/viz-core/src/rules/fourier_epicycles.rs index 8cb7f02..5ba76e9 100644 --- a/crates/viz-core/src/rules/fourier_epicycles.rs +++ b/crates/viz-core/src/rules/fourier_epicycles.rs @@ -30,6 +30,9 @@ pub struct PathPoint { pub struct FourierConfig { /// 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. + /// Defaults to empty so the scalar-only JSON used with + /// `Engine::update_rule_config_with_path` still parses. + #[serde(default)] pub path: Vec, /// Number of DFT terms kept (K), largest amplitudes first. pub epicycles: u32, @@ -312,6 +315,25 @@ impl Rule for FourierEpicycles { } } + /// Install a packed path: `xy` = [x0, y0, x1, y1, …], `pen` one flag per + /// sample. Rejects (returns false, leaving `cfg` untouched) on a shape + /// mismatch so a truncated transfer can never become a silent half-path. + fn apply_path(&self, cfg: &mut Self::Config, xy: &[f32], pen: &[u8]) -> bool { + if xy.len() != pen.len() * 2 { + return false; + } + cfg.path = xy + .chunks_exact(2) + .zip(pen) + .map(|(c, &p)| PathPoint { + x: c[0], + y: c[1], + pen: p != 0, + }) + .collect(); + true + } + fn init(&self, cfg: &Self::Config, _seed: u64) -> Self::State { let (origin, mut epicycles) = dft(&cfg.path); epicycles.truncate(cfg.epicycles as usize); @@ -414,6 +436,32 @@ impl Rule for FourierEpicycles { mod tests { use super::*; + #[test] + fn apply_path_packs_xy_and_pen_and_rejects_shape_mismatch() { + let rule = FourierEpicycles; + let mut cfg = FourierConfig::default(); + assert!(rule.apply_path(&mut cfg, &[1.0, 0.0, 0.0, 1.0, -1.0, 0.0], &[1, 1, 0])); + assert_eq!(cfg.path.len(), 3); + assert_eq!( + cfg.path[1], + PathPoint { + x: 0.0, + y: 1.0, + pen: true + } + ); + assert_eq!(cfg.path[2].pen, false); + // odd coordinate count / flag count mismatch → refused, path unchanged + assert!(!rule.apply_path(&mut cfg, &[1.0, 0.0, 0.0], &[1, 1])); + assert_eq!(cfg.path.len(), 3); + // the scalar-only JSON form parses with an empty path + let parsed: FourierConfig = + serde_json::from_value(serde_json::json!({"epicycles": 5, "max_iterations": 9})) + .unwrap(); + assert!(parsed.path.is_empty()); + assert_eq!(parsed.epicycles, 5); + } + #[test] fn fft_matches_naive_dft_on_a_power_of_two_signal() { let z: Vec<(f64, f64)> = (0..64) diff --git a/crates/viz-core/src/traits.rs b/crates/viz-core/src/traits.rs index 0a8154a..fca74a1 100644 --- a/crates/viz-core/src/traits.rs +++ b/crates/viz-core/src/traits.rs @@ -59,6 +59,15 @@ pub trait Rule { ) { } + /// Optional bulk-path hook: replace the config's path from packed + /// `[x0, y0, x1, y1, …]` coordinates plus one 0/1 pen flag per sample. + /// Rules whose config has no path keep the default and return `false`. + /// Backs `Engine::update_rule_config_with_path`, which lets a 65k-point + /// path cross the WASM boundary as typed-array views instead of JSON. + fn apply_path(&self, _cfg: &mut Self::Config, _xy: &[f32], _pen: &[u8]) -> bool { + false + } + /// Optional structured summary of the rule's current model for the UI /// (e.g. the DFT terms behind a Fourier trace). Read by the shell after /// config changes, never per frame. Default: `null`. diff --git a/crates/viz-core/tests/wasm.rs b/crates/viz-core/tests/wasm.rs index beae761..1667a9b 100644 --- a/crates/viz-core/tests/wasm.rs +++ b/crates/viz-core/tests/wasm.rs @@ -267,3 +267,51 @@ fn fourier_rule_summary_exposes_dft_terms() { let total = js_sys::Reflect::get(&s, &JsValue::from_str("total_terms")).unwrap(); assert_eq!(total.as_f64(), Some(3.0)); } + +#[wasm_bindgen_test] +fn fourier_accepts_a_typed_array_path() { + make_canvas("test-canvas-fourier-typed"); + let mut engine = Engine::new("test-canvas-fourier-typed", Some("fourier".into())) + .expect("engine constructs"); + let xy = [1.0f32, 0.0, 0.0, 1.0, -1.0, 0.0, 0.0, -1.0]; + let pen = [1u8, 1, 1, 0]; + engine + .update_rule_config_with_path(cmd(r#"{"epicycles":3,"max_iterations":4}"#), &xy, &pen) + .expect("typed-array path accepted"); + engine + .dispatch(cmd(r#"{"kind":"StepForward"}"#)) + .expect("dispatch"); + engine + .dispatch(cmd(r#"{"kind":"StepForward"}"#)) + .expect("dispatch"); + let snap = engine.snapshot(); + let iter = js_sys::Reflect::get(&snap, &JsValue::from_str("iteration")) + .expect("iteration field") + .as_f64() + .expect("number"); + assert_eq!(iter as u32, 2); + // rule_config() reflects the installed path. + let cfg = engine.rule_config(); + let path = js_sys::Reflect::get(&cfg, &JsValue::from_str("path")).expect("path"); + assert_eq!(js_sys::Array::from(&path).length(), 4); + engine.frame(32.0); +} + +#[wasm_bindgen_test] +fn typed_array_path_rejects_bad_shapes_and_non_path_rules() { + make_canvas("test-canvas-typed-errors"); + let mut fourier = + Engine::new("test-canvas-typed-errors", Some("fourier".into())).expect("engine constructs"); + assert!(fourier + .update_rule_config_with_path( + cmd(r#"{"epicycles":3,"max_iterations":4}"#), + &[1.0, 0.0, 0.0], + &[1, 1] + ) + .is_err()); + make_canvas("test-canvas-typed-errors-2"); + let mut pyramid = Engine::new("test-canvas-typed-errors-2", None).expect("engine constructs"); + assert!(pyramid + .update_rule_config_with_path(cmd(r#"{"max_iterations":10}"#), &[1.0, 0.0], &[1]) + .is_err()); +} diff --git a/web/src/lib/components/__tests__/FourierLab.test.ts b/web/src/lib/components/__tests__/FourierLab.test.ts index 67b921d..cf3bb21 100644 --- a/web/src/lib/components/__tests__/FourierLab.test.ts +++ b/web/src/lib/components/__tests__/FourierLab.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, fireEvent } from '@testing-library/svelte'; import { tick } from 'svelte'; -import { installRafPolyfill, dispatchSpy, updateRuleConfigSpy } from '../../test/fakeViz'; +import { installRafPolyfill, dispatchSpy, updateRuleConfigWithPathSpy } from '../../test/fakeViz'; import { navigate } from '../../router.svelte'; installRafPolyfill(); @@ -35,21 +35,23 @@ import { textToPath } from '../../fourier/textPath'; describe('FourierLab.svelte', () => { beforeEach(() => { dispatchSpy.mockClear(); - updateRuleConfigSpy.mockClear(); + updateRuleConfigWithPathSpy.mockClear(); navigate('fourier'); // `route` is module-level state; pin it before each render }); it('pushes the default text as a rule config on ready, then dispatches Play', async () => { render(App); - await vi.waitFor(() => expect(updateRuleConfigSpy).toHaveBeenCalled()); + await vi.waitFor(() => expect(updateRuleConfigWithPathSpy).toHaveBeenCalled()); - expect(updateRuleConfigSpy).toHaveBeenCalledTimes(1); - const cfg = updateRuleConfigSpy.mock.calls[0][0] as { + expect(updateRuleConfigWithPathSpy).toHaveBeenCalledTimes(1); + const cfg = updateRuleConfigWithPathSpy.mock.calls[0][0] as { path: unknown[]; epicycles: number; max_iterations: number; }; - expect(cfg.path).toHaveLength(3); + expect(updateRuleConfigWithPathSpy.mock.calls[0][1]).toBeInstanceOf(Float32Array); + expect(updateRuleConfigWithPathSpy.mock.calls[0][1]).toHaveLength(6); // 3 points × (x, y) + expect(updateRuleConfigWithPathSpy.mock.calls[0][2]).toEqual(new Uint8Array([1, 1, 0])); expect(cfg.epicycles).toBe(2000); expect(cfg.max_iterations).toBe(3); @@ -57,13 +59,13 @@ describe('FourierLab.svelte', () => { expect(dispatchSpy).toHaveBeenCalledWith({ kind: 'Play' }); const playCall = dispatchSpy.mock.calls.findIndex((c) => (c[0] as { kind: string }).kind === 'Play'); expect(dispatchSpy.mock.invocationCallOrder[playCall]).toBeGreaterThan( - updateRuleConfigSpy.mock.invocationCallOrder[0], + updateRuleConfigWithPathSpy.mock.invocationCallOrder[0], ); }); it('typesets the series for the pushed text, with the count of terms it leaves out', async () => { const { container, getByText } = render(App); - await vi.waitFor(() => expect(updateRuleConfigSpy).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(updateRuleConfigWithPathSpy).toHaveBeenCalledTimes(1)); expect(getByText('The formula', { selector: 'h3' })).toBeTruthy(); // The fake's rule_summary() reports 2000 terms; the panel expands the top 8. @@ -85,7 +87,7 @@ describe('FourierLab.svelte', () => { it('shows a hint (and pushes nothing) when the text is cleared', async () => { const { getByLabelText, getByText, queryByText } = render(App); - await vi.waitFor(() => expect(updateRuleConfigSpy).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(updateRuleConfigWithPathSpy).toHaveBeenCalledTimes(1)); const input = getByLabelText('Text to trace') as HTMLInputElement; expect(input.value).toBe('POIETIC TECH'); @@ -94,7 +96,7 @@ describe('FourierLab.svelte', () => { await fireEvent.input(input, { target: { value: '' } }); // The push is debounced (150ms); waitFor polls past it. await vi.waitFor(() => expect(getByText(/Nothing to draw/)).toBeTruthy()); - expect(updateRuleConfigSpy).toHaveBeenCalledTimes(1); + expect(updateRuleConfigWithPathSpy).toHaveBeenCalledTimes(1); }); it('keeps the nav and swaps the lab when routing back to Sierpinski', async () => { @@ -112,14 +114,14 @@ describe('FourierLab.svelte', () => { describe('FourierLab.svelte — shareable link params', () => { beforeEach(() => { dispatchSpy.mockClear(); - updateRuleConfigSpy.mockClear(); + updateRuleConfigWithPathSpy.mockClear(); }); it('reads text and n from the hash query and uses them for the first push', async () => { navigate('fourier', 'text=HELLO&n=12'); const { getByLabelText } = render(App); - await vi.waitFor(() => expect(updateRuleConfigSpy).toHaveBeenCalled()); - const cfg = updateRuleConfigSpy.mock.calls[0][0] as { epicycles: number }; + await vi.waitFor(() => expect(updateRuleConfigWithPathSpy).toHaveBeenCalled()); + const cfg = updateRuleConfigWithPathSpy.mock.calls[0][0] as { epicycles: number }; expect(cfg.epicycles).toBe(12); expect((getByLabelText('Text to trace') as HTMLInputElement).value).toBe('HELLO'); }); @@ -127,8 +129,8 @@ describe('FourierLab.svelte — shareable link params', () => { 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 }; + await vi.waitFor(() => expect(updateRuleConfigWithPathSpy).toHaveBeenCalled()); + const cfg = updateRuleConfigWithPathSpy.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 })); @@ -137,12 +139,12 @@ describe('FourierLab.svelte — shareable link params', () => { it('keeps the URL in sync as the text changes and offers a copy-link button', async () => { navigate('fourier'); const { getByLabelText, getByTitle } = render(App); - await vi.waitFor(() => expect(updateRuleConfigSpy).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(updateRuleConfigWithPathSpy).toHaveBeenCalledTimes(1)); expect(location.hash).toBe('#/fourier'); // defaults are omitted from the link const input = getByLabelText('Text to trace') as HTMLInputElement; await fireEvent.input(input, { target: { value: 'ABC' } }); - await vi.waitFor(() => expect(updateRuleConfigSpy).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(updateRuleConfigWithPathSpy).toHaveBeenCalledTimes(2)); expect(location.hash).toBe('#/fourier?text=ABC'); expect(getByTitle('Copy a link to this message')).toBeTruthy(); }); diff --git a/web/src/lib/components/labApi.svelte.ts b/web/src/lib/components/labApi.svelte.ts index 93fc05d..0228087 100644 --- a/web/src/lib/components/labApi.svelte.ts +++ b/web/src/lib/components/labApi.svelte.ts @@ -26,6 +26,19 @@ export class LabApi { this.setRuleConfig({ ...cur, ...patch }); } + /** + * Replace the rule config with scalar fields as JSON plus the path as typed + * arrays — zero-copy across the WASM boundary, so a 65k-point path costs no + * JSON. Only rules that accept a path (the Fourier lab) support this. + */ + setRuleConfigWithPath(cfg: object, xy: Float32Array, pen: Uint8Array) { + try { + this.engine?.update_rule_config_with_path(cfg, xy, pen); + } catch (err) { + console.warn('update_rule_config_with_path failed:', err); + } + } + /** Replace the rule config wholesale (use this when the config is large, e.g. a 2000-point path). */ setRuleConfig(cfg: object) { try { diff --git a/web/src/lib/components/labs/FourierLab.svelte b/web/src/lib/components/labs/FourierLab.svelte index 9e29cbe..703a33a 100644 --- a/web/src/lib/components/labs/FourierLab.svelte +++ b/web/src/lib/components/labs/FourierLab.svelte @@ -4,7 +4,7 @@ import FormulaPanel from '../FormulaPanel.svelte'; import type { LabApi } from '../labApi.svelte'; import { cmd } from '../../playback/commands'; - import { textToPath, samplesFor } from '../../fourier/textPath'; + import { textToPath, samplesFor, packPath } from '../../fourier/textPath'; import { readSummary, type FourierSummary } from '../../fourier/summary'; import { route, replaceQuery } from '../../router.svelte'; import { buildQuery } from '../../router'; @@ -104,7 +104,8 @@ summary = null; return; } - api.setRuleConfig({ path, epicycles, max_iterations: Math.min(path.length, TRACE_STEPS) }); + const { xy, pen } = packPath(path); + api.setRuleConfigWithPath({ epicycles, max_iterations: Math.min(path.length, TRACE_STEPS) }, xy, pen); summary = readEngineSummary(api); api.dispatch(cmd.play()); } diff --git a/web/src/lib/fourier/__tests__/textPath.test.ts b/web/src/lib/fourier/__tests__/textPath.test.ts index 3c7baf7..0c00bd9 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, samplesFor, MIN_SAMPLES, MAX_SAMPLES } from '../textPath'; +import { textToPath, glyphOutlineCommands, samplesFor, packPath, 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,15 @@ describe('textToPath (fake font)', () => { }); }); +describe('packPath', () => { + it('interleaves x/y into a Float32Array and pen flags into a Uint8Array', () => { + const { xy, pen } = packPath([{ x: 1, y: 2, pen: true }, { x: -3, y: 0.5, pen: false }]); + expect(Array.from(xy)).toEqual([1, 2, -3, 0.5]); + expect(Array.from(pen)).toEqual([1, 0]); + expect(packPath([]).xy).toHaveLength(0); + }); +}); + describe('samplesFor', () => { it('is a power of two, at least epicycles + 1, clamped to [MIN, MAX]', () => { expect(samplesFor(1)).toBe(MIN_SAMPLES); diff --git a/web/src/lib/fourier/textPath.ts b/web/src/lib/fourier/textPath.ts index 6df5d16..7bf059f 100644 --- a/web/src/lib/fourier/textPath.ts +++ b/web/src/lib/fourier/textPath.ts @@ -23,6 +23,18 @@ export function samplesFor(epicycles: number): number { return 2 ** Math.ceil(Math.log2(want)); } +/** Pack a path for the typed-array engine call: `xy` = [x0, y0, x1, y1, …], `pen` = one 0/1 flag per point. */ +export function packPath(points: PathPoint[]): { xy: Float32Array; pen: Uint8Array } { + const xy = new Float32Array(points.length * 2); + const pen = new Uint8Array(points.length); + for (let i = 0; i < points.length; i++) { + xy[2 * i] = points[i].x; + xy[2 * i + 1] = points[i].y; + pen[i] = points[i].pen ? 1 : 0; + } + return { xy, pen }; +} + export type TextToPathOptions = { /** Total samples in the closed loop (uniform arc length). Default 2000. */ samples?: number; diff --git a/web/src/lib/test/fakeViz.ts b/web/src/lib/test/fakeViz.ts index a59d0ab..b42a568 100644 --- a/web/src/lib/test/fakeViz.ts +++ b/web/src/lib/test/fakeViz.ts @@ -17,6 +17,8 @@ export const freeSpy = vi.fn(); export const dispatchSpy = vi.fn(); /** Records every `Engine.update_rule_config(cfg)` so tests can assert on the config a lab pushes. */ export const updateRuleConfigSpy = vi.fn(); +/** Records every `Engine.update_rule_config_with_path(cfg, xy, pen)` — the Fourier lab's typed-array push. */ +export const updateRuleConfigWithPathSpy = vi.fn(); /** * What the fake's `rule_summary()` returns on the Fourier lab (null elsewhere, * like the real engine). Mutable so a test can reshape it before a push; @@ -59,6 +61,7 @@ export class FakeEngine { rule_summary() { return this._lab === 'fourier' ? ruleSummaryFixture : null; } viz_config() { return {}; } update_rule_config(cfg: unknown) { updateRuleConfigSpy(cfg); } + update_rule_config_with_path(cfg: unknown, xy: Float32Array, pen: Uint8Array) { updateRuleConfigWithPathSpy(cfg, xy, pen); } update_viz_config(_: unknown) {} capabilities() { return { supports_scrub: true, cheap_recompute: true, checkpoint_every: null }; } resize(_w: number, _h: number) {}