diff --git a/README.md b/README.md index d14594f..71426f3 100644 --- a/README.md +++ b/README.md @@ -28,16 +28,18 @@ Math Visualizer is an interactive collection of mathematical visualizations buil > speed and array size (10–300, default 50). Share a size with `#/sorting?n=`. > Turn on **Sound** for a tone per operation (pitch rises with the value, writes ring > brighter than compares) and a chime when a lane finishes. -> - **Notes & Chords** (`#/notes`) — pick up to three notes from C3 to B5, or set -> **Pick** to a chord (Major, Minor, Diminished, Augmented, Sus2, Sus4) and press its -> root; each note swings a dot on its own bar. Two notes draw the Lissajous loop of their interval (a 3:2 closes -> after two swings of the first note) and three draw a 3D curve in a turning cube. The +> - **Notes & Chords** (`#/notes`) — pick up to four notes from C3 to B5, or set +> **Pick** to a chord (six triads, or a seventh: Dom7, Maj7, Min7, Half-dim7, Dim7) and +> press its root; each note swings a dot on its own bar. Two notes draw the Lissajous loop of their interval (a 3:2 closes +> after two swings of the first note), three draw a 3D curve in a turning cube and four a +> curve in a tesseract that turns through the fourth dimension (**4D turn** holds it, +> **Turn** restarts it). The > speed slider is the first note's swings per second (0.25–1000 Hz); **Real pitch** > ramps it to that note's true frequency (a new root carries the speed along by the step > between the roots, so the chord moves in pitch, not just in name) and **Sound** voices the notes once past about > 20 swings per second. **Pure ratios** closes the loop; **Piano** tuning never quite > does. Share a pick with `#/notes?n=60,67` (`&t=equal` for piano tuning, -> `&c=major` for chord mode). +> `&c=major` or `&c=dom7` for chord mode). > > Every lab page has the same shape: a labelled control bezel, the canvas mounted as a > framed dark stage (a thin terracotta clock line along its top edge fills with the diff --git a/crates/viz-core/src/engine/erased.rs b/crates/viz-core/src/engine/erased.rs index dc62a5b..832a262 100644 --- a/crates/viz-core/src/engine/erased.rs +++ b/crates/viz-core/src/engine/erased.rs @@ -191,6 +191,8 @@ pub trait ErasedVisualization { fn handle_input(&mut self, ev: &InputEvent); fn tick(&mut self, dt: f32); fn set_zoom(&mut self, zoom: f32); + /// Forwards `Visualization::readout`. + fn readout(&self) -> Value; } /// A concrete `Visualization` together with its deserialized config. @@ -255,6 +257,10 @@ where fn set_zoom(&mut self, zoom: f32) { self.viz.set_zoom(zoom); } + + fn readout(&self) -> Value { + self.viz.readout() + } } #[cfg(test)] diff --git a/crates/viz-core/src/engine/mod.rs b/crates/viz-core/src/engine/mod.rs index e08136d..6fca23d 100644 --- a/crates/viz-core/src/engine/mod.rs +++ b/crates/viz-core/src/engine/mod.rs @@ -201,6 +201,12 @@ impl Engine { } } + /// The active visualization's `readout()` (view state the UI mirrors, + /// such as the notes lab's 4D angle); `undefined` in JS (a JSON null) for vizzes without one. + pub fn viz_readout(&self) -> JsValue { + to_js(&self.viz.readout()) + } + pub fn viz_config(&self) -> JsValue { to_js(&self.viz_cfg) } diff --git a/crates/viz-core/src/rules/notes.rs b/crates/viz-core/src/rules/notes.rs index b759cea..6f1634e 100644 --- a/crates/viz-core/src/rules/notes.rs +++ b/crates/viz-core/src/rules/notes.rs @@ -1,5 +1,5 @@ //! Notes & Chords: the oscillator model behind the lab. Each note is a sine -//! that swings a dot back and forth along one bar; two or three notes +//! that swings a dot back and forth along one bar; two to four notes //! together draw a Lissajous figure. //! //! **One iteration is one full swing of the root note** (the first note in @@ -28,8 +28,8 @@ use crate::traits::{Capabilities, Rule, SceneState}; /// MIDI note 60 (C4): the lab's default note and the fallback for an empty /// note list. pub const DEFAULT_NOTE: u8 = 60; -/// The lab draws at most three notes: the left, top and right bar. -pub const MAX_NOTES: usize = 3; +/// The lab draws at most four notes, one per bar: left, top, right, bottom. +pub const MAX_NOTES: usize = 4; /// Highest MIDI note number. const MAX_MIDI: u8 = 127; @@ -113,8 +113,8 @@ fn default_max_iter() -> u32 { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NotesConfig { /// MIDI notes in bar order: `[0]` is the root (left bar, drives y), `[1]` - /// the top bar (x), `[2]` the right bar (depth). May be empty (nothing - /// picked). Use `effective_notes` + /// the top bar (x), `[2]` the right bar (depth), `[3]` the bottom bar. May + /// be empty (nothing picked). Use `effective_notes` /// for the normalised list the model actually runs on. #[serde(default = "default_notes")] pub notes: Vec, @@ -252,7 +252,7 @@ pub fn displacement_at(note: &NoteState, just_intonation: bool, phase: f64) -> f } /// Builds the whole state for iteration `n`, `sub` of the way into the next -/// one. The model is at most three notes, so it is derived from the config +/// one. The model is at most four notes, so it is derived from the config /// every time rather than cached; that is what keeps `advance_to` and /// `substep` pure. fn recompute(cfg: &NotesConfig, n: u32, sub: f32) -> NotesState { @@ -511,6 +511,35 @@ mod tests { assert_eq!(parse(json!({"max_iterations": 7})).max_iterations, 7); } + #[test] + fn keeps_four_notes_and_drops_a_fifth() { + let cfg = NotesConfig { + notes: vec![60, 64, 67, 70, 72], + ..NotesConfig::default() + }; + assert_eq!(cfg.effective_notes(), vec![60, 64, 67, 70]); + assert_eq!(NotesConfig::schema()["properties"]["notes"]["maxItems"], 4); + } + + #[test] + fn a_pure_dominant_seventh_closes_after_twenty_root_swings() { + // C E G B♭: 1, 5/4, 3/2, 9/5 in the 5-limit table, so 20 : 25 : 30 : 36. + let cfg = NotesConfig { + notes: vec![60, 64, 67, 70], + ..NotesConfig::default() + }; + let state = Notes.init(&cfg, 0); + assert_eq!(state.notes.len(), 4); + assert_eq!(state.period, 20); + let summary = Notes.summary(&state); + let notes = summary["notes"].as_array().unwrap(); + assert_eq!(notes.len(), 4); + assert_eq!( + (notes[3]["num"].as_u64(), notes[3]["den"].as_u64()), + (Some(9), Some(5)) + ); + } + #[test] fn config_rejects_bad_notes() { let parse = |v: serde_json::Value| serde_json::from_value::(v); @@ -533,7 +562,7 @@ mod tests { } let notes = &schema["properties"]["notes"]; assert_eq!(notes["minItems"], 0); - assert_eq!(notes["maxItems"], 3); + assert_eq!(notes["maxItems"], 4); assert_eq!(notes["items"]["maximum"], 127); assert_eq!( schema["properties"]["max_iterations"]["default"], @@ -573,11 +602,11 @@ mod tests { fn effective_notes_normalizes() { let eff = |notes: &[u8]| cfg(notes, true).effective_notes(); assert_eq!(eff(&[]), Vec::::new()); - assert_eq!(eff(&[60, 62, 64, 65, 67]), vec![60, 62, 64]); + assert_eq!(eff(&[60, 62, 64, 65, 67]), vec![60, 62, 64, 65]); assert_eq!(eff(&[200]), vec![127]); // Order is bar order, so it must survive; every kept note is clamped. assert_eq!(eff(&[67, 60, 64]), vec![67, 60, 64]); - assert_eq!(eff(&[200, 10, 255, 30]), vec![127, 10, 127]); + assert_eq!(eff(&[200, 10, 255, 30, 40]), vec![127, 10, 127, 30]); } // ---- state and rule ---- @@ -638,8 +667,8 @@ mod tests { assert_eq!(empty.period, 1); assert!(!empty.closed); - let crowded = Notes.init(&cfg(&[60, 64, 67, 72], true), 0); - assert_eq!(crowded.notes.len(), 3); + let crowded = Notes.init(&cfg(&[60, 64, 67, 72, 76], true), 0); + assert_eq!(crowded.notes.len(), 4); let loud = Notes.init(&cfg(&[200], true), 0); assert_eq!(loud.notes[0].midi, 127); diff --git a/crates/viz-core/src/traits.rs b/crates/viz-core/src/traits.rs index f6a1788..72c196c 100644 --- a/crates/viz-core/src/traits.rs +++ b/crates/viz-core/src/traits.rs @@ -112,6 +112,13 @@ pub trait Visualization { /// default framing); >1 zooms in, <1 zooms out. Visualizations that /// support zoom override this; default is no-op. fn set_zoom(&mut self, _zoom: f32) {} + + /// Optional read-only snapshot of view state the UI mirrors every frame + /// (e.g. the notes lab's 4D angle, which the viz advances in `tick`). + /// Keep it cheap. Default: `null`. + fn readout(&self) -> serde_json::Value { + serde_json::Value::Null + } } /// Pointer / keyboard input forwarded from the canvas to the active viz. @@ -189,4 +196,20 @@ mod tests { other => panic!("wrong variant: {other:?}"), } } + + struct Bare; + impl Visualization for Bare { + type State = crate::rules::notes::NotesState; + type Config = crate::visualizations::notes::NotesVizConfig; + fn id(&self) -> &'static str { + "bare" + } + fn init(&mut self, _gl: &WebGl2RenderingContext, _cfg: &Self::Config) {} + fn render(&mut self, _gl: &WebGl2RenderingContext, _s: &Self::State, _c: &Self::Config) {} + } + + #[test] + fn readout_defaults_to_null() { + assert_eq!(Bare.readout(), serde_json::Value::Null); + } } diff --git a/crates/viz-core/src/visualizations/notes.rs b/crates/viz-core/src/visualizations/notes.rs index af6c80d..69e9152 100644 --- a/crates/viz-core/src/visualizations/notes.rs +++ b/crates/viz-core/src/visualizations/notes.rs @@ -2,12 +2,14 @@ //! its own bar, and together the notes draw a figure. //! //! The figure square (`figure`, pushed by the lab in device pixels, or -//! fitted to the canvas while unset) has a gutter on three sides: note 0's -//! bar on the left (it drives y), note 1's on top (x) and note 2's on the -//! right (depth). The plot sits inside the gutters. One note draws a time -//! plot of its swing; two draw their Lissajous figure, with dashed guides -//! from each dot to the pen; three draw a curve inside a slowly turning -//! wireframe cube (drag to orbit), rendered into the plot's own viewport. An +//! fitted to the canvas while unset) has a gutter on every side: note 0's +//! bar on the left (it drives y), note 1's on top (x), note 2's on the +//! right (depth) and note 3's along the bottom (the fourth dimension). The +//! plot sits inside the gutters. One note draws a time plot of its swing; +//! two draw their Lissajous figure, with dashed guides from each dot to the +//! pen; three draw a curve inside a slowly turning wireframe cube (drag to +//! orbit), rendered into the plot's own viewport; four draw it inside a +//! tesseract that also turns through the fourth dimension. An //! optional waveform strip (`strip`) shows each note's wave, their sum and a //! playhead over the current block of swings. //! @@ -42,9 +44,9 @@ const DEFAULT_AUTO_ROTATE_SPEED: f32 = 0.25; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NotesVizConfig { pub background: [f32; 4], - /// One colour per bar, in bar order: note 0 (left), 1 (top), 2 (right). - /// The lab's legend and chips copy these (JS `NOTE_COLORS`). - pub note_colors: [[f32; 4]; 3], + /// One colour per bar, in bar order: note 0 (left), 1 (top), 2 (right), + /// 3 (bottom). The lab's legend and chips copy these (JS `NOTE_COLORS`). + pub note_colors: [[f32; 4]; 4], /// The trail: the time plot, the 2D figure or the 3D curve. pub trail_color: [f32; 4], /// The strip's normalised sum of the notes. @@ -57,6 +59,18 @@ pub struct NotesVizConfig { pub pen_size_px: f32, /// Radians per second; 0 stops the cube's spin (dragging still orbits). pub auto_rotate_speed: f32, + /// Whether `tick` turns the four-note figure through the fourth + /// dimension. Off holds it at `angle_4d`. + #[serde(default = "default_true")] + pub turn_4d: bool, + /// Radians per second of the 4D turn. + #[serde(default = "default_turn_4d_speed")] + pub turn_4d_speed: f32, + /// The 4D angle in radians, taken only while `turn_4d` is off (the lab's + /// slider holds it there); a turning figure ignores it, so a layout push + /// carrying a stale value never snaps it back. + #[serde(default)] + pub angle_4d: f32, /// Trail samples per cycle of the fastest note. pub samples_per_cycle: u32, /// Cap on the trail's samples per frame, and on the segments of each of @@ -83,6 +97,7 @@ impl Default for NotesVizConfig { [0.949, 0.698, 0.235, 1.0], // amber #F2B23C [0.941, 0.400, 0.361, 1.0], // coral #F0665C [0.424, 0.776, 0.557, 1.0], // mint #6CC68E + [0.498, 0.659, 0.910, 1.0], // soft blue #7FA8E8 ], // #F5E6CC at 85% trail_color: [0.961, 0.902, 0.800, 0.85], @@ -95,6 +110,9 @@ impl Default for NotesVizConfig { dot_size_px: 8.0, pen_size_px: 6.0, auto_rotate_speed: DEFAULT_AUTO_ROTATE_SPEED, + turn_4d: true, + turn_4d_speed: DEFAULT_TURN_4D_SPEED, + angle_4d: 0.0, samples_per_cycle: 40, max_samples: 8192, figure: [0.0; 4], @@ -103,6 +121,17 @@ impl Default for NotesVizConfig { } } +fn default_true() -> bool { + true +} + +/// Radians per second the four-note figure turns through 4D. +const DEFAULT_TURN_4D_SPEED: f32 = 0.35; + +fn default_turn_4d_speed() -> f32 { + DEFAULT_TURN_4D_SPEED +} + /// Schema for a device-pixel `[x, y, w, h]` the lab pushes from its layout. fn rect_property(label: &str) -> serde_json::Value { serde_json::json!({ @@ -126,15 +155,16 @@ impl ConfigSchema for NotesVizConfig { "background": color_property("Background", d.background), "note_colors": { "type": "array", - "title": "Note colors (3)", - "minItems": 3, - "maxItems": 3, - // Per-slot defaults so a schema-driven reset keeps three - // distinct bars; items=false forbids a fourth. + "title": "Note colors (4)", + "minItems": 4, + "maxItems": 4, + // Per-slot defaults so a schema-driven reset keeps four + // distinct bars; items=false forbids a fifth. "prefixItems": [ color_property("Note 1 (left bar)", d.note_colors[0]), color_property("Note 2 (top bar)", d.note_colors[1]), color_property("Note 3 (right bar)", d.note_colors[2]), + color_property("Note 4 (bottom bar)", d.note_colors[3]), ], "items": false, }, @@ -157,6 +187,17 @@ impl ConfigSchema for NotesVizConfig { default: DEFAULT_AUTO_ROTATE_SPEED as f64, min: 0.0, max: 3.0, step: 0.05, integer: false, cosmetic: true, widget: None, }), + "turn_4d": { "type": "boolean", "title": "Turn through 4D (four notes)", "default": true }, + "turn_4d_speed": number_property(NumberOpts { + label: "4D turn speed (rad/s)", + default: DEFAULT_TURN_4D_SPEED as f64, min: 0.0, max: 3.0, step: 0.05, + integer: false, cosmetic: true, widget: None, + }), + "angle_4d": number_property(NumberOpts { + label: "4D angle when held (rad)", + default: 0.0, min: 0.0, max: std::f64::consts::TAU, step: 0.01, + integer: false, cosmetic: true, widget: None, + }), "samples_per_cycle": number_property(NumberOpts { label: "Trail samples per cycle", default: 40.0, min: 4.0, max: 400.0, step: 1.0, @@ -211,10 +252,12 @@ pub enum BarSide { Left, Top, Right, + /// The fourth note's bar. + Bottom, } /// One note's bar, in device pixels. `from` = displacement −1 end, `to` = +1 -/// end (left/right bars: bottom → top; top bar: left → right). +/// end (left/right bars: bottom → top; top and bottom bars: left → right). #[derive(Debug, Clone, Copy, PartialEq)] pub struct Bar { pub side: BarSide, @@ -223,13 +266,13 @@ pub struct Bar { } /// Where everything goes this frame, in device pixels. `bars` is in bar -/// order (note 0 left, note 1 top, note 2 right); a frame draws the first -/// `notes.len()` of them. +/// order (note 0 left, note 1 top, note 2 right, note 3 bottom); a frame +/// draws the first `notes.len()` of them. #[derive(Debug, Clone, PartialEq)] pub struct Layout { pub figure: Rect, pub plot: Rect, - pub bars: [Bar; 3], + pub bars: [Bar; 4], pub strip: Option, } @@ -276,6 +319,7 @@ pub fn layout(canvas_px: [u32; 2], figure: [f32; 4], strip: [f32; 4]) -> Layout let left_x = figure.x + midline; let right_x = figure.x + figure.w - midline; let top_y = figure.y + midline; + let bottom_y = figure.y + figure.h - midline; let (plot_top, plot_bottom) = (plot.y, plot.y + plot.h); let bars = [ Bar { @@ -293,6 +337,11 @@ pub fn layout(canvas_px: [u32; 2], figure: [f32; 4], strip: [f32; 4]) -> Layout from: [right_x, plot_bottom], to: [right_x, plot_top], }, + Bar { + side: BarSide::Bottom, + from: [plot.x, bottom_y], + to: [plot.x + plot.w, bottom_y], + }, ]; Layout { figure, @@ -548,7 +597,7 @@ pub fn glow_quad(bar: &Bar, half_thickness_px: f32, color: [f32; 4]) -> QuadInst let lo = [bar.from[0].min(bar.to[0]), bar.from[1].min(bar.to[1])]; let hi = [bar.from[0].max(bar.to[0]), bar.from[1].max(bar.to[1])]; let (min, max) = match bar.side { - BarSide::Top => ([lo[0], lo[1] - h], [hi[0], hi[1] + h]), + BarSide::Top | BarSide::Bottom => ([lo[0], lo[1] - h], [hi[0], hi[1] + h]), BarSide::Left | BarSide::Right => ([lo[0] - h, lo[1]], [hi[0] + h, hi[1]]), }; QuadInstance { min, max, color } @@ -577,6 +626,57 @@ pub fn cube_edges(half: f32) -> [[f32; 3]; 24] { out } +// ---- The fourth dimension ---- + +/// The 4D eye's distance along w: perspective scales a point by +/// `D / (D − w)`, so the far cube of a tesseract shrinks inside the near one. +pub const PERSPECTIVE_4D: f32 = 3.0 * CUBE_HALF; +/// Brings the perspective-projected tesseract back inside the 3D cube's +/// bounding sphere (radius `√3 · CUBE_HALF` ≈ 0.866) at every turn, so the +/// camera framing is unchanged. It sits just under √(5/12) ≈ 0.6455, the +/// scale that puts the worst turn's furthest corner exactly on that sphere, +/// so the worst projected radius (≈ 0.865) stays inside it; pinned by +/// `the_turning_tesseract_stays_inside_the_cube_bounds`. +pub const FIT_4D: f32 = 0.645; + +/// Turns `p` = (x, y, z, w) by `theta` in the x–w plane and then in the z–w +/// plane. Periodic in 2π, so one slider covers every view; y (the root) is +/// never touched. At a quarter turn the fourth note lies along x. +pub fn rotate_4d(p: [f32; 4], theta: f32) -> [f32; 4] { + let (s, c) = theta.sin_cos(); + let [x, y, z, w] = p; + let (x, w) = (x * c - w * s, x * s + w * c); + let (z, w) = (z * c - w * s, z * s + w * c); + [x, y, z, w] +} + +/// Perspective from 4D to 3D along w, then `FIT_4D`: larger w (nearer the 4D +/// eye) draws larger. +pub fn project_4d(p: [f32; 4]) -> [f32; 3] { + let scale = FIT_4D * PERSPECTIVE_4D / (PERSPECTIVE_4D - p[3]); + [p[0] * scale, p[1] * scale, p[2] * scale] +} + +/// The tesseract `[-half, half]⁴` as its 32 edges: each joins two corners +/// that differ in exactly one coordinate. +pub fn tesseract_edges(half: f32) -> [[[f32; 4]; 2]; 32] { + let corner = |bits: u32| -> [f32; 4] { + std::array::from_fn(|axis| if bits & (1 << axis) != 0 { half } else { -half }) + }; + let mut out = [[[0.0; 4]; 2]; 32]; + let mut i = 0; + for bits in 0..16u32 { + for axis in 0..4 { + let other = bits ^ (1 << axis); + if other > bits { + out[i] = [corner(bits), corner(other)]; + i += 1; + } + } + } + out +} + /// `c` with its alpha scaled by `scale`. pub fn with_alpha(c: [f32; 4], scale: f32) -> [f32; 4] { [c[0], c[1], c[2], c[3] * scale] @@ -637,9 +737,9 @@ struct FrameCtx { } /// The Notes & Chords visualization. Between frames it keeps only the orbit -/// camera's angles, the smear tracker and the strip's held block (plus GL -/// resources and scratch); everything it draws is rebuilt from the state -/// each frame. +/// camera's angles, the 4D turn's angle, the smear tracker and the strip's +/// held block (plus GL resources and scratch); everything it draws is +/// rebuilt from the state each frame. pub struct NotesViz { camera: Camera3D, /// Accumulated by `tick`: the cube's slow spin. @@ -651,6 +751,10 @@ pub struct NotesViz { /// `auto_rotate_speed` from the last `init`, so `tick`, which never sees /// the config, can spin the cube. cached_auto_speed: f32, + /// The 4D turn, radians in [0, 2π). + angle_4d: f32, + /// `turn_4d` and its speed from the last `init`, for `tick`. + cached_turn_4d: (bool, f32), /// The phase at the last frame, for the smear; `None` before the first. last_phase: Option, /// This frame's smear, 0 crisp dots … 1 lit bars: quick to rise, a few @@ -686,6 +790,8 @@ impl NotesViz { azimuth_offset: 0.0, elevation: 0.35, cached_auto_speed: DEFAULT_AUTO_ROTATE_SPEED, + angle_4d: 0.0, + cached_turn_4d: (true, DEFAULT_TURN_4D_SPEED), last_phase: None, smear: 0.0, strip_hold: None, @@ -702,8 +808,18 @@ impl NotesViz { } } + /// The turn part of `init`, separate so it is testable without GL: + /// caches whether and how fast `tick` turns, and while held takes the + /// config's angle. + fn apply_turn_config(&mut self, cfg: &NotesVizConfig) { + self.cached_turn_4d = (cfg.turn_4d, cfg.turn_4d_speed); + if !cfg.turn_4d { + self.angle_4d = cfg.angle_4d.rem_euclid(std::f32::consts::TAU); + } + } + /// The 2D batches every frame draws with. The cube's are made by - /// `ensure_3d_resources` once three notes render. + /// `ensure_3d_resources` once three or four notes render. fn ensure_resources(&mut self, gl: &Gl) -> Result<(), String> { if self.lines.is_none() { self.lines = Some(LineBatch::new(gl)?); @@ -717,8 +833,8 @@ impl NotesViz { Ok(()) } - /// The cube's line batch and pen, made the first time three notes - /// render rather than up front: most notes engines (the home tile's + /// The cube's line batch and pen, made the first time three or four + /// notes render rather than up front: most notes engines (the home tile's /// two-note one among them) never draw the cube. fn ensure_3d_resources(&mut self, gl: &Gl) -> Result<(), String> { if self.lines_3d.is_none() { @@ -878,6 +994,86 @@ impl NotesViz { gl.viewport(0, 0, frame.canvas[0] as i32, frame.canvas[1] as i32); } + /// Four notes: the curve inside a tesseract, turned through the fourth + /// dimension by `angle_4d` (see `rotate_4d`), projected to 3D + /// (`project_4d`) and drawn with the cube's camera, batches and pen. + fn draw_figure_4d( + &mut self, + gl: &Gl, + state: &NotesState, + cfg: &NotesVizConfig, + frame: &FrameCtx, + ) { + if self.ensure_3d_resources(gl).is_err() { + return; + } + let [n0, n1, n2, n3] = [ + &state.notes[0], + &state.notes[1], + &state.notes[2], + &state.notes[3], + ]; + let just = state.just_intonation; + let theta = self.angle_4d; + // x = note 1 (top), y = note 0 (left), z = note 2 (right), w = note 3 (bottom). + let point_at = |t: f64| { + project_4d(rotate_4d( + [ + displacement_at(n1, just, t) * CUBE_HALF, + displacement_at(n0, just, t) * CUBE_HALF, + displacement_at(n2, just, t) * CUBE_HALF, + displacement_at(n3, just, t) * CUBE_HALF, + ], + theta, + )) + }; + + let [x, y, w, h] = gl_viewport_rect(frame.canvas[1], &frame.layout.plot); + let plot_px = [w.max(0) as u32, h.max(0) as u32]; + gl.viewport(x, y, w, h); + self.camera.resize(plot_px[0], plot_px[1]); + self.camera.azimuth = self.auto_azimuth + self.azimuth_offset; + self.camera.elevation = self.elevation; + let view_proj = self.camera.view_projection(); + + sample_trail( + state, + cfg, + trail_window(state.period), + &mut self.time_scratch, + ); + self.line_3d_scratch.clear(); + for [a, b] in tesseract_edges(CUBE_HALF) { + for p in [a, b] { + self.line_3d_scratch.push(LineVertex3D { + position: project_4d(rotate_4d(p, theta)), + color: cfg.axis_color, + }); + } + } + push_polyline( + &mut self.line_3d_scratch, + self.time_scratch.iter().map(|&t| LineVertex3D { + position: point_at(t), + color: cfg.trail_color, + }), + ); + let lines_3d = self.lines_3d.as_mut().expect("ensure_3d_resources ran"); + lines_3d.upload(gl, &self.line_3d_scratch); + lines_3d.draw(gl, &view_proj); + + let pen = PointInstance3D { + position: point_at(state.phase), + color: with_alpha(PEN_COLOR, 1.0 - frame.smear), + radius_px: cfg.pen_size_px * 0.5, + }; + let points_3d = self.points_3d.as_mut().expect("ensure_3d_resources ran"); + points_3d.upload(gl, &[pen]); + points_3d.draw(gl, &view_proj, plot_px); + + gl.viewport(0, 0, frame.canvas[0] as i32, frame.canvas[1] as i32); + } + /// The bars of the notes in play: each one's line and centre tick, its /// glow while the swings smear, and its dot. fn draw_bars(&mut self, gl: &Gl, state: &NotesState, cfg: &NotesVizConfig, frame: &FrameCtx) { @@ -888,7 +1084,7 @@ impl NotesViz { for bar in bars { let [cx, cy] = bar_point(bar, 0.0); let (dx, dy) = match bar.side { - BarSide::Top => (0.0, half_tick), + BarSide::Top | BarSide::Bottom => (0.0, half_tick), BarSide::Left | BarSide::Right => (half_tick, 0.0), }; let tick = segment([cx - dx, cy - dy], [cx + dx, cy + dy], axis); @@ -1020,11 +1216,13 @@ impl Visualization for NotesViz { /// Runs at construction and again on every `update_viz_config`, which /// the lab sends whenever its layout changes. So it only refreshes the - /// spin speed `tick` uses and makes sure the 2D GL resources exist (the + /// spin speed `tick` uses and the 4D turn's settings (a held angle is + /// taken from the config), and makes sure the 2D GL resources exist (the /// cube's wait for its first frame); the camera, the smear tracker and /// the strip's hold carry over. fn init(&mut self, gl: &Gl, cfg: &Self::Config) { self.cached_auto_speed = cfg.auto_rotate_speed; + self.apply_turn_config(cfg); // A failure is retried by the next render, like the other vizzes. let _ = self.ensure_resources(gl); } @@ -1061,10 +1259,10 @@ impl Visualization for NotesViz { // Nothing picked leaves the cleared stage (the page shows a prompt). if frame.n_notes > 0 { - if frame.n_notes == 3 { - self.draw_figure_3d(gl, state, cfg, &frame); - } else { - self.draw_figure_2d(gl, state, cfg, &frame); + match frame.n_notes { + 4 => self.draw_figure_4d(gl, state, cfg, &frame), + 3 => self.draw_figure_3d(gl, state, cfg, &frame), + _ => self.draw_figure_2d(gl, state, cfg, &frame), } self.draw_bars(gl, state, cfg, &frame); self.draw_strip(gl, state, cfg, &frame); @@ -1084,6 +1282,10 @@ impl Visualization for NotesViz { fn tick(&mut self, dt: f32) { // The spin always runs; a drag adds its offset on top. self.auto_azimuth += self.cached_auto_speed * dt; + let (turning, speed) = self.cached_turn_4d; + if turning { + self.angle_4d = (self.angle_4d + speed * dt).rem_euclid(std::f32::consts::TAU); + } } /// Dragging with the primary button orbits the cube, as in the @@ -1104,6 +1306,11 @@ impl Visualization for NotesViz { /// Ignored: the lab hides the zoom control, and `CUBE_HALF` only clears /// the frustum from `CAMERA_DISTANCE`. fn set_zoom(&mut self, _zoom: f32) {} + + /// The 4D angle, so the lab's slider can follow the turn. + fn readout(&self) -> serde_json::Value { + serde_json::json!({ "angle_4d": self.angle_4d }) + } } #[cfg(test)] @@ -1145,13 +1352,16 @@ mod tests { "no property for {key}" ); } - // Every config field is required, and nothing else is. + // Every config field is required, and nothing else is, except the + // 4D turn's: older pushes omit them, so they stay optional. + let optional = ["turn_4d", "turn_4d_speed", "angle_4d"]; let defaults = NotesVizConfig::defaults(); let mut fields: Vec<&str> = defaults .as_object() .unwrap() .keys() .map(String::as_str) + .filter(|key| !optional.contains(key)) .collect(); required.sort_unstable(); fields.sort_unstable(); @@ -1164,17 +1374,20 @@ mod tests { assert_eq!(props["figure"]["minItems"], 4); assert_eq!(props["figure"]["x-cosmetic"], true); // One colour slot per bar, each defaulting to that bar's own colour, - // so a schema-driven reset gives three distinct notes. + // so a schema-driven reset gives four distinct notes. let slots = props["note_colors"]["prefixItems"] .as_array() - .expect("three colour slots"); - assert_eq!(slots.len(), 3); + .expect("four colour slots"); + assert_eq!(slots.len(), 4); for (i, slot) in slots.iter().enumerate() { assert_eq!(slot["x-widget"], "color"); assert_eq!(slot["default"], defaults["note_colors"][i], "slot {i}"); } - assert_ne!(slots[0]["default"], slots[1]["default"]); - assert_ne!(slots[1]["default"], slots[2]["default"]); + for i in 0..4 { + for j in i + 1..4 { + assert_ne!(slots[i]["default"], slots[j]["default"], "{i} vs {j}"); + } + } } #[test] @@ -1273,10 +1486,10 @@ mod tests { }; assert_rect_near(l.plot, plot, "plot"); - let [left, top, right] = l.bars; + let [left, top, right, bottom] = l.bars; assert_eq!( - [left.side, top.side, right.side], - [BarSide::Left, BarSide::Top, BarSide::Right] + [left.side, top.side, right.side, bottom.side], + [BarSide::Left, BarSide::Top, BarSide::Right, BarSide::Bottom] ); // On the gutter midlines (6 px in from the square's edge), spanning // the plot; `from` is the −1 end. @@ -1286,6 +1499,8 @@ mod tests { assert_point_near(top.to, [98.0, 26.0], "top to"); assert_point_near(right.from, [104.0, 108.0], "right from"); assert_point_near(right.to, [104.0, 32.0], "right to"); + assert_point_near(bottom.from, [22.0, 114.0], "bottom from"); + assert_point_near(bottom.to, [98.0, 114.0], "bottom to"); assert_eq!( l.strip, @@ -1300,7 +1515,7 @@ mod tests { #[test] fn bar_point_maps_displacement_to_bar_ends() { - let [left, top, right] = explicit().bars; + let [left, top, right, bottom] = explicit().bars; assert_point_near(bar_point(&left, 1.0), [16.0, 32.0], "left +1, plot top"); assert_point_near( bar_point(&left, -1.0), @@ -1312,6 +1527,16 @@ mod tests { assert_point_near(bar_point(&top, 1.0), [98.0, 26.0], "top +1, plot right"); assert_point_near(bar_point(&top, -1.0), [22.0, 26.0], "top -1, plot left"); assert_point_near(bar_point(&top, 0.5), [79.0, 26.0], "top +0.5"); + assert_point_near( + bar_point(&bottom, 1.0), + [98.0, 114.0], + "bottom +1, plot right", + ); + assert_point_near( + bar_point(&bottom, -1.0), + [22.0, 114.0], + "bottom -1, plot left", + ); } #[test] @@ -1330,7 +1555,7 @@ mod tests { // So the dashed guides are axis-aligned: the pen shares its y with // note 0's dot on the left bar and its x with note 1's on the top. - let [left, top, _] = l.bars; + let [left, top, ..] = l.bars; for d in [-1.0, -0.3, 0.0, 0.6, 1.0] { assert_near(pen_point_2d(&plot, 0.25, d)[1], bar_point(&left, d)[1], "y"); assert_near(pen_point_2d(&plot, d, 0.25)[0], bar_point(&top, d)[0], "x"); @@ -1652,7 +1877,7 @@ mod tests { #[test] fn glow_quad_spans_the_bar_with_thickness() { - let [left, top, right] = explicit().bars; + let [left, top, right, bottom] = explicit().bars; let color = [0.9, 0.7, 0.2, 0.6]; let q = glow_quad(&left, 2.0, color); assert_point_near(q.min, [14.0, 32.0], "left min"); @@ -1664,6 +1889,9 @@ mod tests { let q = glow_quad(&top, 2.0, color); assert_point_near(q.min, [22.0, 24.0], "top min"); assert_point_near(q.max, [98.0, 28.0], "top max"); + let q = glow_quad(&bottom, 2.0, color); + assert_point_near(q.min, [22.0, 112.0], "bottom min"); + assert_point_near(q.max, [98.0, 116.0], "bottom max"); } #[test] @@ -1829,4 +2057,189 @@ mod tests { } assert_eq!(prev, 0.0); } + + // ---- the fourth dimension ---- + + fn near4(a: [f32; 4], b: [f32; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5) + } + fn len3(p: [f32; 3]) -> f32 { + (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt() + } + + #[test] + fn rotate_4d_is_the_identity_at_zero_and_periodic() { + let p = [0.1, -0.2, 0.3, 0.4]; + assert!(near4(rotate_4d(p, 0.0), p)); + assert!(near4(rotate_4d(p, std::f32::consts::TAU), p)); + assert!(near4( + rotate_4d(p, 1.0 + std::f32::consts::TAU), + rotate_4d(p, 1.0) + )); + } + + #[test] + fn rotate_4d_keeps_y_and_brings_w_onto_x_at_a_quarter_turn() { + let q = rotate_4d([0.0, 0.25, 0.0, 0.5], std::f32::consts::FRAC_PI_2); + assert!((q[0] + 0.5).abs() < 1e-5, "x′ = −w, got {q:?}"); + assert!((q[1] - 0.25).abs() < 1e-6, "y is untouched"); + } + + #[test] + fn rotate_4d_preserves_length() { + let p = [0.3, -0.1, 0.2, -0.4]; + let l = |v: [f32; 4]| v.iter().map(|c| c * c).sum::(); + assert!((l(rotate_4d(p, 0.7)) - l(p)).abs() < 1e-5); + } + + #[test] + fn rotate_4d_turns_x_onto_z_through_w_at_a_quarter_turn() { + // x–w first: x′ = x·c − w·s = 0, w′ = x·s + w·c = 0.5. Then z–w: + // z′ = z·c − w′·s = −0.5, w″ = z·s + w′·c = 0. So x ends on −z. + let q = rotate_4d([0.5, 0.0, 0.0, 0.0], std::f32::consts::FRAC_PI_2); + assert!(q[0].abs() < 1e-5, "x′ ≈ 0, got {q:?}"); + assert!((q[2] + 0.5).abs() < 1e-5, "z′ ≈ −0.5, got {q:?}"); + assert!(q[3].abs() < 1e-5, "w′ ≈ 0, got {q:?}"); + } + + #[test] + fn project_4d_grows_points_nearer_the_4d_eye() { + let near = project_4d([0.3, 0.3, 0.3, -0.4]); + let far = project_4d([0.3, 0.3, 0.3, 0.4]); + // w toward the viewer (larger w) scales up; away scales down. + assert!(len3(far) > len3(near)); + assert!((project_4d([0.0; 4])[0]).abs() < 1e-7); + } + + #[test] + fn the_turning_tesseract_stays_inside_the_cube_bounds() { + let limit = 3f32.sqrt() * CUBE_HALF + 1e-4; + let mut worst = 0f32; + for k in 0u32..720 { + let theta = std::f32::consts::TAU * k as f32 / 720.0; + for [a, b] in tesseract_edges(CUBE_HALF) { + for p in [a, b] { + worst = worst.max(len3(project_4d(rotate_4d(p, theta)))); + } + } + // Interior points (a trail lives inside the hypercube) too. + for i in 0u32..16 { + let f = |j: u32| ((i * 7 + j * 13 + k) % 17) as f32 / 16.0 * 2.0 - 1.0; + let p = [f(1), f(2), f(3), f(4)].map(|c| c * CUBE_HALF); + worst = worst.max(len3(project_4d(rotate_4d(p, theta)))); + } + } + assert!(worst <= limit, "worst radius {worst} > {limit}"); + assert!(worst > 0.97 * limit, "FIT_4D wastes the frame: {worst}"); + } + + #[test] + fn tesseract_has_32_edges_each_along_one_axis() { + let edges = tesseract_edges(0.5); + assert_eq!(edges.len(), 32); + for [a, b] in edges { + let diff = (0..4).filter(|&i| (a[i] - b[i]).abs() > 1e-6).count(); + assert_eq!(diff, 1, "{a:?} → {b:?}"); + assert!(a.iter().chain(&b).all(|c| (c.abs() - 0.5).abs() < 1e-6)); + } + // No edge appears twice. + let mut keys: Vec = edges.iter().map(|e| format!("{e:?}")).collect(); + keys.sort(); + keys.dedup(); + assert_eq!(keys.len(), 32); + } + + // ---- four notes: the fourth bar and the turn ---- + + #[test] + fn the_fourth_bar_runs_along_the_bottom_gutter_left_to_right() { + let l = layout([1000, 800], [100.0, 50.0, 500.0, 500.0], [0.0; 4]); + let gutter = GUTTER_FRAC * 500.0; + let bottom = l.bars[3]; + assert_eq!(bottom.side, BarSide::Bottom); + let y = 50.0 + 500.0 - gutter * 0.5; + assert_point_near(bottom.from, [l.plot.x, y], "bottom bar from"); + assert_point_near(bottom.to, [l.plot.x + l.plot.w, y], "bottom bar to"); + } + + #[test] + fn turn_config_defaults_and_schema() { + let d = NotesVizConfig::default(); + assert!(d.turn_4d); + assert_eq!(d.turn_4d_speed, 0.35); + assert_eq!(d.angle_4d, 0.0); + assert_eq!(d.note_colors.len(), 4); + assert_eq!(d.note_colors[3], [0.498, 0.659, 0.910, 1.0]); + let schema = NotesVizConfig::schema(); + for key in ["turn_4d", "turn_4d_speed", "angle_4d"] { + assert!(schema["properties"][key].is_object(), "{key} in schema"); + } + assert_eq!(schema["properties"]["note_colors"]["maxItems"], 4); + // Older pushes without the new fields still parse, with the defaults. + let mut older = NotesVizConfig::defaults(); + for key in ["turn_4d", "turn_4d_speed", "angle_4d"] { + older.as_object_mut().unwrap().remove(key); + } + let parsed: NotesVizConfig = + serde_json::from_value(older).expect("parses without the 4D fields"); + assert!(parsed.turn_4d); + assert_eq!(parsed.turn_4d_speed, 0.35); + assert_eq!(parsed.angle_4d, 0.0); + } + + #[test] + fn tick_turns_while_turning_and_holds_while_held() { + let mut viz = NotesViz::new(); + viz.apply_turn_config(&NotesVizConfig { + turn_4d_speed: 1.0, + ..NotesVizConfig::default() + }); + viz.tick(0.5); + assert!((viz.angle_4d - 0.5).abs() < 1e-6); + viz.apply_turn_config(&NotesVizConfig { + turn_4d: false, + angle_4d: 2.0, + ..NotesVizConfig::default() + }); + assert!( + (viz.angle_4d - 2.0).abs() < 1e-6, + "a held config sets the angle" + ); + viz.tick(1.0); + assert!((viz.angle_4d - 2.0).abs() < 1e-6, "held: tick leaves it"); + } + + #[test] + fn a_turning_config_never_snaps_the_angle_back() { + let mut viz = NotesViz::new(); + viz.apply_turn_config(&NotesVizConfig { + turn_4d_speed: 1.0, + ..NotesVizConfig::default() + }); + viz.tick(1.2); + // A layout push carries a stale angle_4d with turn_4d still on. + viz.apply_turn_config(&NotesVizConfig { + turn_4d_speed: 1.0, + angle_4d: 0.1, + ..NotesVizConfig::default() + }); + assert!((viz.angle_4d - 1.2).abs() < 1e-6); + } + + #[test] + fn the_angle_wraps_and_the_readout_reports_it() { + let mut viz = NotesViz::new(); + viz.apply_turn_config(&NotesVizConfig { + turn_4d_speed: 1.0, + ..NotesVizConfig::default() + }); + viz.tick(std::f32::consts::TAU + 0.25); + assert!((viz.angle_4d - 0.25).abs() < 1e-4); + let r = viz.readout(); + assert!((r["angle_4d"].as_f64().unwrap() - 0.25).abs() < 1e-4); + // Through the erased wrapper too. + let erased = crate::engine::erased::TypedViz::new(NotesViz::new()); + use crate::engine::erased::ErasedVisualization; + assert_eq!(erased.readout()["angle_4d"].as_f64(), Some(0.0)); + } } diff --git a/crates/viz-core/tests/wasm.rs b/crates/viz-core/tests/wasm.rs index 4f5279b..6477716 100644 --- a/crates/viz-core/tests/wasm.rs +++ b/crates/viz-core/tests/wasm.rs @@ -577,7 +577,7 @@ fn notes_schemas_expose_notes_and_layout_rects() { } #[wasm_bindgen_test] -fn notes_renders_one_two_and_three_notes() { +fn notes_renders_one_to_four_notes() { let canvas = make_canvas("test-canvas-notes-counts"); let mut engine = Engine::new("test-canvas-notes-counts", Some("notes".into())).expect("engine constructs"); @@ -623,6 +623,21 @@ fn notes_renders_one_two_and_three_notes() { let notes = summary_field(&engine, "notes"); assert_eq!(js_sys::Array::from(¬es).length(), 3); assert_no_gl_error(&gl_of(&canvas)); + + // C, E, G and B♭ (20:25:30:36) draw the tesseract, and the viz reports its 4D angle. + engine + .update_rule_config(cmd(r#"{"notes":[60,64,67,70]}"#)) + .expect("four notes"); + engine.frame(48.0); + assert_eq!(summary_field(&engine, "period").as_f64(), Some(20.0)); + assert_eq!( + js_sys::Array::from(&summary_field(&engine, "notes")).length(), + 4 + ); + let angle = js_sys::Reflect::get(&engine.viz_readout(), &JsValue::from_str("angle_4d")) + .expect("readout has angle_4d"); + assert!(angle.as_f64().is_some()); + assert_no_gl_error(&gl_of(&canvas)); } #[wasm_bindgen_test] diff --git a/docs/superpowers/plans/2026-10-12-notes-tesseract.md b/docs/superpowers/plans/2026-10-12-notes-tesseract.md new file mode 100644 index 0000000..93dc2f2 --- /dev/null +++ b/docs/superpowers/plans/2026-10-12-notes-tesseract.md @@ -0,0 +1,1207 @@ +# Notes & Chords: Four Notes and the Tesseract — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let the Notes & Chords lab play four notes (single picks or seventh chords) and draw them as a curve inside a tesseract that turns through the fourth dimension. + +**Architecture:** The Rust rule accepts four notes; the Rust visualization gains a fourth bar, pure 4D helpers (`rotate_4d`, `project_4d`, `tesseract_edges`) and a `draw_figure_4d` that feeds the existing 3D line/point batches and camera. The viz owns the 4D angle (advanced in `tick`) and exposes it through a new `Visualization::readout()` → `Engine::viz_readout()`; the Svelte lab reads it every frame for its slider and pushes `turn_4d` / `angle_4d` through the viz config only on user input. + +**Tech Stack:** Rust (wasm-bindgen, serde, WebGL2 via `web-sys`), Svelte 5 + TypeScript, Vitest + Testing Library, wasm-pack tests in headless Chrome. + +**Spec:** `docs/superpowers/specs/2026-10-12-notes-tesseract-design.md` + +## Global Constraints + +- Branch: `notes-tesseract` (the spec is already committed there). `main` is PR-only; nothing is pushed until the final PR. +- Validation (AGENTS.md): `cargo fmt --all --check`, `cargo clippy --all-targets --all-features`, `cargo test --all-features`; web: `cd web && npx vitest run` and `npx svelte-check`; wasm: `wasm-pack test --chrome --headless --chromedriver= crates/viz-core` (flag **before** the crate path; never concurrently with another cargo build). +- Axes: (x, y, z, w) = (note 2, root, note 3, note 4), each displacement × `CUBE_HALF` (0.5). +- 4D turn: rotate in the x–w plane, then the z–w plane, both by θ. Perspective `p = D / (D − w′)` with `D = 3 · CUBE_HALF`, then × `FIT_4D = 0.645` (just under √(5/12) ≈ 0.6455, the worst case over a full turn), so the projected tesseract stays within radius `√3 · CUBE_HALF`. +- Fourth note colour: `#7FA8E8` = `[0.498, 0.659, 0.910, 1.0]` (Rust default is the truth; JS `NOTE_COLORS` copies it). If the mockup step changes it, change both places. +- Viz config fields: `turn_4d: bool` (default `true`), `turn_4d_speed: f32` (rad/s, default `0.35`), `angle_4d: f32` (rad, default `0`), all `#[serde(default)]`. `angle_4d` is taken only when `turn_4d` is false. +- Readout shape: `{ "angle_4d": }`; every other visualization returns `null`. +- Seventh chord ids, labels and steps, in this order after the triads: `dom7` "Dom7" [0,4,7,10], `maj7` "Maj7" [0,4,7,11], `min7` "Min7" [0,3,7,10], `halfdim7` "Half-dim7" [0,3,6,10], `dim7` "Dim7" [0,3,6,9]. +- Picker range stays C3–B5 (midi 48–83). `MAX_NOTES = 4` on both sides. +- Copy: sentence case, no emoji, ♯/♭ glyphs only in `.ts` files (a repo test scans `.svelte` files). +- Match the surrounding code: doc comments on every pub item in Rust, JSDoc on exported TS, comment density like the neighbours. + +## Review Focus + +1. **A layout or resize push while the 4D figure is turning** must not snap the angle back to the stale `angle_4d` left in the stored viz config → Task 4 tests `apply_turn_config` with `turn_4d: true` and a non-zero `angle_4d`. +2. **Dropping from four notes to three (or Reset) while the turn is held** must leave the 3D cube exactly as before and the controls hidden; picking four again shows the held angle → Task 8 test "hides the 4D controls below four notes and keeps the held angle". +3. **A seventh chord near the top** (Maj7 on C♯5 needs B♯5 = 84) must grey the root out, not build a three-note chord → Task 5 `chordOn(73, 'maj7')` is undefined, and Task 8 checks C♯5 is `aria-disabled` under Maj7. +4. **A link with five notes or `c=dom7` plus three notes** loads the first four notes and the chord mode without re-voicing → Task 5 `parseNotesParam('60,64,67,70,72')` and Task 8 link test. +5. **Interior trail points, not just corners, staying inside the camera view** at every θ → Task 3 samples random interior points as well as corners. + +--- + +## Task 0: Mockup and look approval (controller only, no code) + +**Files:** scratchpad only (nothing committed). + +- [ ] **Step 1:** Build a static HTML mockup in the session scratchpad of (a) the bezel: Pick label, row 1 radios (Single notes … Sus4), a small "Sevenths" label and row 2 (Dom7 … Dim7), and the 4D controls ("4D turn" slider with a `123°` mono readout and a "Turn" toggle) styled like the existing pills; (b) the stage at 1168×650: figure square with four bars (fourth in `#7FA8E8` in the bottom gutter), a tesseract wireframe with a curve, and the ratio pill `20 : 25 : 30 : 36` in the strip's top-left corner. Light and dark. +- [ ] **Step 2:** Capture with the headless capture script used for the redesign and show the user. Record the approved fourth colour; if it differs from `#7FA8E8`, update Global Constraints before dispatching Tasks 4 and 5. + +--- + +## Task 1: Rule accepts four notes + +**Files:** +- Modify: `crates/viz-core/src/rules/notes.rs` (`MAX_NOTES` near line 32; tests module) + +**Interfaces:** +- Produces: `pub const MAX_NOTES: usize = 4;` (schema `maxItems` already reads it). + +- [ ] **Step 1: Write the failing tests** (in the rule's `mod tests`, next to `config_parses_with_missing_fields`) + +```rust + #[test] + fn keeps_four_notes_and_drops_a_fifth() { + let cfg = NotesConfig { + notes: vec![60, 64, 67, 70, 72], + ..NotesConfig::default() + }; + assert_eq!(cfg.effective_notes(), vec![60, 64, 67, 70]); + assert_eq!(NotesConfig::schema()["properties"]["notes"]["maxItems"], 4); + } + + #[test] + fn a_pure_dominant_seventh_closes_after_twenty_root_swings() { + // C E G B♭: 1, 5/4, 3/2, 9/5 in the 5-limit table, so 20 : 25 : 30 : 36. + let cfg = NotesConfig { + notes: vec![60, 64, 67, 70], + ..NotesConfig::default() + }; + let state = Notes.init(&cfg, 0); + assert_eq!(state.notes.len(), 4); + assert_eq!(state.period, 20); + let summary = Notes.summary(&state); + let notes = summary["notes"].as_array().unwrap(); + assert_eq!(notes.len(), 4); + assert_eq!((notes[3]["num"].as_u64(), notes[3]["den"].as_u64()), (Some(9), Some(5))); + } +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `cargo test -p viz-core --lib rules::notes -- four_notes dominant_seventh` +Expected: FAIL (`effective_notes` returns three notes; `maxItems` is 3; `state.notes.len()` is 3). + +- [ ] **Step 3: Implement** + +Change `pub const MAX_NOTES: usize = 3;` to `pub const MAX_NOTES: usize = 4;` and update its doc comment to say "one per bar: left, top, right, bottom". Search the file for doc comments that say "three" or "at most 3" about the note count and update them. + +- [ ] **Step 4: Run the rule tests** + +Run: `cargo test -p viz-core --lib rules::notes` +Expected: all PASS. If an existing test asserted a three-note cap (e.g. a fourth note dropped), change it to a fifth note being dropped. + +- [ ] **Step 5: Commit** + +```bash +git add crates/viz-core/src/rules/notes.rs +git commit -m "feat(rule): notes accepts four notes; a pure dominant seventh closes after 20 swings" +``` + +--- + +## Task 2: Visualization readout through the engine + +**Files:** +- Modify: `crates/viz-core/src/traits.rs` (`Visualization` trait, after `set_zoom`) +- Modify: `crates/viz-core/src/engine/erased.rs` (`ErasedVisualization` trait and `TypedViz` impl) +- Modify: `crates/viz-core/src/engine/mod.rs` (next to `rule_summary`, ~line 194) +- Test: `crates/viz-core/src/traits.rs` (new `mod tests`) + +**Interfaces:** +- Produces: `Visualization::readout(&self) -> serde_json::Value` (default `Value::Null`); `ErasedVisualization::readout(&self) -> Value`; `Engine::viz_readout(&self) -> JsValue` (wasm-bindgen, JS name `viz_readout`). + +- [ ] **Step 1: Write the failing test** (the erased wrapper's forwarding is pinned in Task 4, once `NotesViz` has a readout). Add to `traits.rs` tests (create `#[cfg(test)] mod tests` at the end of `traits.rs` if none exists) a minimal visualization: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + struct Bare; + impl Visualization for Bare { + type State = crate::rules::notes::NotesState; + type Config = crate::visualizations::notes::NotesVizConfig; + fn id(&self) -> &'static str { + "bare" + } + fn init(&mut self, _gl: &WebGl2RenderingContext, _cfg: &Self::Config) {} + fn render(&mut self, _gl: &WebGl2RenderingContext, _s: &Self::State, _c: &Self::Config) {} + } + + #[test] + fn readout_defaults_to_null() { + assert_eq!(Bare.readout(), serde_json::Value::Null); + } +} +``` + +(Use the import paths that compile in this crate; `NotesState` / `NotesVizConfig` are just convenient existing types that satisfy the bounds.) + +- [ ] **Step 2: Run to verify they fail** + +Run: `cargo test -p viz-core --lib readout` +Expected: FAIL to compile (`no method named readout` on `Bare`). + +- [ ] **Step 3: Implement** + +`traits.rs`, inside `pub trait Visualization`, after `set_zoom`: + +```rust + /// Optional read-only snapshot of view state the UI mirrors every frame + /// (e.g. the notes lab's 4D angle, which the viz advances in `tick`). + /// Keep it cheap. Default: `null`. + fn readout(&self) -> serde_json::Value { + serde_json::Value::Null + } +``` + +`erased.rs`: add `fn readout(&self) -> Value;` to `ErasedVisualization` (after `set_zoom`, with a one-line doc pointing at `Visualization::readout`), and in `impl ErasedVisualization for TypedViz`: + +```rust + fn readout(&self) -> Value { + self.viz.readout() + } +``` + +`engine/mod.rs`, after `rule_summary`: + +```rust + /// The active visualization's `readout()` (view state the UI mirrors, + /// such as the notes lab's 4D angle); `null` for vizzes without one. + pub fn viz_readout(&self) -> JsValue { + to_js(&self.viz.readout()) + } +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p viz-core --lib` then `cargo clippy --all-targets --all-features` +Expected: PASS, no new warnings. + +- [ ] **Step 5: Commit** + +```bash +git add crates/viz-core/src/traits.rs crates/viz-core/src/engine/erased.rs crates/viz-core/src/engine/mod.rs +git commit -m "feat(engine): visualizations can expose a readout, read with Engine::viz_readout" +``` + +--- + +## Task 3: Pure 4D helpers + +**Files:** +- Modify: `crates/viz-core/src/visualizations/notes.rs` (new section after `cube_edges`; tests module) + +**Interfaces:** +- Produces (all `pub`, in `visualizations::notes`): + - `pub const FIT_4D: f32 = 0.645;` + - `pub const PERSPECTIVE_4D: f32 = 3.0 * CUBE_HALF;` (the `D` above) + - `pub fn rotate_4d(p: [f32; 4], theta: f32) -> [f32; 4]` + - `pub fn project_4d(p: [f32; 4]) -> [f32; 3]` + - `pub fn tesseract_edges(half: f32) -> [[[f32; 4]; 2]; 32]` + +- [ ] **Step 1: Write the failing tests** (in `mod tests`) + +```rust + fn near4(a: [f32; 4], b: [f32; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5) + } + fn len3(p: [f32; 3]) -> f32 { + (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt() + } + + #[test] + fn rotate_4d_is_the_identity_at_zero_and_periodic() { + let p = [0.1, -0.2, 0.3, 0.4]; + assert!(near4(rotate_4d(p, 0.0), p)); + assert!(near4(rotate_4d(p, std::f32::consts::TAU), p)); + assert!(near4(rotate_4d(p, 1.0 + std::f32::consts::TAU), rotate_4d(p, 1.0))); + } + + #[test] + fn rotate_4d_keeps_y_and_brings_w_onto_x_at_a_quarter_turn() { + let q = rotate_4d([0.0, 0.25, 0.0, 0.5], std::f32::consts::FRAC_PI_2); + assert!((q[0] + 0.5).abs() < 1e-5, "x′ = −w, got {q:?}"); + assert!((q[1] - 0.25).abs() < 1e-6, "y is untouched"); + } + + #[test] + fn rotate_4d_preserves_length() { + let p = [0.3, -0.1, 0.2, -0.4]; + let l = |v: [f32; 4]| v.iter().map(|c| c * c).sum::(); + assert!((l(rotate_4d(p, 0.7)) - l(p)).abs() < 1e-5); + } + + #[test] + fn project_4d_shrinks_points_further_along_w() { + let near = project_4d([0.3, 0.3, 0.3, -0.4]); + let far = project_4d([0.3, 0.3, 0.3, 0.4]); + // w toward the viewer (larger w) scales up; away scales down. + assert!(len3(far) > len3(near)); + assert!((project_4d([0.0; 4])[0]).abs() < 1e-7); + } + + #[test] + fn the_turning_tesseract_stays_inside_the_cube_bounds() { + let limit = 3f32.sqrt() * CUBE_HALF + 1e-4; + let mut worst = 0f32; + for k in 0u32..720 { + let theta = std::f32::consts::TAU * k as f32 / 720.0; + for [a, b] in tesseract_edges(CUBE_HALF) { + for p in [a, b] { + worst = worst.max(len3(project_4d(rotate_4d(p, theta)))); + } + } + // Interior points (a trail lives inside the hypercube) too. + for i in 0u32..16 { + let f = |j: u32| ((i * 7 + j * 13 + k) % 17) as f32 / 16.0 * 2.0 - 1.0; + let p = [f(1), f(2), f(3), f(4)].map(|c| c * CUBE_HALF); + worst = worst.max(len3(project_4d(rotate_4d(p, theta)))); + } + } + assert!(worst <= limit, "worst radius {worst} > {limit}"); + assert!(worst > 0.97 * limit, "FIT_4D wastes the frame: {worst}"); + } + + #[test] + fn tesseract_has_32_edges_each_along_one_axis() { + let edges = tesseract_edges(0.5); + assert_eq!(edges.len(), 32); + for [a, b] in edges { + let diff = (0..4).filter(|&i| (a[i] - b[i]).abs() > 1e-6).count(); + assert_eq!(diff, 1, "{a:?} → {b:?}"); + assert!(a.iter().chain(&b).all(|c| (c.abs() - 0.5).abs() < 1e-6)); + } + // No edge appears twice. + let mut keys: Vec = edges.iter().map(|e| format!("{e:?}")).collect(); + keys.sort(); + keys.dedup(); + assert_eq!(keys.len(), 32); + } +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `cargo test -p viz-core --lib visualizations::notes -- rotate_4d project_4d tesseract` +Expected: FAIL to compile (functions missing). + +- [ ] **Step 3: Implement** (after `cube_edges`) + +```rust +// ---- The fourth dimension ---- + +/// The 4D eye's distance along w: perspective scales a point by +/// `D / (D − w)`, so the far cube of a tesseract shrinks inside the near one. +pub const PERSPECTIVE_4D: f32 = 3.0 * CUBE_HALF; +/// Brings the perspective-projected tesseract back inside the 3D cube's +/// bounding sphere (radius `√3 · CUBE_HALF`) at every turn, so the camera +/// framing is unchanged. The worst case over a full turn is √(5/12) ≈ 0.6455; +/// pinned by `the_turning_tesseract_stays_inside_the_cube_bounds`. +pub const FIT_4D: f32 = 0.645; + +/// Turns `p` = (x, y, z, w) by `theta` in the x–w plane and then in the z–w +/// plane. Periodic in 2π, so one slider covers every view; y (the root) is +/// never touched. At a quarter turn the fourth note lies along x. +pub fn rotate_4d(p: [f32; 4], theta: f32) -> [f32; 4] { + let (s, c) = theta.sin_cos(); + let [x, y, z, w] = p; + let (x, w) = (x * c - w * s, x * s + w * c); + let (z, w) = (z * c - w * s, z * s + w * c); + [x, y, z, w] +} + +/// Perspective from 4D to 3D along w, then `FIT_4D`: larger w (nearer the 4D +/// eye) draws larger. +pub fn project_4d(p: [f32; 4]) -> [f32; 3] { + let scale = FIT_4D * PERSPECTIVE_4D / (PERSPECTIVE_4D - p[3]); + [p[0] * scale, p[1] * scale, p[2] * scale] +} + +/// The tesseract `[-half, half]⁴` as its 32 edges: each joins two corners +/// that differ in exactly one coordinate. +pub fn tesseract_edges(half: f32) -> [[[f32; 4]; 2]; 32] { + let corner = |bits: u32| -> [f32; 4] { + std::array::from_fn(|axis| if bits & (1 << axis) != 0 { half } else { -half }) + }; + let mut out = [[[0.0; 4]; 2]; 32]; + let mut i = 0; + for bits in 0..16u32 { + for axis in 0..4 { + let other = bits ^ (1 << axis); + if other > bits { + out[i] = [corner(bits), corner(other)]; + i += 1; + } + } + } + out +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p viz-core --lib visualizations::notes` +Expected: PASS. If `worst > 0.97 * limit` fails only because the sampled worst is a hair lower, report the measured value rather than loosening silently. + +- [ ] **Step 5: Commit** + +```bash +git add crates/viz-core/src/visualizations/notes.rs +git commit -m "feat(viz): pure 4D helpers for the notes tesseract (rotate, project, edges)" +``` + +--- + +## Task 4: The fourth bar, the turn and `draw_figure_4d` + +**Files:** +- Modify: `crates/viz-core/src/visualizations/notes.rs` (config, schema, `BarSide`, `layout`, `glow_quad`, `draw_bars`, `NotesViz` fields/`new`/`init`/`tick`/`render`, new `draw_figure_4d`, `readout`; tests) +- Modify: `crates/viz-core/tests/wasm.rs` (extend `notes_renders_one_two_and_three_notes`) + +**Interfaces:** +- Consumes: Task 2 `Visualization::readout`; Task 3 `rotate_4d`, `project_4d`, `tesseract_edges`; Task 1 four-note rule. +- Produces: viz config fields `turn_4d`, `turn_4d_speed`, `angle_4d`; `note_colors: [[f32; 4]; 4]`; `BarSide::Bottom`; `Layout.bars: [Bar; 4]`; `NotesViz::apply_turn_config(&mut self, cfg: &NotesVizConfig)`; readout `{ "angle_4d": θ }`. + +- [ ] **Step 1: Write the failing unit tests** (in `mod tests`) + +```rust + #[test] + fn the_fourth_bar_runs_along_the_bottom_gutter_left_to_right() { + let l = layout([1000, 800], [100.0, 50.0, 500.0, 500.0], [0.0; 4]); + let gutter = GUTTER_FRAC * 500.0; + let bottom = l.bars[3]; + assert_eq!(bottom.side, BarSide::Bottom); + let y = 50.0 + 500.0 - gutter * 0.5; + assert_point_near(bottom.from, [l.plot.x, y], "bottom bar from"); + assert_point_near(bottom.to, [l.plot.x + l.plot.w, y], "bottom bar to"); + } + + #[test] + fn turn_config_defaults_and_schema() { + let d = NotesVizConfig::default(); + assert!(d.turn_4d); + assert_eq!(d.turn_4d_speed, 0.35); + assert_eq!(d.angle_4d, 0.0); + assert_eq!(d.note_colors.len(), 4); + assert_eq!(d.note_colors[3], [0.498, 0.659, 0.910, 1.0]); + let schema = NotesVizConfig::schema(); + for key in ["turn_4d", "turn_4d_speed", "angle_4d"] { + assert!(schema["properties"][key].is_object(), "{key} in schema"); + } + assert_eq!(schema["properties"]["note_colors"]["maxItems"], 4); + // Older pushes without the new fields still parse, with the defaults. + let mut older = NotesVizConfig::defaults(); + for key in ["turn_4d", "turn_4d_speed", "angle_4d"] { + older.as_object_mut().unwrap().remove(key); + } + let parsed: NotesVizConfig = serde_json::from_value(older).expect("parses without the 4D fields"); + assert!(parsed.turn_4d); + assert_eq!(parsed.turn_4d_speed, 0.35); + assert_eq!(parsed.angle_4d, 0.0); + } + + #[test] + fn tick_turns_while_turning_and_holds_while_held() { + let mut viz = NotesViz::new(); + viz.apply_turn_config(&NotesVizConfig { turn_4d_speed: 1.0, ..NotesVizConfig::default() }); + viz.tick(0.5); + assert!((viz.angle_4d - 0.5).abs() < 1e-6); + viz.apply_turn_config(&NotesVizConfig { turn_4d: false, angle_4d: 2.0, ..NotesVizConfig::default() }); + assert!((viz.angle_4d - 2.0).abs() < 1e-6, "a held config sets the angle"); + viz.tick(1.0); + assert!((viz.angle_4d - 2.0).abs() < 1e-6, "held: tick leaves it"); + } + + #[test] + fn a_turning_config_never_snaps_the_angle_back() { + let mut viz = NotesViz::new(); + viz.apply_turn_config(&NotesVizConfig { turn_4d_speed: 1.0, ..NotesVizConfig::default() }); + viz.tick(1.2); + // A layout push carries a stale angle_4d with turn_4d still on. + viz.apply_turn_config(&NotesVizConfig { turn_4d_speed: 1.0, angle_4d: 0.1, ..NotesVizConfig::default() }); + assert!((viz.angle_4d - 1.2).abs() < 1e-6); + } + + #[test] + fn the_angle_wraps_and_the_readout_reports_it() { + let mut viz = NotesViz::new(); + viz.apply_turn_config(&NotesVizConfig { turn_4d_speed: 1.0, ..NotesVizConfig::default() }); + viz.tick(std::f32::consts::TAU + 0.25); + assert!((viz.angle_4d - 0.25).abs() < 1e-4); + let r = viz.readout(); + assert!((r["angle_4d"].as_f64().unwrap() - 0.25).abs() < 1e-4); + // Through the erased wrapper too. + let erased = crate::engine::erased::TypedViz::new(NotesViz::new()); + use crate::engine::erased::ErasedVisualization; + assert_eq!(erased.readout()["angle_4d"].as_f64(), Some(0.0)); + } +``` + +Also update the existing layout test that lists `[BarSide::Left, BarSide::Top, BarSide::Right]` (~line 1279) to include `BarSide::Bottom`, and any test that indexes `cfg.note_colors` as a 3-array. + +- [ ] **Step 2: Run to verify they fail** + +Run: `cargo test -p viz-core --lib visualizations::notes` +Expected: FAIL to compile (`BarSide::Bottom`, `turn_4d`, `apply_turn_config`, `angle_4d` missing). + +- [ ] **Step 3: Config and schema** + +In `NotesVizConfig`: change `note_colors` to `[[f32; 4]; 4]` and its doc to "note 0 (left), 1 (top), 2 (right), 3 (bottom)". Add, after `auto_rotate_speed`: + +```rust + /// Whether `tick` turns the four-note figure through the fourth + /// dimension. Off holds it at `angle_4d`. + #[serde(default = "default_true")] + pub turn_4d: bool, + /// Radians per second of the 4D turn. + #[serde(default = "default_turn_4d_speed")] + pub turn_4d_speed: f32, + /// The 4D angle in radians, taken only while `turn_4d` is off (the lab's + /// slider holds it there); a turning figure ignores it, so a layout push + /// carrying a stale value never snaps it back. + #[serde(default)] + pub angle_4d: f32, +``` + +with + +```rust +fn default_true() -> bool { + true +} +const DEFAULT_TURN_4D_SPEED: f32 = 0.35; +fn default_turn_4d_speed() -> f32 { + DEFAULT_TURN_4D_SPEED +} +``` + +In `Default`: add `[0.498, 0.659, 0.910, 1.0], // soft blue #7FA8E8` as the fourth colour, and `turn_4d: true, turn_4d_speed: DEFAULT_TURN_4D_SPEED, angle_4d: 0.0,`. + +In `schema()`: `note_colors` title "Note colors (4)", `minItems`/`maxItems` 4, add `color_property("Note 4 (bottom bar)", d.note_colors[3])` to `prefixItems` and change the comment to "four distinct bars; items=false forbids a fifth". Add properties: + +```rust + "turn_4d": { "type": "boolean", "title": "Turn through 4D (four notes)", "default": true }, + "turn_4d_speed": number_property(NumberOpts { + label: "4D turn speed (rad/s)", + default: DEFAULT_TURN_4D_SPEED as f64, min: 0.0, max: 3.0, step: 0.05, + integer: false, cosmetic: true, widget: None, + }), + "angle_4d": number_property(NumberOpts { + label: "4D angle when held (rad)", + default: 0.0, min: 0.0, max: std::f64::consts::TAU, step: 0.01, + integer: false, cosmetic: true, widget: None, + }), +``` + +Do **not** add the three new keys to `required` (they are serde-defaulted; older pushes omit them). + +- [ ] **Step 4: Layout and bars** + +Add `Bottom` to `BarSide` (doc: "the fourth note's bar"). In `Bar`'s doc, say the bottom bar also runs left → right. `Layout.bars: [Bar; 4]`, doc "note 3 bottom". In `layout()` add `let bottom_y = figure.y + figure.h - midline;` and a fourth entry: + +```rust + Bar { + side: BarSide::Bottom, + from: [plot.x, bottom_y], + to: [plot.x + plot.w, bottom_y], + }, +``` + +In `glow_quad` and `draw_bars`, change `BarSide::Top =>` to `BarSide::Top | BarSide::Bottom =>`. + +- [ ] **Step 5: Turn state, `apply_turn_config`, `tick`, `readout`** + +`NotesViz` fields (with docs): `angle_4d: f32` ("the 4D turn, radians in [0, 2π)") and `cached_turn_4d: (bool, f32)` ("`turn_4d` and its speed from the last `init`, for `tick`"). Initialise to `0.0` and `(true, DEFAULT_TURN_4D_SPEED)` in `new()`. + +```rust + /// The turn part of `init`, separate so it is testable without GL: + /// caches whether and how fast `tick` turns, and while held takes the + /// config's angle. + fn apply_turn_config(&mut self, cfg: &NotesVizConfig) { + self.cached_turn_4d = (cfg.turn_4d, cfg.turn_4d_speed); + if !cfg.turn_4d { + self.angle_4d = cfg.angle_4d.rem_euclid(std::f32::consts::TAU); + } + } +``` + +Call `self.apply_turn_config(cfg);` in `init` and extend `init`'s doc ("…and the 4D turn's settings; a held angle is taken from the config"). In `tick`, after the azimuth line: + +```rust + let (turning, speed) = self.cached_turn_4d; + if turning { + self.angle_4d = (self.angle_4d + speed * dt).rem_euclid(std::f32::consts::TAU); + } +``` + +In `impl Visualization for NotesViz`: + +```rust + /// The 4D angle, so the lab's slider can follow the turn. + fn readout(&self) -> serde_json::Value { + serde_json::json!({ "angle_4d": self.angle_4d }) + } +``` + +- [ ] **Step 6: `draw_figure_4d` and `render`** + +Add after `draw_figure_3d` (same shape, 4D points): + +```rust + /// Four notes: the curve inside a tesseract, turned through the fourth + /// dimension by `angle_4d` (see `rotate_4d`), projected to 3D + /// (`project_4d`) and drawn with the cube's camera, batches and pen. + fn draw_figure_4d( + &mut self, + gl: &Gl, + state: &NotesState, + cfg: &NotesVizConfig, + frame: &FrameCtx, + ) { + if self.ensure_3d_resources(gl).is_err() { + return; + } + let [n0, n1, n2, n3] = [&state.notes[0], &state.notes[1], &state.notes[2], &state.notes[3]]; + let just = state.just_intonation; + let theta = self.angle_4d; + // x = note 1 (top), y = note 0 (left), z = note 2 (right), w = note 3 (bottom). + let point_at = |t: f64| { + project_4d(rotate_4d( + [ + displacement_at(n1, just, t) * CUBE_HALF, + displacement_at(n0, just, t) * CUBE_HALF, + displacement_at(n2, just, t) * CUBE_HALF, + displacement_at(n3, just, t) * CUBE_HALF, + ], + theta, + )) + }; + + let [x, y, w, h] = gl_viewport_rect(frame.canvas[1], &frame.layout.plot); + let plot_px = [w.max(0) as u32, h.max(0) as u32]; + gl.viewport(x, y, w, h); + self.camera.resize(plot_px[0], plot_px[1]); + self.camera.azimuth = self.auto_azimuth + self.azimuth_offset; + self.camera.elevation = self.elevation; + let view_proj = self.camera.view_projection(); + + sample_trail(state, cfg, trail_window(state.period), &mut self.time_scratch); + self.line_3d_scratch.clear(); + for [a, b] in tesseract_edges(CUBE_HALF) { + for p in [a, b] { + self.line_3d_scratch.push(LineVertex3D { + position: project_4d(rotate_4d(p, theta)), + color: cfg.axis_color, + }); + } + } + push_polyline( + &mut self.line_3d_scratch, + self.time_scratch.iter().map(|&t| LineVertex3D { + position: point_at(t), + color: cfg.trail_color, + }), + ); + let lines_3d = self.lines_3d.as_mut().expect("ensure_3d_resources ran"); + lines_3d.upload(gl, &self.line_3d_scratch); + lines_3d.draw(gl, &view_proj); + + let pen = PointInstance3D { + position: point_at(state.phase), + color: with_alpha(PEN_COLOR, 1.0 - frame.smear), + radius_px: cfg.pen_size_px * 0.5, + }; + let points_3d = self.points_3d.as_mut().expect("ensure_3d_resources ran"); + points_3d.upload(gl, &[pen]); + points_3d.draw(gl, &view_proj, plot_px); + + gl.viewport(0, 0, frame.canvas[0] as i32, frame.canvas[1] as i32); + } +``` + +In `render`, replace the 3/else branch with: + +```rust + match frame.n_notes { + 4 => self.draw_figure_4d(gl, state, cfg, &frame), + 3 => self.draw_figure_3d(gl, state, cfg, &frame), + _ => self.draw_figure_2d(gl, state, cfg, &frame), + } +``` + +Check the type of `cube_edges`/`LineVertex3D.position` (`[f32; 3]`) matches `project_4d`'s return. Update the struct/`ensure_3d_resources` docs that say "three notes" to "three or four notes". + +- [ ] **Step 7: Run unit tests and clippy** + +Run: `cargo test -p viz-core --lib && cargo clippy --all-targets --all-features && cargo fmt --all` +Expected: PASS, no new warnings. + +- [ ] **Step 8: Extend the wasm test** (`crates/viz-core/tests/wasm.rs`, end of `notes_renders_one_two_and_three_notes`; rename it `notes_renders_one_to_four_notes`) + +```rust + // C, E, G and B♭ (20:25:30:36) draw the tesseract, and the viz reports its 4D angle. + engine + .update_rule_config(cmd(r#"{"notes":[60,64,67,70]}"#)) + .expect("four notes"); + engine.frame(48.0); + assert_eq!(summary_field(&engine, "period").as_f64(), Some(20.0)); + assert_eq!(js_sys::Array::from(&summary_field(&engine, "notes")).length(), 4); + let angle = js_sys::Reflect::get(&engine.viz_readout(), &JsValue::from_str("angle_4d")) + .expect("readout has angle_4d"); + assert!(angle.as_f64().is_some()); + assert_no_gl_error(&gl_of(&canvas)); +``` + +Run (alone, nothing else building): `wasm-pack test --chrome --headless --chromedriver= crates/viz-core` +Expected: PASS. (Driver recipe: the README's Chrome for Testing section; download into the session scratchpad.) + +- [ ] **Step 9: Commit** + +```bash +git add crates/viz-core/src/visualizations/notes.rs crates/viz-core/tests/wasm.rs +git commit -m "feat(viz): four notes draw a curve in a tesseract that turns through 4D, with a fourth bar" +``` + +--- + +## Task 5: Web helpers — fourth colour, four-note picker, seventh chords, four voices + +**Files:** +- Modify: `web/src/lib/notes/theory.ts` (`NOTE_COLORS`) +- Modify: `web/src/lib/notes/picker.ts` (`MAX_NOTES`, header comment) +- Modify: `web/src/lib/notes/chords.ts` +- Test: `web/src/lib/notes/__tests__/theory.test.ts`, `picker.test.ts`, `chords.test.ts`, `audio.test.ts` + +**Interfaces:** +- Produces: `NOTE_COLORS.bars: readonly [string, string, string, string]`; `MAX_NOTES = 4`; `ChordId` adds `'dom7' | 'maj7' | 'min7' | 'halfdim7' | 'dim7'`; `Chord = { id; label; group: 'triad' | 'seventh'; steps: readonly number[] }`; `CHORDS` in the Global Constraints order; `chordOn(root, id): number[] | undefined` unchanged in signature. + +- [ ] **Step 1: Write the failing tests** + +`theory.test.ts` (in the `NOTE_COLORS` describe, or a new one): + +```ts + it('has four bar colours, the fourth the soft blue of the bottom bar', () => { + expect(NOTE_COLORS.bars).toHaveLength(4); + expect(NOTE_COLORS.bars[3]).toBe('#7FA8E8'); + }); +``` + +`picker.test.ts`: change `'allows up to three notes…'` to four (`expect(MAX_NOTES).toBe(4)`); in the toggle lock-out test use four picks (`[60, 64, 67, 70]` refuses `72`); change `'…stops at three'` to `'…stops at four'` with input `[60, 60, 48, 999, 61.5, NaN, 72, 64, 67]` → `[60, 48, 72, 64]`; and add: + +```ts + it('reads a link with five notes as its first four', () => { + expect(parseNotesParam('60,64,67,70,72')).toEqual([60, 64, 67, 70]); + }); +``` + +`chords.test.ts`: replace the first two tests' expectations with: + +```ts + it('lists the six triads, then the five sevenths', () => { + expect(CHORDS.map((c) => c.id)).toEqual([ + 'major', 'minor', 'diminished', 'augmented', 'sus2', 'sus4', + 'dom7', 'maj7', 'min7', 'halfdim7', 'dim7', + ]); + expect(CHORDS.map((c) => c.label)).toEqual([ + 'Major', 'Minor', 'Diminished', 'Augmented', 'Sus2', 'Sus4', + 'Dom7', 'Maj7', 'Min7', 'Half-dim7', 'Dim7', + ]); + for (const c of CHORDS) { + expect(c.steps[0]).toBe(0); + expect(c.steps).toHaveLength(c.group === 'triad' ? 3 : 4); + } + expect(CHORDS.filter((c) => c.group === 'seventh')).toHaveLength(5); + }); + + it('spells each seventh in semitones above its root', () => { + expect(chordById('dom7').steps).toEqual([0, 4, 7, 10]); + expect(chordById('maj7').steps).toEqual([0, 4, 7, 11]); + expect(chordById('min7').steps).toEqual([0, 3, 7, 10]); + expect(chordById('halfdim7').steps).toEqual([0, 3, 6, 10]); + expect(chordById('dim7').steps).toEqual([0, 3, 6, 9]); + }); +``` + +(keep the existing triad-spelling test) and in `describe('chordOn')`: + +```ts + it('builds a seventh as four notes, and refuses one that would pass the top', () => { + expect(chordOn(60, 'dom7')).toEqual([60, 64, 67, 70]); + expect(chordOn(72, 'maj7')).toEqual([72, 76, 79, 83]); // C5 Maj7 ends on B5 + expect(chordOn(73, 'maj7')).toBeUndefined(); + }); +``` + +and in `isChordId`'s bad list replace `'dom7'` with `'dom9'`. + +`audio.test.ts`, in `'splits the level so any number of notes totals the same loudness'`, add `[1, 1.25, 1.5, 1.8]` to the list of ratio sets. + +- [ ] **Step 2: Run to verify they fail** + +Run: `cd web && npx vitest run src/lib/notes` +Expected: FAIL (three colours, `MAX_NOTES` 3, no sevenths). + +- [ ] **Step 3: Implement** + +`theory.ts`: `bars: ['#F2B23C', '#F0665C', '#6CC68E', '#7FA8E8'],` and widen the type to `readonly [string, string, string, string]`. + +`picker.ts`: `export const MAX_NOTES = 4;`; header comment "up to four notes"; `toggleNote` doc "a fifth note". + +`chords.ts`: + +```ts +export type ChordId = + | 'major' | 'minor' | 'diminished' | 'augmented' | 'sus2' | 'sus4' + | 'dom7' | 'maj7' | 'min7' | 'halfdim7' | 'dim7'; + +/** A chord: its share-link id, its name in the picker, its row, and its notes in semitones above the root. */ +export type Chord = { id: ChordId; label: string; group: 'triad' | 'seventh'; steps: readonly number[] }; + +/** Triads fill three bars and sevenths all four: root left, then top, right and bottom. */ +export const CHORDS: readonly Chord[] = [ + { id: 'major', label: 'Major', group: 'triad', steps: [0, 4, 7] }, + { id: 'minor', label: 'Minor', group: 'triad', steps: [0, 3, 7] }, + { id: 'diminished', label: 'Diminished', group: 'triad', steps: [0, 3, 6] }, + { id: 'augmented', label: 'Augmented', group: 'triad', steps: [0, 4, 8] }, + { id: 'sus2', label: 'Sus2', group: 'triad', steps: [0, 2, 7] }, + { id: 'sus4', label: 'Sus4', group: 'triad', steps: [0, 5, 7] }, + { id: 'dom7', label: 'Dom7', group: 'seventh', steps: [0, 4, 7, 10] }, + { id: 'maj7', label: 'Maj7', group: 'seventh', steps: [0, 4, 7, 11] }, + { id: 'min7', label: 'Min7', group: 'seventh', steps: [0, 3, 7, 10] }, + { id: 'halfdim7', label: 'Half-dim7', group: 'seventh', steps: [0, 3, 6, 10] }, + { id: 'dim7', label: 'Dim7', group: 'seventh', steps: [0, 3, 6, 9] }, +]; +``` + +Update the file's header comment ("the six triads and five sevenths"). + +- [ ] **Step 4: Run tests** + +Run: `cd web && npx vitest run src/lib/notes && npx svelte-check` +Expected: PASS; `svelte-check` may flag `NotesLab.svelte` places that assumed three colours — leave those for Task 8 only if they are type errors there, otherwise fix the helper. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/lib/notes +git commit -m "feat(web): notes helpers take four notes and five seventh chords" +``` + +--- + +## Task 6: Label anchors for four bars and the ratio in the strip corner + +**Files:** +- Modify: `web/src/lib/notes/layout.ts` (`labelAnchors`) +- Test: `web/src/lib/notes/__tests__/layout.test.ts` + +**Interfaces:** +- Produces: `labelAnchors(figure: Rect, strip: Rect): { bars: [Point, Point, Point, Point]; ratio: Point; ratioInCorner: boolean }`. `ratio` is the strip's top-left corner when the strip has area (`ratioInCorner: true`), else the figure's bottom-centre (`false`). + +- [ ] **Step 1: Write the failing tests** (replace the `labelAnchors` describe) + +```ts +describe('labelAnchors', () => { + const STRIP = { x: 140, y: 30, w: 300, h: 45 }; + + it('puts each bar label at its bar end, the bottom bar at its right end', () => { + const { bars } = labelAnchors({ x: 10, y: 20, w: 100, h: 100 }, STRIP); + expectPoint(bars[0], 16, 108); // left bar: bottom end, 6% in + expectPoint(bars[1], 98, 26); // top bar: right end, 6% down + expectPoint(bars[2], 104, 108); // right bar: bottom end, 94% across + expectPoint(bars[3], 98, 114); // bottom bar: right end, 6% up from the bottom + }); + + it('hangs the ratio in the strip top-left corner', () => { + const { ratio, ratioInCorner } = labelAnchors({ x: 10, y: 20, w: 100, h: 100 }, STRIP); + expectPoint(ratio, 140, 30); + expect(ratioInCorner).toBe(true); + }); + + it('keeps the ratio at the figure bottom-centre when there is no strip', () => { + const { ratio, ratioInCorner } = labelAnchors({ x: 10, y: 20, w: 100, h: 100 }, ZERO); + expectPoint(ratio, 60, 114); + expect(ratioInCorner).toBe(false); + }); + + it('scales with the square', () => { + const { bars } = labelAnchors({ x: 0, y: 0, w: 200, h: 200 }, ZERO); + expectPoint(bars[0], 12, 176); + expectPoint(bars[1], 176, 12); + expectPoint(bars[2], 188, 176); + expectPoint(bars[3], 176, 188); + }); + + it('keeps every bar anchor inside the figure and the ratio on the strip', () => { + const { figure, strip } = notesLayout(1168, 650); + const { bars, ratio } = labelAnchors(figure, strip); + for (const p of bars) { + expect(p.x).toBeGreaterThan(figure.x); + expect(p.x).toBeLessThan(figure.x + figure.w); + expect(p.y).toBeGreaterThan(figure.y); + expect(p.y).toBeLessThan(figure.y + figure.h); + } + expectPoint(ratio, strip.x, strip.y); + }); +}); +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `cd web && npx vitest run src/lib/notes/__tests__/layout.test.ts` +Expected: FAIL (three bars; ratio at figure bottom-centre; no `ratioInCorner`). + +- [ ] **Step 3: Implement** + +```ts +/** + * Where the overlay hangs its text, for a figure of side `figure.w`: one label + * at each bar's end (left bar at the bottom, top bar at the right, right bar + * at the bottom, bottom bar at the right) and the ratio readout. The bars lie + * on their gutters' midlines, half of `BAR_INSET` in from the figure's edge, + * and span the plot, which ends `BAR_INSET` short of the opposite edge. The + * ratio hangs from the strip's top-left corner (the bottom gutter now holds + * the fourth bar), or sits at the figure's bottom-centre when there is no + * strip; `ratioInCorner` says which, so the page can align the pill. + */ +export function labelAnchors( + figure: Rect, + strip: Rect, +): { bars: [Point, Point, Point, Point]; ratio: Point; ratioInCorner: boolean } { + const s = figure.w; + const mid = BAR_INSET / 2; + const far = 1 - BAR_INSET; + const ratioInCorner = strip.w > 0 && strip.h > 0; + return { + bars: [ + { x: figure.x + mid * s, y: figure.y + far * s }, + { x: figure.x + far * s, y: figure.y + mid * s }, + { x: figure.x + (1 - mid) * s, y: figure.y + far * s }, + { x: figure.x + far * s, y: figure.y + (1 - mid) * s }, + ], + ratio: ratioInCorner ? { x: strip.x, y: strip.y } : { x: figure.x + 0.5 * s, y: figure.y + (1 - mid) * s }, + ratioInCorner, + }; +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cd web && npx vitest run src/lib/notes/__tests__/layout.test.ts` +Expected: PASS. (`NotesLab.svelte` still calls `labelAnchors(layout.figure)`; Task 8 updates it. `svelte-check` will flag it until then — expected.) + +- [ ] **Step 5: Commit** + +```bash +git add web/src/lib/notes/layout.ts web/src/lib/notes/__tests__/layout.test.ts +git commit -m "feat(web): label anchors for a fourth bar; the ratio hangs from the strip corner" +``` + +--- + +## Task 7: `LabApi.readVizReadout` and the fake engine + +**Files:** +- Modify: `web/src/lib/components/labApi.svelte.ts` (after `readSummary`) +- Modify: `web/src/lib/test/fakeViz.ts` (FakeEngine, exported fixture) +- Test: `web/src/lib/components/__tests__/labApi.test.ts` if it exists, else add the case to `NotesLab.test.ts` in Task 8 + +**Interfaces:** +- Consumes: Task 2 `Engine.viz_readout()` (generated wasm-bindgen typing appears after a wasm build; the fake supplies it in tests). +- Produces: `LabApi.readVizReadout(): unknown`; `export const notesVizReadout: { angle_4d: number }` in `fakeViz.ts` (mutable; the fake returns it on the notes lab, `null` elsewhere). + +- [ ] **Step 1: Write the failing test** + +If `web/src/lib/components/__tests__/labApi.test.ts` exists, add (following its setup for building a `LabApi` around a fake engine): + +```ts + it('reads the viz readout, or null without an engine', () => { + expect(new LabApi().readVizReadout()).toBeNull(); + }); +``` + +Otherwise skip this step; Task 8's lab tests exercise it. + +- [ ] **Step 2: Implement** + +`labApi.svelte.ts`: + +```ts + /** Read the visualization's readout (view state it advances itself, e.g. the notes lab's 4D angle), or null. */ + readVizReadout(): unknown { + return this.engine?.viz_readout() ?? null; + } +``` + +If `svelte-check` reports `viz_readout` missing on the generated `Engine` type, rebuild the wasm package (`npm --prefix web run build:wasm` or the README's wasm-pack build command) so the `.d.ts` picks up Task 2's method; do not cast around it. + +`fakeViz.ts`: add near the notes fixture + +```ts +/** What the fake's `viz_readout()` returns on the notes lab (null elsewhere). Mutable so a test can move the 4D angle. */ +export const notesVizReadout = { angle_4d: 0 }; +``` + +and in `FakeEngine`: + +```ts + viz_readout() { return this._lab === 'notes' ? notesVizReadout : null; } +``` + +- [ ] **Step 3: Run tests** + +Run: `cd web && npx vitest run && npx svelte-check` +Expected: vitest PASS; `svelte-check` clean apart from the known `labelAnchors` call (Task 8). + +- [ ] **Step 4: Commit** + +```bash +git add web/src/lib/components/labApi.svelte.ts web/src/lib/test/fakeViz.ts +git commit -m "feat(web): LabApi reads the visualization's readout" +``` + +--- + +## Task 8: The lab page — sevenths row, 4D controls, overlay, legend, docs + +**Files:** +- Modify: `web/src/lib/components/labs/NotesLab.svelte` +- Test: `web/src/lib/components/__tests__/NotesLab.test.ts` +- Modify: `README.md` (Notes & Chords bullet), `docs/superpowers/specs/2026-10-04-notes-and-chords-design.md` (one pointer line) + +**Interfaces:** +- Consumes: Task 5 `CHORDS` (with `group`), `MAX_NOTES = 4`, `NOTE_COLORS.bars[3]`; Task 6 `labelAnchors(figure, strip)` → `{ bars, ratio, ratioInCorner }`; Task 7 `api.readVizReadout()`, `notesVizReadout`; viz config keys `turn_4d`, `angle_4d` (radians). + +- [ ] **Step 1: Write the failing tests** (import `notesVizReadout` from `../../test/fakeViz`; add `const lastViz = () => updateVizConfigSpy.mock.lastCall?.[0] as Record;`) + +Update existing tests: +- `'stops at three notes…'` → four: pick `64, 67, 70` after C4 (so `[60, 64, 67, 70]`), `unpicked` length `32`, title `'Pick up to four notes'`, the pressed set `[60, 64, 67, 70]`. +- chord mode `'starts on single notes and offers the six triads as radio buttons'` → twelve labels in order: Single notes, Major, Minor, Diminished, Augmented, Sus2, Sus4, Dom7, Maj7, Min7, Half-dim7, Dim7; still one shared `name`; and `getByText('Sevenths')` exists. +- `'drops a chord id it does not know'` → use `c=dom9`. + +New tests (in `describe('chord mode')`): + +```ts + it('a seventh builds four notes on the pressed root', async () => { + const { getByRole } = await renderLab(); + await choose(getByRole, 'Dom7'); + expect(lastPush()).toEqual(expect.objectContaining({ notes: [60, 64, 67, 70] })); + expect(badge(chipFor(getByRole, 70))).toBe('4'); + await fireEvent.click(chipFor(getByRole, 62)); + expect(lastPush()).toEqual(expect.objectContaining({ notes: [62, 66, 69, 72] })); + expect(location.hash).toBe('#/notes?n=62,66,69,72&c=dom7'); + }); + + it('greys out a root whose seventh would pass B5', async () => { + const { getByRole } = await renderLab(); + await choose(getByRole, 'Maj7'); + expect(chipFor(getByRole, 72).getAttribute('aria-disabled')).not.toBe('true'); + expect(chipFor(getByRole, 73).getAttribute('aria-disabled')).toBe('true'); + }); +``` + +New `describe('4D turn')`: + +```ts + describe('4D turn', () => { + const turnButton = (getByRole: (r: string, o: { name: string }) => HTMLElement) => + getByRole('button', { name: 'Turn' }); + + it('shows its controls only with four notes', async () => { + const { queryByLabelText, getByRole } = await renderLab('n=60,64,67'); + expect(queryByLabelText('4D turn')).toBeNull(); + await fireEvent.click(chipFor(getByRole, 70)); + expect(queryByLabelText('4D turn')).not.toBeNull(); + expect(turnButton(getByRole).getAttribute('aria-pressed')).toBe('true'); + }); + + it('the slider follows the viz while turning', async () => { + const { getByLabelText, container } = await renderLab('n=60,64,67,70'); + notesVizReadout.angle_4d = Math.PI / 2; + try { + await vi.waitFor(() => expect((getByLabelText('4D turn') as HTMLInputElement).value).toBe('90')); + expect(container.querySelector('.turn-4d .value')?.textContent).toBe('90°'); + } finally { + notesVizReadout.angle_4d = 0; + } + }); + + it('grabbing the slider holds the turn at that angle; Turn resumes it', async () => { + const { getByLabelText, getByRole } = await renderLab('n=60,64,67,70'); + updateVizConfigSpy.mockClear(); + await fireEvent.input(getByLabelText('4D turn'), { target: { value: '45' } }); + expect(lastViz()).toEqual(expect.objectContaining({ turn_4d: false })); + expect(lastViz().angle_4d as number).toBeCloseTo(Math.PI / 4); + expect(turnButton(getByRole).getAttribute('aria-pressed')).toBe('false'); + await fireEvent.click(turnButton(getByRole)); + expect(lastViz()).toEqual(expect.objectContaining({ turn_4d: true })); + expect(turnButton(getByRole).getAttribute('aria-pressed')).toBe('true'); + }); + + it('hides the 4D controls below four notes and keeps the held angle', async () => { + const { getByLabelText, queryByLabelText, getByRole } = await renderLab('n=60,64,67,70'); + await fireEvent.input(getByLabelText('4D turn'), { target: { value: '30' } }); + await fireEvent.click(chipFor(getByRole, 70)); + expect(queryByLabelText('4D turn')).toBeNull(); + await fireEvent.click(chipFor(getByRole, 70)); + expect((getByLabelText('4D turn') as HTMLInputElement).value).toBe('30'); + }); + + it('Reset turns it on again from 0', async () => { + const { getByRole } = await renderLab('n=60,64,67,70'); + await fireEvent.click(turnButton(getByRole)); // hold + updateVizConfigSpy.mockClear(); + await fireEvent.click(getByRole('button', { name: 'Reset' })); + const pushes = updateVizConfigSpy.mock.calls.map(([c]) => c as Record); + expect(pushes).toContainEqual(expect.objectContaining({ turn_4d: false, angle_4d: 0 })); + expect(pushes.at(-1)).toEqual(expect.objectContaining({ turn_4d: true })); + }); + }); +``` + +Overlay/legend (in `describe('layout and overlay')` / `'legend and story'`): + +```ts + it('labels four bars and hangs the ratio in the strip corner', async () => { + // The fixture summary has two notes; give it four for this read. + const saved = notesSummaryFixture.notes; + notesSummaryFixture.notes = [...saved, saved[0], saved[1]].slice(0, 4); + try { + const { container } = await renderLab('n=60,64,67,70'); + await vi.waitFor(() => expect(container.querySelectorAll('.note-label')).toHaveLength(4)); + expect(container.querySelectorAll('.note-label.beside')).toHaveLength(2); + } finally { + notesSummaryFixture.notes = saved; + } + }); +``` + +and change the legend swatch count from `5` to `6`. Add to the share-link `it.each`: `['n=60,64,67,70,72', '#/notes?n=60,64,67,70', [60, 64, 67, 70]]`, and a case `renderLab('n=60,64,67&c=dom7')` keeps notes `[60, 64, 67]` and `c=dom7` in the hash (links are not re-voiced). + +If the overlay test's fixture shape differs (check `notesSummaryFixture` in `fakeViz.ts`), build four notes with the fixture's own note objects; the assertion is four `.note-label`s, two of them `.beside`. + +- [ ] **Step 2: Run to verify they fail** + +Run: `cd web && npx vitest run src/lib/components/__tests__/NotesLab.test.ts` +Expected: the new and updated tests FAIL. + +- [ ] **Step 3: Implement — chord rows** + +Replace the `{#each CHORDS …}` inside the Pick radiogroup with two rows; keep one `role="radiogroup"` and the shared `name="notes-pick"`: + +```svelte + {#each CHORDS.filter((c) => c.group === 'triad') as c (c.id)} + + {/each} + + Sevenths + {#each CHORDS.filter((c) => c.group === 'seventh') as c (c.id)} + + {/each} +``` + +CSS: `.pick-mode .row-break { flex-basis: 100%; height: 0; }`. + +- [ ] **Step 4: Implement — 4D state and controls** + +Script: + +```ts + /** The 4D turn (four notes): whether the viz turns it, and the angle in radians the slider shows. */ + let turning = $state(true); + let angle4d = $state(0); + const angleDeg = $derived(Math.round((angle4d * 180) / Math.PI) % 360); + + /** Hold the turn at the slider's angle. */ + function on4dInput(e: Event) { + angle4d = (Number((e.target as HTMLInputElement).value) * Math.PI) / 180; + turning = false; + api?.patchVizConfig({ turn_4d: false, angle_4d: angle4d }); + } + + /** Start or stop the turn; stopping holds it where the slider shows. */ + function toggleTurn() { + turning = !turning; + api?.patchVizConfig(turning ? { turn_4d: true } : { turn_4d: false, angle_4d: angle4d }); + } +``` + +In the per-frame `$effect` (the one that reads `a.readSummary()`), after `summary = next;`: + +```ts + // While turning the viz owns the 4D angle; mirror it for the slider. + const r = a.readVizReadout() as { angle_4d?: unknown } | null; + if (untrack(() => turning) && typeof r?.angle_4d === 'number') angle4d = r.angle_4d; +``` + +(`untrack` keeps the effect from re-running on `turning`; it is already imported in this file — check.) In `onReset`, before `applyConfig()`: + +```ts + // A held angle is only taken while held, so set it held at 0, then turn. + angle4d = 0; + turning = true; + api?.patchVizConfig({ turn_4d: false, angle_4d: 0 }); + api?.patchVizConfig({ turn_4d: true }); +``` + +Update Reset's `title` to "…half a swing a second, the 4D turn back on, playing". + +Markup, after the picker group: + +```svelte + {#if notes.length === 4} +
+ + +
+ {/if} +``` + +CSS: `.turn-4d .btn[aria-pressed="true"]` styled like `.tuning .btn[aria-pressed="true"]`; `.turn-4d .value { width: 4ch; text-align: right; color: var(--ink); }`. + +- [ ] **Step 5: Implement — overlay, legend, copy** + +- `const anchors = $derived(labelAnchors(layout.figure, layout.strip));` +- Note labels: `class:beside={i === 1 || i === 3}`. +- Ratio span: add `class:corner={anchors.ratioInCorner}`; CSS `.ratio.corner { transform: translate(6px, 6px); }`. +- Legend: add `Note 4 (bottom bar)` after note 3. +- `chipBlocked`: `'Pick up to four notes'`. +- Tips: "Pick up to four notes … A third note adds depth, and the curve turns inside a cube; a fourth turns that cube into a tesseract, a four-dimensional cube, and the curve folds through it. The **4D turn** slider holds it at an angle and **Turn** sets it going again; drag to look around it." Chord tip: mention the Sevenths row (Dom7, Maj7, Min7, Half-dim7, Dim7) build four notes. Link tip: `&c=dom7`. +- Any remaining "three" about the note count in this file's comments → "four" where it is about the cap. + +- [ ] **Step 6: Run the web suite** + +Run: `cd web && npx vitest run && npx svelte-check` +Expected: all PASS, 0 errors. + +- [ ] **Step 7: Docs** + +README Notes & Chords bullet: "pick up to four notes from C3 to B5, or set **Pick** to a chord (six triads, or a seventh: Dom7, Maj7, Min7, Half-dim7, Dim7) and press its root; … three draw a 3D curve in a turning cube and four a curve in a tesseract that turns through the fourth dimension (**4D turn** holds it, **Turn** restarts it) …". In `2026-10-04-notes-and-chords-design.md`, under the header add: `**Extended by:** \`2026-10-12-notes-tesseract-design.md\` (four notes, sevenths, the tesseract).` + +- [ ] **Step 8: Commit** + +```bash +git add web/src/lib/components/labs/NotesLab.svelte web/src/lib/components/__tests__/NotesLab.test.ts README.md docs/superpowers/specs/2026-10-04-notes-and-chords-design.md +git commit -m "feat(web): sevenths and four notes in the notes lab, with 4D turn controls" +``` + +--- + +## Task 9: Whole-branch validation, PR, deploy check (controller) + +- [ ] **Step 1:** From the repo root: `cargo fmt --all --check && cargo clippy --all-targets --all-features && cargo test --all-features`; `cd web && npx vitest run && npx svelte-check`; the wasm tests alone. All pass; report counts. +- [ ] **Step 2:** Browser check on the dev server (port 5173, or 5174 if taken): `#/notes?n=60,64,67,70` shows four bars, the tesseract turning, the slider moving; grab the slider (holds), press Turn (resumes), drop a note (cube back, controls hidden); Dom7 on D4 builds D/F♯/A/C; no console errors. Screenshot as proof. +- [ ] **Step 3:** Push `notes-tesseract`, open the PR with summary + validation, bind it, and report. Merge only on the user's say-so. diff --git a/docs/superpowers/specs/2026-10-04-notes-and-chords-design.md b/docs/superpowers/specs/2026-10-04-notes-and-chords-design.md index 40288cc..fa69678 100644 --- a/docs/superpowers/specs/2026-10-04-notes-and-chords-design.md +++ b/docs/superpowers/specs/2026-10-04-notes-and-chords-design.md @@ -3,6 +3,7 @@ **Date:** 2026-10-04 **Status:** Approved for implementation **Scope:** a fourth lab, `#/notes`: Rust rule + WebGL2 visualization in `crates/viz-core`, a Svelte lab page, a home tile, docs. +**Extended by:** `2026-10-12-notes-tesseract-design.md` (four notes, sevenths, the tesseract). ## Summary diff --git a/docs/superpowers/specs/2026-10-12-notes-tesseract-design.md b/docs/superpowers/specs/2026-10-12-notes-tesseract-design.md new file mode 100644 index 0000000..68e6be7 --- /dev/null +++ b/docs/superpowers/specs/2026-10-12-notes-tesseract-design.md @@ -0,0 +1,114 @@ +# Notes & Chords: four notes and the tesseract — Design + +**Date:** 2026-10-12 +**Status:** Draft for review +**Scope:** extend the Notes & Chords lab (`#/notes`) from three notes to four: the Rust rule and visualization in `crates/viz-core`, one small engine/trait addition, the Svelte lab and its helpers, docs. + +## Summary + +Today one note swings a dot on a bar, two draw a Lissajous loop and three draw a curve inside a turning cube. This adds a fourth note. It gets a fourth bar (bottom gutter) and becomes the fourth coordinate, w, of the curve: the cube becomes a tesseract (16 corners, 32 edges, the cube-inside-a-cube picture), turned through the fourth dimension and squashed back to 3D with perspective, then drawn by the existing 3D pipeline. In pure ratios a dominant seventh (C, E, G, B♭) is 20 : 25 : 30 : 36, because the lab's 5-limit table makes the minor seventh 9/5; it closes after 20 swings of the root (the trail shows the last 64 at most, as now). + +Four notes come from single-note mode (up to four picks) and from five new seventh chords in chord mode. + +## Goals + +1. Pick up to four notes in single-note mode; the fourth gets its own colour, bar, badge "4", legend swatch and voice. +2. Chord mode offers, besides the six triads, Dom7, Maj7, Min7, Half-dim7 and Dim7. +3. With four notes the figure is a tesseract frame with the curve inside it, turning through 4D on its own; the 3D spin and drag-to-orbit work as now. +4. A **4D turn** slider shows and sets the 4D angle; a **Turn** toggle starts and stops the automatic turn. +5. One to three notes look and behave exactly as today, apart from the ratio readout's new position. + +## Non-goals + +- Storing the 4D angle or the Turn state in the share link. +- Five or more notes; extended chords (9ths, 11ths), inversions or voicings. +- GPU-side 4D maths (approach B) or orthographic 4D (approach C); see Decisions. +- Any change to the other labs' behaviour. + +## Decisions + +- **Approach A: 4D maths on the CPU in Rust, drawn by the existing 3D pipeline.** At most `max_samples` trail points plus 32 edges per frame, so the cost is negligible; the maths stays in pure, unit-testable functions; no new shader. +- **Axes:** (x, y, z, w) = (note 2, root, note 3, note 4), each displacement × `CUBE_HALF`. Notes 1–3 keep today's axes. +- **The turn:** one angle θ applied as a rotation in the x–w plane and then in the z–w plane, both by θ. Periodic in 360°, so one slider covers it. At θ = 90° the fourth note lies along x (the second note's old axis). +- **Perspective 4D → 3D:** `p = D / (D − w′)` scales x′, y′, z′, with `D = 3 · CUBE_HALF`, then a fixed fit factor brings the result back inside the current cube's bounding sphere (radius `√3 · CUBE_HALF`) for every θ, so the camera framing (`CAMERA_DISTANCE`) is unchanged. The fit factor is a named constant derived from the worst case and pinned by a test. +- **Who owns the angle:** the visualization. `update_viz_config` re-runs `init` and zeroes the next frame's dt, so the page cannot push the angle every frame without freezing the cube's spin. The viz advances the angle in `tick` and the page reads it back through a new readout (below). +- **Fourth colour:** a soft blue, default `#7FA8E8`, final value tuned against the dark stage in the mockup step; Rust's default is the truth and JS `NOTE_COLORS` copies it. +- **Fourth bar:** in the bottom gutter, on its midline, running left → right (mirroring the top bar). +- **Ratio readout:** moves to the strip's top-left corner (the dark pill it already has), for every pick size, because there is no free space below the figure (it fills the stage height in landscape, and the strip sits right under it in portrait). With no strip (a stage too small for the layout), it stays at the figure's bottom-centre. +- **Chord picker:** still one radio group with one shared `name` (arrow keys run through all 12). Row 1: Single notes and the six triads; row 2, after a small "Sevenths" label: the five sevenths. + +## Rust + +### Rule (`crates/viz-core/src/rules/notes.rs`) + +- `MAX_NOTES` 3 → 4; the schema's `maxItems` follows it; `effective_notes()` keeps at most four. +- Ratios, `period_cycles`, the summary and the trail sampling are already per-note: no other change. New tests: four notes are kept and a fifth dropped; `[60, 64, 67, 70]` in pure ratios is 1, 5/4, 3/2, 9/5 (20 : 25 : 30 : 36) with a period of 20 root cycles; the summary lists four notes. + +### Trait and engine (shared) + +- `Visualization` gains `fn readout(&self) -> serde_json::Value`, default `Value::Null`, documented as cheap and read every frame. The erased wrapper forwards it. +- `Engine` exposes `viz_readout() -> JsValue`. +- No other visualization implements it, so the other labs are untouched. + +### Visualization (`crates/viz-core/src/visualizations/notes.rs`) + +- `NotesVizConfig.note_colors: [[f32; 4]; 4]`; the fourth default is the soft blue. Schema and defaults updated. +- New config fields, each `#[serde(default)]`: + - `turn_4d: bool` (default `true`): whether `tick` advances the angle. + - `turn_4d_speed: f32` (radians per second, default `0.35`). + - `angle_4d: f32` (radians, default `0`): taken by `set_config`/`init` **only when `turn_4d` is false**, so the slider can hold an angle and no other config push (layout, resize) snaps a turning figure back. +- `NotesViz` keeps `angle_4d` state; `tick` adds `turn_4d_speed · dt` while turning and wraps it into [0, 2π). +- `readout()` returns `{ "angle_4d": θ }` (radians, in [0, 2π)). +- `Layout.bars: [Bar; 4]`; the fourth bar is on the bottom gutter's midline, spanning the plot left → right. `frame.n_notes` is capped by `layout.bars.len()` (now 4). +- `render`: four notes → `draw_figure_4d`; three → `draw_figure_3d`; one or two → `draw_figure_2d`, as now. +- `draw_figure_4d` mirrors `draw_figure_3d` (same viewport, camera, auto azimuth and drag offset, line and point batches): tesseract edges in `axis_color`, the trail in `trail_color`, the pen with the smear fade, each point through `project_4d(rotate_4d(p, θ))`. +- Pure helpers, all unit-tested: + - `rotate_4d(p: [f32; 4], theta: f32) -> [f32; 4]` + - `project_4d(p: [f32; 4]) -> [f32; 3]` (perspective and the fit factor) + - `tesseract_edges(half: f32) -> [[[f32; 4]; 2]; 32]` +- `draw_bars` and `draw_strip` already loop over the notes in play; they pick up the fourth with no change beyond the colour array. + +## Web + +### Helpers (`web/src/lib/notes/`) + +- `theory.ts`: `NOTE_COLORS.bars` gets the fourth colour. +- `picker.ts`: `MAX_NOTES = 4` (toggle lock-out, `sanitizeNotes`, link parsing). +- `chords.ts`: `Chord` gains `group: 'triad' | 'seventh'` and `steps` of length 3 or 4. New entries, in this order after the triads: `dom7` "Dom7" [0, 4, 7, 10], `maj7` "Maj7" [0, 4, 7, 11], `min7` "Min7" [0, 3, 7, 10], `halfdim7` "Half-dim7" [0, 3, 6, 10], `dim7` "Dim7" [0, 3, 6, 9]. `chordOn` and the B5 limit already work for any length (Maj7's highest root is C5). +- `layout.ts`: `labelAnchors(figure, strip)` returns four bar anchors (the fourth at the bottom bar's right end, placed beside it like the top bar's) and the ratio anchor at the strip's top-left corner, or the figure's bottom-centre when the strip is empty. +- `audio.ts`: no change (`VOICE_LEVEL / n`); a four-voice test. + +### Lab API (`web/src/lib/components/labApi.svelte.ts`) + +- `readVizReadout(): unknown` wraps `engine.viz_readout()`, `null` without an engine. The test fake engine (`test/fakeViz.ts`) gains `viz_readout`. + +### Lab page (`components/labs/NotesLab.svelte`) + +- Chord radios in two rows as described under Decisions; behaviour unchanged (a chord re-voices the current root; Single notes keeps the pick; a root change carries the speed). +- 4D controls, rendered only when four notes are picked: + - **4D turn** slider, 0–359 whole degrees, with a mono degree readout. + - **Turn** toggle, `aria-pressed` while turning. + - The per-frame effect reads `api.readVizReadout()` and, while turning, moves the slider to the reported angle. + - Slider input → `patchVizConfig({ turn_4d: false, angle_4d })` and Turn shows off. + - Turn pressed → `patchVizConfig({ turn_4d: true })` (resumes from the held angle); pressed again → `{ turn_4d: false, angle_4d: current }`. + - Reset → turn on, angle 0. +- Overlay: four swing labels; the ratio readout at its new anchor; the legend gets a fourth swatch. +- Tips: four notes, the tesseract and the 4D controls; the link tip mentions `c=dom7`. + +## Docs + +- README Notes & Chords bullet: up to four notes, sevenths, the tesseract. +- `2026-10-04-notes-and-chords-design.md`: point to this spec for the four-note extension. + +## Mockup before building + +A static mockup (headless capture) of the bezel's two chord rows and the 4D controls, and of the stage with four notes (the fourth bar, the tesseract and the ratio pill in the strip's corner), in light and dark. The look is approved before implementation starts. + +## Testing + +- **Rust unit tests:** `rotate_4d` is periodic in 2π and the identity at 0; at θ = π/2 the input's w lands on the x axis (x′ = −w); `project_4d` shrinks points with larger w′; every projected tesseract corner stays within `√3 · CUBE_HALF` for θ sampled over a full turn; 32 edges, each joining corners that differ in one coordinate; the fourth bar's position; config defaults round-trip and the schema lists the new fields; `tick` advances the angle while turning and not while held; `set_config` takes `angle_4d` only when held; `readout()` shape. +- **Rule tests:** as listed under Rule. +- **wasm test:** four notes render without error and `viz_readout()` returns an angle. +- **Web unit tests:** the seventh chord table and limits; four-note picker and link rules; four label anchors; the ratio anchor in the strip corner and its fallback; four-voice audio levels. +- **Lab tests:** 12 radios in one group in the stated order; a seventh built on a press; the 4D controls present only with four notes; slider input and Turn pushing the stated viz configs; the slider following the readout; Reset; links with four notes and with `c=dom7`. +- **Validation:** `cargo fmt --all --check`, `cargo clippy --all-targets --all-features`, `cargo test --all-features`, the wasm tests, `vitest`, `svelte-check`; then a browser check of the deployed site. diff --git a/web/src/lib/components/__tests__/NotesLab.test.ts b/web/src/lib/components/__tests__/NotesLab.test.ts index 08e7e93..ac29fd6 100644 --- a/web/src/lib/components/__tests__/NotesLab.test.ts +++ b/web/src/lib/components/__tests__/NotesLab.test.ts @@ -6,6 +6,7 @@ import { updateRuleConfigSpy, updateVizConfigSpy, notesSummaryFixture, + notesVizReadout, } from '../../test/fakeViz'; import { navigate } from '../../router.svelte'; import { PICKER_RANGE, midiToHz, noteLabel } from '../../notes/theory'; @@ -46,6 +47,8 @@ type RuleConfig = { notes: number[]; just_intonation: boolean }; const kinds = () => dispatchSpy.mock.calls.map(([c]) => (c as Dispatched).kind); /** The last rule config the lab pushed. */ const lastPush = () => updateRuleConfigSpy.mock.lastCall?.[0] as RuleConfig; +/** The last viz config the lab pushed. */ +const lastViz = () => updateVizConfigSpy.mock.lastCall?.[0] as Record; /** Every speed dispatched (SetSpeed values), oldest first. */ const speeds = () => dispatchSpy.mock.calls.map(([c]) => c as Dispatched).filter((c) => c.kind === 'SetSpeed').map((c) => c.value); @@ -63,7 +66,7 @@ describe('NotesLab.svelte', () => { function chipFor(getByRole: (role: string, opts: { name: string }) => HTMLElement, midi: number) { return getByRole('button', { name: noteLabel(midi) }) as HTMLButtonElement; } - /** The order badge on a chip (1, 2 or 3), or undefined when the chip is not picked. */ + /** The order badge on a chip (1 to 4), or undefined when the chip is not picked. */ const badge = (chip: HTMLElement) => chip.querySelector('.badge')?.textContent; describe('shell', () => { @@ -114,20 +117,19 @@ describe('NotesLab.svelte', () => { expect(badge(chipFor(getByRole, 64))).toBe('2'); }); - it('stops at three notes: every other chip is marked unavailable with a hint but stays focusable, and a click on one pushes nothing', async () => { + it('stops at four notes: every other chip is marked unavailable with a hint but stays focusable, and a click on one pushes nothing', async () => { const { container, getByRole } = await renderLab(); - await fireEvent.click(chipFor(getByRole, 64)); - await fireEvent.click(chipFor(getByRole, 67)); - expect(lastPush()).toEqual(expect.objectContaining({ notes: [60, 64, 67] })); + for (const midi of [64, 67, 70]) await fireEvent.click(chipFor(getByRole, midi)); + expect(lastPush()).toEqual(expect.objectContaining({ notes: [60, 64, 67, 70] })); const unpicked = [...container.querySelectorAll('.chip[aria-pressed="false"]')]; - expect(unpicked).toHaveLength(33); + expect(unpicked).toHaveLength(32); for (const chip of unpicked) { expect(chip.getAttribute('aria-disabled')).toBe('true'); expect(chip.disabled).toBe(false); // still in the tab order, so the hint can be reached - expect(chip.title).toBe('Pick up to three notes'); + expect(chip.title).toBe('Pick up to four notes'); } - for (const midi of [60, 64, 67]) expect(chipFor(getByRole, midi).getAttribute('aria-disabled')).not.toBe('true'); + for (const midi of [60, 64, 67, 70]) expect(chipFor(getByRole, midi).getAttribute('aria-disabled')).not.toBe('true'); updateRuleConfigSpy.mockClear(); dispatchSpy.mockClear(); @@ -476,7 +478,7 @@ describe('NotesLab.svelte', () => { expect(updateVizConfigSpy).toHaveBeenLastCalledWith( expect.objectContaining({ figure: toDevice(figure, 1), strip: toDevice(strip, 1) }), ); - const anchors = labelAnchors(figure); + const anchors = labelAnchors(figure, strip); const px = (el: Element | null, side: 'left' | 'top') => parseFloat((el as HTMLElement).style[side]); container.querySelectorAll('.note-label').forEach((label, i) => { expect(px(label, 'left')).toBeCloseTo(anchors.bars[i].x); @@ -484,6 +486,7 @@ describe('NotesLab.svelte', () => { }); expect(px(container.querySelector('.ratio'), 'left')).toBeCloseTo(anchors.ratio.x); expect(px(container.querySelector('.ratio'), 'top')).toBeCloseTo(anchors.ratio.y); + expect(container.querySelector('.ratio')?.classList.contains('corner')).toBe(anchors.ratioInCorner); } finally { width.mockRestore(); height.mockRestore(); @@ -509,6 +512,21 @@ describe('NotesLab.svelte', () => { notesSummaryFixture.notes = original; } }); + + it('labels four bars and hangs the ratio in the strip corner', async () => { + // The fixture summary has two notes; give it four for this read. + const saved = notesSummaryFixture.notes; + notesSummaryFixture.notes = [...saved, saved[0], saved[1]].slice(0, 4); + try { + const { container } = await renderLab('n=60,64,67,70'); + await vi.waitFor(() => expect(container.querySelectorAll('.note-label')).toHaveLength(4)); + const beside = container.querySelectorAll('.note-label.beside'); + expect(beside).toHaveLength(1); + expect(container.querySelectorAll('.note-label')[1]).toBe(beside[0]); + } finally { + notesSummaryFixture.notes = saved; + } + }); }); describe('share link', () => { @@ -530,7 +548,8 @@ describe('NotesLab.svelte', () => { it.each([ ['n=abc', '#/notes', [60]], ['n=none', '#/notes?n=none', []], - ['n=60,61,62,63', '#/notes?n=60,61,62', [60, 61, 62]], + ['n=60,61,62,63', '#/notes?n=60,61,62,63', [60, 61, 62, 63]], + ['n=60,64,67,70,72', '#/notes?n=60,64,67,70', [60, 64, 67, 70]], ['n=69,85,90', '#/notes?n=69', [69]], ['n=47,48,60', '#/notes?n=48,60', [48, 60]], ])('rewrites ?%s to the pick that loaded (%s)', async (query, hash, notes) => { @@ -567,25 +586,50 @@ describe('NotesLab.svelte', () => { expect(badge(chipFor(getByRole, 69))).toBe('2'); expect(getByRole('button', { name: 'Piano' }).getAttribute('aria-pressed')).toBe('true'); }); + + it('keeps a linked chord mode and its notes as given: links are not re-voiced', async () => { + await renderLab('n=60,64,67&c=dom7'); + expect(updateRuleConfigSpy).toHaveBeenCalledWith(expect.objectContaining({ notes: [60, 64, 67] })); + expect(location.hash).toBe('#/notes?n=60,64,67&c=dom7'); + }); }); describe('chord mode', () => { type Get = (role: string, opts: { name: string }) => HTMLElement; - /** The Pick radio group's choices: Single notes, then the six chords. */ + /** The Pick radio group's choices: Single notes, then the six triads and the five sevenths. */ const radios = (getByRole: Get) => [...getByRole('radiogroup', { name: 'Pick' }).querySelectorAll('input[type="radio"]')]; /** The label of the choice that is on. */ const picked = (getByRole: Get) => radios(getByRole).find((r) => r.checked)?.closest('label')?.textContent?.trim(); const choose = (getByRole: Get, name: string) => fireEvent.click(getByRole('radio', { name })); - it('starts on single notes and offers the six triads as radio buttons', async () => { - const { getByRole } = await renderLab(); + it('starts on single notes and offers the six triads and five sevenths as radio buttons', async () => { + const { getByRole, getByText } = await renderLab(); expect(picked(getByRole)).toBe('Single notes'); expect(radios(getByRole).map((r) => r.closest('label')?.textContent?.trim())).toEqual([ 'Single notes', 'Major', 'Minor', 'Diminished', 'Augmented', 'Sus2', 'Sus4', + 'Dom7', 'Maj7', 'Min7', 'Half-dim7', 'Dim7', ]); // One group: a shared name, so the arrow keys move between them. expect(new Set(radios(getByRole).map((r) => r.name)).size).toBe(1); + expect(getByText('Sevenths')).toBeTruthy(); + }); + + it('a seventh builds four notes on the pressed root', async () => { + const { getByRole } = await renderLab(); + await choose(getByRole, 'Dom7'); + expect(lastPush()).toEqual(expect.objectContaining({ notes: [60, 64, 67, 70] })); + expect(badge(chipFor(getByRole, 70))).toBe('4'); + await fireEvent.click(chipFor(getByRole, 62)); + expect(lastPush()).toEqual(expect.objectContaining({ notes: [62, 66, 69, 72] })); + expect(location.hash).toBe('#/notes?n=62,66,69,72&c=dom7'); + }); + + it('greys out a root whose seventh would pass B5', async () => { + const { getByRole } = await renderLab(); + await choose(getByRole, 'Maj7'); + expect(chipFor(getByRole, 72).getAttribute('aria-disabled')).not.toBe('true'); + expect(chipFor(getByRole, 73).getAttribute('aria-disabled')).toBe('true'); }); it('choosing a chord builds it on the current root, then plays', async () => { @@ -605,7 +649,7 @@ describe('NotesLab.svelte', () => { await choose(getByRole, 'Minor'); await fireEvent.click(chipFor(getByRole, 57)); // A3 minor expect(lastPush()).toEqual(expect.objectContaining({ notes: [57, 60, 64] })); - // No three-note lock-out in chord mode: every chip stays available. + // No full-pick lock-out in chord mode: every chip stays available. expect(chipFor(getByRole, 50).getAttribute('aria-disabled')).not.toBe('true'); await fireEvent.click(chipFor(getByRole, 50)); // D3 minor expect(lastPush()).toEqual(expect.objectContaining({ notes: [50, 53, 57] })); @@ -657,7 +701,7 @@ describe('NotesLab.svelte', () => { }); it('drops a chord id it does not know', async () => { - const { getByRole } = await renderLab('n=60&c=dom7'); + const { getByRole } = await renderLab('n=60&c=dom9'); expect(picked(getByRole)).toBe('Single notes'); expect(location.hash).toBe('#/notes'); }); @@ -669,10 +713,80 @@ describe('NotesLab.svelte', () => { }); }); + describe('4D turn', () => { + const turnButton = (getByRole: (r: string, o: { name: string }) => HTMLElement) => + getByRole('button', { name: 'Turn' }); + + it('shows its controls only with four notes', async () => { + const { queryByLabelText, getByRole } = await renderLab('n=60,64,67'); + expect(queryByLabelText('4D turn')).toBeNull(); + await fireEvent.click(chipFor(getByRole, 70)); + expect(queryByLabelText('4D turn')).not.toBeNull(); + expect(turnButton(getByRole).getAttribute('aria-pressed')).toBe('true'); + }); + + it('the slider follows the viz while turning', async () => { + const { getByLabelText, container } = await renderLab('n=60,64,67,70'); + notesVizReadout.angle_4d = Math.PI / 2; + try { + await vi.waitFor(() => expect((getByLabelText('4D turn') as HTMLInputElement).value).toBe('90')); + expect(container.querySelector('.turn-4d .value')?.textContent).toBe('90°'); + } finally { + notesVizReadout.angle_4d = 0; + } + }); + + it('grabbing the slider holds the turn at that angle; Turn resumes it', async () => { + const { getByLabelText, getByRole } = await renderLab('n=60,64,67,70'); + updateVizConfigSpy.mockClear(); + await fireEvent.input(getByLabelText('4D turn'), { target: { value: '45' } }); + expect(lastViz()).toEqual(expect.objectContaining({ turn_4d: false })); + expect(lastViz().angle_4d as number).toBeCloseTo(Math.PI / 4); + expect(turnButton(getByRole).getAttribute('aria-pressed')).toBe('false'); + await fireEvent.click(turnButton(getByRole)); + expect(lastViz()).toEqual(expect.objectContaining({ turn_4d: true })); + expect(turnButton(getByRole).getAttribute('aria-pressed')).toBe('true'); + }); + + it('pressing Turn while turning holds at the mirrored angle', async () => { + const { getByLabelText, getByRole } = await renderLab('n=60,64,67,70'); + notesVizReadout.angle_4d = Math.PI / 3; + try { + await vi.waitFor(() => expect((getByLabelText('4D turn') as HTMLInputElement).value).toBe('60')); + updateVizConfigSpy.mockClear(); + await fireEvent.click(turnButton(getByRole)); + expect(lastViz()).toEqual(expect.objectContaining({ turn_4d: false })); + expect(lastViz().angle_4d as number).toBeCloseTo(Math.PI / 3); + expect(turnButton(getByRole).getAttribute('aria-pressed')).toBe('false'); + } finally { + notesVizReadout.angle_4d = 0; + } + }); + + it('hides the 4D controls below four notes and keeps the held angle', async () => { + const { getByLabelText, queryByLabelText, getByRole } = await renderLab('n=60,64,67,70'); + await fireEvent.input(getByLabelText('4D turn'), { target: { value: '30' } }); + await fireEvent.click(chipFor(getByRole, 70)); + expect(queryByLabelText('4D turn')).toBeNull(); + await fireEvent.click(chipFor(getByRole, 70)); + expect((getByLabelText('4D turn') as HTMLInputElement).value).toBe('30'); + }); + + it('Reset turns it on again from 0', async () => { + const { getByRole } = await renderLab('n=60,64,67,70'); + await fireEvent.click(turnButton(getByRole)); // hold + updateVizConfigSpy.mockClear(); + await fireEvent.click(getByRole('button', { name: 'Reset' })); + const pushes = updateVizConfigSpy.mock.calls.map(([c]) => c as Record); + expect(pushes).toContainEqual(expect.objectContaining({ turn_4d: false, angle_4d: 0 })); + expect(pushes.at(-1)).toEqual(expect.objectContaining({ turn_4d: true })); + }); + }); + describe('legend and story', () => { it('has a swatch for each note, the trail and the sum, and a story with tips', async () => { const { container, getByRole } = await renderLab(); - expect(container.querySelectorAll('.legend .swatch')).toHaveLength(5); + expect(container.querySelectorAll('.legend .swatch')).toHaveLength(6); expect(getByRole('heading', { level: 2, name: 'How it works' })).toBeTruthy(); expect(container.querySelectorAll('.story .tip').length).toBeGreaterThan(0); }); diff --git a/web/src/lib/components/labApi.svelte.ts b/web/src/lib/components/labApi.svelte.ts index cc6e5bd..bd9e8dc 100644 --- a/web/src/lib/components/labApi.svelte.ts +++ b/web/src/lib/components/labApi.svelte.ts @@ -72,4 +72,9 @@ export class LabApi { readSummary(): unknown { return this.engine?.rule_summary() ?? null; } + + /** Read the visualization's readout (view state it advances itself, e.g. the notes lab's 4D angle), or null. */ + readVizReadout(): unknown { + return this.engine?.viz_readout() ?? null; + } } diff --git a/web/src/lib/components/labs/NotesLab.svelte b/web/src/lib/components/labs/NotesLab.svelte index f4a3c84..2dcf208 100644 --- a/web/src/lib/components/labs/NotesLab.svelte +++ b/web/src/lib/components/labs/NotesLab.svelte @@ -1,7 +1,8 @@