Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,8 @@ math-visualizer/
│ │ │ ├── instanced_quads.rs # Batched filled axis-aligned rects (sorting bars)
│ │ │ ├── sdf_circle.rs # Single-quad antialiased stroked circle
│ │ │ ├── line_batch.rs # 2D colored line segment batch
│ │ │ └── line_batch_3d.rs # 3D colored line segment batch
│ │ │ ├── line_batch_3d.rs # 3D colored line segment batch
│ │ │ └── texture_quad.rs # RGBA texture drawn into a pixel rect
│ │ ├── rules/
│ │ │ ├── sierpinski_chaos.rs # Default flagship rule (3D Chaos Game)
│ │ │ ├── fourier_epicycles.rs # Fourier lab rule: DFT of a pen-tagged closed path
Expand All @@ -165,6 +166,7 @@ math-visualizer/
│ │ │ │ ├── algorithms.rs # 7 algorithms recorded as compare/write/swap traces
│ │ │ │ └── datasets.rs # 4 seeded initial-array generators
│ │ │ ├── notes.rs # Notes lab rule: oscillators in just or equal temperament
│ │ │ ├── jpeg.rs # JPEG lab rule: one encode per config, a scan cursor, the selected block
│ │ │ ├── rng.rs # Shared deterministic SplitMix64 RNG helpers
│ │ │ ├── midpoint_on_circle.rs # Alternative rule (still works)
│ │ │ └── color_cycle.rs # Phase 2 demo rule
Expand All @@ -173,9 +175,10 @@ math-visualizer/
│ │ ├── fourier_epicycles.rs # Fourier lab viz: rings + arms + pen-lifted trail
│ │ ├── sorting.rs # Sorting lab viz: bars drawn via InstancedQuads
│ │ ├── notes.rs # Notes lab viz: bars, Lissajous trail, wave strip, 3D cube
│ │ ├── jpeg.rs # JPEG lab viz: panels the page lays out (image, planes, cube, cells)
│ │ ├── dots_on_circle.rs # Alternative viz (paired with midpoint)
│ │ └── color_cycle.rs # Phase 2 demo viz
│ └── tests/ # Browser tests: wasm.rs (engine), jpeg_kit.rs, jpeg_oracle.rs
│ └── tests/ # Browser tests: wasm.rs (engine), jpeg_kit.rs, jpeg_lab.rs, jpeg_oracle.rs
└── web/ # Vite + Svelte 5 app
├── public/fonts/ # Space Mono (SIL OFL 1.1) + OFL.txt, for the Fourier lab
├── src/
Expand Down
1 change: 1 addition & 0 deletions crates/viz-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ features = [
"WebGlUniformLocation",
"WebGlBuffer",
"WebGlVertexArrayObject",
"WebGlTexture",
"console",
]

Expand Down
81 changes: 81 additions & 0 deletions crates/viz-core/src/engine/erased.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ pub enum ErasedError {
PathUnsupported,
/// `Rule::apply_action` rejected the action as malformed.
Action(String),
/// The active rule refused the image (`Rule::apply_image` returned false):
/// it takes none, or the buffer is not `w × h × 4` bytes.
ImageUnsupported,
}

impl std::fmt::Display for ErasedError {
Expand All @@ -40,6 +43,7 @@ impl std::fmt::Display for ErasedError {
ErasedError::ConfigParse(e) => write!(f, "config parse error: {e}"),
ErasedError::PathUnsupported => f.write_str("this rule does not accept a path"),
ErasedError::Action(msg) => write!(f, "action rejected: {msg}"),
ErasedError::ImageUnsupported => f.write_str("this rule does not accept this image"),
}
}
}
Expand All @@ -65,6 +69,15 @@ pub trait ErasedRule {
xy: &[f32],
pen: &[u8],
) -> Result<(), ErasedError>;
/// Parse the scalar fields from `cfg`, then install the `w × h` RGBA
/// image via `Rule::apply_image`. Atomic: the old config survives any error.
fn set_config_with_image(
&mut self,
cfg: &Value,
w: u32,
h: u32,
rgba: &[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;
Expand All @@ -76,6 +89,10 @@ pub trait ErasedRule {

/// Mutate live state without resetting playback. See `Rule::apply_action`.
fn action(&self, state: &mut dyn Any, action: &Value) -> Result<bool, ErasedError>;
/// On-demand view selected by `key`. See `Rule::inspect`.
fn inspect(&self, state: &dyn Any, key: &str) -> Result<Value, ErasedError>;
/// Binary product of the state. See `Rule::bytes`.
fn bytes(&self, state: &dyn Any) -> Result<Option<Vec<u8>>, ErasedError>;
}

/// A concrete `Rule` together with its deserialized config.
Expand Down Expand Up @@ -136,6 +153,22 @@ where
Ok(())
}

fn set_config_with_image(
&mut self,
cfg: &Value,
w: u32,
h: u32,
rgba: &[u8],
) -> Result<(), ErasedError> {
let mut typed: R::Config =
serde_json::from_value(cfg.clone()).map_err(ErasedError::ConfigParse)?;
if !self.rule.apply_image(&mut typed, w, h, rgba) {
return Err(ErasedError::ImageUnsupported);
}
self.cfg = typed;
Ok(())
}

fn config_json(&self) -> Value {
serde_json::to_value(&self.cfg).unwrap_or(Value::Null)
}
Expand Down Expand Up @@ -175,6 +208,20 @@ where
.apply_action(typed_state, &self.cfg, action)
.map_err(ErasedError::Action)
}

fn inspect(&self, state: &dyn Any, key: &str) -> Result<Value, ErasedError> {
let typed = state
.downcast_ref::<R::State>()
.ok_or(ErasedError::StateDowncastFailed)?;
Ok(self.rule.inspect(typed, key))
}

fn bytes(&self, state: &dyn Any) -> Result<Option<Vec<u8>>, ErasedError> {
let typed = state
.downcast_ref::<R::State>()
.ok_or(ErasedError::StateDowncastFailed)?;
Ok(self.rule.bytes(typed))
}
}

pub trait ErasedVisualization {
Expand Down Expand Up @@ -349,6 +396,40 @@ mod tests {
Err(ErasedError::StateDowncastFailed)
));
}

#[test]
fn rules_without_an_image_refuse_one_and_keep_their_config() {
let mut rule = TypedRule::new(SierpinskiChaos);
let before = rule.config_json();
let err = rule
.set_config_with_image(&before, 1, 1, &[0, 0, 0, 255])
.unwrap_err();
assert!(matches!(err, ErasedError::ImageUnsupported));
// It is also what a wrong-length buffer gets, so the message names the image, not the rule's kind.
assert_eq!(err.to_string(), "this rule does not accept this image");
assert_eq!(rule.config_json(), before);
let state = rule.init(0);
assert_eq!(rule.inspect(&*state, "tables").unwrap(), Value::Null);
assert_eq!(rule.bytes(&*state).unwrap(), None);
}

#[test]
fn the_jpeg_rule_takes_an_image_and_reports_bytes() {
use crate::rules::jpeg::JpegRule;
let mut rule = TypedRule::new(JpegRule);
let cfg = rule.config_json();
rule.set_config_with_image(&cfg, 2, 1, &[255, 0, 0, 255, 0, 0, 255, 255])
.expect("image accepted");
let state = rule.init(0);
assert_eq!(rule.summary(&*state).unwrap()["width"], 2);
assert!(rule.bytes(&*state).unwrap().is_some());
// The pixels never appear in the reported config.
assert!(rule.config_json().get("pixels").is_none());
// A malformed config is refused before the image is looked at.
assert!(rule
.set_config_with_image(&json!({"quality": "high"}), 2, 1, &[0; 8])
.is_err());
}
}

#[cfg(test)]
Expand Down
45 changes: 44 additions & 1 deletion crates/viz-core/src/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,50 @@ impl Engine {
Ok(())
}

/// Shared tail of the two rule-config setters: rewind, pause, adopt the
/// Like `update_rule_config`, but the rule's image arrives as a typed
/// array: `rgba` is `w × h` pixels, 4 bytes each (JS passes a
/// Uint8Array or Uint8ClampedArray view, so a 1 MB image skips JSON).
/// `cfg` carries the scalar fields. Errors if the active rule has no
/// image or rejects the pixels.
pub fn update_rule_config_with_image(
&mut self,
cfg: JsValue,
w: u32,
h: u32,
rgba: &[u8],
) -> Result<(), JsValue> {
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_image(&parsed, w, h, rgba)
.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(())
}

/// The rule's on-demand view for `key` (see `Rule::inspect`): `undefined`
/// in JS (a JSON null that wasm-bindgen turns into `undefined`) for an
/// unknown key or a rule without one, and `null` when the rule failed
/// internally (the error is logged to the console).
pub fn rule_inspect(&self, key: &str) -> JsValue {
match self.rule.inspect(self.state.as_ref(), key) {
Ok(v) => to_js(&v),
Err(e) => {
warn(&format!("rule.inspect failed: {e}"));
JsValue::NULL
}
}
}

/// The rule's binary product (see `Rule::bytes`), e.g. the JPEG lab's
/// file; `undefined` when it has none.
pub fn rule_bytes(&self) -> Option<Vec<u8>> {
self.rule.bytes(self.state.as_ref()).ok().flatten()
}

/// Shared tail of the 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;
Expand Down
20 changes: 19 additions & 1 deletion crates/viz-core/src/engine/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ use serde_json::Value;
use super::erased::{ErasedRule, ErasedVisualization, TypedRule, TypedViz};
use crate::config::ConfigSchema;
use crate::rules::fourier_epicycles::{FourierConfig, FourierEpicycles};
use crate::rules::jpeg::{JpegConfig, JpegRule};
use crate::rules::notes::{Notes, NotesConfig};
use crate::rules::sierpinski_chaos::{ChaosGameConfig, SierpinskiChaos};
use crate::rules::sorting::{SortingConfig, SortingRace};
use crate::visualizations::fourier_epicycles::{FourierEpicyclesViz, FourierEpicyclesVizConfig};
use crate::visualizations::jpeg::{JpegViz, JpegVizConfig};
use crate::visualizations::notes::{NotesViz, NotesVizConfig};
use crate::visualizations::sierpinski_pyramid::{SierpinskiPyramid, SierpinskiPyramidVizConfig};
use crate::visualizations::sorting::{SortingViz, SortingVizConfig};
Expand All @@ -32,7 +34,7 @@ pub struct LabParts {
pub const DEFAULT_LAB: &str = "sierpinski";

/// Every id `build_lab` accepts.
pub const LAB_IDS: &[&str] = &["sierpinski", "fourier", "sorting", "notes"];
pub const LAB_IDS: &[&str] = &["sierpinski", "fourier", "sorting", "notes", "jpeg"];

/// Build the rule/viz pair for `id`, or `None` if the id is unknown.
///
Expand Down Expand Up @@ -65,6 +67,12 @@ pub fn build_lab(id: &str) -> Option<LabParts> {
rule_cfg: NotesConfig::defaults(),
viz_cfg: NotesVizConfig::defaults(),
}),
"jpeg" => Some(LabParts {
rule: Box::new(TypedRule::new(JpegRule)),
viz: Box::new(TypedViz::new(JpegViz::new())),
rule_cfg: JpegConfig::defaults(),
viz_cfg: JpegVizConfig::defaults(),
}),
_ => None,
}
}
Expand Down Expand Up @@ -170,4 +178,14 @@ mod tests {
// The layout rects come from the DOM; zero means "fit the canvas".
assert_eq!(parts.viz_cfg["figure"], json!([0.0, 0.0, 0.0, 0.0]));
}

#[test]
fn jpeg_lab_pairs_rule_and_viz() {
let parts = build_lab("jpeg").expect("jpeg lab should build");
assert_eq!(parts.rule.id(), "jpeg");
assert_eq!(parts.viz.id(), "jpeg");
// The default gradient at 256² in 4:2:0 is 16 × 16 MCUs.
assert_eq!(max_iterations_of(&parts.rule_cfg, 1), 256);
assert_eq!(parts.viz_cfg["panels"], json!([]));
}
}
1 change: 1 addition & 0 deletions crates/viz-core/src/render/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub mod line_batch;
pub mod line_batch_3d;
pub mod sdf_circle;
pub mod shader;
pub mod texture_quad;

pub use camera_2d::{pixel_projection, Camera2D};
pub use camera_3d::Camera3D;
Expand Down
Loading
Loading