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
69 changes: 69 additions & 0 deletions crates/viz-core/src/engine/erased.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,16 @@ 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 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
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"),
}
}
}
Expand All @@ -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<dyn Any>;
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>;
Expand Down Expand Up @@ -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<dyn Any> {
Box::new(self.rule.init(&self.cfg, seed))
}
Expand Down Expand Up @@ -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");
}
}
43 changes: 40 additions & 3 deletions crates/viz-core/src/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -214,16 +216,51 @@ 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;
self.playback.max_iterations = new_max;
// 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
Expand Down
48 changes: 48 additions & 0 deletions crates/viz-core/src/rules/fourier_epicycles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathPoint>,
/// Number of DFT terms kept (K), largest amplitudes first.
pub epicycles: u32,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions crates/viz-core/src/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
48 changes: 48 additions & 0 deletions crates/viz-core/tests/wasm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Loading
Loading