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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ math-visualizer/
├── crates/viz-core/ # Rust crate compiled to WebAssembly
│ ├── src/
│ │ ├── lib.rs # wasm-bindgen entry point
│ │ ├── jpeg_images.rs # Generated JPEG lab test images (gradient, text, bars)
│ │ ├── jpeg_images.rs # Generated JPEG lab pictures (gradient, text, bars, cosines, shifted cosines, noise)
│ │ ├── jpeg_kit.rs # Stateless wasm-bindgen wrappers over the jpeg crate
│ │ ├── traits.rs # SceneState, Rule, Visualization, Capabilities, InputEvent
│ │ ├── config/ # ConfigSchema trait + JSON Schema helpers
Expand Down Expand Up @@ -182,6 +182,7 @@ math-visualizer/
│ └── 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
├── public/jpeg/ # JPEG lab gallery pictures (PNG) + CREDITS.md
├── src/
│ ├── App.svelte # Top nav + hash-route switch between labs
│ ├── main.ts # Svelte 5 mount entry
Expand Down Expand Up @@ -209,3 +210,13 @@ math-visualizer/
See [docs/superpowers/specs/2026-05-23-math-visualizer-foundation-design.md](docs/superpowers/specs/2026-05-23-math-visualizer-foundation-design.md) for the full design — including the planned Rule/Visualization/Engine abstractions that land in Phase 2+.

See [docs/configuration.md](docs/configuration.md) for build-time configuration, [docs/testing.md](docs/testing.md) for measured coverage and test scope, and [docs/licensing.md](docs/licensing.md) for the workspace's declared license.

## Image credits

The JPEG lab's gallery pictures are bundled in `web/public/jpeg/`. spring and shuttle are cropped from their Commons originals, and all four Commons-based pictures are scaled. Full credits and crop boxes are in [`web/public/jpeg/CREDITS.md`](web/public/jpeg/CREDITS.md); the scripts that regenerate the pictures are in [`scripts/jpeg-gallery/`](scripts/jpeg-gallery/).

- spring: "Grand Prismatic Spring, Yellowstone" · Jim Peaco, National Park Service · Public domain · https://commons.wikimedia.org/wiki/File:Grand_prismatic_spring.jpg
- shuttle: "Atlantis (STS-135) on pad 39A" · Bill Ingalls, NASA · Public domain · https://commons.wikimedia.org/wiki/File:Brightly_lit_STS-135_on_launch_pad_39a.jpg
- doge: "Doge (homemade)" · Euterpia, after a photo by Roberto Vasarri · CC0 · https://commons.wikimedia.org/wiki/File:Doge_homemade_meme.jpg
- honest-work: "It ain't much, but it's honest work" · Cakelot1, photo by Dianne Johnson, USDA NRCS · Public domain · https://commons.wikimedia.org/wiki/File:Farmer_meme_with_apostrophe.jpg
- cartoon: "Needs more JPEG" · made for this project · CC0
161 changes: 160 additions & 1 deletion crates/viz-core/src/jpeg_images.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
//! Built-in test images for the JPEG lab, generated in code so they need no
//! assets: each one is chosen to provoke one artifact.

use std::f32::consts::PI;

use jpeg::Rgb8Image;

use crate::rules::rng::splitmix64;

/// Ids accepted by `builtin_image`, in picker order.
pub const GENERATED_IMAGES: &[&str] = &["gradient", "text", "bars"];
pub const GENERATED_IMAGES: &[&str] = &[
"gradient",
"text",
"bars",
"cosines",
"cosines-shifted",
"noise",
];

/// Largest width or height `builtin_image` will generate (guards against
/// enormous allocations requested from JavaScript).
Expand All @@ -22,6 +31,9 @@ pub fn builtin_image(id: &str, w: usize, h: usize) -> Option<Rgb8Image> {
"gradient" => gradient_px,
"text" => text_px,
"bars" => bars_px,
"cosines" => cosines_px,
"cosines-shifted" => cosines_shifted_px,
"noise" => noise_px,
_ => return None,
};
let mut rgba = Vec::with_capacity(w * h * 4);
Expand Down Expand Up @@ -136,9 +148,43 @@ fn bars_px(x: usize, y: usize, w: usize, h: usize) -> [u8; 3] {
to_px(rgb.map(|c| c * 255.0))
}

/// A quilt of the 64 DCT basis patterns, one per 8×8 block, on a constant teal
/// tint: block `(bx, by)` carries frequency `(bx % 8, by % 8)`. Every block is
/// exactly one cosine the transform has a coefficient for, so it codes to one
/// or two numbers and the whole picture compresses almost perfectly.
fn cosines_px(x: usize, y: usize, _w: usize, _h: usize) -> [u8; 3] {
let (u, v) = ((x / 8) % 8, (y / 8) % 8);
let c = |k: usize, n: usize| (PI * (2 * n + 1) as f32 * k as f32 / 16.0).cos();
let l = 128.0 + 90.0 * c(u, x % 8) * c(v, y % 8);
to_px([l - 40.0, l + 10.0, l + 25.0])
}

/// The same cosines slid 4 pixels right and down, so every 8×8 block straddles
/// four patterns. The picture is as simple to describe as before, but no block
/// is a single cosine any more, so it costs several times the bytes. The
/// transform only rewards what lines up with its grid.
fn cosines_shifted_px(x: usize, y: usize, w: usize, h: usize) -> [u8; 3] {
cosines_px(x + 4, y + 4, w, h)
}

/// Seeded random bytes in every channel. There is no structure to find, so
/// compression can only shrink it by destroying it: the file stays large and
/// the decoded picture is a poor copy of the original.
fn noise_px(x: usize, y: usize, w: usize, _h: usize) -> [u8; 3] {
let r = splitmix64(0x4A50_4547 ^ (y * w + x) as u64);
[r as u8, (r >> 8) as u8, (r >> 16) as u8]
}

#[cfg(test)]
mod tests {
use super::*;
use jpeg::blocks::level_shift;
use jpeg::codec::{decode, encode, HuffmanMode, Options};
use jpeg::color::rgb_to_ycbcr;
use jpeg::dct::fdct;
use jpeg::metrics::psnr;
use jpeg::quant::{quantize, scale_table, STD_LUMA};
use jpeg::Subsampling;

fn max_neighbour_step(img: &Rgb8Image) -> u8 {
let mut m = 0;
Expand Down Expand Up @@ -180,4 +226,117 @@ mod tests {
.count();
assert!(saturated > 256 * 64, "bars are colourful");
}

fn baseline() -> Options {
Options {
quality: 50,
subsampling: Subsampling::S420,
huffman: HuffmanMode::Optimized,
..Options::default()
}
}

fn file_bytes(img: &Rgb8Image, quality: u8) -> usize {
let opts = Options {
quality,
..baseline()
};
encode(img, &opts).unwrap().bytes.len()
}

/// Shannon entropy of the byte values, in bits per byte.
fn entropy(bytes: &[u8]) -> f64 {
let mut freq = [0u32; 256];
for &b in bytes {
freq[b as usize] += 1;
}
let n = bytes.len() as f64;
freq.iter()
.filter(|&&f| f > 0)
.map(|&f| {
let p = f as f64 / n;
-p * p.log2()
})
.sum()
}

#[test]
fn the_generated_list_names_every_picture() {
assert_eq!(
GENERATED_IMAGES,
[
"gradient",
"text",
"bars",
"cosines",
"cosines-shifted",
"noise"
]
);
for id in GENERATED_IMAGES {
assert!(builtin_image(id, 16, 16).is_some(), "{id}");
}
}

#[test]
fn every_cosine_block_quantizes_to_at_most_two_numbers() {
let img = builtin_image("cosines", 256, 256).unwrap();
let table = scale_table(&STD_LUMA, 50);
for by in 0..32 {
for bx in 0..32 {
let mut luma = [0.0f32; 64];
for j in 0..8 {
for i in 0..8 {
let o = ((bx * 8 + i) + img.w * (by * 8 + j)) * 4;
let [r, g, b] = [0, 1, 2].map(|c| img.rgba[o + c] as f32);
luma[i + 8 * j] = rgb_to_ycbcr(r, g, b)[0];
}
}
let q = quantize(&fdct(&level_shift(&luma)), &table);
let nonzero = q.iter().filter(|&&v| v != 0).count();
assert!(
nonzero <= 2,
"block ({bx}, {by}) has {nonzero} non-zero values"
);
let (u, v) = (bx % 8, by % 8);
if (u, v) != (0, 0) {
assert_ne!(
q[u + 8 * v],
0,
"block ({bx}, {by}) lost its (u, v) = ({u}, {v}) value"
);
}
}
}
}

#[test]
fn shifting_the_cosines_by_four_pixels_costs_at_least_five_times_the_bytes() {
let aligned = file_bytes(&builtin_image("cosines", 256, 256).unwrap(), 50);
let shifted = file_bytes(&builtin_image("cosines-shifted", 256, 256).unwrap(), 50);
assert!(shifted >= 5 * aligned, "{shifted} vs {aligned}");
}

#[test]
fn noise_is_seeded_and_does_not_survive_compression() {
let noise = builtin_image("noise", 256, 256).unwrap();
assert_eq!(noise, builtin_image("noise", 256, 256).unwrap());
let samples: Vec<u8> = noise
.rgba
.chunks_exact(4)
.flat_map(|p| [p[0], p[1], p[2]])
.collect();
let h = entropy(&samples);
assert!(h > 7.99, "entropy {h}");
for quality in [50u8, 90, 100] {
let opts = Options {
quality,
..baseline()
};
let bytes = encode(&noise, &opts).unwrap().bytes;
let back = decode(&bytes).unwrap().image;
let db = psnr(&noise, &back).unwrap();
assert!(db < 15.0, "q{quality}: {db} dB");
}
}
}
16 changes: 14 additions & 2 deletions crates/viz-core/src/visualizations/jpeg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
//! grid with the scan path growing through it at `zigzag_speed` cells a
//! second, restarting when the block or the encode changes; the viz reports
//! how far it has got through `readout()`),
//! `wipe` (the original with the decoded image over its left `wipe` share)
//! and `error` (per-pixel |decoded − original|, brightened by `error_gain`).
//! `wipe` (the original with the decoded image over its left `wipe` share),
//! `original` and `decoded` (the whole picture before and after compression at
//! full brightness, whatever the scan position) and `error` (per-pixel
//! |decoded − original|, brightened by `error_gain`).

use jpeg::color::{rgb_to_ycbcr, ycbcr_to_rgb};
use jpeg::dct::{basis, idct, idct_partial};
Expand Down Expand Up @@ -983,6 +985,16 @@ impl Visualization for JpegViz {
}
}
}
"original" | "decoded" => {
if let (Some(q), Some(t)) = (&self.quad, &self.textures) {
let tex = if panel.kind == "original" {
&t.original
} else {
&t.decoded
};
q.draw(gl, tex, rect, canvas, 1.0);
}
}
"wipe" => {
if let (Some(q), Some(t)) = (&self.quad, &self.textures) {
q.draw(gl, &t.original, rect, canvas, 1.0);
Expand Down
113 changes: 113 additions & 0 deletions crates/viz-core/tests/jpeg_lab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ fn every_panel_kind_renders_without_a_gl_error() {
{"kind":"zigzag","rect":[0,200,80,80]},
{"kind":"wipe","rect":[80,200,100,100]},
{"kind":"error","rect":[180,200,100,100]},
{"kind":"original","rect":[0,300,80,80]},
{"kind":"decoded","rect":[80,300,80,80]},
{"kind":"unknown","rect":[0,0,1,1]}
],"cube_mix":0.5,"dct_k":6,"hover":[1,2],"zigzag_speed":30,"wipe":0.3,"error_gain":8}"#))
.expect("panels accepted");
Expand Down Expand Up @@ -266,3 +268,114 @@ fn panels_render_after_a_re_encode() {
engine.frame(48.0);
assert_eq!(gl_of(&canvas).get_error(), WebGl2RenderingContext::NO_ERROR);
}

/// The centre pixel of the default framebuffer, read in the task that drew it
/// (the drawing buffer is not preserved once the browser composites).
fn centre_pixel(gl: &WebGl2RenderingContext, side: i32) -> [u8; 4] {
let mut px = [0u8; 4];
gl.read_pixels_with_opt_u8_array(
side / 2,
side / 2,
1,
1,
WebGl2RenderingContext::RGBA,
WebGl2RenderingContext::UNSIGNED_BYTE,
Some(&mut px),
)
.expect("read_pixels");
px
}

#[wasm_bindgen_test]
fn original_and_decoded_panels_ignore_the_scan() {
let canvas = make_canvas("jpeg-undimmed");
canvas.set_width(64);
canvas.set_height(64);
let mut engine = Engine::new("jpeg-undimmed", Some("jpeg".into())).unwrap();
let gl = gl_of(&canvas);
// Nothing scanned: an `image` panel shows only the dimmed original.
engine
.dispatch(js(r#"{"kind":"JumpTo","iteration":0}"#))
.unwrap();
let render = |engine: &mut Engine, kind: &str| {
engine
.update_viz_config(js(&format!(
r#"{{"panels":[{{"kind":"{kind}","rect":[0,0,64,64]}}]}}"#
)))
.expect("panels accepted");
engine.frame(0.0);
engine.frame(16.0);
centre_pixel(&gl, 64)
};
let decoded = render(&mut engine, "decoded");
let original = render(&mut engine, "original");
let image = render(&mut engine, "image");
assert_eq!(gl.get_error(), WebGl2RenderingContext::NO_ERROR);

// The 256 × 256 gradient, sampled where the 64-pixel panel's centre lands:
// the textures are sampled with `Filter::Nearest`, so the mean of the four texels around (128, 128) stands in for the texel under the centre.
let src = viz_core::jpeg_images::builtin_image("gradient", 256, 256).unwrap();
let mean = |c: usize| {
let sum: u32 = [(127, 127), (128, 127), (127, 128), (128, 128)]
.iter()
.map(|&(x, y)| src.rgba[(y * 256 + x) * 4 + c] as u32)
.sum();
sum as f32 / 4.0
};
for c in 0..3 {
let full = mean(c);
let dimmed = full * 0.35; // `dim` defaults to 0.35
assert!(
(image[c] as f32 - dimmed).abs() <= 3.0,
"image[{c}] = {} should be dimmed ~{dimmed}",
image[c]
);
assert!(
(original[c] as f32 - full).abs() <= 3.0,
"original[{c}] = {} should be undimmed ~{full}",
original[c]
);
// Decoded is lossy (quality 75), so it only matches the original loosely.
assert!(
(decoded[c] as f32 - full).abs() <= 12.0,
"decoded[{c}] = {} should be undimmed ~{full}",
decoded[c]
);
assert!(
decoded[c] as f32 > dimmed + 30.0,
"decoded[{c}] = {} is not brighter than the dimmed {dimmed}",
decoded[c]
);
}
}

#[wasm_bindgen_test]
fn a_zero_size_panel_draws_nothing_where_an_empty_list_fits_the_image() {
// The page sends one zero-size panel until its first picture is in: an
// empty list would make the viz fit (and show) its own stand-in gradient.
let canvas = make_canvas("jpeg-blank");
canvas.set_width(64);
canvas.set_height(64);
let mut engine = Engine::new("jpeg-blank", Some("jpeg".into())).unwrap();
let gl = gl_of(&canvas);
let render = |engine: &mut Engine, panels: &str| {
engine
.update_viz_config(js(&format!(r#"{{"panels":{panels}}}"#)))
.expect("panels accepted");
engine.frame(0.0);
engine.frame(16.0);
centre_pixel(&gl, 64)
};
let nothing = render(&mut engine, r#"[{"kind":"none","rect":[0,0,64,64]}]"#);
let blank = render(&mut engine, r#"[{"kind":"image","rect":[0,0,0,0]}]"#);
let fitted = render(&mut engine, "[]");
assert_eq!(gl.get_error(), WebGl2RenderingContext::NO_ERROR);
assert_eq!(
blank, nothing,
"a zero-size panel must leave the background"
);
assert_ne!(
fitted, nothing,
"an empty list fits the image over the background"
);
}
Loading
Loading