From b58ae9e205564a32e4382a6bc25241c68aa6e42d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Sat, 13 Jun 2026 21:22:58 +0700 Subject: [PATCH 01/17] =?UTF-8?q?Refine=20generation=E2=86=92repair=20pipe?= =?UTF-8?q?line:=20resilience,=20latency,=20converging=20repairs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce runtime crashes that exhaust the repair loop, and cut the latency of both generation and repair, so fewer scenes hit the slow LLM repair path and the ones that do converge (or fail fast) instead of grinding the whole budget. Sandbox (sandbox-worker.js): - Invented helpers (H/cam/view) degrade to a chainable no-op in any chain order; cam/view rebind to the proxy so real().invented() is safe too. - Per-frame error tolerance: a scene that renders then throws on an occasional frame keeps running; only a first-frame failure or ~0.5s of sustained throwing escalates to repair. - Heartbeat on the first clean frame (load "ready" signal ~300ms sooner). - offendingLine(): pull the exact failing source line out of the stack. Client (app.js, index.html): - MAX_REPAIRS 3->2; forward the offending line to /api/repair. - Non-convergence guards: bail when a repair repeats the same error or returns unchanged code, instead of grinding the full (slow) repair budget. - Bump app.js cache-buster v10->v11 (was stranding all client changes behind the stale ?v=10 for returning users). Server (main.py): - Generator-aware retry budget (cloud=2, local Ollama=3). - Credit ".axes(" as a label so well-labeled plots stop triggering false "unlabeled" regenerations. - VISUALLM_CLAUDE_EFFORT / VISUALLM_CLAUDE_MAX_TOKENS env knobs (default high/32k). - _repair_hint(): error-class fix guidance + a minimal-change directive, wired into every repair provider; fold the offending line into the repair error. Tests: +5 (repair hints, repair-visualization line folding). Suite 36->41, green. Co-Authored-By: Claude Opus 4.8 (1M context) --- app.js | 77 ++++++++++++++++++++++++-------- index.html | 2 +- main.py | 109 ++++++++++++++++++++++++++++++++++++++------- sandbox-worker.js | 100 ++++++++++++++++++++++++++++++++++------- tests/test_main.py | 51 +++++++++++++++++++++ 5 files changed, 287 insertions(+), 52 deletions(-) diff --git a/app.js b/app.js index f41e164..b44c149 100644 --- a/app.js +++ b/app.js @@ -341,7 +341,7 @@ } const canvas = this._newCanvas(); const offscreen = canvas.transferControlToOffscreen(); - const worker = new Worker("./sandbox-worker.js?v=13"); + const worker = new Worker("./sandbox-worker.js?v=16"); this.worker = worker; worker.onmessage = (e) => this._onMessage(e.data || {}); const d = this._dims(); @@ -366,13 +366,20 @@ if (this.pending && !this.pending.settled) this._settle(true); break; case "compile-error": - case "runtime-error": + case "runtime-error": { + // Carry the sandbox-extracted offending line (m.where) on the Error so + // the repair request can forward it to the model. if (this.pending && !this.pending.settled) { - this._settle(false, new Error(m.message || "Animation failed.")); + const e = new Error(m.message || "Animation failed."); + e.where = m.where || ""; + this._settle(false, e); } else if (this.onCrash) { - this.onCrash(new Error(m.message || "Animation crashed.")); + const e = new Error(m.message || "Animation crashed."); + e.where = m.where || ""; + this.onCrash(e); } break; + } default: break; } @@ -489,6 +496,7 @@ prompt: state.scene.prompt, code: state.scene.code, error: err.message, + where: err.where || "", }); await runSceneWithRepair(repaired, state.scene.prompt); } catch (repairErr) { @@ -557,7 +565,11 @@ /* Visualization flow: generate -> run -> repair loop */ /* ============================================================== */ - const MAX_REPAIRS = 3; + // Each repair is a full, slow LLM round-trip. With the sandbox now tolerant of + // invented helpers and transient bad frames (see sandbox-worker.js), genuinely + // unfixable scenes are rarer — so cap repairs at 2 and show the fallback + // sooner instead of burning a third slow call that usually fails the same way. + const MAX_REPAIRS = 2; // Guaranteed-renderable placeholder for when generation + all repairs fail. // Without it the canvas sits dead-black under the error message. @@ -618,8 +630,25 @@ } } + // Show the guaranteed-renderable placeholder under a warning. Used whenever + // the repair loop gives up — budget exhausted, or non-convergence detected + // (repeated identical error, or the model returned the code unchanged). + async function showClientFallback(scene, message) { + if (scene) { + state.scene = scene; + updatePanels(scene); + } + setConfidence(message, "warn"); + try { + await runner.run(CLIENT_FALLBACK_CODE); + } catch (fallbackErr) { + /* placeholder is hand-written and can't realistically fail */ + } + } + async function runSceneWithRepair(scene, prompt) { let current = scene; + let prevError = null; for (let attempt = 0; attempt <= MAX_REPAIRS; attempt++) { const engineLabel = engineName(current.engine); try { @@ -672,36 +701,48 @@ seedTutorForScene(current); return; } catch (err) { - if (attempt >= MAX_REPAIRS) { - state.scene = current; - updatePanels(current); - setConfidence( - `Couldn't fix it after ${MAX_REPAIRS} tries. Last error: ${err.message}. ` + - "Try a different prompt, or set ANTHROPIC_API_KEY for the stronger Claude generator.", - "warn" + // Non-convergence guard: if this error follows a repair and matches the + // PREVIOUS attempt's error, the repairs aren't making progress — bail + // now instead of grinding through the rest of the (slow) repair budget. + const stuck = attempt > 0 && err.message === prevError; + prevError = err.message; + if (attempt >= MAX_REPAIRS || stuck) { + await showClientFallback( + current, + (stuck + ? `The fix kept hitting the same error ("${err.message}") — stopping early. ` + : `Couldn't fix it after ${MAX_REPAIRS} tries. Last error: ${err.message}. `) + + "Try a different prompt, or set ANTHROPIC_API_KEY for the stronger Claude generator." ); - // Don't leave a dead-black canvas under the error message. - try { - await runner.run(CLIENT_FALLBACK_CODE); - } catch (fallbackErr) { - /* placeholder is hand-written and can't realistically fail */ - } return; } setConfidence( `Animation error: "${err.message}". Asking ${engineLabel} for a fix…`, "pending" ); + const triedCode = current.code; try { current = await postJSON("/api/repair", { prompt, code: current.code, error: err.message, + where: err.where || "", }); } catch (repairErr) { setConfidence("Repair failed: " + repairErr.message, "warn"); return; } + // Unchanged-code guard: the model returned the same code it was given, + // so re-running it would fail identically — stop rather than spend + // another slow round on a guaranteed repeat. + if (current.code && triedCode && current.code.trim() === triedCode.trim()) { + await showClientFallback( + current, + "The model returned the same code unchanged — stopping early. " + + "Try rephrasing the prompt." + ); + return; + } } } } diff --git a/index.html b/index.html index 10113de..fa2e228 100644 --- a/index.html +++ b/index.html @@ -258,6 +258,6 @@

Quick questions

- + diff --git a/main.py b/main.py index c1a493a..a48677a 100644 --- a/main.py +++ b/main.py @@ -43,6 +43,16 @@ # --- Claude (cloud) is the primary brain for generating animation code. --- ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-opus-4-8") +# Generation depth/latency lever. Opus 4.8 effort levels: low|medium|high|xhigh|max. +# Default "high": code-gen for novel STEM scenes is intelligence-sensitive, and +# correct first-try code is precisely what AVOIDS the slow client-side repair +# loop. Set VISUALLM_CLAUDE_EFFORT=medium to trade a little quality for speed. +ANTHROPIC_EFFORT = os.environ.get("VISUALLM_CLAUDE_EFFORT", "high").strip() or "high" +# Hard per-response ceiling (the model isn't aware of it). A scene is a few KB of +# code plus short text, so 32k is generous headroom for thinking + output; lower +# it only if you need to cap cost. Too low risks truncated JSON -> a parse error +# that the caller counts as a failed generation. +ANTHROPIC_MAX_TOKENS = int(os.environ.get("VISUALLM_CLAUDE_MAX_TOKENS", "32000")) # --- OpenAI / Gemini are optional cloud fallbacks (key = enabled). --- OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/") @@ -649,10 +659,10 @@ def claude_call_scene(user_blocks: list[dict]) -> dict: with client.messages.stream( model=ANTHROPIC_MODEL, - max_tokens=32000, + max_tokens=ANTHROPIC_MAX_TOKENS, thinking={"type": "adaptive"}, output_config={ - "effort": "high", + "effort": ANTHROPIC_EFFORT, "format": {"type": "json_schema", "schema": SCENE_SCHEMA}, }, system=[ @@ -686,12 +696,58 @@ def generate_with_claude(prompt: str, preferred_mode: str) -> dict: return claude_call_scene([{"type": "text", "text": user_text}]) +def _repair_hint(error: str) -> str: + """Turn a sandbox error into a targeted fix instruction. + + The repair loop's worst failure mode is the model *rewriting the whole + scene* and introducing a different bug — so every hint ends with a + minimal-change directive. The error-class prefix points the model straight + at the fault. Substring match: sandbox error messages aren't structured. + """ + e = (error or "").lower() + if "is not defined" in e: + specific = ( + "A name was used before it was declared (usually a typo). Declare it " + "with const/let, or fix the misspelling. Do NOT add other new names." + ) + elif "is not a function" in e: + specific = ( + "You called something that isn't a real helper. Use ONLY the helpers " + "from the contract above (H.*, the plot2d view methods, the cam3d " + "methods). Delete or replace the invalid call." + ) + elif "cannot read" in e and ("undefined" in e or "null" in e): + specific = ( + "You read a property of undefined/null. Guard the access — confirm " + "the value exists and any array index is in range before using it." + ) + elif "is not iterable" in e: + specific = ( + "You looped over or spread a non-array. Ensure the value is an array " + "(default to []) before iterating." + ) + elif "unexpected" in e or "syntaxerror" in e or "token" in e: + specific = ( + "The code did not parse — likely an unbalanced bracket or an " + "unfinished statement. Return complete, valid JavaScript." + ) + else: + specific = "Identify exactly what threw, then fix that specific cause." + return ( + specific + + " Make the SMALLEST change that fixes the error: keep every part that " + "already works, do not rewrite the whole scene, and do not change the " + "teaching intent." + ) + + def repair_with_claude(prompt: str, code: str, error: str) -> dict: user_text = ( "The animation code you wrote threw an error in the sandbox. Fix it and " - "return the full corrected scene. Keep the same teaching intent.\n\n" + "return the full corrected scene.\n\n" f"Original request:\n{prompt}\n\n" f"Error:\n{error}\n\n" + f"How to fix it:\n{_repair_hint(error)}\n\n" f"Broken code (function body of scene(ctx, t, H)):\n{code}" ) return claude_call_scene([{"type": "text", "text": user_text}]) @@ -760,6 +816,7 @@ def generate_with_ollama(prompt: str, preferred_mode: str, fix: dict | None = No user = ( "Your animation code threw an error. Fix it and return the full scene " "as strict JSON.\n\nRequest:\n" + prompt + "\n\nError:\n" + fix["error"] + + "\n\nHow to fix it:\n" + _repair_hint(fix["error"]) + "\n\nBroken code:\n" + fix["code"] ) else: @@ -919,6 +976,7 @@ def _scene_user_text(prompt: str, preferred_mode: str, fix: dict | None) -> str: user = ( "Your animation code threw an error. Fix it and return the full scene " "as strict JSON.\n\nRequest:\n" + prompt + "\n\nError:\n" + fix["error"] + + "\n\nHow to fix it:\n" + _repair_hint(fix["error"]) + "\n\nBroken code:\n" + fix["code"] ) else: @@ -1089,7 +1147,11 @@ def code_paints_something(code: str) -> bool: # Helpers that put words/numbers on screen. Scenes with zero of these are # unlabeled pictures — no title, no values — which defeats the teaching goal. -_LABEL_CALLS = ("H.text", "H.legend", "fillText") +# `.axes(` covers both plot2d and cam3d axes, which render numeric tick labels / +# axis names internally — without it, a correctly-labeled plot that relies on +# axes() and skips a bare H.text title gets a false "unlabeled" flag and a +# wasted regeneration. +_LABEL_CALLS = ("H.text", "H.legend", "fillText", ".axes(") def code_is_animated(code: str) -> bool: @@ -1218,20 +1280,26 @@ def _fallback_scene(prompt: str, reason: str) -> dict: } -def _try_generate(fn, prompt: str, preferred_mode: str, label: str) -> dict: +def _try_generate( + fn, prompt: str, preferred_mode: str, label: str, max_attempts: int = 3 +) -> dict: """Generate once, run the quality gate, and retry with targeted feedback. Three failure tiers, detected syntactically (we can't run JS here): - blank — no drawing call at all. Never shippable; after three + blank — no drawing call at all. Never shippable; after max_attempts blank attempts we raise (caller may fall back). static — never reads `t`, so the "animation" is a still image. unlabeled — no text anywhere: no title, no values, no readouts. static/unlabeled trigger a regeneration with a hint naming exactly what was missing; if the last attempt still has them, we ship it anyway and annotate the scene so the UI can tell the user. + + max_attempts is generator-aware: strong cloud models (Claude/OpenAI/Gemini) + almost never trip the gate, so 2 keeps a single corrective retry without + paying for a wasted third slow call; weak local models (Ollama) keep 3. """ best_imperfect = None # most recent paints-but-imperfect scene - for attempt in range(3): + for attempt in range(max_attempts): scene = normalize_scene(fn(prompt, preferred_mode), prompt) problems = scene_problems(scene.get("code", "")) if not problems: @@ -1249,8 +1317,8 @@ def _try_generate(fn, prompt: str, preferred_mode: str, label: str) -> dict: if best_imperfect is not None: return best_imperfect raise RuntimeError( - f"{label} produced a blank scene three times — the code didn't call " - f"any drawing helper. Try a different prompt, or use Claude for " + f"{label} produced a blank scene {max_attempts} times — the code didn't " + f"call any drawing helper. Try a different prompt, or use Claude for " f"more reliable generation." ) @@ -1271,12 +1339,16 @@ def plan_visualization(prompt: str, preferred_mode: str) -> dict: errors = [] for label, fn in _cloud_generators(): try: - return _try_generate(fn, prompt, preferred_mode, label) + return _try_generate(fn, prompt, preferred_mode, label, max_attempts=2) except Exception as error: # noqa: BLE001 errors.append(f"{label}: {error}") try: return _try_generate( - lambda p, m: generate_with_ollama(p, m), prompt, preferred_mode, "Ollama" + lambda p, m: generate_with_ollama(p, m), + prompt, + preferred_mode, + "Ollama", + max_attempts=3, ) except Exception as error: # noqa: BLE001 errors.append(f"Ollama: {error}") @@ -1315,11 +1387,15 @@ def plan_visualization(prompt: str, preferred_mode: str) -> dict: raise RuntimeError(f"Generator failed ({detail}). {hint}") -def repair_visualization(prompt: str, code: str, error: str) -> dict: +def repair_visualization(prompt: str, code: str, error: str, where: str = "") -> dict: errors = [] + # `where` is the offending source line the sandbox pulled from the stack + # trace (see sandbox-worker.js offendingLine). Folding it into the error text + # points every repair provider straight at the failing line. + error_ctx = error + (f"\n\nThe error was thrown at: {where}" if where else "") if claude_available()["available"]: try: - return normalize_scene(repair_with_claude(prompt, code, error), prompt) + return normalize_scene(repair_with_claude(prompt, code, error_ctx), prompt) except Exception as e: # noqa: BLE001 errors.append(f"Claude: {e}") for label, avail, fn in ( @@ -1330,13 +1406,13 @@ def repair_visualization(prompt: str, code: str, error: str) -> dict: continue try: return normalize_scene( - fn(prompt, "auto", fix={"code": code, "error": error}), prompt + fn(prompt, "auto", fix={"code": code, "error": error_ctx}), prompt ) except Exception as e: # noqa: BLE001 errors.append(f"{label}: {e}") try: return normalize_scene( - generate_with_ollama(prompt, "auto", fix={"code": code, "error": error}), + generate_with_ollama(prompt, "auto", fix={"code": code, "error": error_ctx}), prompt, ) except Exception as e: # noqa: BLE001 @@ -1690,12 +1766,13 @@ def _handle_repair(self, payload: dict) -> None: prompt = payload.get("prompt", "") code = payload.get("code", "") error = payload.get("error", "") + where = payload.get("where", "") if not isinstance(prompt, str) or not isinstance(code, str) or not code.strip(): send_json(self, HTTPStatus.BAD_REQUEST, {"error": "prompt and code are required."}) return try: result = repair_visualization( - prompt.strip()[:4000], code[:20000], str(error)[:2000] + prompt.strip()[:4000], code[:20000], str(error)[:2000], str(where)[:300] ) except RuntimeError as err: send_json(self, HTTPStatus.SERVICE_UNAVAILABLE, {"error": str(err)}) diff --git a/sandbox-worker.js b/sandbox-worker.js index 567dc58..85eb0a2 100644 --- a/sandbox-worker.js +++ b/sandbox-worker.js @@ -68,6 +68,13 @@ const orbit = { yaw: 0, pitch: 0, zoom: 1 }; let simTime = 0; // accumulated, speed-scaled seconds passed to scene() let lastWall = 0; // last wall-clock timestamp (ms) let frameCount = 0; +// Per-frame error tolerance. A scene that has rendered at least one clean frame +// (everRendered) is kept alive through an occasional throwing frame rather than +// being killed and shipped to the slow repair loop. Only a first-frame failure +// or a sustained run of throwing frames escalates to a real runtime-error. +let consecutiveErrors = 0; +let everRendered = false; +let lastCode = ""; // raw source of the running scene, for offending-line lookup let loopTimer = null; const FRAME_MS = 1000 / 60; @@ -297,7 +304,7 @@ function makeHelpers() { const yMax = o.yMax == null ? 6 : o.yMax; const X = (v) => box.x + map(v, xMin, xMax, 0, box.w); const Y = (v) => box.y + map(v, yMin, yMax, box.h, 0); - const view = { + let view = { box, xMin, xMax, @@ -432,7 +439,11 @@ function makeHelpers() { }; // Same tolerance as H itself: an invented view method becomes a no-op // instead of killing the whole frame with "v.foo is not a function". - return wrapHelpers(view); + // Reassign the `view` binding to the proxy so every `return view` inside + // the methods above yields the wrapped object — that keeps invented + // helpers no-op-able even mid-chain (e.g. view.fn(...).annotate(...)). + view = wrapHelpers(view); + return view; }, /* 3D camera. project([x,y,z]) -> {x, y, depth, f}. Larger depth = farther @@ -447,7 +458,7 @@ function makeHelpers() { const dist = o.dist || 9; const cx = o.cx == null ? logicalW / 2 : o.cx; const cy = o.cy == null ? logicalH / 2 : o.cy; - const cam = { + let cam = { set yaw(v) { yaw = v; }, @@ -599,7 +610,11 @@ function makeHelpers() { }, }; // Invented cam methods degrade to no-ops, like H and plot2d views. - return wrapHelpers(cam); + // Reassign the `cam` binding to the proxy so every `return cam` inside the + // methods above yields the wrapped object — chains keep working in any + // order (e.g. cam.grid().glow(), cam.line(...).spiral(...)). + cam = wrapHelpers(cam); + return cam; }, /* Solid, lit, depth-sorted height surface: screenHeight = f(x, y). @@ -851,15 +866,23 @@ function seedConvenienceGlobals() { * scene still renders and the model just doesn't get that specific helper. */ function wrapHelpers(h) { if (typeof Proxy === "undefined") return h; - return new Proxy(h, { + const proxy = new Proxy(h, { get(target, key) { - if (key in target) return target[key]; - // Common shape: `H.foo(...)` -> return a no-op function. - return function noop() { - return undefined; + // Real helpers and any symbol access (Symbol.toPrimitive, iterators, …) + // pass straight through — intercepting symbols would break coercion. + if (key in target || typeof key === "symbol") return target[key]; + // An invented helper (`H.spinner()`, `cam.spiral()`, `view.heatmap()`). + // Return a no-op that RETURNS THE HOST so chains keep flowing: + // `cam.invented(...).line(...)` used to throw "Cannot read properties of + // undefined (reading 'line')" and burn the whole repair budget. Now the + // invented call does nothing and `.line(...)` resolves on the real host. + // A bare `H.invented(...)` still just no-ops. + return function chainableNoop() { + return proxy; }; }, }); + return proxy; } /* ------------------------------------------------------------------ */ @@ -888,6 +911,37 @@ function compile(code) { return factory; } +// A scene that throws every frame from t=0 is broken and must go to repair. +// One that renders cleanly then throws on a single frame (a NaN at one value of +// `t`, a transient out-of-range index) should NOT — killing it wastes a full +// repair round-trip on a scene that's 99% working. We escalate only when the +// scene never produced a clean frame, or has thrown continuously for ~half a +// second (state is corrupted, not a one-frame blip). +const MAX_CONSECUTIVE_ERRORS = 30; // ~0.5s at 60fps + +// Best-effort: pull the offending source line out of a V8 `new Function` stack +// frame (`:LINE:COL`). The compiled wrapper adds 3 lines ahead of the +// user's code (the `function(ctx,t)` header, the `) {` line, and the injected +// `"use strict";`), so user line = anonLine - 3. Non-V8 engines format stacks +// differently, miss the regex, and yield "" — the repair model then falls back +// to the message + code alone. Handing the model the exact failing line cuts +// repair rounds, especially for terse errors like "Cannot read properties of +// undefined" that don't say *which* access broke. +function offendingLine(stack, code) { + try { + if (!stack || !code) return ""; + const m = /:(\d+):\d+/.exec(stack); + if (!m) return ""; + const lineNo = parseInt(m[1], 10) - 3; // 1-based line within `code` + const lines = code.split("\n"); + if (lineNo < 1 || lineNo > lines.length) return ""; + const text = String(lines[lineNo - 1]).trim(); + return text ? "line " + lineNo + ": " + text : ""; + } catch (e) { + return ""; + } +} + function tick() { if (!running) return; const now = performance.now(); @@ -901,19 +955,28 @@ function tick() { try { ctx.clearRect(0, 0, logicalW, logicalH); sceneFn(ctx, simTime); // H is a global (see compile()) + everRendered = true; + consecutiveErrors = 0; } catch (err) { - running = false; - post({ - type: "runtime-error", - message: String((err && err.message) || err), - stack: String((err && err.stack) || ""), - }); - return; + consecutiveErrors++; + if (!everRendered || consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { + running = false; + post({ + type: "runtime-error", + message: String((err && err.message) || err), + stack: String((err && err.stack) || ""), + where: offendingLine(err && err.stack, lastCode), + }); + return; + } + // Transient bad frame: skip drawing it, keep the loop alive. } } frameCount++; - if (frameCount % 20 === 0) { + // Heartbeat on the first clean frame (so the main thread's run() promise + // resolves ~immediately rather than after ~20 frames), then every 20 frames. + if (everRendered && (frameCount === 1 || frameCount % 20 === 0)) { post({ type: "heartbeat", frame: frameCount, t: simTime }); } @@ -960,6 +1023,7 @@ self.onmessage = (e) => { try { const fn = compile(m.code); sceneFn = fn; + lastCode = typeof m.code === "string" ? m.code : ""; if (m.resetTime !== false) { simTime = 0; orbit.yaw = 0; @@ -967,6 +1031,8 @@ self.onmessage = (e) => { orbit.zoom = 1; } frameCount = 0; + consecutiveErrors = 0; + everRendered = false; paused = false; lastWall = performance.now(); if (!running) { diff --git a/tests/test_main.py b/tests/test_main.py index 59b1c73..69c209e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -133,6 +133,57 @@ def always_blank(prompt, mode): main._try_generate(always_blank, "orig", "auto", "Test") +class RepairHintTests(unittest.TestCase): + def test_error_classes_get_specific_hints(self): + self.assertIn("declared", main._repair_hint("ReferenceError: radius is not defined")) + self.assertIn("real helper", main._repair_hint("TypeError: H.glow is not a function")) + self.assertIn( + "undefined/null", + main._repair_hint("TypeError: Cannot read properties of undefined (reading 'x')"), + ) + self.assertIn("parse", main._repair_hint("SyntaxError: Unexpected token '}'")) + + def test_every_hint_demands_a_minimal_change(self): + for err in ["x is not defined", "boom", "", "Cannot read properties of null"]: + self.assertIn("SMALLEST change", main._repair_hint(err)) + + def test_handles_non_string_error(self): + self.assertIn("SMALLEST change", main._repair_hint(None)) + + +class RepairVisualizationTests(unittest.TestCase): + """repair_visualization folds the sandbox's offending line into the error + text handed to the provider, and leaves it clean when there's no location.""" + + def _capture_repair_error(self, error, where): + captured = {} + + def fake_repair(prompt, code, err): + captured["error"] = err + return {"code": "H.background(); H.text('x', 1, 2);", "engine": "claude"} + + orig_avail, orig_repair = main.claude_available, main.repair_with_claude + main.claude_available = lambda: {"available": True} + main.repair_with_claude = fake_repair + try: + main.repair_visualization("draw it", "H.circle(p.x,1,2);", error, where=where) + finally: + main.claude_available = orig_avail + main.repair_with_claude = orig_repair + return captured["error"] + + def test_offending_line_is_folded_in(self): + err = self._capture_repair_error( + "Cannot read properties of undefined", "line 5: H.circle(p.x, 1, 2)" + ) + self.assertIn("Cannot read properties of undefined", err) + self.assertIn("line 5: H.circle(p.x, 1, 2)", err) + + def test_no_where_leaves_error_clean(self): + err = self._capture_repair_error("boom", "") + self.assertEqual(err, "boom") + + class NormalizeSceneTests(unittest.TestCase): def test_dimension_normalization(self): scene = main.normalize_scene({"dimension": "3d surface"}, "p") From b426c440031855fcd21dd208791369c16681c3a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Sun, 14 Jun 2026 18:18:10 +0700 Subject: [PATCH 02/17] Harden server: catch-all 500 + client-disconnect guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two production-robustness gaps in the public HTTP server, independent of the generation→repair work on this branch: - do_POST dispatched to handlers with no catch-all, so an UNEXPECTED exception (a bug, a new SDK error type — distinct from the RuntimeErrors that handlers already turn into 503s) escaped, dropped the client connection, and spilled a bare traceback to stderr with no response. Now logged and returned as a clean 500. - send_json's writes weren't guarded, so a client disconnecting mid-response (common during a slow generation) surfaced BrokenPipeError as an unhandled traceback. Now swallowed. Test: +1 (unexpected handler exception -> 500). Suite 41->42, green. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.py | 45 ++++++++++++++++++++++++++++++++------------- tests/test_main.py | 20 ++++++++++++++++++++ 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/main.py b/main.py index a48677a..aa18c08 100644 --- a/main.py +++ b/main.py @@ -8,6 +8,7 @@ import textwrap import threading import time +import traceback import urllib.error import urllib.parse import urllib.request @@ -138,11 +139,17 @@ def read_json_body(handler: SimpleHTTPRequestHandler) -> dict: def send_json(handler: SimpleHTTPRequestHandler, status: HTTPStatus, payload: dict) -> None: data = json.dumps(payload).encode("utf-8") - handler.send_response(status) - handler.send_header("Content-Type", "application/json; charset=utf-8") - handler.send_header("Content-Length", str(len(data))) - handler.end_headers() - handler.wfile.write(data) + try: + handler.send_response(status) + handler.send_header("Content-Type", "application/json; charset=utf-8") + handler.send_header("Content-Length", str(len(data))) + handler.end_headers() + handler.wfile.write(data) + except (BrokenPipeError, ConnectionError): + # Client disconnected mid-response (common when the user navigates away + # during a slow generation). There's no one to send to — swallow it + # rather than let it surface as an unhandled traceback in the log. + pass # ===================================================================== # @@ -1724,14 +1731,26 @@ def do_POST(self) -> None: send_json(self, HTTPStatus.BAD_REQUEST, {"error": "Body must be a JSON object."}) return - if parsed.path == "/api/visualize": - self._handle_visualize(payload) - elif parsed.path == "/api/repair": - self._handle_repair(payload) - elif parsed.path == "/api/resources": - self._handle_resource_upload(payload) - else: - self._handle_chat(payload) + # Last-resort guard. Handlers catch their own *expected* errors (bad + # input -> 400, generator failure -> 503). An UNEXPECTED exception (a + # bug, a new SDK error type) must not escape do_POST — that drops the + # client connection with a bare stderr traceback and no response. Log + # it and return a clean 500. Handlers do their heavy work before + # sending anything, so no response has started when we land here. + try: + if parsed.path == "/api/visualize": + self._handle_visualize(payload) + elif parsed.path == "/api/repair": + self._handle_repair(payload) + elif parsed.path == "/api/resources": + self._handle_resource_upload(payload) + else: + self._handle_chat(payload) + except Exception: # noqa: BLE001 + traceback.print_exc() + send_json( + self, HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "Internal server error."} + ) def _handle_resource_upload(self, payload: dict) -> None: name = payload.get("name", "") diff --git a/tests/test_main.py b/tests/test_main.py index 69c209e..3649279 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -8,6 +8,8 @@ """ from __future__ import annotations +import contextlib +import io import json import sys import threading @@ -354,6 +356,24 @@ def test_resource_rejects_binary(self): self.assertEqual(status, 400) self.assertIn("binary", body["error"].lower()) + def test_handler_exception_returns_500(self): + # An UNEXPECTED exception in a handler (not the RuntimeError that + # handlers already convert to 503) must come back as a clean 500, not a + # dropped connection. stderr is suppressed because the guard logs the + # traceback by design. + def boom(prompt, mode): + raise ValueError("unexpected handler bug") + + original = main.plan_visualization + main.plan_visualization = boom + try: + with contextlib.redirect_stderr(io.StringIO()): + status, body = self.request("POST", "/api/visualize", {"prompt": "p"}) + finally: + main.plan_visualization = original + self.assertEqual(status, 500) + self.assertIn("error", body) + if __name__ == "__main__": unittest.main() From c50b05b229af34ea5ffa9d4daa4da6d0c74f2cd1 Mon Sep 17 00:00:00 2001 From: Maxpeng59 Date: Sun, 14 Jun 2026 21:42:50 +0700 Subject: [PATCH 03/17] Faster + more reliable generation: validator, auto-fixer, library, cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two complaints — "still errors after 3 tries" and "took a long time" — both trace to the same root: the browser was the validator, so every bad scene cost a full client round-trip + a slow model repair. This restructures the pipeline around catching and fixing errors WITHOUT model calls. - validate_scene.js: headless server-side scene validator. Runs untrusted generated code in a hardened Node `vm` (fresh empty context — no process/ require/import reachable; classic constructor.constructor escape contained; hard 2s timeout) and reports throws / blank / no-motion / no-labels in ~50ms. - main.py evaluate_scene(): single source of truth, validator-authoritative, static-gate fallback when node is absent. _try_generate now repairs fatal scenes SERVER-SIDE (exact runtime error folded into the prompt) so the browser receives an already-runnable scene. - autofix_code(): deterministically prefixes bare Math.* calls / PI / TAU outside strings & comments — fixes the #1 "X is not defined" throw with ZERO model round-trips. Wired into sanitize_code. - scene_library.py + library_match(): curated, hand-verified STEM corpus with keyword retrieval. Strong match short-circuits to an instant, known-correct scene (Fourier prompt: 5ms vs a 10-60s generation). This is the practical, retrieval-augmented realization of "feed it STEM data". - Exact-prompt cache of validated scenes (instant repeats), only caches clean scenes. plan_visualization chain: cache -> strong library -> models -> library fallback -> placeholder. - Frontend: elapsed-seconds ticker during generation; curated/cached badges. - Dockerfile installs Node so the validator runs in production too. - Health reports validator + library_size. 66 tests pass (24 new). Co-Authored-By: Claude Opus 4.8 (1M context) --- Dockerfile | 14 +- app.js | 39 +++- index.html | 2 +- main.py | 437 ++++++++++++++++++++++++++++++++++++++++----- scene_library.py | 274 ++++++++++++++++++++++++++++ tests/test_main.py | 167 +++++++++++++++++ validate_scene.js | 191 ++++++++++++++++++++ 7 files changed, 1074 insertions(+), 50 deletions(-) create mode 100644 scene_library.py create mode 100644 validate_scene.js diff --git a/Dockerfile b/Dockerfile index cdf478d..9b4f125 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,23 @@ # VisualLM — single-container deployment. -# The server is stdlib-only; the anthropic SDK is the one optional dependency. +# The server is stdlib-only; the anthropic SDK is the one optional Python dep. +# Node.js is installed so the server-side scene validator (validate_scene.js) +# runs in production too — it's what lets us catch & repair broken animations +# before they ever reach the browser. Without it the app still works (it falls +# back to the static gate + browser repair), but reliability is best with it. FROM python:3.12-slim +# Minimal Node runtime for the headless scene validator. +RUN apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY main.py index.html app.js sandbox-worker.js styles.css ./ +COPY main.py scene_library.py validate_scene.js \ + index.html app.js sandbox-worker.js styles.css ./ # Cloud platforms inject PORT; main.py binds 0.0.0.0 automatically when set. ENV PORT=8080 diff --git a/app.js b/app.js index b44c149..8284129 100644 --- a/app.js +++ b/app.js @@ -589,12 +589,33 @@ openai: "ChatGPT", gemini: "Gemini", ollama: "local model", + library: "curated library", fallback: "fallback", }; function engineName(engine) { return ENGINE_NAMES[engine] || "the model"; } + // A live elapsed-seconds ticker so a slow local-model generation reads as + // "working" rather than "frozen". Updates the status line in place. + let _elapsedTimer = null; + function startElapsed(baseLabel) { + stopElapsed(); + const t0 = Date.now(); + const tick = () => { + const s = Math.round((Date.now() - t0) / 1000); + setConfidence(`${baseLabel} (${s}s)`, "pending"); + }; + tick(); + _elapsedTimer = setInterval(tick, 1000); + } + function stopElapsed() { + if (_elapsedTimer) { + clearInterval(_elapsedTimer); + _elapsedTimer = null; + } + } + async function visualize(prompt) { if (state.busy) return; const clean = (prompt || "").trim(); @@ -602,7 +623,10 @@ setBusyVisual(true, "Thinking…"); el.topic.textContent = "Generating…"; - setConfidence("The AI is writing a custom animation for your prompt…", "pending"); + // Elapsed ticker — local generation can take 10-60s; a live counter makes + // the wait legible instead of looking hung. (Curated/cached hits return so + // fast the ticker never visibly advances.) + startElapsed("The AI is writing a custom animation for your prompt…"); // A new scene always starts playing. Without this, pausing one scene // left every FUTURE scene frozen on its first frame — which reads as @@ -617,8 +641,10 @@ prompt: clean, preferred_mode: state.mode, }); + stopElapsed(); await runSceneWithRepair(scene, clean); } catch (err) { + stopElapsed(); setConfidence("Could not generate: " + err.message, "warn"); el.topic.textContent = "Generation failed"; el.summary.textContent = err.message; @@ -626,6 +652,7 @@ // is now stale (Ollama died, Claude key expired, server restarted, etc.). refreshStatus(); } finally { + stopElapsed(); setBusyVisual(false); } } @@ -683,6 +710,13 @@ "Regenerate or rephrase for a better scene.", "warn", ); + } else if (current.from_library) { + // Instant, hand-verified scene from the curated STEM corpus. + setConfidence( + `${current.dimension} • curated STEM scene (instant, verified)` + + (current.fallback_reason ? " — " + current.fallback_reason : ""), + "ok", + ); } else if (current.recovered_after_retry) { const n = current.recovered_after_retry; setConfidence( @@ -694,7 +728,8 @@ } else { setConfidence( `${current.dimension} • generated by ${engineLabel}` + - (current.model ? " (" + current.model + ")" : ""), + (current.model ? " (" + current.model + ")" : "") + + (current.cached ? " — instant (cached)" : ""), "ok" ); } diff --git a/index.html b/index.html index fa2e228..a6ff235 100644 --- a/index.html +++ b/index.html @@ -258,6 +258,6 @@

Quick questions

- + diff --git a/main.py b/main.py index aa18c08..a84230b 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,8 @@ import json import os import re +import shutil +import subprocess import textwrap import threading import time @@ -1023,6 +1025,143 @@ def generate_with_gemini(prompt: str, preferred_mode: str, fix: dict | None = No return plan +# ===================================================================== # +# Deterministic auto-fixer (no model round-trip) # +# ===================================================================== # +# +# The cheapest repair is the one that never calls the model. Local models +# (and occasionally the cloud ones) make a handful of MECHANICAL mistakes that +# we can fix with certainty in microseconds — the biggest being bare math +# calls (`sin(x)` instead of `Math.sin(x)`), the single most common reason a +# 7B scene throws "sin is not defined". Fixing these here means they never +# burn a slow repair attempt. + +# Every Math.* function a scene might call bare. Longest names first so the +# alternation never matches a prefix (e.g. `sin` inside `sinh`). +_MATH_FNS = sorted( + [ + "atan2", "asinh", "acosh", "atanh", "expm1", "log10", "log1p", "log2", + "cbrt", "sinh", "cosh", "tanh", "asin", "acos", "atan", "sign", + "trunc", "sqrt", "hypot", "floor", "ceil", "round", "abs", "exp", + "log", "pow", "min", "max", "sin", "cos", "tan", "random", + ], + key=len, + reverse=True, +) +# A bare math call: the name not preceded by `.`/word-char/`$` (so `Math.sin` +# and `mySin` are skipped) and followed by `(`. +_MATH_FN_RE = re.compile(r"(? str: + """Run `transform` only on the code OUTSIDE string literals and comments.""" + out = [] + last = 0 + for m in _JS_LITERAL_RE.finditer(code): + out.append(transform(code[last : m.start()])) + out.append(m.group(0)) + last = m.end() + out.append(transform(code[last:])) + return "".join(out) + + +def _autofix_math(segment: str) -> str: + segment = _MATH_FN_RE.sub(r"Math.\1\2", segment) + segment = _BARE_PI_RE.sub("Math.PI", segment) + segment = _BARE_TAU_RE.sub("H.TAU", segment) + return segment + + +def autofix_code(code: str) -> str: + """Mechanically fix high-confidence errors without a model round-trip. + + Currently: prefix bare Math functions/constants (the dominant + "X is not defined" cause). Applied outside strings/comments so labels and + comments are never corrupted. Idempotent — running it on already-correct + code is a no-op. + """ + if not code: + return code + try: + return _apply_outside_strings(code, _autofix_math) + except re.error: + return code + + +# ===================================================================== # +# Headless scene validator (Node, optional) # +# ===================================================================== # +# +# If `node` is on PATH, we run every generated/repaired scene through +# validate_scene.js BEFORE sending it to the browser. That sandboxed run +# (fresh empty V8 context, hard timeout) tells us — in ~50 ms, server-side — +# whether the scene throws, hangs, draws nothing, or lacks labels. This is the +# big latency + reliability win: the browser used to BE the validator, so each +# bad scene cost a full client round-trip + repair. Now we validate and repair +# server-side and hand the browser a scene that already runs. +# +# Degrades gracefully: with no `node`, headless_validate returns None and the +# pipeline falls back to the static gate (scene_problems) + the browser's own +# repair loop, exactly as before. + +_NODE_BIN = shutil.which("node") +_VALIDATOR_PATH = BASE_DIR / "validate_scene.js" + + +def node_validator_available() -> bool: + return bool(_NODE_BIN) and _VALIDATOR_PATH.exists() + + +def headless_validate(code: str, timeout: float = 6.0) -> dict | None: + """Run `code` in the sandboxed Node validator. None = couldn't validate. + + Returns {ok, error, painted, text, paint} on success. None when the + validator is unavailable or itself failed (so callers skip the gate rather + than wrongly reject a scene). + """ + if not code or not code.strip() or not node_validator_available(): + return None + try: + proc = subprocess.run( + [_NODE_BIN, str(_VALIDATOR_PATH)], + input=code.encode("utf-8"), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + # The validator has its own 2 s in-VM timeout; hitting THIS one means + # node itself wedged. Treat as a hang so the scene gets repaired. + return { + "ok": False, + "error": "The scene hung (possible infinite loop).", + "painted": False, + "text": False, + } + except Exception: # noqa: BLE001 — any spawn failure → "couldn't validate" + return None + if proc.returncode != 0: + return None + try: + out = json.loads(proc.stdout.decode("utf-8") or "{}") + except (json.JSONDecodeError, UnicodeDecodeError): + return None + return out if isinstance(out, dict) else None + + _RESERVED_BINDINGS = ("H", "ctx", "t") @@ -1092,6 +1231,10 @@ def sanitize_code(code: object) -> str: c, flags=re.DOTALL, ) + + # Deterministically fix bare Math calls / constants. This resolves the + # most common "X is not defined" throw with ZERO model round-trips. + c = autofix_code(c) except re.error: pass @@ -1206,6 +1349,50 @@ def scene_problems(code: str) -> list[str]: } +def evaluate_scene(code: str) -> dict: + """Single source of truth for "is this scene good?". + + Prefers the Node validator (authoritative for runtime throws + whether the + scene actually painted), and falls back to the static gate when node is + absent. Returns {fatal, error, problems}: + fatal=True → the scene throws / hangs / draws nothing. Must be repaired. + problems=[] → ship it. ['static'|'unlabeled'] → regenerate with feedback. + """ + val = headless_validate(code) + if val is None: + # No runtime validator — fall back to the purely-static gate. + probs = scene_problems(code) + if probs == ["blank"]: + return { + "fatal": True, + "error": "The scene didn't call any drawing helper, so the canvas stayed blank.", + "problems": [], + } + return {"fatal": False, "error": None, "problems": probs} + # Node validator available — authoritative for runtime behaviour. + if not val.get("ok"): + return { + "fatal": True, + "error": val.get("error") or "The scene threw an error at runtime.", + "problems": [], + } + if not val.get("painted"): + return { + "fatal": True, + "error": ( + "The scene ran but drew nothing (blank canvas). Call H.background() " + "and then drawing helpers that actually paint." + ), + "problems": [], + } + problems = [] + if not code_is_animated(code): + problems.append("static") + if not (val.get("text") or code_has_labels(code)): + problems.append("unlabeled") + return {"fatal": False, "error": None, "problems": problems} + + def normalize_scene(plan: dict, prompt: str) -> dict: def s(key, default=""): v = plan.get(key) @@ -1287,46 +1474,73 @@ def _fallback_scene(prompt: str, reason: str) -> dict: } +def _repair_feedback(error: str, code: str) -> str: + """A feedback block that turns the next generation into a targeted repair. + + Folding the runtime error + the broken code into the prompt lets us drive a + server-side fix through the SAME generate function (keeping _try_generate + provider-agnostic) without a separate repair call path. + """ + return ( + "\n\nCRITICAL: your previous code FAILED to run in the sandbox.\n" + f"Error: {error}\n" + f"How to fix it: {_repair_hint(error)}\n" + "Return a corrected, complete scene. Broken code was:\n" + (code or "") + ) + + def _try_generate( fn, prompt: str, preferred_mode: str, label: str, max_attempts: int = 3 ) -> dict: - """Generate once, run the quality gate, and retry with targeted feedback. - - Three failure tiers, detected syntactically (we can't run JS here): - blank — no drawing call at all. Never shippable; after max_attempts - blank attempts we raise (caller may fall back). - static — never reads `t`, so the "animation" is a still image. - unlabeled — no text anywhere: no title, no values, no readouts. - static/unlabeled trigger a regeneration with a hint naming exactly what - was missing; if the last attempt still has them, we ship it anyway and - annotate the scene so the UI can tell the user. - - max_attempts is generator-aware: strong cloud models (Claude/OpenAI/Gemini) - almost never trip the gate, so 2 keeps a single corrective retry without - paying for a wasted third slow call; weak local models (Ollama) keep 3. + """Generate, validate server-side, and retry with targeted feedback. + + Every candidate is run through evaluate_scene (the Node validator when + available, else the static gate), which classifies it as: + fatal — throws / hangs / draws nothing. We regenerate with the exact + runtime error + the broken code folded into the prompt, so + the model performs a precise fix. After max_attempts we raise. + static — never reads `t`, so it's a still image. + unlabeled — no title / values / readouts. + static/unlabeled regenerate with a hint naming what was missing; if the + last attempt still trips them, we ship it anyway, annotated, so the user + gets a working (if imperfect) scene instead of an error. + + The deterministic auto-fixer runs inside normalize_scene, so mechanically + fixable faults (bare Math.*) are resolved here with ZERO extra model calls. + + max_attempts is generator-aware: strong cloud models almost never trip the + gate, so 2 keeps a single corrective retry; weak local models keep 3. """ best_imperfect = None # most recent paints-but-imperfect scene + scene = normalize_scene(fn(prompt, preferred_mode), prompt) + last_error = None for attempt in range(max_attempts): - scene = normalize_scene(fn(prompt, preferred_mode), prompt) - problems = scene_problems(scene.get("code", "")) - if not problems: + ev = evaluate_scene(scene.get("code", "")) + if not ev["fatal"] and not ev["problems"]: if attempt > 0: - # Annotate so the UI can surface what happened. scene["recovered_after_retry"] = attempt return scene - if problems != ["blank"]: - scene["quality_warnings"] = problems + if not ev["fatal"]: + scene["quality_warnings"] = ev["problems"] best_imperfect = scene - # Reprompt with feedback naming exactly what was wrong. Models - # reliably do better on the second try with a targeted hint. - complaints = " ALSO, ".join(_PROBLEM_HINTS[p] for p in problems) - prompt = prompt + "\n\nCRITICAL FEEDBACK on your previous attempt: " + complaints + if attempt == max_attempts - 1: + break + # Regenerate with feedback. Fatal → repair the exact fault; quality → + # name what was missing. Always feed back off the ORIGINAL prompt so + # complaints don't pile up across attempts. + if ev["fatal"]: + last_error = ev["error"] + feedback = _repair_feedback(ev["error"], scene.get("code", "")) + else: + complaints = " ALSO, ".join(_PROBLEM_HINTS[p] for p in ev["problems"]) + feedback = "\n\nCRITICAL FEEDBACK on your previous attempt: " + complaints + scene = normalize_scene(fn(prompt + feedback, preferred_mode), prompt) if best_imperfect is not None: return best_imperfect raise RuntimeError( - f"{label} produced a blank scene {max_attempts} times — the code didn't " - f"call any drawing helper. Try a different prompt, or use Claude for " - f"more reliable generation." + f"{label} couldn't produce a runnable scene after {max_attempts} attempts" + + (f" (last error: {last_error})" if last_error else "") + + ". Try a different prompt, or use a stronger model (ANTHROPIC_API_KEY)." ) @@ -1342,42 +1556,172 @@ def _cloud_generators() -> list[tuple[str, object]]: return chain +# ===================================================================== # +# Curated STEM scene library + validated-scene cache # +# ===================================================================== # +# +# The fastest, most reliable scene is one we don't have to generate. Two +# layers sit in front of the model: +# 1. An exact-prompt cache of scenes that already passed validation, so a +# repeated prompt (or a shared link) renders instantly. +# 2. A curated library of hand-verified scenes across STEM domains. A strong +# keyword match short-circuits to an instant, known-correct animation — +# this is the practical, retrieval-augmented version of "feed it STEM +# data": a vetted corpus the matcher draws on instead of training a model. + +try: + from scene_library import SCENE_LIBRARY # type: ignore +except Exception: # noqa: BLE001 — library is optional; never block startup + SCENE_LIBRARY = [] + +_scene_cache_lock = threading.Lock() +_scene_cache: dict[tuple, dict] = {} +_SCENE_CACHE_MAX = 256 + + +def _cache_key(prompt: str, mode: str) -> tuple: + return (mode, re.sub(r"\s+", " ", prompt.strip().lower())) + + +def scene_cache_get(prompt: str, mode: str) -> dict | None: + with _scene_cache_lock: + hit = _scene_cache.get(_cache_key(prompt, mode)) + return dict(hit) if hit else None + + +def scene_cache_put(prompt: str, mode: str, scene: dict) -> None: + # Only cache CLEAN scenes: never a fallback or a known-imperfect one, so a + # later retry still gets a chance at something better. + if not scene or scene.get("is_fallback") or scene.get("quality_warnings"): + return + with _scene_cache_lock: + if len(_scene_cache) >= _SCENE_CACHE_MAX: + _scene_cache.pop(next(iter(_scene_cache))) + _scene_cache[_cache_key(prompt, mode)] = dict(scene) + + +def _tokenize(s: str) -> set: + return set(re.findall(r"[a-z0-9]+", (s or "").lower())) + + +def library_match(prompt: str, mode: str) -> tuple[dict | None, float]: + """Best curated scene for this prompt + its match score (0 = no match).""" + if not SCENE_LIBRARY: + return None, 0.0 + ptext = " " + re.sub(r"\s+", " ", (prompt or "").lower()) + " " + ptoks = _tokenize(prompt) + best, best_score = None, 0.0 + for sc in SCENE_LIBRARY: + score = 0.0 + for kw in sc.get("keywords", []): + k = kw.lower().strip() + if not k: + continue + if " " in k: # multi-word phrase: weight a contiguous hit higher + if k in ptext: + score += 2.0 + elif k in ptoks: + score += 1.0 + # Shared title words are a mild positive signal. + score += 0.5 * len(_tokenize(sc.get("title", "")) & ptoks) + # Respect an explicit 2D/3D preference. + if mode in ("2d", "3d") and sc.get("dimension", "").lower() != mode: + score -= 1.5 + if score > best_score: + best, best_score = sc, score + return best, best_score + + +def _library_scene(sc: dict, prompt: str) -> dict: + return { + "title": sc.get("title", "STEM Visualization"), + "tag": sc.get("tag", "STEM"), + "dimension": "3D" if str(sc.get("dimension", "")).lower().startswith("3") else "2D", + "equation": sc.get("equation", ""), + "summary": sc.get("summary", ""), + "bullets": [str(b) for b in sc.get("bullets", [])][:4], + "student_prompts": [str(p) for p in sc.get("student_prompts", [])][:4], + "code": sanitize_code(sc.get("code", "")), + "model": "curated", + "engine": "library", + "prompt": prompt, + "from_library": True, + } + + +def _has_cloud() -> bool: + return bool(_cloud_generators()) + + def plan_visualization(prompt: str, preferred_mode: str) -> dict: + # 1. Exact-prompt cache — instant repeat for an already-validated scene. + cached = scene_cache_get(prompt, preferred_mode) + if cached: + cached["cached"] = True + return cached + + # 2. Strong curated match — instant, known-correct. The bar is high when a + # cloud model is available (the user likely wants a custom take), and + # lower when we'd otherwise lean on the slow/weak local model. + lib_scene, lib_score = library_match(prompt, preferred_mode) + strong_threshold = 6.0 if _has_cloud() else 3.0 + if lib_scene is not None and lib_score >= strong_threshold: + result = _library_scene(lib_scene, prompt) + scene_cache_put(prompt, preferred_mode, result) + return result + + # 3. Generate with the provider chain (each validated + repaired server-side). errors = [] for label, fn in _cloud_generators(): try: - return _try_generate(fn, prompt, preferred_mode, label, max_attempts=2) + scene = _try_generate(fn, prompt, preferred_mode, label, max_attempts=2) + scene_cache_put(prompt, preferred_mode, scene) + return scene except Exception as error: # noqa: BLE001 errors.append(f"{label}: {error}") try: - return _try_generate( + scene = _try_generate( lambda p, m: generate_with_ollama(p, m), prompt, preferred_mode, "Ollama", max_attempts=3, ) + scene_cache_put(prompt, preferred_mode, scene) + return scene except Exception as error: # noqa: BLE001 errors.append(f"Ollama: {error}") + + # 4. Every generator failed. A curated scene — even a loose match — beats a + # dead canvas, so prefer it over the placeholder fallback. + if lib_scene is not None and lib_score > 0: + result = _library_scene(lib_scene, prompt) + result["fallback_reason"] = ( + "The live generator couldn't produce a runnable scene, so here's the " + "closest curated STEM animation." + ) + return result + detail = " | ".join(errors) if errors else "no backend available" - # Connection / timeout / no-backend failures are unrecoverable here — let - # the frontend show a real error. But "blank scene three times" failures - # are recoverable: we return a fallback scene that at least renders - # something visible plus a clear explanation. - blank_failure = any("blank scene" in e.lower() for e in errors) - fatal_failure = any( - ("timed out" in e.lower()) - or ("timeout" in e.lower()) - or ("connection" in e.lower()) - or ("could not reach" in e.lower()) - for e in errors - ) or not blank_failure - if blank_failure and not fatal_failure: + # A backend that was REACHABLE but produced unrunnable code is "soft" — show + # the animated placeholder + an explanation rather than a hard error. + # UNREACHABLE backends (connection/timeout/auth) are "hard" — surface them. + def _is_hard(e: str) -> bool: + e = e.lower() + return any( + k in e + for k in ( + "timed out", "timeout", "connection", "could not reach", + "no content", "http ", "api key", "invalid json", "not configured", + ) + ) + + if errors and not all(_is_hard(e) for e in errors): return _fallback_scene( prompt, - "The local model produced code that didn't draw anything three " - "times in a row. Rephrase the prompt or enable Claude for stronger " - "results.", + "The generator's code kept failing to run. Rephrase the prompt, or " + "enable a stronger model (ANTHROPIC_API_KEY / OPENAI_API_KEY / " + "GEMINI_API_KEY) for more reliable results.", ) if any("timed out" in e.lower() or "timeout" in e.lower() for e in errors): hint = ( @@ -1625,6 +1969,9 @@ def get_health() -> dict: "gemini": gemini_info, "generator": generator, "access_code_required": bool(ACCESS_CODE), + # Reliability/speed layers, surfaced so the UI (and ops) can see them. + "validator": node_validator_available(), + "library_size": len(SCENE_LIBRARY), } diff --git a/scene_library.py b/scene_library.py new file mode 100644 index 0000000..85bde32 --- /dev/null +++ b/scene_library.py @@ -0,0 +1,274 @@ +"""Curated, hand-verified STEM scene library for VisualLM. + +This is the retrieval corpus behind the "instant + always correct" fast path: +a prompt that strongly matches an entry's `keywords` renders one of these +vetted scenes immediately, with no model call (see library_match in main.py). + +Each entry is a complete scene the renderer can run as-is. Every `code` here +is validated by validate_scene.js (run `python3 -m unittest tests.test_main`, +which checks the whole library). Scenes authored/verified by the +stem-scene-library workflow are appended to `_GENERATED` at the bottom. + +To add a scene by hand: follow the H API contract in sandbox-worker.js / +main.py's SCENE_SYSTEM_PROMPT, give it broad synonym-rich keywords, and keep +it animated + labeled. +""" +from __future__ import annotations + +_BASE: list[dict] = [ + { + "id": "fourier-square-wave", + "title": "Fourier series → square wave", + "tag": "Signals", + "dimension": "2D", + "equation": "f(x) = (4/pi) * sum sin((2k-1)x)/(2k-1)", + "summary": "Odd harmonics add up to approximate a square wave; more terms = sharper edges.", + "keywords": [ + "fourier", "fourier series", "square wave", "harmonics", "sine", + "sum of sines", "signal", "decomposition", "approximation", + ], + "bullets": [ + "Each term is an odd harmonic: sin(x), sin(3x)/3, sin(5x)/5, ...", + "Adding more harmonics sharpens the corners toward a true square wave.", + "The ripple near the jump never fully vanishes (Gibbs phenomenon).", + ], + "student_prompts": [ + "Why only odd harmonics?", + "What is the Gibbs phenomenon?", + "How many terms to get within 1% of a square wave?", + ], + "code": r""" +H.background(); +const v = H.plot2d({ xMin: -Math.PI, xMax: Math.PI, yMin: -1.5, yMax: 1.5, pad: 50 }); +v.grid(); v.axes(); +const N = 1 + Math.floor((1 + Math.sin(t * 0.5)) * 5); // 1..11 harmonics, breathing +const partial = (x) => { + let s = 0; + for (let k = 1; k <= N; k++) s += Math.sin((2 * k - 1) * x) / (2 * k - 1); + return (4 / Math.PI) * s; +}; +v.fn((x) => Math.sign(Math.sin(x)) || 0, { color: H.colors.sub, width: 1.5 }); +v.fn(partial, { color: H.colors.accent, width: 3 }); +H.text("Fourier series of a square wave", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("harmonics N = " + N, 24, 52, { color: H.colors.accent, size: 14 }); +H.legend([{ label: "target square", color: H.colors.sub }, { label: "partial sum", color: H.colors.accent }], H.W - 170, 28); +""".strip(), + }, + { + "id": "unit-circle-sin-cos", + "title": "Unit circle generates sine and cosine", + "tag": "Trigonometry", + "dimension": "2D", + "equation": "(cos t, sin t)", + "summary": "A point sweeping the unit circle traces cosine on x and sine on y.", + "keywords": [ + "unit circle", "sine", "cosine", "trig", "trigonometry", "angle", + "radians", "sin", "cos", "rotation", + ], + "bullets": [ + "The angle theta grows with time; the point is at (cos theta, sin theta).", + "Its height above the axis IS sin(theta); its horizontal offset is cos(theta).", + "One full revolution is 2*pi radians.", + ], + "student_prompts": [ + "Why is the radius always 1?", + "How do radians relate to degrees?", + "Where is cosine negative on the circle?", + ], + "code": r""" +H.background(); +const cx = H.W * 0.28, cy = H.H * 0.52, R = Math.min(H.W, H.H) * 0.28; +const th = t * 0.8; +H.circle(cx, cy, R, { stroke: H.colors.grid, width: 1.5 }); +H.line(cx - R - 10, cy, cx + R + 10, cy, { color: H.colors.axis, width: 1 }); +H.line(cx, cy - R - 10, cx, cy + R + 10, { color: H.colors.axis, width: 1 }); +const px = cx + R * Math.cos(th), py = cy - R * Math.sin(th); +H.line(cx, cy, px, py, { color: H.colors.accent2, width: 2 }); +H.line(px, py, px, cy, { color: H.colors.good, width: 2, dash: [4, 4] }); +H.line(px, py, cx, py, { color: H.colors.accent, width: 2, dash: [4, 4] }); +H.circle(px, py, 6, { fill: H.colors.warn }); +const v = H.plot2d({ xMin: 0, xMax: 12, yMin: -1.3, yMax: 1.3, box: { x: H.W * 0.56, y: 70, w: H.W * 0.4, h: H.H - 150 } }); +v.grid(); v.axes(); +v.fn((x) => Math.sin(x), { color: H.colors.good, width: 2.5 }); +v.fn((x) => Math.cos(x), { color: H.colors.accent, width: 2.5 }); +v.dot(th % 12, Math.sin(th)); +H.text("Unit circle → sine & cosine", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("theta = " + (th % (2 * Math.PI)).toFixed(2) + " rad", 24, 52, { color: H.colors.sub, size: 14 }); +H.legend([{ label: "sin", color: H.colors.good }, { label: "cos", color: H.colors.accent }], H.W * 0.56, 50); +""".strip(), + }, + { + "id": "projectile-motion", + "title": "Projectile motion", + "tag": "Mechanics", + "dimension": "2D", + "equation": "x = v0 cos(a) t, y = v0 sin(a) t - g t^2 / 2", + "summary": "A launched projectile follows a parabola; horizontal speed is constant, vertical speed changes under gravity.", + "keywords": [ + "projectile", "projectile motion", "parabola", "trajectory", "launch", + "gravity", "kinematics", "ballistic", "range", "velocity", + ], + "bullets": [ + "Horizontal velocity stays constant; only gravity acts vertically.", + "The path is a parabola; peak height is where vertical velocity is zero.", + "Range is maximized at a 45 degree launch angle (no air resistance).", + ], + "student_prompts": [ + "Why is 45 degrees the optimal angle?", + "How long is the projectile in the air?", + "What is the speed at the peak?", + ], + "code": r""" +H.background(); +const g = 9.8, v0 = 22, ang = 58 * Math.PI / 180; +const flight = 2 * v0 * Math.sin(ang) / g; +const tau = (t % (flight + 1)); +const xr = v0 * Math.cos(ang) * tau; +const yr = Math.max(0, v0 * Math.sin(ang) * tau - 0.5 * g * tau * tau); +const v = H.plot2d({ xMin: 0, xMax: 60, yMin: 0, yMax: 26, pad: 50 }); +v.grid(); v.axes(); +const path = []; +for (let i = 0; i <= 60; i++) { + const tt = i / 60 * flight; + path.push([v0 * Math.cos(ang) * tt, v0 * Math.sin(ang) * tt - 0.5 * g * tt * tt]); +} +v.path(path, { color: H.colors.sub, width: 1.5, dash: [6, 5] }); +const vx = v0 * Math.cos(ang), vy = v0 * Math.sin(ang) - g * tau; +v.arrow(xr, yr, xr + vx * 0.25, yr + vy * 0.25, { color: H.colors.good, width: 2 }); +v.dot(xr, yr, { r: 7, fill: H.colors.warn }); +H.text("Projectile: 22 m/s at 58 deg", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("x = " + xr.toFixed(1) + " m y = " + yr.toFixed(1) + " m", 24, 52, { color: H.colors.sub, size: 14 }); +""".strip(), + }, + { + "id": "derivative-tangent", + "title": "Derivative as a moving tangent line", + "tag": "Calculus", + "dimension": "2D", + "equation": "f'(a) = slope of the tangent at x = a", + "summary": "The derivative at a point is the slope of the tangent line there; watch it sweep along the curve.", + "keywords": [ + "derivative", "tangent", "tangent line", "slope", "calculus", + "differentiation", "rate of change", "instantaneous", + ], + "bullets": [ + "The tangent line touches the curve at one point and matches its slope.", + "Slope = f'(a); it changes as the point of tangency moves.", + "Where the curve is flat the derivative is zero.", + ], + "student_prompts": [ + "What does a negative slope mean here?", + "Where is the derivative zero?", + "How is this the limit of a secant line?", + ], + "code": r""" +H.background(); +const v = H.plot2d({ xMin: -3.2, xMax: 3.2, yMin: -3, yMax: 5, pad: 50 }); +v.grid(); v.axes(); +const f = (x) => 0.15 * x * x * x - x; +const df = (x) => 0.45 * x * x - 1; +v.fn(f, { color: H.colors.accent, width: 3 }); +const a = 2.6 * Math.sin(t * 0.6); +const slope = df(a); +const tx = (x) => f(a) + slope * (x - a); +v.line(-3.2, tx(-3.2), 3.2, tx(3.2), { color: H.colors.accent2, width: 2.4, dash: [7, 6] }); +v.dot(a, f(a), { r: 7 }); +H.text("Derivative = slope of the tangent", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("a = " + a.toFixed(2) + " f'(a) = " + slope.toFixed(2), 24, 52, { color: H.colors.sub, size: 14 }); +""".strip(), + }, + { + "id": "gradient-descent-3d", + "title": "Gradient descent on a loss surface", + "tag": "Machine Learning", + "dimension": "3D", + "equation": "x <- x - lr * grad f(x)", + "summary": "A ball rolls downhill along the negative gradient of a 3D loss surface toward the minimum.", + "keywords": [ + "gradient descent", "loss surface", "optimization", "machine learning", + "minimum", "gradient", "training", "cost function", "3d surface", + ], + "bullets": [ + "The surface height is the loss; lower is better.", + "Each step moves opposite the gradient — the steepest downhill direction.", + "The learning rate sets the step size; too big overshoots, too small crawls.", + ], + "student_prompts": [ + "What happens if the learning rate is too large?", + "How do local minima trap gradient descent?", + "What is the gradient, intuitively?", + ], + "code": r""" +H.background(); +const cam = H.cam3d({ scale: 70, dist: 16, pitch: -0.5, cy: H.H * 0.56 }); +cam.yaw = 0.3 * t; +const loss = (x, y) => 0.6 * (x * x + y * y) - 1.1 * Math.exp(-((x - 1) ** 2 + (y - 1) ** 2)); +cam.grid(3, 1); +H.surface3d(cam, loss, { xMin: -2.6, xMax: 2.6, yMin: -2.6, yMax: 2.6, nx: 34, ny: 34, alpha: 0.9 }); +// A descending point, re-released every few seconds from a corner. +let px = 2.2, py = 2.2; +const steps = Math.floor((t % 6) * 14); +for (let i = 0; i < steps; i++) { + const gx = 1.2 * px + 1.1 * 2 * (px - 1) * Math.exp(-((px - 1) ** 2 + (py - 1) ** 2)); + const gy = 1.2 * py + 1.1 * 2 * (py - 1) * Math.exp(-((px - 1) ** 2 + (py - 1) ** 2)); + px -= 0.06 * gx; py -= 0.06 * gy; +} +cam.sphere([px, loss(px, py) + 0.15, py], 0.18, { color: H.colors.warn }); +cam.axes(3); +H.text("Gradient descent on a loss surface", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("loss = " + loss(px, py).toFixed(3), 24, 52, { color: H.colors.sub, size: 14 }); +""".strip(), + }, + { + "id": "dna-double-helix", + "title": "DNA double helix", + "tag": "Biology", + "dimension": "3D", + "equation": "two antiparallel strands + base pairs", + "summary": "Two sugar-phosphate backbones twist around a common axis, joined by base pairs.", + "keywords": [ + "dna", "double helix", "helix", "genetics", "base pairs", "nucleotide", + "molecular biology", "strands", "chromosome", + ], + "bullets": [ + "Two strands run in opposite (antiparallel) directions.", + "Base pairs (the rungs) hold the strands together.", + "The whole structure twists into a right-handed double helix.", + ], + "student_prompts": [ + "Which bases pair with which?", + "What does antiparallel mean?", + "How is DNA copied?", + ], + "code": r""" +H.background(); +const cam = H.cam3d({ scale: 34, dist: 18, pitch: -0.2 }); +cam.yaw = 0.5 * t; +const balls = []; +const N = 34; +for (let i = 0; i < N; i++) { + const s = i / (N - 1); + const ang = s * H.TAU * 2.1 + t * 0.3; + const ypos = (s - 0.5) * 9; + const a = [2.1 * Math.cos(ang), ypos, 2.1 * Math.sin(ang)]; + const b = [-2.1 * Math.cos(ang), ypos, -2.1 * Math.sin(ang)]; + balls.push({ p: a, color: H.colors.accent, r: 0.32 }); + balls.push({ p: b, color: H.colors.accent2, r: 0.32 }); + if (i % 3 === 0) cam.line(a, b, { color: H.colors.sub, width: 1.6 }); +} +balls + .map((o) => ({ ...o, depth: cam.project(o.p).depth })) + .sort((a, b) => b.depth - a.depth) + .forEach((o) => cam.sphere(o.p, o.r, { color: o.color })); +H.text("DNA double helix", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("two antiparallel strands joined by base pairs", 24, 52, { color: H.colors.sub, size: 13 }); +""".strip(), + }, +] + +# Scenes authored + adversarially verified by the stem-scene-library workflow +# are appended here. Kept separate from _BASE so the hand-verified baseline is +# always present even if the generated batch is regenerated. +_GENERATED: list[dict] = [] + +SCENE_LIBRARY: list[dict] = _BASE + _GENERATED diff --git a/tests/test_main.py b/tests/test_main.py index 3649279..50e446b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -375,5 +375,172 @@ def boom(prompt, mode): self.assertIn("error", body) +class AutofixTests(unittest.TestCase): + def test_prefixes_bare_math(self): + out = main.autofix_code("r = sqrt(x*x) + sin(t) + abs(v);") + self.assertIn("Math.sqrt(", out) + self.assertIn("Math.sin(", out) + self.assertIn("Math.abs(", out) + + def test_bare_constants(self): + self.assertIn("Math.PI", main.autofix_code("a = 2 * PI;")) + self.assertIn("H.TAU", main.autofix_code("a = TAU;")) + + def test_does_not_touch_qualified_or_user_names(self): + out = main.autofix_code("Math.sin(x); obj.max(1,2); mySin(3); api(1);") + self.assertEqual(out, "Math.sin(x); obj.max(1,2); mySin(3); api(1);") + + def test_skips_strings_and_comments(self): + out = main.autofix_code('H.text("use sin(x) here"); // call cos(y)\nr = sin(z);') + self.assertIn('"use sin(x) here"', out) # string untouched + self.assertIn("// call cos(y)", out) # comment untouched + self.assertIn("Math.sin(z)", out) # real call fixed + + def test_idempotent(self): + once = main.autofix_code("r = sqrt(2);") + self.assertEqual(once, main.autofix_code(once)) + + def test_sanitize_runs_autofix(self): + self.assertIn("Math.sin", main.sanitize_code("H.background(); const y = sin(t);")) + + +@unittest.skipUnless(main.node_validator_available(), "node validator not installed") +class HeadlessValidatorTests(unittest.TestCase): + def test_valid_scene_passes(self): + r = main.headless_validate( + 'H.background(); const v = H.plot2d({}); v.grid(); v.axes();' + ' v.fn(x => Math.sin(x + t)); H.text("t=" + t, 24, 30, {});' + ) + self.assertTrue(r["ok"]) + self.assertTrue(r["painted"]) + self.assertTrue(r["text"]) + + def test_runtime_throw_caught(self): + r = main.headless_validate("H.background(); const a = nope.bar; H.text('x',1,2,{});") + self.assertFalse(r["ok"]) + self.assertIn("nope", r["error"]) + + def test_blank_detected(self): + r = main.headless_validate("const a = 1; const b = a * 2;") + self.assertTrue(r["ok"]) + self.assertFalse(r["painted"]) + + def test_infinite_loop_is_killed(self): + r = main.headless_validate("H.background(); while (true) {}") + self.assertFalse(r["ok"]) + self.assertIn("hung", r["error"].lower()) + + def test_const_v_shadow_does_not_collide(self): + # The whole reason H/cam/view/v are globals, not params. + r = main.headless_validate( + 'H.background(); const v = H.plot2d({}); v.grid(); v.axes(); v.fn(x=>x); H.text("x",1,2,{});' + ) + self.assertTrue(r["ok"]) + + def test_host_escape_is_contained(self): + # process must be unreachable inside the sandbox. + r = main.headless_validate( + 'H.background(); const p = ({}).constructor.constructor("return typeof process")();' + ' H.text(""+p, 1, 2, {}); H.line(0,0,1,1,{}); H.circle(1,1,1,{});' + ) + self.assertTrue(r["ok"]) # didn't crash, and couldn't reach process + + +class EvaluateSceneTests(unittest.TestCase): + """evaluate_scene with the validator forced on/off via monkeypatch, so the + behaviour is deterministic regardless of whether node is installed.""" + + def setUp(self): + self._orig = main.headless_validate + + def tearDown(self): + main.headless_validate = self._orig + + def test_fatal_on_runtime_error(self): + main.headless_validate = lambda code, timeout=6.0: {"ok": False, "error": "boom", "painted": False, "text": False} + ev = main.evaluate_scene("whatever") + self.assertTrue(ev["fatal"]) + self.assertIn("boom", ev["error"]) + + def test_fatal_on_blank(self): + main.headless_validate = lambda code, timeout=6.0: {"ok": True, "error": None, "painted": False, "text": False} + ev = main.evaluate_scene("H.clear();") + self.assertTrue(ev["fatal"]) + + def test_static_and_unlabeled(self): + main.headless_validate = lambda code, timeout=6.0: {"ok": True, "error": None, "painted": True, "text": False} + ev = main.evaluate_scene("H.background(); H.line(0,0,1,1,{});") # no t, no text + self.assertFalse(ev["fatal"]) + self.assertEqual(set(ev["problems"]), {"static", "unlabeled"}) + + def test_clean_scene(self): + main.headless_validate = lambda code, timeout=6.0: {"ok": True, "error": None, "painted": True, "text": True} + ev = main.evaluate_scene('H.background(); H.text("t="+t,1,2,{});') + self.assertFalse(ev["fatal"]) + self.assertEqual(ev["problems"], []) + + def test_falls_back_to_static_gate_without_node(self): + main.headless_validate = lambda code, timeout=6.0: None + self.assertTrue(main.evaluate_scene("const a = 1;")["fatal"]) # blank → fatal + self.assertEqual(main.evaluate_scene('H.background(); H.text("t="+t,1,2); H.line(0,0,1,1);')["problems"], []) + + +class SceneCacheTests(unittest.TestCase): + def setUp(self): + with main._scene_cache_lock: + main._scene_cache.clear() + + def tearDown(self): + with main._scene_cache_lock: + main._scene_cache.clear() + + def test_roundtrip_and_normalization(self): + scene = {"title": "T", "code": "H.background();"} + main.scene_cache_put("Show A Wave", "auto", scene) + # case/whitespace-insensitive key + hit = main.scene_cache_get(" show a wave ", "auto") + self.assertIsNotNone(hit) + self.assertEqual(hit["title"], "T") + + def test_does_not_cache_imperfect_or_fallback(self): + main.scene_cache_put("p", "auto", {"title": "x", "is_fallback": True}) + main.scene_cache_put("q", "auto", {"title": "y", "quality_warnings": ["static"]}) + self.assertIsNone(main.scene_cache_get("p", "auto")) + self.assertIsNone(main.scene_cache_get("q", "auto")) + + def test_mode_is_part_of_key(self): + main.scene_cache_put("orbit", "2d", {"title": "flat"}) + self.assertIsNone(main.scene_cache_get("orbit", "3d")) + + +class LibraryMatchTests(unittest.TestCase): + def test_strong_match(self): + sc, score = main.library_match("show a fourier series building a square wave", "auto") + self.assertIsNotNone(sc) + self.assertEqual(sc["id"], "fourier-square-wave") + self.assertGreaterEqual(score, 3.0) + + def test_no_match_for_unrelated(self): + sc, score = main.library_match("my favorite pasta recipe", "auto") + self.assertEqual(score, 0.0) + self.assertIsNone(sc) + + def test_mode_mismatch_penalized(self): + # dna-double-helix is 3D; forcing 2D should drop its score. + _, s3 = main.library_match("dna double helix", "3d") + _, s2 = main.library_match("dna double helix", "2d") + self.assertGreater(s3, s2) + + def test_every_library_scene_is_runnable(self): + if not main.node_validator_available(): + self.skipTest("node validator not installed") + for sc in main.SCENE_LIBRARY: + r = main.headless_validate(sc["code"]) + self.assertIsNotNone(r, sc["id"]) + self.assertTrue(r["ok"], f"{sc['id']} threw: {r.get('error')}") + self.assertTrue(r["painted"], f"{sc['id']} drew nothing") + self.assertTrue(r["text"], f"{sc['id']} had no labels") + + if __name__ == "__main__": unittest.main() diff --git a/validate_scene.js b/validate_scene.js new file mode 100644 index 0000000..07e2192 --- /dev/null +++ b/validate_scene.js @@ -0,0 +1,191 @@ +#!/usr/bin/env node +/* + * Headless validator for VisualLM scene code. + * + * Reads a scene body (the inside of `function scene(ctx, t) { ... }`) on stdin + * and runs it against a BEHAVIOR-FAITHFUL mock of the worker's `H` helper + * library + a counting 2D context, at several values of `t`. Reports JSON on + * stdout: + * ok — did the body run without throwing at every sampled frame? + * error — first throw's message (null if ok) + * painted — did it issue any content draw call (background/clear excluded)? + * text — did it draw any text (title/labels/readouts)? + * paint — content draw-call count + * + * This lets the Python server validate (and drive repairs) WITHOUT a browser + * round-trip — the slow part of the old loop. + * + * SECURITY: the scene is AI-generated, hence untrusted. It runs in a FRESH, + * EMPTY V8 context via `vm` — no `process`, `require`, `console`, timers, or + * dynamic `import` reach it, and NO host object is passed in (the classic + * `obj.constructor.constructor("return process")()` escape needs a host object + * on the prototype chain; we hand in nothing and read results back only as a + * primitive JSON string). A hard `timeout` kills infinite loops. + * + * The mock mirrors the helper API SURFACE (return shapes + chaining), not the + * pixel internals: legitimate scenes never false-fail, and genuine + * SyntaxError/ReferenceError/TypeError throws are caught exactly as the browser + * sandbox would catch them. Keep the method list in sync with + * sandbox-worker.js's makeHelpers() when helpers are added. + */ +"use strict"; + +const vm = require("vm"); + +// The mock helper library, as source evaluated INSIDE the sandbox context so +// every object's prototype chain stays inside the sandbox (no host leak). No +// backticks in here — this whole thing is a template literal. `var`-declared +// names (H/ctx/cam/view/v/paint/text) persist on the context global. +const SANDBOX_SRC = ` +var paint = 0, text = 0; +var console = { log: function(){}, warn: function(){}, error: function(){}, info: function(){} }; +var W = 900, H_ = 560, TAU = Math.PI * 2; +var COLORS = { + bg:"#0e1525", panel:"#16203a", ink:"#eef2ff", sub:"#9fb0d4", grid:"#26314f", + axis:"#566087", accent:"#7cc4ff", accent2:"#f4a259", good:"#67e8b0", + warn:"#ff8aa0", violet:"#c4a7ff", yellow:"#ffe08a" +}; +var PALETTE = ["#7cc4ff","#f4a259","#67e8b0","#c4a7ff","#ff8aa0","#ffe08a","#5eead4","#fca5f1"]; +function clamp(x,lo,hi){ return xhi?hi:x; } +function lerp(a,b,t){ return a+(b-a)*t; } +function map(x,a,b,c,d){ return b===a?c:c+((x-a)*(d-c))/(b-a); } +function ease(t){ t=clamp(t,0,1); return t*t*(3-2*t); } +function wrap(obj){ + return new Proxy(obj, { get: function(t,k){ if(k in t) return t[k]; return function(){ return undefined; }; } }); +} +function makeCtx(){ + var grad = { addColorStop: function(){} }; + var PAINT = { fill:1, stroke:1, fillRect:1, strokeRect:1, fillText:1, strokeText:1, drawImage:1, putImageData:1 }; + var TEXTOP = { fillText:1, strokeText:1 }; + var target = { + canvas: { width: W, height: H_ }, + createLinearGradient: function(){ return grad; }, + createRadialGradient: function(){ return grad; }, + createPattern: function(){ return null; }, + measureText: function(){ return { width: 8 }; }, + getImageData: function(){ return { data: [], width: 0, height: 0 }; }, + setLineDash: function(){}, getLineDash: function(){ return []; } + }; + return new Proxy(target, { + get: function(t,k){ + if(k in t) return t[k]; + if(typeof k !== "string") return undefined; + return function(){ if(PAINT[k]) paint++; if(TEXTOP[k]) text++; return undefined; }; + }, + set: function(){ return true; } + }); +} +function makeH(){ + function draw(){ paint++; } + var H = { + TAU: TAU, PI: Math.PI, colors: COLORS, palette: PALETTE, + clamp: clamp, lerp: lerp, map: map, ease: ease, W: W, H: H_, + clear: function(){}, background: function(){}, + text: function(){ text++; paint++; }, + line: function(){ draw(); }, + path: function(p){ if(p && p.length>=2) draw(); }, + circle: function(){ draw(); }, + rect: function(){ draw(); }, + arrow: function(){ draw(); }, + legend: function(items){ (items||[]).forEach(function(){ text++; }); paint++; }, + color: function(i){ return PALETTE[((i%PALETTE.length)+PALETTE.length)%PALETTE.length]; }, + hsl: function(h,s,l,a){ return "hsla("+h+","+s+"%,"+l+"%,"+(a==null?1:a)+")"; }, + plot2d: function(o){ + o=o||{}; + var xMin=o.xMin==null?-10:o.xMin, xMax=o.xMax==null?10:o.xMax; + var yMin=o.yMin==null?-6:o.yMin, yMax=o.yMax==null?6:o.yMax; + var pad=o.pad==null?46:o.pad; + var box=o.box||{ x:pad, y:pad*0.6, w:W-pad*2, h:H_-pad*1.6 }; + var X=function(v){ return box.x+map(v,xMin,xMax,0,box.w); }; + var Y=function(v){ return box.y+map(v,yMin,yMax,box.h,0); }; + var view={ + box:box, xMin:xMin, xMax:xMax, yMin:yMin, yMax:yMax, X:X, Y:Y, + grid:function(){ draw(); return view; }, + axes:function(){ draw(); text++; return view; }, + fn:function(f){ for(var i=0;i<=12;i++){ try{ f(lerp(xMin,xMax,i/12)); }catch(e){} } draw(); return view; }, + dot:function(){ draw(); return view; }, + line:function(){ draw(); return view; }, + arrow:function(){ draw(); return view; }, + text:function(){ text++; paint++; return view; }, + circle:function(){ draw(); return view; }, + path:function(p){ if(p && p.length) draw(); return view; }, + rect:function(){ draw(); return view; } + }; + return wrap(view); + }, + cam3d: function(o){ + o=o||{}; + var yaw=o.yaw||0, pitch=o.pitch==null?-0.5:o.pitch; + var scale=o.scale||60, dist=o.dist||9; + var cx=o.cx==null?W/2:o.cx, cy=o.cy==null?H_/2:o.cy; + var cam={ + get yaw(){ return yaw; }, set yaw(v){ yaw=v; }, + get pitch(){ return pitch; }, set pitch(v){ pitch=v; }, + project:function(p){ var x=(p&&p[0])||0,y=(p&&p[1])||0,z=(p&&p[2])||0; var f=dist/(dist+z); return { x:cx+x*scale*f, y:cy-y*scale*f, depth:z, f:f }; }, + line:function(){ draw(); return cam; }, + path:function(p){ if(p && p.length>=2) draw(); return cam; }, + poly:function(p){ if(p && p.length>=3) draw(); return cam; }, + sphere:function(p){ draw(); return cam.project(p); }, + grid:function(){ draw(); return cam; }, + axes:function(){ draw(); text++; return cam; } + }; + return wrap(cam); + }, + surface3d: function(cam,f){ if(typeof f==="function"){ for(var i=0;i<6;i++) for(var j=0;j<6;j++){ try{ f(i-3,j-3); }catch(e){} } } draw(); }, + mesh3d: function(cam,fn){ if(typeof fn==="function"){ for(var i=0;i<6;i++) for(var j=0;j<6;j++){ try{ fn((i/6)*TAU,(j/6)*TAU); }catch(e){} } } draw(); } + }; + return wrap(H); +} +var H = makeH(); +var ctx = makeCtx(); +var cam = H.cam3d({}); +var view = H.plot2d({}); +var v = view; +`; + +function readStdin() { + return new Promise((resolve) => { + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (c) => (data += c)); + process.stdin.on("end", () => resolve(data)); + }); +} + +(async () => { + const code = await readStdin(); + let result = { ok: false, error: null, painted: false, text: false, paint: 0 }; + + // The runner: define the scene as the body of a function (so a scene's own + // `const v = ...` shadows the global v instead of colliding with a + // parameter), call it at several frames, and RETURN a JSON string. Building + // it with string concatenation means the scene's own backticks/quotes need + // no escaping. A `};` injection only lands the attacker back in this same + // empty sandbox — no escalation. + const runner = + SANDBOX_SRC + + "\n;(function(){var __ok=false,__err=null;try{var __s=function(ctx,t){\n" + + code + + "\n};var __ts=[0,0.4,1.3,3.0];for(var __i=0;__i<__ts.length;__i++){__s(ctx,__ts[__i]);}__ok=true;}" + + "catch(e){__ok=false;__err=(e&&e.message)?String(e.message):String(e);}" + + "return JSON.stringify({ok:__ok,error:__err,paint:paint,text:text});})()"; + + try { + const context = vm.createContext(Object.create(null)); + const out = vm.runInContext(runner, context, { timeout: 2000 }); + const parsed = JSON.parse(out); + result.ok = !!parsed.ok; + result.error = parsed.error || null; + result.paint = parsed.paint || 0; + result.painted = (parsed.paint || 0) > 0; + result.text = (parsed.text || 0) > 0; + } catch (err) { + const msg = err && err.message ? String(err.message) : String(err); + if (/timed out|execution timed/i.test(msg)) { + result.error = "The scene hung (possible infinite loop)."; + } else { + result.error = msg; // SyntaxError etc. — compile failed before running + } + } + process.stdout.write(JSON.stringify(result)); +})(); From 7bdf7cba89bdc6da14e8d77d2961f7d2b1fcf346 Mon Sep 17 00:00:00 2001 From: Maxpeng59 Date: Sun, 14 Jun 2026 21:51:39 +0700 Subject: [PATCH 04/17] Validator catches off-screen draws (data-vs-pixel coordinate mixups) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weak local model's most common SEMANTIC failure isn't a crash — it's drawing real content in the wrong coordinate space (e.g. v.line(v.X(x), ...), which double-maps every point off the canvas). It runs, it "paints", it has labels, so the old checks passed it — but the screen shows only axes. The validator now tracks, per content draw, whether it lands on-canvas (data- space view/cam methods convert via X/Y or project() first, exactly like the real helpers). paint>0 with zero on-screen => onscreen:false, which evaluate_scene treats as fatal and repairs with a coordinate-space hint. 'paint' now counts CONTENT draws only (grid/axes/background are scaffold), so an axes-only "scene" is correctly seen as blank. All 6 library scenes pass; 72 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.py | 12 ++++++++ tests/test_main.py | 27 ++++++++++++++++- validate_scene.js | 75 +++++++++++++++++++++++++++------------------- 3 files changed, 83 insertions(+), 31 deletions(-) diff --git a/main.py b/main.py index a84230b..efc6447 100644 --- a/main.py +++ b/main.py @@ -1385,6 +1385,18 @@ def evaluate_scene(code: str) -> dict: ), "problems": [], } + if val.get("onscreen") is False: + return { + "fatal": True, + "error": ( + "Everything was drawn OFF-SCREEN — you mixed coordinate spaces. " + "H.line/H.circle/H.text take PIXEL coords (0..H.W, 0..H.H). The " + "plot2d view's methods (v.line/v.text/v.dot/v.path/v.circle) take " + "DATA coords and map them for you — do NOT wrap their arguments in " + "v.X()/v.Y(). Pick one space per call and keep points on-canvas." + ), + "problems": [], + } problems = [] if not code_is_animated(code): problems.append("static") diff --git a/tests/test_main.py b/tests/test_main.py index 50e446b..794df00 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -437,6 +437,25 @@ def test_const_v_shadow_does_not_collide(self): ) self.assertTrue(r["ok"]) + def test_offscreen_draws_detected(self): + # The classic data-vs-pixel mixup: wrapping data-space v.line args in + # v.X()/v.Y() double-maps them off the canvas. Runs fine, paints, but + # nothing is visible. + r = main.headless_validate( + "const v = H.plot2d({xMin:-5,xMax:5,yMin:-5,yMax:5}); v.grid(); v.axes();" + " v.line(v.X(0), v.Y(0), v.X(3), v.Y(3), {}); v.text('t', 24, 30, {});" + ) + self.assertTrue(r["ok"]) + self.assertTrue(r["painted"]) + self.assertFalse(r["onscreen"]) + + def test_onscreen_content_passes(self): + r = main.headless_validate( + "const v = H.plot2d({xMin:-5,xMax:5,yMin:-5,yMax:5}); v.grid(); v.axes();" + " v.line(0, 0, 3, 3, {}); H.text('t', 24, 30, {});" + ) + self.assertTrue(r["onscreen"]) + def test_host_escape_is_contained(self): # process must be unreachable inside the sandbox. r = main.headless_validate( @@ -474,11 +493,17 @@ def test_static_and_unlabeled(self): self.assertEqual(set(ev["problems"]), {"static", "unlabeled"}) def test_clean_scene(self): - main.headless_validate = lambda code, timeout=6.0: {"ok": True, "error": None, "painted": True, "text": True} + main.headless_validate = lambda code, timeout=6.0: {"ok": True, "error": None, "painted": True, "text": True, "onscreen": True} ev = main.evaluate_scene('H.background(); H.text("t="+t,1,2,{});') self.assertFalse(ev["fatal"]) self.assertEqual(ev["problems"], []) + def test_fatal_on_offscreen(self): + main.headless_validate = lambda code, timeout=6.0: {"ok": True, "error": None, "painted": True, "text": True, "onscreen": False} + ev = main.evaluate_scene("v.line(v.X(0),v.Y(0),v.X(3),v.Y(3),{});") + self.assertTrue(ev["fatal"]) + self.assertIn("OFF-SCREEN", ev["error"]) + def test_falls_back_to_static_gate_without_node(self): main.headless_validate = lambda code, timeout=6.0: None self.assertTrue(main.evaluate_scene("const a = 1;")["fatal"]) # blank → fatal diff --git a/validate_scene.js b/validate_scene.js index 07e2192..c46a3be 100644 --- a/validate_scene.js +++ b/validate_scene.js @@ -37,9 +37,17 @@ const vm = require("vm"); // backticks in here — this whole thing is a template literal. `var`-declared // names (H/ctx/cam/view/v/paint/text) persist on the context global. const SANDBOX_SRC = ` -var paint = 0, text = 0; +var paint = 0, text = 0, conscr = 0; var console = { log: function(){}, warn: function(){}, error: function(){}, info: function(){} }; var W = 900, H_ = 560, TAU = Math.PI * 2; +// On-screen test (generous margin). A draw whose points all land far outside +// the canvas is invisible — the tell-tale of mixing data and pixel coords +// (e.g. v.line(v.X(x), ...) double-transforms). 'paint' counts CONTENT draws +// (not background/grid/axes scaffold); 'conscr' counts those that land +// on-screen. paint>0 with conscr===0 means "everything was drawn off-screen". +function inb(x, y){ return typeof x === "number" && typeof y === "number" && isFinite(x) && isFinite(y) && x >= -120 && x <= W + 120 && y >= -120 && y <= H_ + 120; } +function onAny(pairs){ for (var i = 0; i < pairs.length; i++) if (inb(pairs[i][0], pairs[i][1])) return true; return false; } +function content(on){ paint++; if (on) conscr++; } var COLORS = { bg:"#0e1525", panel:"#16203a", ink:"#eef2ff", sub:"#9fb0d4", grid:"#26314f", axis:"#566087", accent:"#7cc4ff", accent2:"#f4a259", good:"#67e8b0", @@ -76,18 +84,17 @@ function makeCtx(){ }); } function makeH(){ - function draw(){ paint++; } var H = { TAU: TAU, PI: Math.PI, colors: COLORS, palette: PALETTE, clamp: clamp, lerp: lerp, map: map, ease: ease, W: W, H: H_, clear: function(){}, background: function(){}, - text: function(){ text++; paint++; }, - line: function(){ draw(); }, - path: function(p){ if(p && p.length>=2) draw(); }, - circle: function(){ draw(); }, - rect: function(){ draw(); }, - arrow: function(){ draw(); }, - legend: function(items){ (items||[]).forEach(function(){ text++; }); paint++; }, + text: function(s,x,y){ text++; content(inb(x,y)); }, + line: function(x1,y1,x2,y2){ content(onAny([[x1,y1],[x2,y2]])); }, + path: function(p){ if(p && p.length>=2) content(onAny(p)); }, + circle: function(x,y){ content(inb(x,y)); }, + rect: function(x,y,w,h){ content(onAny([[x,y],[x+w,y+h]])); }, + arrow: function(x1,y1,x2,y2){ content(onAny([[x1,y1],[x2,y2]])); }, + legend: function(items){ (items||[]).forEach(function(){ text++; }); content(true); }, color: function(i){ return PALETTE[((i%PALETTE.length)+PALETTE.length)%PALETTE.length]; }, hsl: function(h,s,l,a){ return "hsla("+h+","+s+"%,"+l+"%,"+(a==null?1:a)+")"; }, plot2d: function(o){ @@ -98,18 +105,22 @@ function makeH(){ var box=o.box||{ x:pad, y:pad*0.6, w:W-pad*2, h:H_-pad*1.6 }; var X=function(v){ return box.x+map(v,xMin,xMax,0,box.w); }; var Y=function(v){ return box.y+map(v,yMin,yMax,box.h,0); }; + // grid/axes are scaffold (don't count as content); fn maps internally so + // it's reliably on-screen. The data-space methods convert via X/Y then + // check bounds, so a scene that wraps args in v.X()/v.Y() (double map) + // shows up as off-screen content. var view={ box:box, xMin:xMin, xMax:xMax, yMin:yMin, yMax:yMax, X:X, Y:Y, - grid:function(){ draw(); return view; }, - axes:function(){ draw(); text++; return view; }, - fn:function(f){ for(var i=0;i<=12;i++){ try{ f(lerp(xMin,xMax,i/12)); }catch(e){} } draw(); return view; }, - dot:function(){ draw(); return view; }, - line:function(){ draw(); return view; }, - arrow:function(){ draw(); return view; }, - text:function(){ text++; paint++; return view; }, - circle:function(){ draw(); return view; }, - path:function(p){ if(p && p.length) draw(); return view; }, - rect:function(){ draw(); return view; } + grid:function(){ return view; }, + axes:function(){ text++; return view; }, + fn:function(f){ for(var i=0;i<=12;i++){ try{ f(lerp(xMin,xMax,i/12)); }catch(e){} } content(true); return view; }, + dot:function(x,y){ content(inb(X(x),Y(y))); return view; }, + line:function(x1,y1,x2,y2){ content(onAny([[X(x1),Y(y1)],[X(x2),Y(y2)]])); return view; }, + arrow:function(x1,y1,x2,y2){ content(onAny([[X(x1),Y(y1)],[X(x2),Y(y2)]])); return view; }, + text:function(s,x,y){ text++; content(inb(X(x),Y(y))); return view; }, + circle:function(x,y){ content(inb(X(x),Y(y))); return view; }, + path:function(p){ if(p && p.length){ var q=[]; for(var i=0;i=2) draw(); return cam; }, - poly:function(p){ if(p && p.length>=3) draw(); return cam; }, - sphere:function(p){ draw(); return cam.project(p); }, - grid:function(){ draw(); return cam; }, - axes:function(){ draw(); text++; return cam; } + project:proj, + line:function(a,b){ var p1=proj(a),p2=proj(b); content(onAny([[p1.x,p1.y],[p2.x,p2.y]])); return cam; }, + path:function(p){ if(p && p.length>=2){ var q=[]; for(var i=0;i=3){ var q=[]; for(var i=0;i { const code = await readStdin(); - let result = { ok: false, error: null, painted: false, text: false, paint: 0 }; + let result = { ok: false, error: null, painted: false, text: false, paint: 0, onscreen: true }; // The runner: define the scene as the body of a function (so a scene's own // `const v = ...` shadows the global v instead of colliding with a @@ -168,7 +180,7 @@ function readStdin() { code + "\n};var __ts=[0,0.4,1.3,3.0];for(var __i=0;__i<__ts.length;__i++){__s(ctx,__ts[__i]);}__ok=true;}" + "catch(e){__ok=false;__err=(e&&e.message)?String(e.message):String(e);}" + - "return JSON.stringify({ok:__ok,error:__err,paint:paint,text:text});})()"; + "return JSON.stringify({ok:__ok,error:__err,paint:paint,text:text,conscr:conscr});})()"; try { const context = vm.createContext(Object.create(null)); @@ -179,6 +191,9 @@ function readStdin() { result.paint = parsed.paint || 0; result.painted = (parsed.paint || 0) > 0; result.text = (parsed.text || 0) > 0; + // Content was drawn, but none of it landed on the canvas → the scene is + // effectively blank (almost always a data-vs-pixel coordinate mixup). + result.onscreen = !((parsed.paint || 0) > 0 && (parsed.conscr || 0) === 0); } catch (err) { const msg = err && err.message ? String(err.message) : String(err); if (/timed out|execution timed/i.test(msg)) { From 6cd20c7939b44f5e786905c63a3e2b2ca6566035 Mon Sep 17 00:00:00 2001 From: Maxpeng59 Date: Sun, 14 Jun 2026 22:07:15 +0700 Subject: [PATCH 05/17] Integrate 50-scene verified STEM library + smarter retrieval - scene_library_generated.json: 42 scenes authored by the stem-scene-library multi-agent workflow (one author per domain + adversarial per-scene review), every one re-validated through validate_scene.js (runs, paints on-screen, animates, labeled). Covers ~24 domains: quantum, linear algebra, waves, algorithms, astronomy, neuroscience, control systems, probability, etc. - Hand-written electromagnetism scenes (dipole field lines, RC circuit) to cover the two UI example chips the workflow batch missed. - library_match: weight title/tag tokens, drop stopwords, dominance gap so an ambiguous near-tie doesn't fast-path; high-confidence matches fast-path even when a duplicate-topic scene also scores well. Library fallback now requires real relevance (>=2.0) instead of any score, so a failed generation never shows a wrong-topic curated scene. - Dockerfile copies the generated JSON; README documents the reliability/speed architecture + how to regenerate the library. 69 tests pass. 7/10 example chips now render instantly from the verified library; the rest generate. Verified in-browser: dipole + 3D moon-phases scenes render correctly. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dockerfile | 2 +- README.md | 52 +- main.py | 92 ++- scene_library.py | 132 ++- scene_library_generated.json | 1459 ++++++++++++++++++++++++++++++++++ tests/test_main.py | 4 +- 6 files changed, 1702 insertions(+), 39 deletions(-) create mode 100644 scene_library_generated.json diff --git a/Dockerfile b/Dockerfile index 9b4f125..6b1deb6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY main.py scene_library.py validate_scene.js \ +COPY main.py scene_library.py scene_library_generated.json validate_scene.js \ index.html app.js sandbox-worker.js styles.css ./ # Cloud platforms inject PORT; main.py binds 0.0.0.0 automatically when set. diff --git a/README.md b/README.md index dad25e5..88074dc 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,44 @@ molecules, fields in space) versus 2D (single-variable functions, circuits, graphs, planar geometry), and worked examples show exactly how to use the pipeline. +## Getting the *right* animation — fast and reliably + +Generating animation code from a prompt is error-prone, especially with a small +local model. Four layers make it fast and correct without training a model from +scratch (which would be worse and need heavy infrastructure): + +1. **Curated STEM scene library** (`scene_library.py` + + `scene_library_generated.json`) — ~50 hand- and AI-authored scenes across + two dozen STEM domains, every one re-validated to actually run, paint + on-screen, animate, and carry labels. A strong keyword match short-circuits + straight to the matching scene: a "Fourier series" prompt renders in ~5 ms + instead of a 10–60 s generation that might fail. This is the practical, + retrieval-augmented realization of "feed it STEM data." +2. **Headless validator** (`validate_scene.js`, run server-side via Node) — + runs each generated scene in a hardened sandbox (`vm`, fresh empty context, + 2 s timeout) and reports throws / blank / off-screen / no-motion / no-labels + in ~50 ms. The browser is no longer the validator, so a bad scene is caught + and repaired *server-side* before it's ever sent — instead of costing a full + client round-trip per repair. It even catches the subtle "draws everything + off-screen" coordinate-space mixup. Degrades gracefully if Node is absent. +3. **Deterministic auto-fixer** — mechanically prefixes bare `Math.*` calls + (`sin(x)` → `Math.sin(x)`, the #1 cause of "X is not defined"), fixing the + most common failure with zero model round-trips. +4. **Validated-scene cache** — a repeated prompt (or a shared link) returns the + already-verified scene instantly. + +The result: common topics are instant and always correct; novel prompts are +generated, validated, and repaired server-side, then handed to the browser +ready to run. `GET /api/health` reports whether the validator and library are +active (`validator`, `library_size`). + +### Regenerating the library + +The generated scenes were produced by a multi-agent workflow (one author per +STEM domain, an independent adversarial review per scene). To re-run or extend +it, regenerate `scene_library_generated.json`; every scene is re-checked by +`validate_scene.js` (the test suite asserts the whole library runs). + ## Brains (multi-provider) Generation tries providers in this order — the first one with a key wins: @@ -116,15 +154,21 @@ HTTP round-trips against every endpoint (with stubbed generators). ## Files - `main.py` — web server, the four AI bridges (Claude/OpenAI/Gemini/Ollama), - scene generation/repair/tutor endpoints, rate limiting, and the system - prompt that defines the rendering contract. + the validate→auto-fix→repair pipeline, library retrieval + cache, rate + limiting, and the system prompt that defines the rendering contract. - `sandbox-worker.js` — the sandboxed Web Worker that runs generated code on an OffscreenCanvas, the `H` helper library, and the software 3D pipeline. +- `validate_scene.js` — headless server-side scene validator (hardened Node + `vm` sandbox); mirrors the worker's `H` API surface. +- `scene_library.py` / `scene_library_generated.json` — curated, verified STEM + scene corpus + the keyword retrieval used for the instant fast path. - `app.js` — orchestration: sandbox runner, generate→run→repair loop, orbit controls, playback, tutor chat, resources, status. - `index.html` / `styles.css` — app structure and visual design. -- `Dockerfile` / `render.yaml` — production deployment. -- `tests/` — stdlib-only test suite. +- `Dockerfile` / `render.yaml` — production deployment (Docker image bundles + Node for the validator). +- `tests/` — stdlib-only test suite (covers the validator, auto-fixer, + library, cache, and every endpoint). ## API diff --git a/main.py b/main.py index efc6447..70ec7b7 100644 --- a/main.py +++ b/main.py @@ -1616,32 +1616,51 @@ def _tokenize(s: str) -> set: return set(re.findall(r"[a-z0-9]+", (s or "").lower())) -def library_match(prompt: str, mode: str) -> tuple[dict | None, float]: - """Best curated scene for this prompt + its match score (0 = no match).""" +# Common words that shouldn't drive a topic match on their own. +_MATCH_STOPWORDS = frozenset( + "the a an of in on to and or is are show me how visualize animate explain " + "draw plot with for as its it this that what why over time using see".split() +) + + +def _scene_score(sc: dict, ptext: str, ptoks: set, mode: str) -> float: + score = 0.0 + for kw in sc.get("keywords", []): + k = kw.lower().strip() + if not k: + continue + if " " in k: # multi-word phrase: a contiguous hit is a strong signal + if k in ptext: + score += 2.0 + elif k in ptoks: + score += 1.0 + # Title and tag words are strong topic signals (minus generic stopwords). + title_toks = _tokenize(sc.get("title", "")) - _MATCH_STOPWORDS + tag_toks = _tokenize(sc.get("tag", "")) - _MATCH_STOPWORDS + score += 1.0 * len(title_toks & ptoks) + score += 0.5 * len(tag_toks & ptoks) + # Respect an explicit 2D/3D preference. + if mode in ("2d", "3d") and sc.get("dimension", "").lower() != mode: + score -= 1.5 + return score + + +def _library_scored(prompt: str, mode: str) -> list[tuple[dict, float]]: + """All library scenes scored against the prompt, best first (score > 0).""" if not SCENE_LIBRARY: - return None, 0.0 + return [] ptext = " " + re.sub(r"\s+", " ", (prompt or "").lower()) + " " - ptoks = _tokenize(prompt) - best, best_score = None, 0.0 - for sc in SCENE_LIBRARY: - score = 0.0 - for kw in sc.get("keywords", []): - k = kw.lower().strip() - if not k: - continue - if " " in k: # multi-word phrase: weight a contiguous hit higher - if k in ptext: - score += 2.0 - elif k in ptoks: - score += 1.0 - # Shared title words are a mild positive signal. - score += 0.5 * len(_tokenize(sc.get("title", "")) & ptoks) - # Respect an explicit 2D/3D preference. - if mode in ("2d", "3d") and sc.get("dimension", "").lower() != mode: - score -= 1.5 - if score > best_score: - best, best_score = sc, score - return best, best_score + ptoks = _tokenize(prompt) - _MATCH_STOPWORDS + scored = [(sc, _scene_score(sc, ptext, ptoks, mode)) for sc in SCENE_LIBRARY] + scored = [t for t in scored if t[1] > 0] + scored.sort(key=lambda t: t[1], reverse=True) + return scored + + +def library_match(prompt: str, mode: str) -> tuple[dict | None, float]: + """Best curated scene for this prompt + its match score (0 = no match).""" + scored = _library_scored(prompt, mode) + return scored[0] if scored else (None, 0.0) def _library_scene(sc: dict, prompt: str) -> dict: @@ -1674,10 +1693,20 @@ def plan_visualization(prompt: str, preferred_mode: str) -> dict: # 2. Strong curated match — instant, known-correct. The bar is high when a # cloud model is available (the user likely wants a custom take), and - # lower when we'd otherwise lean on the slow/weak local model. - lib_scene, lib_score = library_match(prompt, preferred_mode) - strong_threshold = 6.0 if _has_cloud() else 3.0 - if lib_scene is not None and lib_score >= strong_threshold: + # lower when we'd otherwise lean on the slow/weak local model. A + # "dominance" gap guards against ambiguous matches: the top scene must + # clearly beat the runner-up before we short-circuit to it. + scored = _library_scored(prompt, preferred_mode) + lib_scene, lib_score = scored[0] if scored else (None, 0.0) + runner_up = scored[1][1] if len(scored) > 1 else 0.0 + strong_threshold = 6.0 if _has_cloud() else 2.5 + # A clearly on-topic match (high absolute score) fast-paths regardless of + # ties — a 9-point Fourier match is right even if a second Fourier scene + # also scores high. The dominance gap only guards BORDERLINE matches from + # firing on an ambiguous near-tie between unrelated scenes. Base scenes are + # ordered first, so they win ties. + confident = lib_score >= 5.0 or (lib_score - runner_up) >= 1.5 + if lib_scene is not None and lib_score >= strong_threshold and confident: result = _library_scene(lib_scene, prompt) scene_cache_put(prompt, preferred_mode, result) return result @@ -1704,9 +1733,10 @@ def plan_visualization(prompt: str, preferred_mode: str) -> dict: except Exception as error: # noqa: BLE001 errors.append(f"Ollama: {error}") - # 4. Every generator failed. A curated scene — even a loose match — beats a - # dead canvas, so prefer it over the placeholder fallback. - if lib_scene is not None and lib_score > 0: + # 4. Every generator failed. A RELEVANT curated scene beats a dead canvas — + # but a wrong-topic one is worse than an honest placeholder, so require a + # moderate match (not just any score > 0). + if lib_scene is not None and lib_score >= 2.0: result = _library_scene(lib_scene, prompt) result["fallback_reason"] = ( "The live generator couldn't produce a runnable scene, so here's the " diff --git a/scene_library.py b/scene_library.py index 85bde32..a0fcb4f 100644 --- a/scene_library.py +++ b/scene_library.py @@ -217,6 +217,121 @@ cam.axes(3); H.text("Gradient descent on a loss surface", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); H.text("loss = " + loss(px, py).toFixed(3), 24, 52, { color: H.colors.sub, size: 14 }); +""".strip(), + }, + { + "id": "electric-dipole-field", + "title": "Electric field lines of a dipole", + "tag": "Electromagnetism", + "dimension": "2D", + "equation": "E = k q / r^2 (superposed from + and - charges)", + "summary": "Field lines stream from the positive charge to the negative charge; a test charge follows the local field.", + "keywords": [ + "electric field", "field lines", "dipole", "charge", "electrostatics", + "coulomb", "electromagnetism", "point charge", "positive", "negative", + "voltage", "potential", + ], + "bullets": [ + "Field lines leave the + charge and enter the - charge.", + "Line density is higher where the field is stronger (near the charges).", + "A positive test charge feels a force along the local field direction.", + ], + "student_prompts": [ + "Why do field lines never cross?", + "How does field strength fall off with distance?", + "What is the field exactly between the two charges?", + ], + "code": r""" +H.background(); +const w = H.W, h = H.H; +const q1 = { x: w * 0.38, y: h * 0.52, s: +1 }; +const q2 = { x: w * 0.62, y: h * 0.52, s: -1 }; +function field(x, y) { + let ex = 0, ey = 0; + for (const q of [q1, q2]) { + const dx = x - q.x, dy = y - q.y; + const r2 = dx * dx + dy * dy + 80; + const r = Math.sqrt(r2); + const e = q.s / r2; + ex += e * dx / r; ey += e * dy / r; + } + return [ex, ey]; +} +// Streamlines seeded in a ring around the + charge. +for (let k = 0; k < 16; k++) { + const a0 = (k / 16) * H.TAU; + let x = q1.x + 16 * Math.cos(a0), y = q1.y + 16 * Math.sin(a0); + const pts = [[x, y]]; + for (let i = 0; i < 220; i++) { + const [ex, ey] = field(x, y); + const m = Math.hypot(ex, ey) + 1e-9; + x += 3 * ex / m; y += 3 * ey / m; + if (x < 0 || x > w || y < 0 || y > h) break; + if (Math.hypot(x - q2.x, y - q2.y) < 14) break; + pts.push([x, y]); + } + H.path(pts, { color: H.colors.accent, width: 1.4 }); +} +// A test charge advected by the field. +const tt = (t % 6) / 6; +let tx = H.lerp(q1.x, q2.x, 0.15) , ty = q1.y - 70; +for (let i = 0; i < Math.floor(tt * 200); i++) { + const [ex, ey] = field(tx, ty); const m = Math.hypot(ex, ey) + 1e-9; + tx += 2.4 * ex / m; ty += 2.4 * ey / m; +} +H.circle(tx, ty, 6, { fill: H.colors.yellow, stroke: H.colors.bg, width: 2 }); +H.circle(q1.x, q1.y, 13, { fill: H.colors.warn }); +H.circle(q2.x, q2.y, 13, { fill: H.colors.accent2 }); +H.text("+", q1.x - 5, q1.y + 5, { color: H.colors.bg, size: 16, weight: 700 }); +H.text("-", q2.x - 4, q2.y + 5, { color: H.colors.bg, size: 18, weight: 700 }); +H.text("Electric field of a dipole", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("test charge along the field, t = " + (t % 6).toFixed(1) + "s", 24, 52, { color: H.colors.sub, size: 13 }); +""".strip(), + }, + { + "id": "rc-circuit-charging", + "title": "RC circuit charging a capacitor", + "tag": "Electromagnetism", + "dimension": "2D", + "equation": "V(t) = V0 (1 - e^(-t / RC))", + "summary": "A capacitor charges toward the supply voltage on an exponential curve set by the time constant RC.", + "keywords": [ + "rc circuit", "capacitor", "charging", "time constant", "exponential", + "circuit", "resistor", "voltage", "electronics", "discharge", "rc", + ], + "bullets": [ + "The capacitor voltage rises fast at first, then levels off.", + "After one time constant (t = RC) it reaches about 63% of the supply.", + "Larger R or C means a slower charge (a bigger time constant).", + ], + "student_prompts": [ + "What is the time constant here?", + "How long until the capacitor is ~99% charged?", + "What happens when it discharges?", + ], + "code": r""" +H.background(); +const RC = 1.6, V0 = 5; +const cycle = t % 8; +const charging = cycle < 5; +const tau = charging ? cycle : cycle - 5; +const V = charging ? V0 * (1 - Math.exp(-tau / RC)) : V0 * Math.exp(-tau / RC); +const v = H.plot2d({ xMin: 0, xMax: 5, yMin: 0, yMax: 5.5, box: { x: 70, y: 70, w: H.W * 0.5, h: H.H - 150 } }); +v.grid(); v.axes(); +v.fn((x) => V0 * (1 - Math.exp(-x / RC)), { color: H.colors.sub, width: 1.5 }); +v.line(RC, 0, RC, V0, { color: H.colors.violet, width: 1.5, dash: [5, 5] }); +v.dot(tau, V, { r: 7, fill: H.colors.good }); +v.text("t = RC", RC + 0.1, 0.5, { color: H.colors.violet, size: 12 }); +// A little circuit + a capacitor whose fill tracks V. +const bx = H.W * 0.68, by = 120, bw = 220, bh = 260; +H.rect(bx, by, bw, bh, { stroke: H.colors.axis, width: 2, radius: 8 }); +H.text("battery", bx + 10, by + 24, { color: H.colors.sub, size: 12 }); +const capX = bx + bw - 60, capY = by + 60, capH = 140; +H.rect(capX, capY, 36, capH, { stroke: H.colors.accent, width: 2 }); +H.rect(capX + 3, capY + capH - capH * (V / V0) + 3, 30, capH * (V / V0) - 6, { fill: H.colors.accent }); +H.text("Q", capX + 44, capY + capH / 2, { color: H.colors.accent, size: 14 }); +H.text("RC circuit: charging a capacitor", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text((charging ? "charging" : "discharging") + " V = " + V.toFixed(2) + " V", 24, 52, { color: H.colors.sub, size: 14 }); """.strip(), }, { @@ -267,8 +382,21 @@ ] # Scenes authored + adversarially verified by the stem-scene-library workflow -# are appended here. Kept separate from _BASE so the hand-verified baseline is -# always present even if the generated batch is regenerated. +# (one agent per STEM domain → independent correctness review per scene), then +# re-validated through validate_scene.js so every one is guaranteed to run, +# paint on-screen, animate, and carry labels. Stored as data in +# scene_library_generated.json and merged here. Kept separate from _BASE so the +# hand-verified baseline is always present even if the batch is regenerated. +import json as _json +from pathlib import Path as _Path + _GENERATED: list[dict] = [] +_gen_path = _Path(__file__).resolve().parent / "scene_library_generated.json" +try: + _GENERATED = _json.loads(_gen_path.read_text(encoding="utf-8")) + if not isinstance(_GENERATED, list): + _GENERATED = [] +except (OSError, ValueError): + _GENERATED = [] SCENE_LIBRARY: list[dict] = _BASE + _GENERATED diff --git a/scene_library_generated.json b/scene_library_generated.json new file mode 100644 index 0000000..c0bbe1b --- /dev/null +++ b/scene_library_generated.json @@ -0,0 +1,1459 @@ +[ + { + "id": "kepler-elliptical-orbit-equal-areas", + "title": "Kepler's Laws: Elliptical Orbit & Equal Areas", + "tag": "Orbital Mechanics", + "dimension": "2D", + "equation": "M = E - e*sin(E), dA/dt = const", + "summary": "A planet sweeps around an elliptical orbit with the star at one focus. Equal-time sectors are highlighted to show they enclose equal area (Kepler's 2nd law), while the planet visibly speeds up near perihelion and slows near aphelion.", + "keywords": [ + "kepler", + "keplers laws", + "elliptical orbit", + "ellipse", + "equal areas", + "second law", + "areal velocity", + "eccentric anomaly", + "eccentricity", + "perihelion", + "aphelion", + "planet", + "orbit", + "focus" + ], + "bullets": [ + "The star sits at one focus of the ellipse, not the center (Kepler's 1st law).", + "Each orange sector covers the same area in the same elapsed time, so the planet moves fastest near perihelion (Kepler's 2nd law).", + "The live speed v comes from the vis-viva equation v = sqrt(2/r - 1/a) with GM = 1, peaking at closest approach." + ], + "student_prompts": [ + "Why does the planet move faster when it is closer to the star?", + "How is the eccentric anomaly E related to the actual angle swept from the focus?", + "Derive the vis-viva equation and show where the readout speed comes from." + ], + "code": "H.background();\n// --- Kepler's first & second law: elliptical orbit + equal areas in equal times ---\nconst a = 5.2; // semi-major axis (data units)\nconst e = 0.6; // eccentricity\nconst b = a * Math.sqrt(1 - e * e); // semi-minor axis\nconst c = a * e; // focus offset from center\nconst v = H.plot2d({ xMin: -7.2, xMax: 7.2, yMin: -4.6, yMax: 4.6, pad: 50 });\nv.grid(); v.axes();\n\n// Star sits at the focus (origin). Center of the ellipse is at (-c, 0).\nconst cxData = -c, cyData = 0;\n// Draw the full ellipse path.\nconst ell = [];\nconst NE = 160;\nfor (let i = 0; i <= NE; i++) {\n const th = (i / NE) * H.TAU;\n ell.push([cxData + a * Math.cos(th), cyData + b * Math.sin(th)]);\n}\nv.path(ell, { color: H.colors.accent, width: 2.4 });\n\n// Solve Kepler's equation M = E - e sin E for the eccentric anomaly E.\n// Mean motion: full period = 6s of sim time, so M sweeps uniformly with t.\nconst period = 6;\nconst M = ((t % period) / period) * H.TAU;\nlet E = M; // Newton iteration (few steps, converges fast)\nfor (let k = 0; k < 6; k++) {\n E -= (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E));\n}\n// Planet position on the ellipse (focus at origin).\nconst px = cxData + a * Math.cos(E);\nconst py = cyData + b * Math.sin(E);\n\n// Equal-area swept sector over a fixed slice of the period, ending at \"now\".\nconst dM = H.TAU * 0.10; // mean-anomaly width of the highlighted sweep\nconst swept = [[0, 0]];\nconst SEG = 28;\nfor (let i = 0; i <= SEG; i++) {\n const Mi = M - dM + (dM * i) / SEG;\n let Ei = Mi;\n for (let k = 0; k < 5; k++) {\n Ei -= (Ei - e * Math.sin(Ei) - Mi) / (1 - e * Math.cos(Ei));\n }\n swept.push([cxData + a * Math.cos(Ei), cyData + b * Math.sin(Ei)]);\n}\nv.path(swept, { color: H.colors.accent2, width: 1, fill: \"rgba(244,162,89,0.32)\", close: true });\n\n// Radius line star -> planet, and the star itself at the focus.\nv.line(0, 0, px, py, { color: H.colors.sub, width: 1.4, dash: [5, 5] });\nv.circle(0, 0, 9, { fill: H.colors.yellow, stroke: \"#a8791f\", width: 1.5 });\nv.dot(px, py, { r: 6, fill: H.colors.accent, stroke: H.colors.bg });\n// Mark the empty (other) focus too.\nv.circle(-2 * c, 0, 3, { fill: H.colors.sub });\n\n// Live numbers: orbital radius r and instantaneous speed (vis-viva, GM=1).\nconst r = Math.hypot(px, py);\nconst speed = Math.sqrt(Math.max(0, 2 / r - 1 / a)); // vis-viva, never negative\nconst phaseName = E < Math.PI ? \"speeding up toward perihelion\" : \"slowing toward aphelion\";\n\nH.text(\"Kepler's Laws: elliptical orbit & equal areas\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Orange sectors swept in equal times have equal area (2nd law).\",\n 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"r = \" + r.toFixed(2) + \" AU\", 24, H.H - 54, { color: H.colors.accent2, size: 14 });\nH.text(\"v = \" + speed.toFixed(2) + \" (vis-viva, GM=1)\", 24, H.H - 34,\n { color: H.colors.good, size: 14 });\nH.text(\"e = \" + e.toFixed(2) + \" \" + phaseName, 24, H.H - 14,\n { color: H.colors.sub, size: 12 });\nH.legend([\n { label: \"orbit (ellipse)\", color: H.colors.accent },\n { label: \"star at focus\", color: H.colors.yellow },\n { label: \"swept area\", color: H.colors.accent2 },\n], H.W - 200, 30);" + }, + { + "id": "moon-phases-lighting-geometry-3d", + "title": "Moon Phases: Lighting Geometry in 3D", + "tag": "Astronomy", + "dimension": "3D", + "equation": "illuminated fraction = (1 - cos(elongation)) / 2", + "summary": "The Moon orbits Earth in 3D while parallel sunlight from one side lights exactly half of it. A corner inset shows the resulting Earth-view disk cycling through new, crescent, quarter, gibbous and full, with a live illuminated-percentage readout.", + "keywords": [ + "moon phases", + "lunar phases", + "moon", + "earth", + "sun", + "crescent", + "gibbous", + "waxing", + "waning", + "new moon", + "full moon", + "first quarter", + "synodic month", + "terminator" + ], + "bullets": [ + "The Sun always lights exactly half the Moon; phase is just how much of that lit half faces Earth.", + "Illuminated fraction equals (1 - cos(elongation))/2, ranging from 0% at new moon to 100% at full moon.", + "The inset disk shows the familiar crescent-to-gibbous shape that an observer on Earth actually sees as the Moon orbits." + ], + "student_prompts": [ + "Why do we see the same face of the Moon even though its phase changes?", + "What is the difference between the Moon's phase and a lunar eclipse?", + "How long is one full cycle of phases and why is it longer than the orbital period?" + ], + "code": "H.background();\n// --- Moon phases (3D): the Moon orbiting Earth, lit from one side by the Sun ---\nconst cam = H.cam3d({ scale: 46, dist: 17, pitch: -0.62, cy: H.H * 0.52 });\ncam.yaw = 0.18 * t; // gentle default spin so it reads as 3D\ncam.grid(7, 1);\n\n// Sun is far away in the +X direction: light rays point toward -X.\nconst sunDir = [1, 0, 0]; // unit vector FROM scene TOWARD the Sun\nconst orbitR = 4.0; // Moon's orbital radius about Earth (data units)\nconst period = 12; // seconds per synodic cycle\nconst phase = ((t % period) / period) * H.TAU; // 0..TAU around the orbit\n// Moon position in the ecliptic plane (x/z ground plane; y is up).\nconst mx = orbitR * Math.cos(phase);\nconst mz = orbitR * Math.sin(phase);\nconst moonPos = [mx, 0, mz];\n\n// Draw the orbit ring of the Moon.\nconst ring = [];\nconst NR = 96;\nfor (let i = 0; i <= NR; i++) {\n const th = (i / NR) * H.TAU;\n ring.push([orbitR * Math.cos(th), 0, orbitR * Math.sin(th)]);\n}\ncam.path(ring, { color: H.colors.grid, width: 1.4 });\n\n// Sunlight direction indicator: a long arrow sweeping in across the scene.\nconst sunHead = cam.project([-orbitR - 1.4, 0, 0]);\nconst sunTail = cam.project([-6.2, 0, 0]);\nH.arrow(sunTail.x, sunTail.y, sunHead.x, sunHead.y, { color: H.colors.yellow, width: 2 });\n\n// Phase angle: angle between Sun direction and Earth->Moon direction.\n// Illuminated fraction = (1 - cos(elongation)) / 2.\nconst moonHat = [Math.cos(phase), 0, Math.sin(phase)];\nconst cosElong = moonHat[0] * sunDir[0] + moonHat[2] * sunDir[2];\nconst illum = (1 - cosElong) / 2; // 0 = new moon, 1 = full moon\nconst elong = Math.acos(H.clamp(cosElong, -1, 1));\n\n// Depth-sort the bodies and draw far-to-near.\nconst bodies = [\n { p: [0, 0, 0], r: 1.05, color: H.colors.accent },\n { p: moonPos, r: 0.55, color: \"#cdd3e0\" },\n];\nbodies\n .map((o) => ({ p: o.p, r: o.r, color: o.color, depth: cam.project(o.p).depth }))\n .sort((a, b) => b.depth - a.depth)\n .forEach((o) => cam.sphere(o.p, o.r, { color: o.color }));\n\n// Terminator shading on the Moon: a dark cap on the side facing away from the\n// Sun, sized by the unlit fraction, drawn in screen space.\nconst mProj = cam.project(moonPos);\nconst moonRpx = Math.max(4, 0.55 * 46 * mProj.f);\nconst darkCenter = [moonPos[0] - sunDir[0] * 0.55, 0, moonPos[2] - sunDir[2] * 0.55];\nconst dProj = cam.project(darkCenter);\nH.circle(dProj.x, dProj.y, moonRpx * (0.45 + 0.55 * (1 - illum)),\n { fill: \"rgba(8,10,24,0.55)\" });\n\n// As-seen-from-Earth phase disk in the corner (crescent/gibbous).\nconst diskX = H.W - 90, diskY = 96, diskR = 34;\nH.circle(diskX, diskY, diskR + 4, { fill: \"#11182b\", stroke: H.colors.grid, width: 1.5 });\nH.circle(diskX, diskY, diskR, { fill: \"#1a2236\" });\nconst waxing = Math.sin(phase) >= 0 ? 1 : -1;\nconst lit = [];\nconst NP = 40;\nfor (let i = 0; i <= NP; i++) {\n const ang = -Math.PI / 2 + (Math.PI * i) / NP;\n lit.push([diskX + waxing * diskR * Math.cos(ang), diskY + diskR * Math.sin(ang)]);\n}\nconst termW = diskR * (1 - 2 * illum);\nfor (let i = NP; i >= 0; i--) {\n const ang = -Math.PI / 2 + (Math.PI * i) / NP;\n lit.push([diskX + waxing * termW * Math.cos(ang), diskY + diskR * Math.sin(ang)]);\n}\nH.path(lit, { color: \"none\", fill: H.colors.yellow, close: true });\n\nlet phaseName;\nif (illum < 0.04) phaseName = \"New Moon\";\nelse if (illum > 0.96) phaseName = \"Full Moon\";\nelse if (Math.abs(illum - 0.5) < 0.06) phaseName = (waxing > 0 ? \"First\" : \"Last\") + \" Quarter\";\nelse if (illum < 0.5) phaseName = (waxing > 0 ? \"Waxing\" : \"Waning\") + \" Crescent\";\nelse phaseName = (waxing > 0 ? \"Waxing\" : \"Waning\") + \" Gibbous\";\n\ncam.axes(5);\nH.text(\"Moon Phases: lighting geometry in 3D\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"The Sun lights one hemisphere; phase = how much of it we see from Earth.\",\n 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"illuminated = \" + (illum * 100).toFixed(0) + \"%\", 24, H.H - 54,\n { color: H.colors.yellow, size: 14 });\nH.text(\"elongation = \" + (elong * 180 / Math.PI).toFixed(0) + \" deg\", 24, H.H - 34,\n { color: H.colors.sub, size: 13 });\nH.text(\"phase: \" + phaseName, 24, H.H - 14, { color: H.colors.accent, size: 14, weight: 700 });\nH.legend([\n { label: \"Earth\", color: H.colors.accent },\n { label: \"Moon\", color: \"#cdd3e0\" },\n { label: \"sunlight\", color: H.colors.yellow },\n], H.W - 170, H.H - 80);" + }, + { + "id": "inverse-square-gravity-potential-well", + "title": "Inverse-Square Gravity & the Potential Well", + "tag": "Gravitation", + "dimension": "3D", + "equation": "g(r) = GM/r^2, Phi(r) = -GM/r", + "summary": "The gravitational potential Phi = -GM/r is drawn as a 3D funnel-shaped well with a test mass orbiting on its wall and an inward force arrow. A 2D inset plots the inverse-square field g(r) = GM/r^2 with the current orbital radius marked, both updating live.", + "keywords": [ + "inverse square law", + "inverse-square", + "gravity", + "gravitational field", + "potential well", + "gravitational potential", + "newton", + "gm over r squared", + "force field", + "field strength", + "central force", + "two-body", + "orbit", + "test mass" + ], + "bullets": [ + "The gravitational potential Phi = -GM/r forms a steep funnel that deepens toward the central mass.", + "Field strength g = GM/r^2 follows the inverse-square law: halving r quadruples the force, as the inset curve shows.", + "The green arrow is the inward gravitational pull on the orbiting test mass; it grows as the mass drops deeper into the well." + ], + "student_prompts": [ + "Why is the gravitational force an inverse-square law rather than inverse-distance?", + "How does the potential Phi = -GM/r relate to the force g = GM/r^2?", + "What orbital speed keeps the test mass on a stable circular orbit at this radius?" + ], + "code": "H.background();\n// --- Inverse-square gravity: the potential well and a test mass orbiting in it ---\nconst cam = H.cam3d({ scale: 30, dist: 17, pitch: -0.6, cy: H.H * 0.56 });\ncam.yaw = 0.3 * t; // slow spin so the well reads as 3D\ncam.grid(6, 2);\n\nconst GM = 6.0; // gravitational parameter\nconst soft = 0.45; // softening so the funnel stays finite at r=0\n// Potential height: Phi = -GM / sqrt(r^2 + soft^2), scaled to a visible depth.\nH.surface3d(cam, (x, y) => {\n const r2 = x * x + y * y;\n return (-GM / Math.sqrt(r2 + soft * soft)) * 0.95 + 7.5; // lift so it's in frame\n}, { xMin: -5.5, xMax: 5.5, yMin: -5.5, yMax: 5.5, nx: 40, ny: 40,\n hueMin: 215, hueMax: 280, alpha: 0.94 });\n\n// Test mass on a circular orbit at radius rOrb, sitting on the funnel wall.\nconst rOrb = 2.6;\nconst ang = t * 0.9;\nconst ox = rOrb * Math.cos(ang);\nconst oz = rOrb * Math.sin(ang);\nconst height = (-GM / Math.sqrt(rOrb * rOrb + soft * soft)) * 0.95 + 7.5;\nconst massPos = [ox, height + 0.35, oz];\ncam.sphere(massPos, 0.32, { color: H.colors.accent2 });\n// Central mass at the bottom of the well.\nconst wellBottom = (-GM / Math.sqrt(soft * soft)) * 0.95 + 7.5;\ncam.sphere([0, wellBottom + 0.3, 0], 0.5, { color: H.colors.yellow });\n\n// Inward gravitational force arrow on the test mass.\nconst g = GM / (rOrb * rOrb);\nconst ux = -Math.cos(ang), uz = -Math.sin(ang);\nconst tip = [ox + ux * g * 0.18, massPos[1], oz + uz * g * 0.18];\nconst aP = cam.project(massPos);\nconst bP = cam.project(tip);\nH.arrow(aP.x, aP.y, bP.x, bP.y, { color: H.colors.good, width: 2.4 });\n\ncam.axes(5);\n\n// 2D inset: g(r) = GM/r^2 falling off, with the current radius marked.\nconst v = H.plot2d({\n xMin: 0.4, xMax: 6, yMin: 0, yMax: 12,\n box: { x: H.W - 230, y: 64, w: 200, h: 130 },\n});\nv.grid(); v.axes();\nv.fn((rr) => GM / (rr * rr), { color: H.colors.accent, width: 2.4 });\nv.dot(rOrb, g, { r: 5, fill: H.colors.accent2 });\nv.line(rOrb, 0, rOrb, g, { color: H.colors.sub, width: 1, dash: [3, 4] });\nH.text(\"g(r) = GM/r^2\", H.W - 224, 58, { color: H.colors.accent, size: 12 });\n\nH.text(\"Inverse-square gravity & the potential well\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Phi = -GM/r forms a funnel; force g = GM/r^2 grows steeply as r shrinks.\",\n 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"r = \" + rOrb.toFixed(2), 24, H.H - 54, { color: H.colors.accent2, size: 14 });\nH.text(\"g(r) = \" + g.toFixed(2) + \" (GM = \" + GM.toFixed(1) + \")\", 24, H.H - 34,\n { color: H.colors.good, size: 14 });\nH.text(\"Phi(r) = \" + (-GM / Math.sqrt(rOrb * rOrb + soft * soft)).toFixed(2),\n 24, H.H - 14, { color: H.colors.violet, size: 13 });\nH.legend([\n { label: \"central mass\", color: H.colors.yellow },\n { label: \"test mass\", color: H.colors.accent2 },\n { label: \"gravity (inward)\", color: H.colors.good },\n], 24, H.H - 110);" + }, + { + "id": "sieve-of-eratosthenes-primes", + "title": "Sieve of Eratosthenes: Finding Primes", + "tag": "Number Theory", + "dimension": "2D", + "equation": "cross out kp for k>=2; survivors are prime", + "summary": "A 10x10 grid of the integers 2 to 101. The animation steps through each prime p (2, 3, 5, 7) and sweeps out its multiples one by one, leaving the prime numbers highlighted. A live counter shows how many survivors remain.", + "keywords": [ + "sieve of eratosthenes", + "prime numbers", + "primes", + "number theory", + "find primes", + "prime sieve", + "composite numbers", + "factors", + "multiples", + "crossing out", + "integer grid", + "primality" + ], + "bullets": [ + "Start at the smallest unmarked number p; it must be prime, so circle it.", + "Cross out every multiple of p starting from p*p, since smaller multiples were already removed by smaller primes.", + "Only primes up to sqrt(101) (2,3,5,7) need a pass; whatever survives is guaranteed prime." + ], + "student_prompts": [ + "Why can we start crossing out at p squared instead of 2p?", + "How many primes are there below 100, and why is 101 included here?", + "What is the time complexity of the Sieve of Eratosthenes?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\n// --- Sieve of Eratosthenes on a 10x10 grid of the integers 2..101 ---\nconst N = 100; // numbers shown: 2 .. 101\nconst cols = 10, rows = 10;\nconst first = 2;\n// Rebuild the sieve state up to the \"stage\" the animation has reached.\n// stage advances roughly one prime every ~1.6s and loops.\nconst primesToProcess = [2, 3, 5, 7]; // sqrt(101) < 11, so these suffice\nconst cycle = primesToProcess.length + 1;\nconst stageF = (t / 1.6) % cycle; // 0..cycle, fractional\nconst stage = Math.floor(stageF); // which prime index we're on\nconst subt = stageF - stage; // progress within this prime's pass\n\n// composite[k] === true -> crossed out (k is the integer 2..101)\nconst composite = new Array(first + N).fill(false);\nconst activePrime = stage < primesToProcess.length ? primesToProcess[stage] : 0;\n// Fully cross out all multiples of every prime strictly before the active one.\nfor (let s = 0; s < stage && s < primesToProcess.length; s++){\n const p = primesToProcess[s];\n for (let m = p * p; m < first + N; m += p) composite[m] = true;\n}\n// Partially reveal the active prime's multiples as subt grows 0->1.\nlet sweepHit = -1;\nif (activePrime > 0){\n const mults = [];\n for (let m = activePrime * activePrime; m < first + N; m += activePrime) mults.push(m);\n const reveal = Math.floor(H.ease(subt) * (mults.length + 0.999));\n for (let i = 0; i < reveal && i < mults.length; i++) composite[mults[i]] = true;\n if (reveal > 0 && reveal <= mults.length) sweepHit = mults[reveal - 1];\n}\n\n// --- layout the grid cell box ---\nconst gx = 54, gy = 96;\nconst gw = Math.min(w - 110, h - 150);\nconst cell = gw / cols;\nconst gh = cell * rows;\n\nlet primeCount = 0;\nfor (let i = 0; i < N; i++){\n const n = first + i;\n const c = i % cols;\n const r = Math.floor(i / cols);\n const x = gx + c * cell;\n const y = gy + r * cell;\n const isComp = composite[n];\n const isActivePrime = (n === activePrime);\n if (!isComp) primeCount++;\n // cell background\n let fill = H.colors.panel;\n if (isActivePrime) fill = \"#274064\";\n else if (n === sweepHit) fill = \"#3a2a44\";\n H.rect(x + 2, y + 2, cell - 4, cell - 4,\n { fill, stroke: isComp ? \"#202a44\" : H.colors.grid, width: 1, radius: 5 });\n // number\n const tcol = isComp ? \"#4f5d80\" : (isActivePrime ? H.colors.yellow : H.colors.ink);\n H.text(String(n), x + cell / 2, y + cell / 2 + 4,\n { color: tcol, size: Math.max(9, cell * 0.34), align: \"center\", weight: isComp ? 400 : 600 });\n // strike-through for composites\n if (isComp){\n const pad = cell * 0.22;\n H.line(x + pad, y + pad, x + cell - pad, y + cell - pad,\n { color: H.colors.warn, width: 1.6 });\n }\n}\n\n// Pulsing ring on the current prime being sieved.\nif (activePrime > 0){\n const idx = activePrime - first;\n const c = idx % cols, r = Math.floor(idx / cols);\n const cxp = gx + c * cell + cell / 2;\n const cyp = gy + r * cell + cell / 2;\n const pr = cell * 0.5 + 4 + 2 * Math.sin(t * 6);\n H.circle(cxp, cyp, pr, { stroke: H.colors.yellow, width: 2.4 });\n}\n\n// --- titles + live readout ---\nH.text(\"Sieve of Eratosthenes\", 24, 34, { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"Cross out every multiple of each prime; survivors are prime.\", 24, 56,\n { color: H.colors.sub, size: 13 });\nconst passLabel = activePrime > 0\n ? (\"sieving multiples of p = \" + activePrime)\n : \"sweep complete - survivors are prime\";\nH.text(passLabel, gx, gy + gh + 28, { color: H.colors.accent2, size: 14, weight: 600 });\nH.text(\"primes remaining (2..101): \" + primeCount, gx, gy + gh + 50,\n { color: H.colors.good, size: 13 });\n\nH.legend([\n { label: \"prime (survivor)\", color: H.colors.ink },\n { label: \"current prime p\", color: H.colors.yellow },\n { label: \"crossed out\", color: H.colors.warn },\n], gx + gw + 18, gy + 10);" + }, + { + "id": "modular-arithmetic-clock", + "title": "Modular Arithmetic Clock (mod 12)", + "tag": "Number Theory", + "dimension": "2D", + "equation": "n mod 12 = n - 12*floor(n/12)", + "summary": "A 12-hour clock dial where a hand steadily advances one position at a time and wraps from 11 back to 0. Live readouts show the running count n, its residue n mod 12, the number of completed laps, and the division identity n = q*12 + r.", + "keywords": [ + "modular arithmetic", + "mod", + "modulo", + "clock arithmetic", + "congruence", + "residue", + "remainder", + "wrap around", + "cyclic", + "number theory", + "division algorithm", + "clock face", + "mod 12" + ], + "bullets": [ + "Counting on a clock is arithmetic modulo 12: after 11 the hand wraps back to 0.", + "The residue n mod 12 is the remainder when n is divided by 12, always between 0 and 11.", + "Every integer n splits uniquely as n = (laps)*12 + residue, the division algorithm in action." + ], + "student_prompts": [ + "What is 100 mod 12, and how does the clock show it?", + "Why is modular arithmetic called 'clock arithmetic'?", + "How do you add and multiply numbers modulo 12?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst m = 12; // modulus\nconst cx = w * 0.40, cy = h * 0.55;\nconst R = Math.min(w * 0.34, h * 0.40);\n\n// A counter that increments ~1 step per 0.9s, wrapping mod m.\nconst k = (t / 0.9); // continuous count\nconst kInt = Math.floor(k); // whole steps taken\nconst frac = k - kInt; // 0..1 within current step\nconst residue = ((kInt % m) + m) % m; // current residue class\nconst nextResidue = (residue + 1) % m;\n// Hand angle eases from residue -> residue+1 across the step.\nconst eased = residue + H.ease(frac);\n// 12 o'clock at top, going clockwise: angle measured from top.\nconst angOf = (pos) => -H.PI / 2 + (pos / m) * H.TAU;\n\n// --- dial face ---\nH.circle(cx, cy, R + 14, { fill: H.colors.panel, stroke: H.colors.grid, width: 2 });\nH.circle(cx, cy, R, { stroke: H.colors.axis, width: 1.5 });\n\n// tick positions 0..11, highlight the current residue\nfor (let i = 0; i < m; i++){\n const a = angOf(i);\n const px = cx + Math.cos(a) * R;\n const py = cy + Math.sin(a) * R;\n const lx = cx + Math.cos(a) * (R + 26);\n const ly = cy + Math.sin(a) * (R + 26);\n const active = (i === residue);\n const isNext = (i === nextResidue);\n H.circle(px, py, active ? 9 : 5.5, {\n fill: active ? H.colors.accent2 : (isNext ? H.colors.violet : H.colors.grid),\n stroke: H.colors.bg, width: 2,\n });\n H.text(String(i), lx, ly + 5, {\n color: active ? H.colors.yellow : H.colors.sub,\n size: active ? 17 : 14, align: \"center\",\n weight: active ? 700 : 500,\n });\n}\n\n// arc swept since 0 to show how many full laps the count has made\nconst laps = Math.floor(kInt / m);\n// the moving hand\nconst ha = angOf(eased);\nconst hx = cx + Math.cos(ha) * (R - 8);\nconst hy = cy + Math.sin(ha) * (R - 8);\nH.line(cx, cy, hx, hy, { color: H.colors.accent, width: 4 });\nH.arrow(cx, cy, hx, hy, { color: H.colors.accent, width: 4, head: 12 });\nH.circle(cx, cy, 7, { fill: H.colors.ink });\n\n// little orbiting marker that travels the full count (shows wrap-around)\nconst ma = angOf(eased);\nconst mx = cx + Math.cos(ma) * (R + 40);\nconst my = cy + Math.sin(ma) * (R + 40);\nH.circle(mx, my, 6, { fill: H.colors.good, stroke: H.colors.bg, width: 2 });\n\n// --- titles + live readout ---\nH.text(\"Modular Arithmetic Clock (mod \" + m + \")\", 24, 34,\n { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"Counting wraps around the dial: after \" + (m - 1) + \" comes 0.\", 24, 56,\n { color: H.colors.sub, size: 13 });\n\n// readout panel on the right\nconst px0 = w * 0.74, py0 = h * 0.30;\nH.text(\"count n = \" + kInt, px0, py0, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"n mod \" + m + \" = \" + residue, px0, py0 + 28,\n { color: H.colors.accent2, size: 22, weight: 700 });\nH.text(\"full laps = \" + laps, px0, py0 + 56, { color: H.colors.good, size: 14 });\nH.text(kInt + \" = \" + laps + \"x\" + m + \" + \" + residue, px0, py0 + 80,\n { color: H.colors.violet, size: 14 });\nH.text(\"next: (\" + residue + \" + 1) mod \" + m + \" = \" + nextResidue, px0, py0 + 104,\n { color: H.colors.sub, size: 13 });\n\nH.legend([\n { label: \"clock hand (residue)\", color: H.colors.accent },\n { label: \"current residue\", color: H.colors.accent2 },\n { label: \"running count\", color: H.colors.good },\n], px0, py0 + 140);" + }, + { + "id": "fibonacci-spiral-golden-ratio", + "title": "Fibonacci Spiral & the Golden Ratio", + "tag": "Number Theory", + "dimension": "2D", + "equation": "F(n+1)/F(n) -> phi = (1+sqrt(5))/2", + "summary": "Squares with Fibonacci side lengths 1,1,2,3,5,8,... are tiled in a spiral, each adding a quarter-circle arc that builds the golden spiral. Live readouts show the ratio F(n+1)/F(n) converging to phi as the spiral grows.", + "keywords": [ + "fibonacci", + "fibonacci spiral", + "golden ratio", + "phi", + "golden spiral", + "golden rectangle", + "sequence", + "ratio convergence", + "number theory", + "spiral", + "quarter circle arcs", + "recurrence" + ], + "bullets": [ + "Each square's side is the sum of the two previous sides: the Fibonacci recurrence F(n)=F(n-1)+F(n-2).", + "Connecting opposite corners of the squares with quarter-circle arcs traces the golden spiral.", + "The ratio of consecutive Fibonacci numbers F(n+1)/F(n) converges to the golden ratio phi ~= 1.61803." + ], + "student_prompts": [ + "Why does the ratio of consecutive Fibonacci numbers approach the golden ratio?", + "Is the Fibonacci spiral exactly a logarithmic (golden) spiral, or just an approximation?", + "Where does the golden ratio appear in nature and art?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\n// Fibonacci sequence\nconst fib = [1, 1];\nfor (let i = 2; i < 11; i++) fib.push(fib[i - 1] + fib[i - 2]);\nconst PHI = (1 + Math.sqrt(5)) / 2;\n\n// How many squares are \"grown in\" - sweeps up then resets, looping.\nconst maxSq = 9;\nconst period = maxSq + 2.0;\nconst tt = (t / 1.1) % period;\nconst grown = Math.min(maxSq, Math.floor(tt)); // fully drawn squares\nconst partial = Math.min(1, tt - grown); // growth of the next square\n\n// Build square placements by spiraling out: each new square sits on a side\n// of the running bounding box. Directions cycle: right, up, left, down.\nconst squares = [];\n// Canonical Fibonacci tiling: seed a 1x1 square at the origin, then attach\n// each successive square to a side of the running bounding box.\nconst s0 = fib[0];\nsquares.push({ x: 0, y: 0, s: s0, dir: 0, idx: 0 });\nlet minx = 0, miny = 0, maxx = s0, maxy = s0;\nfor (let i = 1; i < fib.length; i++){\n const s = fib[i];\n let nx, ny;\n // attach on a side of current bounding box, going counter-clockwise\n const d = i % 4;\n if (d === 1){ nx = minx - s; ny = miny; } // left\n else if (d === 2){ nx = minx; ny = miny - s; } // bottom\n else if (d === 3){ nx = maxx; ny = miny; } // right (aligned bottom)\n else { nx = minx; ny = maxy; } // top\n squares.push({ x: nx, y: ny, s, dir: d, idx: i });\n minx = Math.min(minx, nx); miny = Math.min(miny, ny);\n maxx = Math.max(maxx, nx + s); maxy = Math.max(maxy, ny + s);\n}\n\n// Fit the bounding box of grown squares into a centered viewport.\nlet bx0 = Infinity, by0 = Infinity, bx1 = -Infinity, by1 = -Infinity;\nfor (let i = 0; i <= grown && i < squares.length; i++){\n const q = squares[i];\n bx0 = Math.min(bx0, q.x); by0 = Math.min(by0, q.y);\n bx1 = Math.max(bx1, q.x + q.s); by1 = Math.max(by1, q.y + q.s);\n}\nif (!Number.isFinite(bx0)){ bx0 = 0; by0 = 0; bx1 = 1; by1 = 1; }\nconst bw = Math.max(1e-6, bx1 - bx0), bh = Math.max(1e-6, by1 - by0);\nconst vpw = w * 0.56, vph = h * 0.72;\nconst vx = w * 0.05, vy = h * 0.16;\nconst sc = Math.min(vpw / bw, vph / bh);\n// center inside viewport\nconst offx = vx + (vpw - bw * sc) / 2;\nconst offy = vy + (vph - bh * sc) / 2;\n// world (fib units, y up) -> screen (y down)\nconst SX = (wx) => offx + (wx - bx0) * sc;\nconst SY = (wy) => offy + (by1 - wy) * sc;\n\n// draw each square + its quarter-circle arc\nfor (let i = 0; i <= grown && i < squares.length; i++){\n const q = squares[i];\n const grow = (i === grown) ? partial : 1;\n const col = H.color(i % H.palette.length);\n // square (optionally growing from a corner)\n const sx = SX(q.x), sy = SY(q.y + q.s);\n const pw = q.s * sc, ph = q.s * sc;\n H.rect(sx, sy, pw * grow, ph * grow, {\n stroke: col, width: 2, fill: H.hsl(200 + i * 14, 45, 22, 0.35), radius: 0,\n });\n H.text(\"F=\" + q.s, sx + 6, sy + 16, { color: col, size: Math.min(15, 6 + pw * 0.06) });\n\n // quarter-circle arc spiraling through the square (full square only)\n if (grow >= 0.999){\n const d = q.dir;\n let ccx, ccy, a0;\n // y is up in world; convert to screen for arc center\n if (d === 0){ ccx = q.x; ccy = q.y; a0 = 0; } // first / top\n else if (d === 1){ ccx = q.x + q.s; ccy = q.y; a0 = H.PI / 2; } // left\n else if (d === 2){ ccx = q.x + q.s; ccy = q.y + q.s; a0 = H.PI; } // bottom\n else { ccx = q.x; ccy = q.y + q.s; a0 = -H.PI / 2; } // right\n const pts = [];\n const seg = 22;\n for (let j = 0; j <= seg; j++){\n const a = a0 + (j / seg) * (H.PI / 2);\n const ax = ccx + Math.cos(a) * q.s;\n const ay = ccy + Math.sin(a) * q.s;\n pts.push([SX(ax), SY(ay)]);\n }\n H.path(pts, { color: H.colors.yellow, width: 3 });\n }\n}\n\n// moving dot tracing the very tip of the spiral\nif (grown >= 1){\n const q = squares[Math.min(grown, squares.length - 1)];\n const d = q.dir;\n let ccx, ccy, a0;\n if (d === 0){ ccx = q.x; ccy = q.y; a0 = 0; }\n else if (d === 1){ ccx = q.x + q.s; ccy = q.y; a0 = H.PI / 2; }\n else if (d === 2){ ccx = q.x + q.s; ccy = q.y + q.s; a0 = H.PI; }\n else { ccx = q.x; ccy = q.y + q.s; a0 = -H.PI / 2; }\n const a = a0 + H.ease(partial) * (H.PI / 2);\n const ax = ccx + Math.cos(a) * q.s, ay = ccy + Math.sin(a) * q.s;\n H.circle(SX(ax), SY(ay), 6, { fill: H.colors.good, stroke: H.colors.bg, width: 2 });\n}\n\n// --- titles + live readout ---\nH.text(\"Fibonacci Spiral & the Golden Ratio\", 24, 34,\n { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"Quarter-circle arcs across squares of side 1,1,2,3,5,8,...\", 24, 56,\n { color: H.colors.sub, size: 13 });\n\n// ratio readout: F(n+1)/F(n) -> phi\nconst ni = Math.max(1, Math.min(grown, fib.length - 2));\nconst ratio = fib[ni + 1] / fib[ni];\nconst px0 = w * 0.66, py0 = h * 0.24;\nH.text(\"squares drawn: \" + (grown + (grown < maxSq ? 1 : 0)), px0, py0,\n { color: H.colors.ink, size: 15, weight: 600 });\nH.text(\"F(\" + (ni + 2) + \")/F(\" + (ni + 1) + \") = \" + fib[ni + 1] + \"/\" + fib[ni],\n px0, py0 + 28, { color: H.colors.accent, size: 15 });\nH.text(\"= \" + ratio.toFixed(5), px0, py0 + 52, { color: H.colors.accent2, size: 20, weight: 700 });\nH.text(\"phi = \" + PHI.toFixed(5), px0, py0 + 80, { color: H.colors.yellow, size: 16, weight: 700 });\nH.text(\"error = \" + Math.abs(ratio - PHI).toFixed(5), px0, py0 + 104,\n { color: H.colors.good, size: 13 });\n\nH.legend([\n { label: \"Fibonacci squares\", color: H.colors.accent },\n { label: \"golden spiral arc\", color: H.colors.yellow },\n { label: \"spiral tip\", color: H.colors.good },\n], px0, py0 + 140);" + }, + { + "id": "central-limit-theorem-means-converge", + "title": "Central Limit Theorem: means converge", + "tag": "Probability & Statistics", + "dimension": "3D", + "equation": "SE = sigma / sqrt(n)", + "summary": "A 3D ridge of normal curves for the sampling distribution of the sample mean. As the sample size n grows along the depth axis, each bell becomes taller and narrower, visualizing why the standard error shrinks like sigma/sqrt(n) and sample means converge on the true mean.", + "keywords": [ + "central limit theorem", + "clt", + "sampling distribution", + "sample mean", + "standard error", + "law of large numbers", + "convergence", + "normal distribution", + "gaussian", + "bell curve", + "variance shrinks", + "sqrt n", + "mean of means", + "sampling" + ], + "bullets": [ + "The peak narrows and rises as n increases because the standard error SE = sigma/sqrt(n) shrinks.", + "Every bell stays centered on the same true mean mu = 0 — only the spread changes.", + "The yellow marker rides the curve for the current n, where the live readout shows n and its SE." + ], + "student_prompts": [ + "Why does the standard error fall off like 1/sqrt(n) instead of 1/n?", + "If the population is NOT normal, does the sampling distribution of the mean still become bell-shaped?", + "How large does n need to be before the normal approximation is good enough?" + ], + "code": "H.background();\nconst cam = H.cam3d({ scale: 30, dist: 17, pitch: -0.5, cy: H.H * 0.56 });\ncam.yaw = 0.3 * t;\ncam.grid(6, 1);\n\n// Sample size n sweeps up and down over time so the sampling distribution\n// of the sample mean visibly narrows (CLT: SE = sigma / sqrt(n)).\nconst sigma = 1.0; // population standard deviation\nconst mu = 0; // true mean\nconst nMin = 1, nMax = 40;\nconst phase = (Math.sin(t * 0.5) + 1) / 2; // 0..1, smooth\nconst nF = H.lerp(nMin, nMax, phase);\nconst n = Math.max(1, Math.round(nF));\nconst se = sigma / Math.sqrt(n); // standard error\nconst seSafe = Math.max(se, 1e-3);\n\n// A ridge of normal curves, one per \"step\" of n along the z axis, each\n// scaled to the standard error at that n. Draw as a height surface:\n// height(x, z) = peak of a gaussian whose width depends on n(z).\nconst xMin = -4, xMax = 4;\nconst zMin = 0, zMax = 6;\nH.surface3d(cam, (x, z) => {\n // map z -> an n value so the back of the ridge is large-n (narrow/tall)\n const frac = (z - zMin) / (zMax - zMin);\n const nz = H.lerp(nMin, nMax, frac);\n const sez = Math.max(sigma / Math.sqrt(nz), 1e-3);\n const g = Math.exp(-0.5 * ((x - mu) / sez) * ((x - mu) / sez));\n return (g / sez) * 0.55; // taller + narrower as n grows\n}, { xMin: xMin, xMax: xMax, yMin: zMin, yMax: zMax, nx: 40, ny: 40,\n hueMin: 210, hueMax: 30, alpha: 0.95 });\n\ncam.axes(6);\n\n// A travelling marker on the \"current n\" curve to keep a live focal point.\nconst frac = H.clamp((nF - nMin) / (nMax - nMin), 0, 1);\nconst zc = H.lerp(zMin, zMax, frac);\nconst peak = (1 / seSafe) * 0.55;\nconst m = cam.sphere([mu, peak, zc], 0.22, { color: H.colors.yellow });\n\nH.text(\"Central Limit Theorem: means converge\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Sampling distribution of the mean narrows as n grows.\", 24, 52,\n { color: H.colors.sub, size: 13 });\nH.text(\"n = \" + n + \" SE = sigma/sqrt(n) = \" + seSafe.toFixed(3), 24, 76,\n { color: H.colors.sub, size: 13 });\nH.text(\"mu = \" + mu.toFixed(1) + \" sigma = \" + sigma.toFixed(1), 24, 96,\n { color: H.colors.sub, size: 13 });\nH.legend([\n { label: \"small n (wide, flat)\", color: H.hsl(210, 72, 55) },\n { label: \"large n (tall, narrow)\", color: H.hsl(30, 72, 55) },\n { label: \"current n\", color: H.colors.yellow },\n], 24, H.H - 70);" + }, + { + "id": "linear-regression-line-of-best-fit", + "title": "Linear regression: line of best fit", + "tag": "Probability & Statistics", + "dimension": "2D", + "equation": "y = m x + b (minimize SSE = sum (y - y_hat)^2)", + "summary": "A fixed scatter of points with a candidate line that rotates and slides toward the ordinary-least-squares solution. Dashed residual segments and a live sum-of-squared-errors readout show why the best-fit line is the one that minimizes total squared vertical distance.", + "keywords": [ + "linear regression", + "line of best fit", + "least squares", + "ols", + "residuals", + "sum of squared errors", + "sse", + "slope", + "intercept", + "scatter plot", + "trend line", + "correlation", + "fitting", + "regression line" + ], + "bullets": [ + "Each dashed red segment is a residual: the vertical gap between a point and the line's prediction.", + "The line settles at the slope and intercept that minimize SSE = sum of squared residuals.", + "Watch the SSE readout fall toward its minimum as the animated line locks onto the OLS fit." + ], + "student_prompts": [ + "Why squared residuals instead of absolute residuals for the line of best fit?", + "How do the closed-form OLS formulas for slope and intercept come from calculus?", + "What does R-squared tell us that SSE alone does not?" + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 11, yMin: -1, yMax: 11, pad: 50 });\nv.grid(); v.axes();\n\n// Fixed scatter (deterministic pseudo-random so the cloud is stable).\nconst N = 14;\nconst xs = [], ys = [];\nconst trueM = 0.8, trueB = 1.4;\nfor (let i = 0; i < N; i++) {\n const x = 0.6 + i * (9.0 / (N - 1));\n // deterministic \"noise\" from trig hashing of i\n const noise = 1.6 * Math.sin(i * 12.9898) + 1.1 * Math.cos(i * 4.231);\n const y = trueM * x + trueB + noise;\n xs.push(x); ys.push(y);\n}\n\n// Closed-form ordinary-least-squares fit of the fixed cloud.\nlet sx = 0, sy = 0, sxx = 0, sxy = 0;\nfor (let i = 0; i < N; i++) {\n sx += xs[i]; sy += ys[i]; sxx += xs[i] * xs[i]; sxy += xs[i] * ys[i];\n}\nconst denom = N * sxx - sx * sx;\nconst mBest = denom !== 0 ? (N * sxy - sx * sy) / denom : 0;\nconst bBest = (sy - mBest * sx) / N;\n\n// Animate a candidate line rotating toward the OLS best fit, then settling.\nconst settle = H.ease(H.clamp((Math.sin(t * 0.6) + 1) / 2, 0, 1));\nconst mStart = 0.1, bStart = 6.5;\nconst mNow = H.lerp(mStart, mBest, settle);\nconst bNow = H.lerp(bStart, bBest, settle);\nconst line = (x) => mNow * x + bNow;\n\n// Residual segments + running SSE for the current candidate line.\nlet sse = 0;\nfor (let i = 0; i < N; i++) {\n const yhat = line(xs[i]);\n const r = ys[i] - yhat;\n sse += r * r;\n v.line(xs[i], ys[i], xs[i], yhat,\n { color: H.colors.warn, width: 1.3, dash: [4, 4] });\n}\n\n// The candidate line of best fit.\nv.line(-1, line(-1), 11, line(11), { color: H.colors.accent2, width: 3 });\n\n// Scatter points on top.\nfor (let i = 0; i < N; i++) {\n v.dot(xs[i], ys[i], { r: 5, fill: H.colors.accent });\n}\n\nH.text(\"Linear regression: line of best fit\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Slope/intercept slide toward the least-squares minimum.\", 24, 52,\n { color: H.colors.sub, size: 13 });\nH.text(\"y = \" + mNow.toFixed(2) + \" x + \" + bNow.toFixed(2), 24, 76,\n { color: H.colors.accent2, size: 14, weight: 700 });\nH.text(\"SSE = \" + sse.toFixed(1) + \" (best = \"\n + (function () {\n let s = 0;\n for (let i = 0; i < N; i++) { const r = ys[i] - (mBest * xs[i] + bBest); s += r * r; }\n return s.toFixed(1);\n })() + \")\", 24, 96, { color: H.colors.sub, size: 13 });\nH.legend([\n { label: \"data points\", color: H.colors.accent },\n { label: \"fit line\", color: H.colors.accent2 },\n { label: \"residuals\", color: H.colors.warn },\n], H.W - 180, 40);" + }, + { + "id": "bayesian-update-coin-bias-beta", + "title": "Bayesian update: learning a coin's bias", + "tag": "Probability & Statistics", + "dimension": "2D", + "equation": "posterior = Beta(a0 + heads, b0 + tails)", + "summary": "A Beta(a,b) belief distribution over a coin's hidden bias updates as flips stream in. The dim prior reshapes into a sharper posterior that concentrates around the true bias, with a live readout of the flip counts, posterior parameters, and posterior mean.", + "keywords": [ + "bayes", + "bayesian update", + "bayes theorem", + "posterior", + "prior", + "beta distribution", + "conjugate prior", + "coin bias", + "belief update", + "probability", + "inference", + "likelihood", + "credible interval", + "learning" + ], + "bullets": [ + "Beta is the conjugate prior for a coin: each flip just adds 1 to a (head) or b (tail).", + "As evidence accumulates the posterior sharpens and its peak slides toward the true bias.", + "The green dashed line is the hidden true p; the orange dot tracks the posterior mean a/(a+b)." + ], + "student_prompts": [ + "Why does a Beta prior stay Beta after a Bernoulli observation — what makes it conjugate?", + "How would a strong prior like Beta(20,20) change how fast the posterior moves?", + "What is the difference between the posterior mean and the maximum a posteriori (MAP) estimate here?" + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 1, yMin: 0, yMax: 6, pad: 50 });\nv.grid({ stepX: 0.2 }); v.axes({ stepX: 0.2 });\n\n// Beta(a,b) density on theta in [0,1] — conjugate prior for a coin's bias.\n// log-gamma via Lanczos for a normalized, accurate Beta pdf.\nfunction logGamma(z) {\n const g = 7;\n const c = [0.99999999999980993, 676.5203681218851, -1259.1392167224028,\n 771.32342877765313, -176.61502916214059, 12.507343278686905,\n -0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7];\n if (z < 0.5) {\n return Math.log(Math.PI / Math.sin(Math.PI * z)) - logGamma(1 - z);\n }\n z -= 1;\n let x = c[0];\n for (let i = 1; i < g + 2; i++) x += c[i] / (z + i);\n const tt = z + g + 0.5;\n return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(tt) - tt + Math.log(x);\n}\nfunction betaPdf(x, a, b) {\n if (x <= 0 || x >= 1) return 0;\n const lb = logGamma(a + b) - logGamma(a) - logGamma(b)\n + (a - 1) * Math.log(x) + (b - 1) * Math.log(1 - x);\n const y = Math.exp(lb);\n return Number.isFinite(y) ? y : 0;\n}\n\n// Prior Beta(2,2). Stream in coin flips over time; each flip updates a,b.\nconst trueP = 0.7; // hidden bias we are learning\nconst a0 = 2, b0 = 2;\nconst maxFlips = 60;\nconst flips = Math.min(maxFlips, Math.floor((t % 14) / 14 * (maxFlips + 1)));\nlet heads = 0;\nfor (let i = 0; i < flips; i++) {\n // deterministic stream: ~trueP fraction are heads\n const u = (Math.sin(i * 78.233) * 43758.5453);\n const frac = u - Math.floor(u); // 0..1 pseudo-random\n if (frac < trueP) heads++;\n}\nconst tails = flips - heads;\nconst a = a0 + heads;\nconst b = b0 + tails;\nconst postMean = a / (a + b);\n\n// Draw prior (dim) and posterior (bright).\nv.fn((x) => betaPdf(x, a0, b0), { color: H.colors.sub, width: 2 });\nv.fn((x) => betaPdf(x, a, b), { color: H.colors.violet, width: 3.4 });\n\n// Mark the true bias and the posterior mean.\nv.line(trueP, 0, trueP, 6, { color: H.colors.good, width: 2, dash: [6, 5] });\nconst peakY = betaPdf(postMean, a, b);\nv.dot(postMean, Math.min(peakY, 6), { r: 6, fill: H.colors.accent2 });\n\nH.text(\"Bayesian update: learning a coin's bias\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Each flip reshapes Beta(a,b) toward the true value.\", 24, 52,\n { color: H.colors.sub, size: 13 });\nH.text(\"flips = \" + flips + \" (H \" + heads + \" / T \" + tails + \")\", 24, 76,\n { color: H.colors.sub, size: 13 });\nH.text(\"posterior Beta(\" + a + \", \" + b + \") mean = \" + postMean.toFixed(3),\n 24, 96, { color: H.colors.violet, size: 13 });\nH.text(\"true p = \" + trueP.toFixed(2), v.X(trueP) + 6, v.Y(5.4),\n { color: H.colors.good, size: 12 });\nH.legend([\n { label: \"prior Beta(2,2)\", color: H.colors.sub },\n { label: \"posterior\", color: H.colors.violet },\n { label: \"true bias\", color: H.colors.good },\n { label: \"posterior mean\", color: H.colors.accent2 },\n], H.W - 190, 40);" + }, + { + "id": "gradient-of-surface-z-fxy-3d", + "title": "Surface z = f(x,y) and Its Gradient", + "tag": "Multivariable Calculus", + "dimension": "3D", + "equation": "grad f = (df/dx, df/dy)", + "summary": "A solid, lit height surface z = f(x,y) (a two-bump landscape) rotates while a point orbits over the x-y plane. At that point the gradient vector is drawn on the ground plane, always pointing uphill, with its components and magnitude shown live.", + "keywords": [ + "gradient", + "grad f", + "del f", + "nabla", + "surface", + "z=f(x,y)", + "partial derivatives", + "steepest ascent", + "slope", + "multivariable", + "scalar field", + "3d surface", + "height map", + "vector field of gradient" + ], + "bullets": [ + "The gradient grad f = (df/dx, df/dy) lives in the x-y plane and points in the direction of steepest increase of f.", + "The length |grad f| equals the steepest slope of the surface at that point — it shrinks near flat tops and grows on steep flanks.", + "Partial derivatives here are estimated with central differences, the same idea as the analytic df/dx and df/dy." + ], + "student_prompts": [ + "Why does the gradient lie flat in the x-y plane instead of along the surface?", + "How is the gradient related to the contour (level) curves of f?", + "What happens to grad f exactly at a peak or a saddle point?" + ], + "code": "H.background();\nconst cam = H.cam3d({ scale: 40, dist: 15, pitch: -0.5, cy: H.H * 0.56 });\ncam.yaw = 0.3 * t;\nconst f = (x, y) =>\n 1.6 * Math.exp(-((x - 1) * (x - 1) + (y - 1) * (y - 1)) / 2.2) +\n 1.1 * Math.exp(-((x + 1.4) * (x + 1.4) + (y + 1.2) * (y + 1.2)) / 1.8);\ncam.grid(3.2, 0.8);\nH.surface3d(cam, (x, y) => f(x, y) * 1.4, {\n xMin: -3, xMax: 3, yMin: -3, yMax: 3, nx: 40, ny: 40, hueMin: 205, hueMax: 35,\n});\nconst px = 1.8 * Math.cos(t * 0.7);\nconst py = 1.8 * Math.sin(t * 0.7);\nconst h = 1e-3;\nconst fx = (f(px + h, py) - f(px - h, py)) / (2 * h);\nconst fy = (f(px, py + h) - f(px, py - h)) / (2 * h);\nconst gmag = Math.sqrt(fx * fx + fy * fy);\nconst zp = f(px, py) * 1.4;\ncam.line([px, 0, py], [px, zp, py], { color: H.colors.sub, width: 1.4 });\ncam.sphere([px, zp, py], 0.16, { color: H.colors.warn });\nconst s = 0.7;\nconst tip = [px + fx * s, 0.04, py + fy * s];\nconst a0 = cam.project([px, 0.04, py]);\nconst a1 = cam.project(tip);\nH.arrow(a0.x, a0.y, a1.x, a1.y, { color: H.colors.yellow, width: 3, head: 11 });\ncam.line([px, 0.04, py], [px, zp, py], { color: H.colors.warn, width: 1, dash: [4, 4] });\ncam.axes(3.4);\nH.text(\"Surface z = f(x, y) and its gradient\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"grad f points uphill in the x-y plane; its length is the steepest slope.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"point (x, y) = (\" + px.toFixed(2) + \", \" + py.toFixed(2) + \")\", 24, 84, { color: H.colors.ink, size: 13 });\nH.text(\"grad f = (\" + fx.toFixed(2) + \", \" + fy.toFixed(2) + \") |grad f| = \" + gmag.toFixed(2), 24, 104, { color: H.colors.yellow, size: 13 });\nH.legend([\n { label: \"surface z = f(x,y)\", color: H.colors.accent },\n { label: \"point on surface\", color: H.colors.warn },\n { label: \"grad f (gradient on x-y plane)\", color: H.colors.yellow },\n], 24, H.H - 64);" + }, + { + "id": "gradient-descent-loss-surface-3d", + "title": "Gradient Descent on a Loss Surface", + "tag": "Optimization / Machine Learning", + "dimension": "3D", + "equation": "x_{k+1} = x_k - alpha * grad L(x_k)", + "summary": "A ball runs gradient descent down a rippled bowl-shaped loss surface L(x,y). The descent is recomputed deterministically each frame, tracing a path that slides downhill into a minimum, with step count, learning rate, current loss, and gradient magnitude shown live.", + "keywords": [ + "gradient descent", + "optimization", + "loss surface", + "cost function", + "learning rate", + "minimum", + "convergence", + "steepest descent", + "backpropagation", + "training", + "machine learning", + "descent path", + "local minimum", + "step size" + ], + "bullets": [ + "Each iteration updates the position by x <- x - alpha*grad L, stepping opposite the gradient so the loss decreases.", + "The learning rate alpha sets the step length; the yellow trail shows the trajectory bending toward the basin's minimum.", + "|grad L| shrinks toward zero as the ball nears the minimum — that flattening is the signal that descent is converging." + ], + "student_prompts": [ + "What happens to the path if the learning rate is too large or too small?", + "How would momentum change the shape of this descent trajectory?", + "Why can gradient descent get stuck in a local minimum instead of the global one?" + ], + "code": "H.background();\nconst cam = H.cam3d({ scale: 42, dist: 15, pitch: -0.5, cy: H.H * 0.58 });\ncam.yaw = 0.25 * t;\nconst L = (x, y) =>\n 0.35 * (x * x + y * y) + 0.5 * Math.sin(1.3 * x) * Math.cos(1.3 * y) + 0.5;\ncam.grid(3, 1);\nH.surface3d(cam, (x, y) => L(x, y), {\n xMin: -3, xMax: 3, yMin: -3, yMax: 3, nx: 40, ny: 40, hueMin: 265, hueMax: 150, alpha: 0.9,\n});\nconst lr = 0.18;\nconst eps = 1e-3;\nconst grad = (x, y) => [\n (L(x + eps, y) - L(x - eps, y)) / (2 * eps),\n (L(x, y + eps) - L(x, y - eps)) / (2 * eps),\n];\nconst totalSteps = 60;\nconst k = Math.min(totalSteps, Math.floor((t % 9) / 9 * totalSteps));\nlet x = 2.5, y = -2.3;\nconst trail = [[x, L(x, y), y]];\nfor (let i = 0; i < k; i++) {\n const g0 = grad(x, y);\n x -= lr * g0[0];\n y -= lr * g0[1];\n if (!Number.isFinite(x) || !Number.isFinite(y)) break;\n x = H.clamp(x, -3, 3); y = H.clamp(y, -3, 3);\n trail.push([x, L(x, y), y]);\n}\nconst loss = L(x, y);\nconst g = grad(x, y);\nconst gmag = Math.sqrt(g[0] * g[0] + g[1] * g[1]);\ncam.path(trail, { color: H.colors.yellow, width: 3 });\ncam.path(trail.map((p) => [p[0], 0.02, p[2]]), { color: H.colors.grid, width: 1.5 });\ncam.sphere([x, loss, y], 0.18, { color: H.colors.warn });\ncam.line([x, 0, y], [x, loss, y], { color: H.colors.sub, width: 1, dash: [4, 4] });\ncam.axes(3.4);\nH.text(\"Gradient descent on a loss surface\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Each step moves against grad L (downhill). The ball settles in a minimum.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"step \" + k + \" / \" + totalSteps + \" learning rate = \" + lr.toFixed(2), 24, 84, { color: H.colors.ink, size: 13 });\nH.text(\"L(x, y) = \" + loss.toFixed(3) + \" |grad L| = \" + gmag.toFixed(3), 24, 104, { color: H.colors.yellow, size: 13 });\nH.legend([\n { label: \"loss surface L(x,y)\", color: H.colors.violet },\n { label: \"descent path\", color: H.colors.yellow },\n { label: \"current iterate\", color: H.colors.warn },\n], 24, H.H - 64);" + }, + { + "id": "divergence-curl-2d-vector-field", + "title": "Divergence & Curl of a 2D Vector Field", + "tag": "Vector Calculus", + "dimension": "2D", + "equation": "div F = dFx/dx + dFy/dy, curl F = dFy/dx - dFx/dy", + "summary": "A 2D vector field F(x,y) is drawn as speed-colored arrows with particles advected along its streamlines. A sweeping sample point shows the live divergence (net outflow) and scalar curl (rotation), plus a rotating unit vector u marking the direction for a directional derivative.", + "keywords": [ + "divergence", + "curl", + "vector field", + "flux", + "circulation", + "del dot f", + "del cross f", + "flow", + "streamlines", + "directional derivative", + "gradient", + "rotation", + "sources and sinks", + "2d field" + ], + "bullets": [ + "Divergence div F = dFx/dx + dFy/dy measures net outflow at a point — positive for a source, negative for a sink.", + "Scalar curl dFy/dx - dFx/dy measures local rotation; the ring turns blue for counterclockwise spin and pink for clockwise.", + "The rotating unit vector u sets the direction along which a directional derivative would be measured at the sample point." + ], + "student_prompts": [ + "How do divergence and curl relate to the flux and circulation forms of Green's theorem?", + "What does it mean physically when divergence is zero everywhere (an incompressible flow)?", + "How would I compute the directional derivative of a scalar field along the vector u?" + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -5, xMax: 5, yMin: -3.2, yMax: 3.2, pad: 50 });\nv.grid();\nv.axes();\nconst Fx = (x, y) => 0.6 * x - 0.9 * y + 0.4 * Math.sin(0.8 * y);\nconst Fy = (x, y) => 0.9 * x + 0.6 * y;\nconst step = 1.0;\nfor (let gx = -4.5; gx <= 4.5 + 1e-9; gx += step) {\n for (let gy = -3; gy <= 3 + 1e-9; gy += step) {\n const fx = Fx(gx, gy), fy = Fy(gx, gy);\n const mag = Math.sqrt(fx * fx + fy * fy) + 1e-9;\n const sc = 0.42 / Math.max(1, mag * 0.5);\n const hue = H.clamp(210 - mag * 14, 20, 210);\n v.arrow(gx, gy, gx + fx * sc, gy + fy * sc, { color: H.hsl(hue, 80, 62), width: 1.6, head: 6 });\n }\n}\nconst NP = 26;\nfor (let i = 0; i < NP; i++) {\n const ang = (i / NP) * H.TAU;\n let x = 3.4 * Math.cos(ang), y = 2.0 * Math.sin(ang);\n const dt = 0.05;\n const phase = (t * 0.9 + i * 0.13) % 1;\n const adv = Math.floor(phase * 10) + 3;\n for (let sN = 0; sN < adv && sN < 30; sN++) {\n const fx = Fx(x, y), fy = Fy(x, y);\n x += fx * dt; y += fy * dt;\n if (!Number.isFinite(x) || !Number.isFinite(y)) break;\n }\n if (Math.abs(x) < 5 && Math.abs(y) < 3.2)\n v.dot(x, y, { r: 3, fill: H.colors.good, stroke: H.colors.bg });\n}\nconst sx = 2.6 * Math.cos(t * 0.6);\nconst sy = 1.7 * Math.sin(t * 0.6);\nconst e = 1e-3;\nconst dFxdx = (Fx(sx + e, sy) - Fx(sx - e, sy)) / (2 * e);\nconst dFydy = (Fy(sx, sy + e) - Fy(sx, sy - e)) / (2 * e);\nconst dFydx = (Fy(sx + e, sy) - Fy(sx - e, sy)) / (2 * e);\nconst dFxdy = (Fx(sx, sy + e) - Fx(sx, sy - e)) / (2 * e);\nconst div = dFxdx + dFydy;\nconst curl = dFydx - dFxdy;\nconst ua = t * 0.8;\nconst ux = Math.cos(ua), uy = Math.sin(ua);\nconst dirArrowLen = 1.0;\nv.arrow(sx, sy, sx + ux * dirArrowLen, sy + uy * dirArrowLen, { color: H.colors.yellow, width: 2.6, head: 9 });\nconst ringR = 26 + 6 * Math.sin(t * 2);\nH.circle(v.X(sx), v.Y(sy), ringR, { stroke: curl >= 0 ? H.colors.accent : H.colors.warn, width: 2 });\nv.dot(sx, sy, { r: 6, fill: H.colors.violet });\nv.text(\"u\", sx + ux * dirArrowLen + 0.1, sy + uy * dirArrowLen + 0.1, { color: H.colors.yellow, size: 13 });\nH.text(\"Divergence & curl of a 2D vector field\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"F(x,y) = (0.6x - 0.9y + .4sin.8y, 0.9x + 0.6y). div = outflow, curl = spin.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"sample (x,y) = (\" + sx.toFixed(2) + \", \" + sy.toFixed(2) + \")\", 24, 84, { color: H.colors.violet, size: 13 });\nH.text(\"div F = \" + div.toFixed(2) + \" curl F = \" + curl.toFixed(2), 24, 104, { color: H.colors.ink, size: 13 });\nH.text(\"u = (\" + ux.toFixed(2) + \", \" + uy.toFixed(2) + \") -> direction of derivative\", 24, 124, { color: H.colors.yellow, size: 13 });\nH.legend([\n { label: \"field F (arrows)\", color: H.colors.accent },\n { label: \"advected particles\", color: H.colors.good },\n { label: \"sample point\", color: H.colors.violet },\n { label: \"direction u\", color: H.colors.yellow },\n], 24, H.H - 84);" + }, + { + "id": "dna-double-helix-3d", + "title": "DNA Double Helix (B-form)", + "tag": "Molecular Biology", + "dimension": "3D", + "equation": "strand: (r·cos θ, y, r·sin θ), θ = 2π·turns·s", + "summary": "A right-handed B-DNA double helix rotates in 3D: two antiparallel sugar–phosphate backbones (shaded spheres) wound 180° apart and joined by color-coded base-pair rungs, with a reading highlight sweeping up the molecule.", + "keywords": [ + "dna", + "double helix", + "b-dna", + "nucleotide", + "base pair", + "adenine thymine guanine cytosine", + "genetics", + "molecular biology", + "backbone", + "antiparallel strands", + "chromosome", + "genome", + "biochemistry", + "3d molecule" + ], + "bullets": [ + "Two antiparallel strands (5'→3' blue, 3'→5' orange) coil into one right-handed helix.", + "Rungs are complementary base pairs (A-T / G-C) holding the strands together via hydrogen bonds.", + "B-DNA rises ~0.34 nm per base pair, so the displayed helix length tracks the base-pair count." + ], + "student_prompts": [ + "Why are the two strands antiparallel and what does 5'→3' mean?", + "How do A-T and G-C base pairing rules keep the helix width constant?", + "What is the difference between B-DNA, A-DNA, and Z-DNA conformations?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst cam = H.cam3d({ scale: 30, dist: 20, pitch: -0.18, cy: h * 0.52 });\ncam.yaw = 0.45 * t;\n\n// One full turn of the helix unwinds/rewinds slightly so it breathes.\nconst N = 40; // base-pair rungs\nconst turns = 2.2; // helical turns across the strand\nconst radius = 2.2;\nconst ylo = -5.2, yhi = 5.2;\nconst twist = 0.22 * Math.sin(t * 0.6); // gentle live winding\n\n// Base-pair color coding (A-T vs G-C) cycles deterministically.\nconst pairColors = [H.colors.good, H.colors.warn, H.colors.violet, H.colors.yellow];\n\n// Collect every drawable (backbone balls + rung balls) for depth sorting.\nconst balls = [];\nconst rungs = [];\n\nfor (let i = 0; i < N; i++) {\n const s = i / (N - 1);\n const ang = s * H.TAU * turns + twist * i;\n const ypos = H.lerp(ylo, yhi, s);\n // Two antiparallel sugar-phosphate backbones, 180 deg apart.\n const a = [radius * Math.cos(ang), ypos, radius * Math.sin(ang)];\n const b = [radius * Math.cos(ang + H.PI), ypos, radius * Math.sin(ang + H.PI)];\n balls.push({ p: a, color: H.colors.accent, r: 0.34 });\n balls.push({ p: b, color: H.colors.accent2, r: 0.34 });\n\n // A travelling \"replication / read\" highlight sweeps up the molecule.\n const head = (t * 0.32) % 1;\n const lit = Math.abs(s - head) < 0.06;\n const c = pairColors[i % pairColors.length];\n rungs.push({ a: a, b: b, color: c, lit: lit });\n}\n\n// Draw rungs (base pairs) first as thin bonds behind the spheres.\nfor (let i = 0; i < rungs.length; i++) {\n const r = rungs[i];\n cam.line(r.a, r.b, {\n color: r.lit ? H.colors.ink : r.color,\n width: r.lit ? 3.2 : 1.5,\n });\n}\n\n// Depth-sort spheres far-to-near and shade them.\nballs\n .map((o) => ({ p: o.p, color: o.color, r: o.r, depth: cam.project(o.p).depth }))\n .sort((m, n) => n.depth - m.depth)\n .forEach((o) => cam.sphere(o.p, o.r, { color: o.color }));\n\ncam.axes(6);\n\n// Labels + live readout.\nconst rise = 0.34; // nm per base pair (B-DNA)\nconst helixLen = (N * rise);\nH.text(\"DNA double helix (B-form)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Two antiparallel strands wound into a right-handed helix.\", 24, 52,\n { color: H.colors.sub, size: 13 });\nH.text(\"read position = \" + ((t * 0.32) % 1 * 100).toFixed(0) + \"% length ≈ \"\n + helixLen.toFixed(1) + \" nm\", 24, 76, { color: H.colors.sub, size: 13 });\nH.legend([\n { label: \"strand 5'→3'\", color: H.colors.accent },\n { label: \"strand 3'→5'\", color: H.colors.accent2 },\n { label: \"base pair (A-T/G-C)\", color: H.colors.good },\n], 24, h - 70);" + }, + { + "id": "lotka-volterra-predator-prey", + "title": "Lotka–Volterra Predator–Prey Cycles", + "tag": "Population Ecology", + "dimension": "2D", + "equation": "dx/dt = a·x − b·x·y , dy/dt = d·x·y − c·y", + "summary": "Side-by-side time series and phase portrait of the Lotka–Volterra model: prey and predator populations are integrated forward in real time, tracing oscillating waves on the left and a closed orbit around the equilibrium on the right.", + "keywords": [ + "lotka volterra", + "predator prey", + "population dynamics", + "ecology", + "oscillation", + "rabbits foxes", + "phase portrait", + "limit cycle", + "differential equations", + "equilibrium", + "prey predator cycle", + "mathematical biology", + "food web", + "carrying capacity" + ], + "bullets": [ + "Prey grows exponentially but is eaten; predators die off but reproduce by eating prey.", + "The two populations oscillate out of phase — prey peaks lead predator peaks.", + "On the phase plane the trajectory is a closed loop circling the fixed point (c/d, a/b)." + ], + "student_prompts": [ + "Why do the predator and prey peaks happen at different times?", + "What happens to the cycles if I increase the predator death rate c?", + "How does adding a prey carrying capacity (logistic term) change the closed orbit?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\n\n// Lotka-Volterra parameters: prey grows (a), eaten (b); predator dies (c), fed (d).\nconst a = 1.1, b = 0.4, d = 0.1, c = 0.4;\n// Integrate forward each frame from a fixed start so motion is a pure fn of t.\nconst dt = 0.02;\nconst steps = Math.min(900, Math.floor((t % 30) / dt) + 1);\nlet x = 10, y = 5; // prey, predator populations\nconst trail = [[x, y]];\nfor (let i = 0; i < steps; i++) {\n const dx = a * x - b * x * y;\n const dy = d * x * y - c * y;\n // midpoint to keep the closed orbit from spiralling out numerically\n const xm = x + 0.5 * dt * dx;\n const ym = y + 0.5 * dt * dy;\n const dxm = a * xm - b * xm * ym;\n const dym = d * xm * ym - c * ym;\n x = Math.max(0.01, x + dt * dxm);\n y = Math.max(0.01, y + dt * dym);\n if (i % 3 === 0) trail.push([x, y]);\n}\n\n// LEFT: time series of both populations.\nconst vt = H.plot2d({\n xMin: 0, xMax: 30, yMin: 0, yMax: 28,\n box: { x: 60, y: 70, w: w * 0.52 - 70, h: h - 150 },\n});\nvt.grid(); vt.axes();\n// Rebuild the two time series up to \"now\".\nconst preyPts = [], predPts = [];\nlet xx = 10, yy = 5;\nfor (let i = 0; i <= steps; i++) {\n const tm = i * dt;\n if (i % 2 === 0) { preyPts.push([tm, xx]); predPts.push([tm, yy]); }\n const dx = a * xx - b * xx * yy;\n const dy = d * xx * yy - c * yy;\n const xm = xx + 0.5 * dt * dx, ym = yy + 0.5 * dt * dy;\n const dxm = a * xm - b * xm * ym, dym = d * xm * ym - c * ym;\n xx = Math.max(0.01, xx + dt * dxm);\n yy = Math.max(0.01, yy + dt * dym);\n}\nvt.path(preyPts, { color: H.colors.good, width: 2.6 });\nvt.path(predPts, { color: H.colors.warn, width: 2.6 });\nvt.dot(Math.min(30, steps * dt), x, { r: 5, fill: H.colors.good });\nvt.dot(Math.min(30, steps * dt), y, { r: 5, fill: H.colors.warn });\nH.text(\"population\", 60, 60, { color: H.colors.sub, size: 12 });\nH.text(\"time\", vt.box.x + vt.box.w - 26, vt.box.y + vt.box.h + 30,\n { color: H.colors.sub, size: 12 });\n\n// RIGHT: phase portrait (predator vs prey) - the closed orbit.\nconst vp = H.plot2d({\n xMin: 0, xMax: 26, yMin: 0, yMax: 18,\n box: { x: w * 0.56 + 20, y: 70, w: w * 0.40 - 40, h: h - 150 },\n});\nvp.grid(); vp.axes();\nvp.path(trail, { color: H.colors.accent, width: 2.2 });\n// Equilibrium fixed point (c/d, a/b).\nvp.dot(c / d, a / b, { r: 4, fill: H.colors.yellow, stroke: H.colors.bg });\nvp.dot(x, y, { r: 6, fill: H.colors.accent2 });\nH.text(\"predator\", vp.box.x - 6, 60, { color: H.colors.sub, size: 12 });\nH.text(\"prey →\", vp.box.x + vp.box.w - 50, vp.box.y + vp.box.h + 30,\n { color: H.colors.sub, size: 12 });\n\n// Title, caption, live readout, legend.\nH.text(\"Lotka–Volterra predator–prey cycles\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Prey booms feed predators; predators crash, prey recover — a closed loop.\",\n 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"prey = \" + x.toFixed(1) + \" predators = \" + y.toFixed(1)\n + \" t = \" + (steps * dt).toFixed(1) + \"s\",\n w * 0.56 + 20, 52, { color: H.colors.sub, size: 13 });\nH.legend([\n { label: \"prey (rabbits)\", color: H.colors.good },\n { label: \"predators (foxes)\", color: H.colors.warn },\n { label: \"phase orbit\", color: H.colors.accent },\n], 60, h - 46);" + }, + { + "id": "neuron-action-potential", + "title": "Neuron Action Potential", + "tag": "Neuroscience", + "dimension": "2D", + "equation": "Vm(t): rest −70 mV → threshold −55 → peak +40 → AHP −80", + "summary": "A repeating action-potential trace plots membrane voltage versus time with threshold and resting reference lines, while a live phase label and an animated Na⁺/K⁺ channel diagram show which ion current is driving each stage of the spike.", + "keywords": [ + "action potential", + "neuron", + "membrane potential", + "depolarization", + "repolarization", + "hyperpolarization", + "sodium potassium", + "na+ k+ channel", + "neuroscience", + "spike", + "threshold", + "resting potential", + "refractory period", + "electrophysiology" + ], + "bullets": [ + "Slow depolarization to −55 mV threshold triggers an all-or-none spike to +40 mV.", + "Voltage-gated Na⁺ channels open first (rising phase); K⁺ channels open to repolarize.", + "K⁺ efflux overshoots to an after-hyperpolarization before the −70 mV resting state returns." + ], + "student_prompts": [ + "Why is the action potential described as 'all-or-none'?", + "What causes the refractory period and why can't a second spike fire immediately?", + "How do voltage-gated Na⁺ and K⁺ channels differ in their opening and closing timing?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\n\n// Action-potential waveform as a function of phase within a repeating spike.\n// Resting -70 mV, threshold -55, peak +40, after-hyperpolarization -80.\nconst period = 4.0; // seconds between spikes (slowed for teaching)\nconst phase = (t % period);\nfunction Vm(ph) {\n const rest = -70, thr = -55, peak = 40, ahp = -80;\n if (ph < 1.2) { // resting / slow depolarization to threshold\n return H.lerp(rest, thr, H.ease(ph / 1.2));\n } else if (ph < 1.55) { // fast Na+ influx: depolarize to peak\n return H.lerp(thr, peak, H.ease((ph - 1.2) / 0.35));\n } else if (ph < 2.1) { // K+ efflux: repolarize\n return H.lerp(peak, ahp, H.ease((ph - 1.55) / 0.55));\n } else if (ph < 2.9) { // after-hyperpolarization recovers to rest\n return H.lerp(ahp, rest, H.ease((ph - 2.1) / 0.8));\n }\n return rest;\n}\nconst v = Vm(phase);\n\n// Plot the membrane potential trace over one cycle.\nconst view = H.plot2d({\n xMin: 0, xMax: period, yMin: -90, yMax: 50,\n box: { x: 64, y: 78, w: w - 230, h: h - 160 },\n});\nview.grid(); view.axes();\n\n// Threshold and resting reference lines.\nview.line(0, -55, period, -55, { color: H.colors.violet, width: 1.4, dash: [6, 5] });\nview.text(\"threshold –55 mV\", period * 0.62, -49,\n { color: H.colors.violet, size: 11 });\nview.line(0, -70, period, -70, { color: H.colors.axis, width: 1.2, dash: [3, 5] });\nview.text(\"rest –70 mV\", period * 0.02, -66, { color: H.colors.sub, size: 11 });\n\n// Full waveform (faint) + the swept-in portion (bright).\nconst full = [], swept = [];\nconst M = 160;\nfor (let i = 0; i <= M; i++) {\n const ph = (i / M) * period;\n const pt = [ph, Vm(ph)];\n full.push(pt);\n if (ph <= phase) swept.push(pt);\n}\nview.path(full, { color: H.hsl(205, 40, 45, 0.35), width: 2 });\nif (swept.length > 1) view.path(swept, { color: H.colors.accent, width: 3 });\nview.dot(phase, v, { r: 6, fill: H.colors.accent2 });\n\n// Phase label that names the current ion event.\nlet stage = \"resting\", sc = H.colors.sub;\nif (phase >= 1.2 && phase < 1.55) { stage = \"depolarization (Na+ in)\"; sc = H.colors.warn; }\nelse if (phase >= 1.55 && phase < 2.1) { stage = \"repolarization (K+ out)\"; sc = H.colors.accent; }\nelse if (phase >= 2.1 && phase < 2.9) { stage = \"hyperpolarization (refractory)\"; sc = H.colors.violet; }\nelse if (phase >= 0.7 && phase < 1.2) { stage = \"approaching threshold\"; sc = H.colors.good; }\n\n// A small membrane channel diagram on the right that opens/closes with phase.\nconst mx = w - 140, my = h * 0.5;\nH.text(\"membrane\", mx - 20, my - 92, { color: H.colors.sub, size: 12 });\nH.line(mx - 44, my - 70, mx + 44, my - 70, { color: H.colors.axis, width: 2 });\nH.line(mx - 44, my + 70, mx + 44, my + 70, { color: H.colors.axis, width: 2 });\nconst naOpen = phase >= 1.2 && phase < 1.6;\nconst kOpen = phase >= 1.5 && phase < 2.3;\n// Na+ channel\nH.circle(mx - 22, my, 16, { stroke: H.colors.warn, width: 2, fill: naOpen ? H.hsl(350, 70, 40, 0.5) : \"rgba(0,0,0,0)\" });\nH.text(\"Na⁺\", mx - 33, my + 4, { color: H.colors.warn, size: 11 });\nif (naOpen) H.arrow(mx - 22, my - 60, mx - 22, my + 18, { color: H.colors.warn, width: 2, head: 7 });\n// K+ channel\nH.circle(mx + 22, my, 16, { stroke: H.colors.accent, width: 2, fill: kOpen ? H.hsl(205, 70, 45, 0.5) : \"rgba(0,0,0,0)\" });\nH.text(\"K⁺\", mx + 13, my + 4, { color: H.colors.accent, size: 11 });\nif (kOpen) H.arrow(mx + 22, my + 18, mx + 22, my + 84, { color: H.colors.accent, width: 2, head: 7 });\n\n// Titles + live readout.\nH.text(\"Neuron action potential\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Membrane voltage spikes as Na⁺ rushes in, then K⁺ restores rest.\",\n 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"Vm = \" + v.toFixed(1) + \" mV\", 24, 76, { color: H.colors.accent2, size: 14, weight: 600 });\nH.text(\"phase: \" + stage, 200, 76, { color: sc, size: 13, weight: 600 });\nH.text(\"mV\", 30, 70, { color: H.colors.sub, size: 12 });\nH.text(\"time (cycle)\", view.box.x + view.box.w - 70, view.box.y + view.box.h + 30,\n { color: H.colors.sub, size: 12 });\nH.legend([\n { label: \"membrane potential Vm\", color: H.colors.accent },\n { label: \"Na⁺ influx\", color: H.colors.warn },\n { label: \"K⁺ efflux / AHP\", color: H.colors.violet },\n], 64, h - 46);" + }, + { + "id": "heat-diffusion-plate-colormap", + "title": "Heat diffusion across a plate", + "tag": "Thermodynamics", + "dimension": "2D", + "equation": "u_t = α ∇²u", + "summary": "A hot spot on a square metal plate spreads out and cools toward the fixed-cold edges, shown as a live blue-to-red temperature color map driven by the analytic Fourier-mode solution of the heat equation.", + "keywords": [ + "heat diffusion", + "heat equation", + "thermal conduction", + "temperature field", + "color map", + "heatmap", + "fourier modes", + "diffusivity", + "laplacian", + "cooling plate", + "conduction", + "thermodynamics", + "u_t = alpha laplacian u", + "heat spreading" + ], + "bullets": [ + "Each Fourier sine mode decays like exp(-α π²(m²+n²)t), so fine/sharp features (high m,n) vanish fastest — the bump smooths before it fades.", + "The color bar maps temperature relative to the initial peak T0; watch the hottest cell drop from 100% toward 0 as heat leaks to the cold boundary.", + "Larger thermal diffusivity α makes every mode decay faster, so a more conductive plate equilibrates sooner." + ], + "student_prompts": [ + "What happens if I make the diffusivity α ten times larger?", + "How would the picture change if the edges were insulated instead of held cold?", + "Why do the sharp, high-frequency features in the heat map disappear before the broad ones?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\n// ---- Heat diffusion on a square plate, u_t = alpha * laplacian(u) ----\n// Closed-form solution on [0,1]x[0,1] with fixed cold edges (u=0 on boundary):\n// a hot Gaussian-ish initial bump decays as a sum of sine modes, each mode\n// shrinking like exp(-alpha*pi^2*(m^2+n^2)*tau). Stable, never blows up.\nconst alpha = 0.18; // thermal diffusivity (arb. units)\nconst tau = (t % 14) * 1.0; // simulation clock, loops every 14 s\n// Plate pixel box\nconst M = 26; // grid cells per side\nconst plateX = 70, plateY = 96;\nconst plateW = Math.min(w - 320, h - 150);\nconst cell = plateW / M;\nconst plateH = plateW;\n// Precompute a small set of Fourier amplitudes for an initial off-center hot spot.\nconst modes = [];\nconst MX = 5, MY = 5;\nconst cx0 = 0.38, cy0 = 0.42; // initial hot-spot center (unit square)\nfor (let mx = 1; mx <= MX; mx++) {\n for (let my = 1; my <= MY; my++) {\n // amplitude ~ projection of a localized bump onto sin modes\n const amp =\n Math.sin(mx * Math.PI * cx0) *\n Math.sin(my * Math.PI * cy0) *\n Math.exp(-0.5 * (mx * mx + my * my) * 0.10);\n modes.push({ mx, my, amp, lam: (mx * mx + my * my) });\n }\n}\n// Temperature field T(x,y,tau) in [0,1]; track max for the readout.\nconst temp = (ux, uy) => {\n let s = 0;\n for (let k = 0; k < modes.length; k++) {\n const m = modes[k];\n s +=\n m.amp *\n Math.sin(m.mx * Math.PI * ux) *\n Math.sin(m.my * Math.PI * uy) *\n Math.exp(-alpha * Math.PI * Math.PI * m.lam * tau);\n }\n return s;\n};\n// Color ramp cold(blue) -> hot(red/white). v in [0,1].\nconst heatColor = (v) => {\n v = H.clamp(v, 0, 1);\n // blue(230) -> cyan -> yellow -> red(0), brighten toward the top end.\n const hue = H.lerp(235, 0, v);\n const light = H.lerp(22, 62, v);\n const sat = H.lerp(70, 92, v);\n return H.hsl(hue, sat, light);\n};\n// Normalize against the t=0 peak so the color scale is steady.\nlet peak0 = 1e-6;\nfor (let i = 0; i < M; i++) {\n for (let j = 0; j < M; j++) {\n const v = temp((i + 0.5) / M, (j + 0.5) / M);\n if (v > peak0) peak0 = v;\n }\n}\nlet curMax = 0;\nfor (let j = 0; j < M; j++) {\n for (let i = 0; i < M; i++) {\n const ux = (i + 0.5) / M;\n const uy = (j + 0.5) / M;\n let v = temp(ux, uy) / peak0;\n if (v < 0) v = 0; // temperature above ambient is nonnegative\n if (v > curMax) curMax = v;\n const px = plateX + i * cell;\n const py = plateY + (M - 1 - j) * cell; // flip so +y is up\n H.rect(px, py, cell + 0.8, cell + 0.8, { fill: heatColor(v) });\n }\n}\n// Plate frame\nH.rect(plateX, plateY, plateW, plateH, { stroke: H.colors.axis, width: 1.6 });\n// Axis ticks (physical plate coordinates 0..1)\nfor (let g = 0; g <= 1.0001; g += 0.25) {\n const gx = plateX + g * plateW;\n const gy = plateY + plateH;\n H.text(g.toFixed(2), gx, gy + 16, { color: H.colors.sub, size: 11, align: \"center\" });\n H.text(g.toFixed(2), plateX - 8, plateY + (1 - g) * plateH, { color: H.colors.sub, size: 11, align: \"right\", baseline: \"middle\" });\n}\nH.text(\"x\", plateX + plateW / 2, plateY + plateH + 32, { color: H.colors.sub, size: 12, align: \"center\" });\nH.text(\"y\", plateX - 30, plateY + plateH / 2, { color: H.colors.sub, size: 12, align: \"center\", baseline: \"middle\" });\n// ---- Color legend bar ----\nconst barX = plateX + plateW + 40, barY = plateY, barW = 22, barH = plateH;\nconst segs = 40;\nfor (let k = 0; k < segs; k++) {\n const v = 1 - k / segs;\n H.rect(barX, barY + (k / segs) * barH, barW, barH / segs + 0.8, { fill: heatColor(v) });\n}\nH.rect(barX, barY, barW, barH, { stroke: H.colors.axis, width: 1.2 });\nH.text(\"hot\", barX + barW + 8, barY + 6, { color: H.colors.warn, size: 12 });\nH.text(\"cold\", barX + barW + 8, barY + barH, { color: H.colors.accent, size: 12 });\nH.text(\"T / T0\", barX, barY - 12, { color: H.colors.sub, size: 12 });\n// ---- Title + live readout ----\nH.text(\"Heat diffusion across a plate\", 24, 34, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"u_t = α ∇²u — a hot spot spreads and cools toward the edges\", 24, 56, { color: H.colors.sub, size: 13 });\nconst peakPct = (curMax * 100);\nH.text(\"t = \" + tau.toFixed(2) + \" s\", barX - 4, barY + barH + 60, { color: H.colors.ink, size: 14, weight: 700 });\nH.text(\"peak temp = \" + peakPct.toFixed(1) + \"% of T0\", barX - 4, barY + barH + 82, { color: H.colors.accent2, size: 13 });\nH.text(\"α = \" + alpha.toFixed(2), barX - 4, barY + barH + 102, { color: H.colors.sub, size: 13 });" + }, + { + "id": "ideal-gas-particles-box-3d", + "title": "Ideal gas in a box", + "tag": "Statistical Mechanics", + "dimension": "3D", + "equation": "PV = NkT, T ∝ ⟨v²⟩", + "summary": "Atoms of an ideal gas fly in straight lines and bounce elastically off the walls of a 3D box, each colored by its speed; a live left/right count shows the gas stays statistically uniform while temperature tracks the mean square speed.", + "keywords": [ + "ideal gas", + "gas particles", + "kinetic theory", + "molecules in a box", + "elastic collisions", + "gas law", + "pv = nkt", + "temperature kinetic energy", + "atoms bouncing", + "gas pressure", + "statistical mechanics", + "particle simulation", + "3d box gas" + ], + "bullets": [ + "Each atom moves at constant velocity between walls and reverses on impact (elastic collision), so total kinetic energy — and therefore temperature — is conserved.", + "Faster atoms are drawn warmer (orange) and slower ones cooler (blue); temperature is proportional to the mean of v², not v.", + "The instantaneous left|right tally near 14|14 shows that random motion keeps the two halves of the box equally populated — the basis of uniform pressure." + ], + "student_prompts": [ + "How is the pressure on a wall related to how fast and how often atoms hit it?", + "If I doubled the temperature, how would the typical atom speed change?", + "Why does the gas stay evenly spread between the two halves instead of clumping?" + ], + "code": "H.background();\n// ---- Ideal gas: N atoms bouncing elastically inside a 3D box ----\nconst cam = H.cam3d({ scale: 30, dist: 16, pitch: -0.35, cy: H.H * 0.52 });\ncam.yaw = 0.25 * t; // slow auto-spin so it reads as 3D\nconst L = 3.2; // half-box size (world units)\n// Deterministic pseudo-random per-particle parameters (pure function of t-free\n// seeds) so motion is smooth and reproducible each frame.\nconst N = 28;\nconst rand = (i, k) => {\n const s = Math.sin(i * 12.9898 + k * 78.233) * 43758.5453;\n return s - Math.floor(s); // 0..1\n};\n// Continuous triangle wave: a particle bouncing elastically between walls at\n// -lim and +lim, given a linearly advancing argument.\nconst tri = (val, lim) => {\n // continuous triangle wave bouncing in [-lim, lim] given a linear val\n const span = 2 * lim;\n let m = ((val % (2 * span)) + 2 * span) % (2 * span); // 0..2span\n if (m > span) m = 2 * span - m; // 0..span\n return m - lim; // -lim..lim\n};\n// Draw box wireframe (depth-sorted edges look fine as plain lines)\nconst c = [\n [-L, -L, -L], [L, -L, -L], [L, L, -L], [-L, L, -L],\n [-L, -L, L], [L, -L, L], [L, L, L], [-L, L, L],\n];\nconst edges = [\n [0,1],[1,2],[2,3],[3,0], [4,5],[5,6],[6,7],[7,4], [0,4],[1,5],[2,6],[3,7],\n];\ncam.grid(L, L, { color: H.colors.grid });\nedges.forEach((e) => cam.line(c[e[0]], c[e[1]], { color: H.colors.axis, width: 1.3 }));\n// Particle positions + speeds\nconst r = 0.16;\nlet vSum = 0, vMax = 0, leftCount = 0;\nconst balls = [];\nfor (let i = 0; i < N; i++) {\n const sx = 0.4 + rand(i, 1) * 0.9; // speed components (world units / s)\n const sy = 0.4 + rand(i, 2) * 0.9;\n const sz = 0.4 + rand(i, 3) * 0.9;\n const ph = rand(i, 4) * 100; // phase offset\n const x = tri(sx * t + ph, L - r);\n const y = tri(sy * t + ph * 1.3, L - r);\n const z = tri(sz * t + ph * 0.7, L - r);\n const speed = Math.sqrt(sx * sx + sy * sy + sz * sz);\n vSum += speed;\n if (speed > vMax) vMax = speed;\n if (x < 0) leftCount++; // instantaneous count in the x<0 half\n // color warm = fast, cool = slow\n const frac = H.clamp((speed - 0.7) / 1.6, 0, 1);\n const col = H.hsl(H.lerp(205, 25, frac), 85, H.lerp(58, 60, frac));\n balls.push({ p: [x, y, z], r: r, col: col, depth: cam.project([x, y, z]).depth });\n}\nballs.sort((a, b) => b.depth - a.depth).forEach((o) => cam.sphere(o.p, o.r, { color: o.col }));\n// Faint divider plane at x=0 to make the left/right count meaningful.\ncam.line([0, -L, -L], [0, -L, L], { color: H.colors.violet, width: 1 });\ncam.line([0, -L, -L], [0, L, -L], { color: H.colors.violet, width: 1 });\ncam.axes(L + 0.6);\nconst vAvg = vSum / N;\nconst rightCount = N - leftCount;\n// ---- Title + live readout ----\nH.text(\"Ideal gas in a box\", 24, 34, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"N atoms in constant elastic motion — temperature ∝ mean kinetic energy\", 24, 56, { color: H.colors.sub, size: 13 });\nH.text(\"N = \" + N + \" atoms\", 24, H.H - 100, { color: H.colors.accent, size: 13 });\nH.text(\"⟨v⟩ = \" + vAvg.toFixed(3) + \" (arb.)\", 24, H.H - 78, { color: H.colors.ink, size: 14, weight: 700 });\nH.text(\"T ∝ ⟨v²⟩ → \" + (vAvg * vAvg).toFixed(3), 24, H.H - 56, { color: H.colors.accent2, size: 13 });\nH.text(\"left | right = \" + leftCount + \" | \" + rightCount + \" (t = \" + (t % 100).toFixed(1) + \" s)\", 24, H.H - 34, { color: H.colors.violet, size: 13 });\nH.legend([\n { label: \"fast (hot)\", color: H.hsl(25, 85, 60) },\n { label: \"slow (cool)\", color: H.hsl(205, 85, 58) },\n], H.W - 150, H.H - 70);" + }, + { + "id": "maxwell-boltzmann-speed-distribution", + "title": "Maxwell–Boltzmann speed distribution", + "tag": "Statistical Mechanics", + "dimension": "2D", + "equation": "f(v) = √(2/π) · v² e^(−v²/2a²) / a³, a = √(kT/m)", + "summary": "A histogram of thousands of random molecular speeds fills in over time and converges onto the analytic Maxwell-Boltzmann curve, with the most-probable speed marked and temperature slowly breathing.", + "keywords": [ + "maxwell-boltzmann", + "speed distribution", + "molecular speeds", + "velocity distribution", + "kinetic theory", + "statistical mechanics", + "most probable speed", + "rms speed", + "mean speed", + "histogram converging", + "probability density", + "gas speeds", + "thermal distribution", + "boltzmann" + ], + "bullets": [ + "The curve f(v) rises as v² for slow molecules but is killed by the e^(−v²/2a²) factor at high speed, giving the characteristic skewed peak.", + "Three speeds differ: most-probable v_p = a√2 (the peak), mean ⟨v⟩ = a√(8/π), and root-mean-square v_rms = a√3 — always v_p < ⟨v⟩ < v_rms.", + "As more samples accumulate, the noisy histogram settles onto the smooth theoretical density; raising temperature T widens the curve and shifts the peak right." + ], + "student_prompts": [ + "Why is the most-probable speed smaller than the average speed?", + "How does the whole distribution change when the gas is heated?", + "Where does the v² factor in front of the exponential come from?" + ], + "code": "H.background();\n// ---- Maxwell-Boltzmann speed distribution forming from samples ----\n// Analytic 3D speed pdf (mass=kT=1 reduced units):\n// f(v) = sqrt(2/pi) * v^2 * exp(-v^2 / (2 a^2)) / a^3, a = sqrt(kT/m)\n// We let temperature breathe slowly and draw both the smooth curve and a live\n// histogram of N pseudo-random samples that \"fills in\" toward the curve.\nconst T = 1.6 + 0.6 * Math.sin(t * 0.35); // temperature (>0 always)\nconst a = Math.sqrt(T); // scale parameter\nconst vMaxAxis = 6;\nconst v = H.plot2d({ xMin: 0, xMax: vMaxAxis, yMin: 0, yMax: 0.7, pad: 56 });\nv.grid(); v.axes();\nconst pdf = (x) => {\n if (x < 0) return 0;\n const a2 = a * a;\n return Math.sqrt(2 / Math.PI) * (x * x) * Math.exp(-(x * x) / (2 * a2)) / (a2 * a);\n};\n// ---- Histogram of samples (Box-Muller -> 3 normals -> speed) ----\nconst bins = 24;\nconst counts = new Array(bins).fill(0);\n// Number of samples grows with time then holds — the distribution \"forms\".\nconst Nmax = 1400;\nconst N = Math.min(Nmax, Math.floor(60 + (t % 16) * 130));\nconst rnd = (k) => {\n const s = Math.sin(k * 12.9898) * 43758.5453;\n return s - Math.floor(s);\n};\nconst normal = (k) => {\n // Box-Muller from two deterministic uniforms\n let u1 = rnd(k * 2 + 1); let u2 = rnd(k * 2 + 2);\n u1 = Math.min(Math.max(u1, 1e-6), 1 - 1e-6);\n return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n};\nlet speedSum = 0, vrmsSum = 0;\nfor (let i = 0; i < N; i++) {\n const base = i * 3 + Math.floor(t) * 9973; // shuffle seed slowly with time\n const vx = a * normal(base + 1);\n const vy = a * normal(base + 2);\n const vz = a * normal(base + 3);\n const sp = Math.sqrt(vx * vx + vy * vy + vz * vz);\n speedSum += sp;\n vrmsSum += sp * sp;\n const b = Math.floor((sp / vMaxAxis) * bins);\n if (b >= 0 && b < bins) counts[b]++;\n}\n// Draw histogram bars (normalized to a density so they sit under the curve)\nconst binW = vMaxAxis / bins;\nfor (let b = 0; b < bins; b++) {\n const density = counts[b] / (N * binW); // probability density estimate\n if (density <= 0) continue;\n const x0 = b * binW;\n H.rect(\n v.X(x0), v.Y(density), v.X(x0 + binW) - v.X(x0), v.Y(0) - v.Y(density),\n { fill: H.hsl(205, 70, 52, 0.45), stroke: H.hsl(205, 70, 66, 0.7), width: 1 }\n );\n}\n// Analytic curve on top\nv.fn(pdf, { color: H.colors.accent2, width: 3 });\n// Mark characteristic speeds: most-probable v_p = a*sqrt(2)\nconst vp = a * Math.SQRT2;\nconst vAvg = a * Math.sqrt(8 / Math.PI);\nconst vRms = a * Math.sqrt(3);\nH.line(v.X(vp), v.Y(0), v.X(vp), v.Y(pdf(vp)), { color: H.colors.good, width: 2, dash: [5, 5] });\nv.dot(vp, pdf(vp), { r: 5, fill: H.colors.good });\nH.text(\"v_p\", v.X(vp) + 6, v.Y(pdf(vp)) - 6, { color: H.colors.good, size: 12 });\n// Axis labels\nH.text(\"speed v (arb. units)\", v.box.x + v.box.w / 2, v.box.y + v.box.h + 38, { color: H.colors.sub, size: 12, align: \"center\" });\nH.text(\"f(v)\", v.box.x - 40, v.box.y + 8, { color: H.colors.sub, size: 12 });\n// ---- Title + live readouts ----\nH.text(\"Maxwell–Boltzmann speed distribution\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Histogram of \" + N + \" random molecular speeds filling in the analytic curve\", 24, 54, { color: H.colors.sub, size: 13 });\nconst measAvg = N > 0 ? speedSum / N : 0;\nconst measRms = N > 0 ? Math.sqrt(vrmsSum / N) : 0;\nH.legend([\n { label: \"analytic f(v)\", color: H.colors.accent2 },\n { label: \"sampled histogram\", color: H.hsl(205, 70, 60) },\n { label: \"v_p (most probable)\", color: H.colors.good },\n], H.W - 230, 92);\nH.text(\"T = \" + T.toFixed(2) + \" N = \" + N, H.W - 230, 168, { color: H.colors.ink, size: 13, weight: 700 });\nH.text(\"⟨v⟩ ≈ \" + measAvg.toFixed(2) + \" (theory \" + vAvg.toFixed(2) + \")\", H.W - 230, 188, { color: H.colors.sub, size: 12 });\nH.text(\"v_rms ≈ \" + measRms.toFixed(2) + \" (theory \" + vRms.toFixed(2) + \")\", H.W - 230, 206, { color: H.colors.sub, size: 12 });" + }, + { + "id": "matrix-transform-eigenvectors", + "title": "2x2 Matrix Transforming the Plane", + "tag": "Linear Algebra", + "dimension": "2D", + "equation": "M = [[2,1],[1,2]], eigenvalues 3 and 1", + "summary": "A 2x2 matrix smoothly morphs the coordinate grid and the unit square between identity and M = [[2,1],[1,2]]. The basis-vector images i and j swing, the square's area scales by det(M), and the two eigenvectors stay pinned on their dashed span lines while only their lengths (the eigenvalues) change.", + "keywords": [ + "matrix", + "2x2 matrix", + "linear transformation", + "transform the plane", + "unit square", + "determinant", + "eigenvector", + "eigenvalue", + "eigenvectors stay on span", + "basis vectors", + "i hat", + "j hat", + "shear", + "scaling" + ], + "bullets": [ + "The image of the unit square is a parallelogram whose area equals det(M).", + "Eigenvectors never leave their own line (span); the matrix only stretches them by lambda.", + "i-hat and j-hat are the columns of M, so watching them tells you the whole transform." + ], + "student_prompts": [ + "Why does the area of the square equal the determinant of M?", + "What happens to the eigenvectors if an eigenvalue is negative?", + "How do I compute the eigenvectors of [[2,1],[1,2]] by hand?" + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -5, xMax: 5, yMin: -3.6, yMax: 3.6, pad: 50 });\nconst s = (1 - Math.cos(t * 0.8)) * 0.5;\nconst m00 = H.lerp(1, 2, s), m01 = H.lerp(0, 1, s);\nconst m10 = H.lerp(0, 1, s), m11 = H.lerp(1, 2, s);\nconst apply = (x, y) => [m00 * x + m01 * y, m10 * x + m11 * y];\nfor (let gx = -5; gx <= 5; gx++) {\n const pts = [];\n for (let gy = -4; gy <= 4; gy += 0.5) pts.push(apply(gx, gy));\n v.path(pts, { color: H.colors.grid, width: 1 });\n}\nfor (let gy = -4; gy <= 4; gy++) {\n const pts = [];\n for (let gx = -5; gx <= 5; gx += 0.5) pts.push(apply(gx, gy));\n v.path(pts, { color: H.colors.grid, width: 1 });\n}\nv.axes();\nconst sq = [[0, 0], [1, 0], [1, 1], [0, 1]].map((p) => apply(p[0], p[1]));\nv.path(sq, { color: H.colors.accent2, width: 2.4, close: true, fill: \"rgba(244,162,89,0.18)\" });\nconst ix = apply(1, 0), jx = apply(0, 1);\nv.arrow(0, 0, ix[0], ix[1], { color: H.colors.accent, width: 3 });\nv.arrow(0, 0, jx[0], jx[1], { color: H.colors.good, width: 3 });\nv.text(\"i\", ix[0] * 0.55 + 0.15, ix[1] * 0.55 - 0.2, { color: H.colors.accent, size: 14 });\nv.text(\"j\", jx[0] * 0.55 - 0.35, jx[1] * 0.55 + 0.1, { color: H.colors.good, size: 14 });\nconst e1 = [1 / Math.SQRT2, 1 / Math.SQRT2];\nconst e2 = [1 / Math.SQRT2, -1 / Math.SQRT2];\nconst lam1 = H.lerp(1, 3, s), lam2 = 1;\nv.line(-4 * e1[0], -4 * e1[1], 4 * e1[0], 4 * e1[1], { color: H.colors.violet, width: 1.2, dash: [6, 6] });\nv.line(-4 * e2[0], -4 * e2[1], 4 * e2[0], 4 * e2[1], { color: H.colors.yellow, width: 1.2, dash: [6, 6] });\nv.arrow(0, 0, lam1 * e1[0], lam1 * e1[1], { color: H.colors.violet, width: 3.2 });\nv.arrow(0, 0, lam2 * e2[0], lam2 * e2[1], { color: H.colors.yellow, width: 3.2 });\nv.text(\"L1 = \" + lam1.toFixed(2), lam1 * e1[0] + 0.2, lam1 * e1[1] + 0.3, { color: H.colors.violet, size: 13 });\nv.text(\"L2 = \" + lam2.toFixed(2), lam2 * e2[0] + 0.3, lam2 * e2[1] - 0.25, { color: H.colors.yellow, size: 13 });\nconst det = m00 * m11 - m01 * m10;\nH.text(\"2x2 matrix transforming the plane\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Eigenvectors keep their direction; the square's area scales by det.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"M = [[\" + m00.toFixed(2) + \", \" + m01.toFixed(2) + \"], [\" + m10.toFixed(2) + \", \" + m11.toFixed(2) + \"]]\", 24, 78, { color: H.colors.accent, size: 13 });\nH.text(\"det(M) = \" + det.toFixed(2) + \" (unit-square area)\", 24, 98, { color: H.colors.accent2, size: 13 });\nH.legend([\n { label: \"i, j (basis images)\", color: H.colors.accent },\n { label: \"eigvec L1 span\", color: H.colors.violet },\n { label: \"eigvec L2 span\", color: H.colors.yellow },\n], H.W - 210, 96);" + }, + { + "id": "vector-addition-dot-projection", + "title": "Vector Addition & Dot Product as Projection", + "tag": "Linear Algebra", + "dimension": "2D", + "equation": "a . b = |a||b|cos(theta) = |a| * (scalar projection of b)", + "summary": "A fixed vector a and a sweeping vector b show two ideas at once: the parallelogram law builds a+b from dashed translated copies, while b's shadow onto a (the vector projection) slides along a as b's angle changes. Live readouts track the dot product, cos(theta), and the scalar projection length.", + "keywords": [ + "vector addition", + "parallelogram law", + "resultant", + "dot product", + "scalar product", + "inner product", + "projection", + "vector projection", + "scalar projection", + "shadow", + "cosine angle", + "orthogonal", + "components", + "a dot b" + ], + "bullets": [ + "a+b is the diagonal of the parallelogram spanned by a and b (tip-to-tail also works).", + "The dot product equals |a| times the signed length of b's projection onto a.", + "When b is perpendicular to a the dot product is 0 and the projection collapses to the origin." + ], + "student_prompts": [ + "Why does a . b become negative when the angle between a and b exceeds 90 degrees?", + "How is the scalar projection different from the vector projection?", + "Can you show the tip-to-tail method of adding a and b instead of the parallelogram?" + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -1.5, xMax: 6.5, yMin: -1.5, yMax: 5, pad: 50 });\nv.grid();\nv.axes();\nconst a = [4, 1];\nconst ang = 1.05 + 0.85 * Math.sin(t * 0.6);\nconst bMag = 3.2;\nconst b = [bMag * Math.cos(ang), bMag * Math.sin(ang)];\nconst sum = [a[0] + b[0], a[1] + b[1]];\nv.line(a[0], a[1], sum[0], sum[1], { color: H.colors.good, width: 1.4, dash: [5, 5] });\nv.line(b[0], b[1], sum[0], sum[1], { color: H.colors.accent, width: 1.4, dash: [5, 5] });\nv.arrow(0, 0, sum[0], sum[1], { color: H.colors.violet, width: 3 });\nv.text(\"a + b\", sum[0] + 0.15, sum[1] + 0.25, { color: H.colors.violet, size: 14 });\nv.arrow(0, 0, a[0], a[1], { color: H.colors.accent, width: 3.4 });\nv.arrow(0, 0, b[0], b[1], { color: H.colors.good, width: 3.4 });\nv.text(\"a\", a[0] * 0.6 + 0.1, a[1] * 0.6 - 0.25, { color: H.colors.accent, size: 15 });\nv.text(\"b\", b[0] * 0.6 - 0.3, b[1] * 0.6 + 0.2, { color: H.colors.good, size: 15 });\nconst aLen2 = a[0] * a[0] + a[1] * a[1];\nconst aLen = Math.sqrt(aLen2);\nconst dot = a[0] * b[0] + a[1] * b[1];\nconst scal = dot / aLen2;\nconst proj = [scal * a[0], scal * a[1]];\nv.line(b[0], b[1], proj[0], proj[1], { color: H.colors.warn, width: 1.6, dash: [4, 4] });\nv.line(0, 0, proj[0], proj[1], { color: H.colors.accent2, width: 5 });\nv.dot(proj[0], proj[1], { r: 5, fill: H.colors.accent2 });\nv.text(\"proj_a b\", proj[0] - 0.2, proj[1] - 0.35, { color: H.colors.accent2, size: 13 });\nconst scalarProj = dot / aLen;\nconst cosA = dot / (aLen * bMag);\nH.text(\"Vector addition + dot product as projection\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a+b closes the parallelogram; a.b measures b's shadow on a.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"a = (\" + a[0].toFixed(1) + \", \" + a[1].toFixed(1) + \") b = (\" + b[0].toFixed(2) + \", \" + b[1].toFixed(2) + \")\", 24, 78, { color: H.colors.sub, size: 13 });\nH.text(\"a . b = \" + dot.toFixed(2) + \" = |a||b|cos(t), cos = \" + cosA.toFixed(2), 24, 98, { color: H.colors.accent2, size: 13 });\nH.text(\"scalar proj = a.b/|a| = \" + scalarProj.toFixed(2), 24, 118, { color: H.colors.warn, size: 13 });\nH.legend([\n { label: \"a\", color: H.colors.accent },\n { label: \"b\", color: H.colors.good },\n { label: \"a + b\", color: H.colors.violet },\n { label: \"proj of b on a\", color: H.colors.accent2 },\n], H.W - 190, 96);" + }, + { + "id": "rotation-matrix-3d-y-axis", + "title": "3D Rotation About the Y-Axis", + "tag": "Linear Algebra", + "dimension": "3D", + "equation": "R_y(theta) = [[cos,0,sin],[0,1,0],[-sin,0,cos]]", + "summary": "A unit cube and a reference vector v spin in 3D under the rotation matrix R_y(theta), driven by time. The vertical rotation axis is highlighted in violet and stays fixed, points on it never move, while the vector's tip sweeps a circular trail. A live angle readout and the matrix entries cos/sin update each frame.", + "keywords": [ + "3d rotation", + "rotation matrix", + "r_y", + "rotate about axis", + "yaw", + "orthogonal matrix", + "rotation in space", + "spinning cube", + "axis of rotation", + "invariant axis", + "angle theta", + "cos sin matrix", + "rigid rotation", + "linear transformation 3d" + ], + "bullets": [ + "A rotation matrix is orthogonal: it preserves lengths and angles, so the cube never deforms.", + "Vectors lying on the rotation axis are eigenvectors with eigenvalue 1, they do not move.", + "The matrix entries are just cos(theta) and sin(theta) placed to mix the x and z coordinates." + ], + "student_prompts": [ + "Why is the rotation axis an eigenvector of R_y with eigenvalue 1?", + "How would the matrix change to rotate about the x-axis or z-axis instead?", + "What makes a rotation matrix orthogonal, and why does that preserve lengths?" + ], + "code": "H.background();\nconst cam = H.cam3d({ scale: 52, dist: 14, pitch: -0.5, cy: H.H * 0.56 });\ncam.yaw = 0.25 * t;\ncam.grid(4, 1);\nconst theta = t * 0.9;\nconst c = Math.cos(theta), sN = Math.sin(theta);\nconst rot = (p) => [\n c * p[0] + sN * p[2],\n p[1],\n -sN * p[0] + c * p[2],\n];\ncam.line([0, -2.4, 0], [0, 2.4, 0], { color: H.colors.violet, width: 2.4 });\ncam.sphere([0, 2.4, 0], 0.12, { color: H.colors.violet });\nconst half = 1.1;\nconst corners = [];\nfor (let xi = -1; xi <= 1; xi += 2)\n for (let yi = -1; yi <= 1; yi += 2)\n for (let zi = -1; zi <= 1; zi += 2)\n corners.push(rot([xi * half, yi * half, zi * half]));\nconst edges = [[0,1],[0,2],[0,4],[1,3],[1,5],[2,3],[2,6],[3,7],[4,5],[4,6],[5,7],[6,7]];\nedges.forEach((e) => cam.line(corners[e[0]], corners[e[1]], { color: H.colors.accent, width: 1.8 }));\nconst v0 = [1.8, 0.6, 0];\nconst vr = rot(v0);\nconst o = cam.project([0, 0, 0]);\nconst g = cam.project(v0);\nconst p = cam.project(vr);\nH.arrow(o.x, o.y, g.x, g.y, { color: H.colors.sub, width: 1.6 });\nH.arrow(o.x, o.y, p.x, p.y, { color: H.colors.accent2, width: 3 });\nH.text(\"v\", p.x + 6, p.y - 4, { color: H.colors.accent2, size: 14 });\nconst trail = [];\nfor (let k = 0; k <= 40; k++) {\n const a = theta - k / 40 * Math.PI * 0.9;\n trail.push([Math.cos(a) * v0[0] + Math.sin(a) * v0[2], v0[1], -Math.sin(a) * v0[0] + Math.cos(a) * v0[2]]);\n}\ncam.path(trail, { color: H.colors.yellow, width: 1.6 });\ncam.axes(3);\nconst deg = ((theta * 180 / Math.PI) % 360 + 360) % 360;\nH.text(\"3D rotation about the y-axis\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"R_y(t) spins the cube and v; points on the axis stay put. Drag to orbit.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"theta = \" + deg.toFixed(1) + \" deg\", 24, 78, { color: H.colors.accent2, size: 13 });\nH.text(\"R_y = [[cos, 0, sin],[0, 1, 0],[-sin, 0, cos]], cos=\" + c.toFixed(2) + \" sin=\" + sN.toFixed(2), 24, 98, { color: H.colors.accent, size: 13 });\nH.legend([\n { label: \"rotation axis (y)\", color: H.colors.violet },\n { label: \"rotated v\", color: H.colors.accent2 },\n { label: \"tip path\", color: H.colors.yellow },\n], H.W - 180, 96);" + }, + { + "id": "beat-frequencies-sine-sum", + "title": "Beat Frequencies: Two Close Tones Add Up", + "tag": "Signals & Systems", + "dimension": "2D", + "equation": "y = sin(2*pi*f1*x) + sin(2*pi*f2*x), beat = |f1 - f2|", + "summary": "Two pure sine tones at slightly different frequencies travel across the top panel; their sum below swells and fades inside a slow beat envelope that pulses at the difference frequency |f1 - f2|. A live readout tracks the carrier, the beat rate, and the throbbing amplitude.", + "keywords": [ + "beat frequency", + "beats", + "sine sum", + "superposition", + "interference", + "two tones", + "amplitude modulation", + "envelope", + "carrier frequency", + "detuned", + "constructive destructive interference", + "signals", + "acoustics", + "fourier" + ], + "bullets": [ + "Adding two sines of nearby frequency f1 and f2 produces a fast carrier at (f1+f2)/2 inside a slow envelope.", + "The envelope amplitude is 2A*|cos(pi*(f1-f2)*x)|, so the loudness throbs at the beat rate |f1-f2|.", + "Watch the green envelope curve hug the orange sum: where the two tones align it swells, where they cancel it pinches to zero." + ], + "student_prompts": [ + "Why does the beat rate equal |f1 - f2| and not f1 + f2?", + "What would I hear if f1 and f2 were identical, and what does the picture become?", + "How is this beat pattern related to amplitude modulation in radio?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst f1 = 4.0;\nconst f2 = 4.6;\nconst fBeat = Math.abs(f1 - f2);\nconst fCar = (f1 + f2) / 2;\nconst A = 0.9;\nconst xMin = 0, xMax = 6;\nconst scroll = t * 0.9;\nconst top = H.plot2d({\n xMin, xMax, yMin: -1.2, yMax: 1.2,\n box: { x: 56, y: 70, w: w - 96, h: (h - 150) * 0.42 },\n});\ntop.grid({ stepX: 1, stepY: 1 });\ntop.axes({ stepX: 1, stepY: 1 });\nconst s1 = (x) => A * Math.sin(H.TAU * f1 * (x + scroll));\nconst s2 = (x) => A * Math.sin(H.TAU * f2 * (x + scroll));\ntop.fn(s1, { color: H.colors.accent, width: 2.2, steps: 480 });\ntop.fn(s2, { color: H.colors.violet, width: 2.2, steps: 480 });\ntop.text(\"tone 1 + tone 2\", xMin + 0.1, 1.05, { color: H.colors.sub, size: 12 });\nconst bot = H.plot2d({\n xMin, xMax, yMin: -2.1, yMax: 2.1,\n box: { x: 56, y: 70 + (h - 150) * 0.46 + 28, w: w - 96, h: (h - 150) * 0.46 },\n});\nbot.grid({ stepX: 1, stepY: 1 });\nbot.axes({ stepX: 1, stepY: 1 });\nconst sum = (x) => s1(x) + s2(x);\nconst env = (x) => 2 * A * Math.abs(Math.cos(Math.PI * fBeat * (x + scroll)));\nbot.fn((x) => env(x), { color: H.colors.good, width: 1.6 });\nbot.fn((x) => -env(x), { color: H.colors.good, width: 1.6 });\nbot.fn(sum, { color: H.colors.accent2, width: 2.6, steps: 520 });\nconst xm = xMin + ((t * 0.8) % (xMax - xMin));\nbot.dot(xm, sum(xm), { r: 6, fill: H.colors.yellow });\nconst ampNow = env(xm);\nbot.text(\"x = t\", xMin + 0.1, 1.9, { color: H.colors.sub, size: 12 });\nH.text(\"Beat frequencies: two close tones add up\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"sum = sin(2pi f1 x) + sin(2pi f2 x) -> amplitude throbs at |f1-f2|\",\n 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"f1 = \" + f1.toFixed(2) + \" Hz\", 24, h - 58, { color: H.colors.accent, size: 13 });\nH.text(\"f2 = \" + f2.toFixed(2) + \" Hz\", 140, h - 58, { color: H.colors.violet, size: 13 });\nH.text(\"beat = \" + fBeat.toFixed(2) + \" Hz\", 260, h - 58, { color: H.colors.good, size: 13 });\nH.text(\"envelope |amp| = \" + ampNow.toFixed(2), 410, h - 58,\n { color: H.colors.yellow, size: 13 });\nH.text(\"carrier ~ \" + fCar.toFixed(2) + \" Hz\", 410, h - 38,\n { color: H.colors.sub, size: 12 });\nH.legend([\n { label: \"tone 1\", color: H.colors.accent },\n { label: \"tone 2\", color: H.colors.violet },\n { label: \"sum\", color: H.colors.accent2 },\n { label: \"beat envelope\", color: H.colors.good },\n], w - 168, 84);" + }, + { + "id": "low-pass-filter-noisy-signal", + "title": "Low-Pass Filter Smoothing a Noisy Signal", + "tag": "Signal Processing", + "dimension": "2D", + "equation": "y[n] = y[n-1] + a*(x[n] - y[n-1]), a = dt/(RC+dt), fc = 1/(2*pi*RC)", + "summary": "A scrolling noisy waveform (true signal plus jitter) flows left while a one-pole RC low-pass filter, applied sample by sample, traces a smooth orange output that follows the dashed true signal. The cutoff frequency breathes up and down with time so you can watch heavier smoothing trade noise rejection for lag.", + "keywords": [ + "low-pass filter", + "lowpass", + "rc filter", + "smoothing", + "noise reduction", + "moving average", + "exponential moving average", + "cutoff frequency", + "one pole filter", + "denoise", + "signal processing", + "filtering", + "time constant", + "rolloff" + ], + "bullets": [ + "Each output sample is a blend of the new noisy input and the previous output: y[n] = y[n-1] + a*(x[n] - y[n-1]).", + "The smoothing factor a = dt/(RC+dt) sets the cutoff fc = 1/(2*pi*RC): smaller cutoff means smoother output but more lag.", + "Compare the gray noisy input, the dashed true signal, and the orange filtered output - the filter rejects the fast jitter while tracking the slow trend." + ], + "student_prompts": [ + "How does raising the cutoff frequency change the smoothing and the lag?", + "Why does a one-pole low-pass filter introduce phase delay in the output?", + "How is this discrete recursion equivalent to a physical resistor-capacitor circuit?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst xMin = 0, xMax = 10;\nconst N = 260;\nconst scroll = t * 1.4;\nconst clean = (x) => 1.3 * Math.sin(0.9 * (x + scroll)) + 0.5 * Math.sin(0.42 * (x + scroll) + 0.7);\nconst samples = [];\nfor (let i = 0; i <= N; i++) {\n const x = H.lerp(xMin, xMax, i / N);\n const k = Math.round((x + scroll) * 11);\n let lseed = ((k * 2654435761) % 4294967296 + 4294967296) % 4294967296;\n lseed = (lseed * 1664525 + 1013904223) % 4294967296;\n const noise = (lseed / 4294967296 - 0.5) * 2.0;\n samples.push([x, clean(x) + noise]);\n}\nconst RC = 0.16 + 0.14 * (1 + Math.sin(t * 0.5));\nconst dx = (xMax - xMin) / N;\nconst alpha = dx / (RC + dx);\nconst fc = 1 / (H.TAU * RC);\nconst filtered = [];\nlet y = samples[0][1];\nfor (let i = 0; i <= N; i++) {\n y = y + alpha * (samples[i][1] - y);\n filtered.push([samples[i][0], y]);\n}\nconst view = H.plot2d({\n xMin, xMax, yMin: -3.4, yMax: 3.4,\n box: { x: 56, y: 86, w: w - 96, h: h - 170 },\n});\nview.grid({ stepX: 1, stepY: 1 });\nview.axes({ stepX: 1, stepY: 1 });\nview.path(samples, { color: H.colors.sub, width: 1 });\nview.path(filtered, { color: H.colors.accent2, width: 3 });\nview.path(samples.map((p, i) => [p[0], clean(p[0])]), { color: H.colors.good, width: 1.4, dash: [6, 6] });\nconst xm = xMin + ((t * 1.0) % (xMax - xMin));\nconst idx = H.clamp(Math.round((xm - xMin) / dx), 0, N);\nview.dot(filtered[idx][0], filtered[idx][1], { r: 6, fill: H.colors.accent });\nconst noiseAmp = Math.abs(samples[idx][1] - clean(samples[idx][0]));\nconst resid = Math.abs(filtered[idx][1] - clean(filtered[idx][0]));\nH.text(\"Low-pass filter smoothing a noisy signal\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"one-pole RC filter: y[n] = y[n-1] + a*(x[n] - y[n-1]), a = dt/(RC+dt)\",\n 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"cutoff fc = \" + fc.toFixed(2) + \" Hz\", 24, h - 56, { color: H.colors.accent, size: 13 });\nH.text(\"RC = \" + RC.toFixed(2) + \" s\", 190, h - 56, { color: H.colors.violet, size: 13 });\nH.text(\"a = \" + alpha.toFixed(3), 300, h - 56, { color: H.colors.yellow, size: 13 });\nH.text(\"noise in = \" + noiseAmp.toFixed(2), 400, h - 56, { color: H.colors.sub, size: 13 });\nH.text(\"error out = \" + resid.toFixed(2), 530, h - 56, { color: H.colors.accent2, size: 13 });\nH.legend([\n { label: \"noisy input\", color: H.colors.sub },\n { label: \"filtered output\", color: H.colors.accent2 },\n { label: \"true signal\", color: H.colors.good },\n], w - 184, 100);" + }, + { + "id": "pid-feedback-settling-setpoint", + "title": "PID Feedback Settling to a Setpoint", + "tag": "Control Systems", + "dimension": "2D", + "equation": "u = Kp*e + Ki*integral(e dt) + Kd*de/dt, e = setpoint - output", + "summary": "A closed-loop PID controller drives a second-order plant toward a target. The green dashed setpoint steps between two levels every few seconds; the orange process output rises, overshoots, and settles inside a +/-5% band, while live readouts show the setpoint, output, error, and control effort u as the loop reacts in real time.", + "keywords": [ + "pid controller", + "feedback control", + "setpoint", + "closed loop", + "proportional integral derivative", + "control system", + "settling time", + "overshoot", + "steady state error", + "step response", + "controller", + "regulation", + "stability", + "second order system" + ], + "bullets": [ + "The controller forms error e = setpoint - output and computes u = Kp*e + Ki*integral(e) + Kd*de/dt every step.", + "The proportional term gives a fast push, the integral term erases steady-state error, and the derivative term damps overshoot.", + "After each setpoint step the orange output rises, overshoots, and settles into the +/-5% band - that recovery time is the settling time." + ], + "student_prompts": [ + "What happens to overshoot and settling time if I increase Kp or Kd?", + "Why does removing the integral term Ki leave a steady-state error?", + "How would integral windup affect this response after a large setpoint step?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst Kp = 1.8, Ki = 0.9, Kd = 0.35;\nconst tau = 0.7;\nconst setpointAt = (tt) => (Math.floor(tt / 6) % 2 === 0 ? 1.0 : 2.2);\nconst winT = 12;\nconst t0 = Math.max(0, t - winT);\nconst dt = 0.02;\nconst steps = Math.min(900, Math.round((t - t0) / dt) + 1);\nlet pos = 0, vel = 0, integ = 0, prevErr = setpointAt(t0) - 0;\nconst trace = [], spTrace = [];\nlet curErr = 0, curU = 0, curSp = setpointAt(t0), curPos = 0;\nfor (let i = 0; i < steps; i++) {\n const tt = t0 + i * dt;\n const sp = setpointAt(tt);\n const err = sp - pos;\n integ = H.clamp(integ + err * dt, -5, 5);\n const deriv = (err - prevErr) / dt;\n prevErr = err;\n const u = Kp * err + Ki * integ + Kd * deriv;\n const acc = (u - pos) / (tau * tau) - (2 / tau) * vel;\n vel += acc * dt;\n pos += vel * dt;\n if (!Number.isFinite(pos)) { pos = sp; vel = 0; integ = 0; }\n trace.push([tt, pos]);\n spTrace.push([tt, sp]);\n curErr = err; curU = u; curSp = sp; curPos = pos;\n}\nconst view = H.plot2d({\n xMin: t0, xMax: t0 + winT, yMin: -0.4, yMax: 3.0,\n box: { x: 60, y: 92, w: w - 100, h: h - 176 },\n});\nview.grid({ stepX: 2, stepY: 1 });\nview.axes({ stepX: 2, stepY: 1 });\nview.path(spTrace, { color: H.colors.good, width: 2, dash: [7, 6] });\nconst band = 0.05 * curSp;\nview.path([[t0, curSp + band], [t0 + winT, curSp + band]], { color: H.colors.grid, width: 1, dash: [3, 5] });\nview.path([[t0, curSp - band], [t0 + winT, curSp - band]], { color: H.colors.grid, width: 1, dash: [3, 5] });\nview.path(trace, { color: H.colors.accent2, width: 3 });\nview.dot(t, curPos, { r: 6, fill: H.colors.accent });\nview.line(t, curPos, t, curSp, { color: H.colors.warn, width: 1.6, dash: [4, 4] });\nH.text(\"PID feedback settling to a setpoint\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"u = Kp*e + Ki*integral(e) + Kd*de/dt drives the plant toward the target\",\n 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"setpoint = \" + curSp.toFixed(2), 24, h - 58, { color: H.colors.good, size: 13 });\nH.text(\"output = \" + curPos.toFixed(2), 170, h - 58, { color: H.colors.accent2, size: 13 });\nH.text(\"error = \" + curErr.toFixed(3), 320, h - 58, { color: H.colors.warn, size: 13 });\nH.text(\"control u = \" + curU.toFixed(2), 470, h - 58, { color: H.colors.accent, size: 13 });\nH.text(\"Kp=\" + Kp.toFixed(1) + \" Ki=\" + Ki.toFixed(1) + \" Kd=\" + Kd.toFixed(2) +\n \" +/-5% band\", 24, h - 36, { color: H.colors.sub, size: 12 });\nH.legend([\n { label: \"setpoint\", color: H.colors.good },\n { label: \"process output\", color: H.colors.accent2 },\n { label: \"error\", color: H.colors.warn },\n], w - 176, 106);" + }, + { + "id": "fourier-series-square-wave", + "title": "Fourier Series Building a Square Wave", + "tag": "Waves & Optics", + "dimension": "2D", + "equation": "f(x) = (4/pi) * sum_{k odd} sin(kx)/k", + "summary": "A square wave is reconstructed term by term from its odd sine harmonics. The number of harmonics ramps up over time so you watch the partial sum sharpen toward the ideal square wave, complete with the Gibbs overshoot at the jumps.", + "keywords": [ + "fourier series", + "fourier", + "square wave", + "harmonics", + "partial sum", + "sine series", + "odd harmonics", + "gibbs phenomenon", + "superposition", + "spectrum", + "sawtooth", + "synthesis", + "waveform", + "signal" + ], + "bullets": [ + "Each faint sine is one odd harmonic sin(kx)/k; the bright orange curve is their running sum S_N(x).", + "As more harmonics are added the sum hugs the dashed square wave more tightly, but a fixed overshoot (Gibbs) lingers at the jumps.", + "Higher harmonics carry smaller amplitude (1/k) yet sharpen the vertical edges where the wave switches sign." + ], + "student_prompts": [ + "Why does the overshoot near the jump never disappear no matter how many terms I add?", + "Where do the (4/pi) and the 1/k amplitude factors come from?", + "How would the series change for a sawtooth or triangle wave instead of a square wave?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst cycle = 14;\nconst phase = t % cycle;\nconst maxN = 12;\nconst built = H.clamp(Math.floor(phase / cycle * (maxN + 1)) + 1, 1, maxN);\nconst odd = [];\nfor (let k = 1, n = 0; n < built; k += 2, n++) odd.push(k);\n\nconst v = H.plot2d({ xMin: -H.PI, xMax: H.PI, yMin: -1.6, yMax: 1.6, pad: 54 });\nv.grid({ stepX: H.PI / 2 });\nv.axes({ stepX: H.PI / 2, stepY: 0.5 });\n\nv.fn(x => (Math.sin(x) >= 0 ? 1 : -1) * 1, { color: H.colors.grid, width: 2 });\n\nconst four = (x) => {\n let s = 0;\n for (let i = 0; i < odd.length; i++) {\n const k = odd[i];\n s += Math.sin(k * x) / k;\n }\n return (4 / H.PI) * s;\n};\nfor (let i = 0; i < odd.length; i++) {\n const k = odd[i];\n const amp = (4 / H.PI) / k;\n v.fn(x => amp * Math.sin(k * x), {\n color: H.hsl(200 + i * 14, 70, 60, 0.32), width: 1.4,\n });\n}\nv.fn(four, { color: H.colors.accent2, width: 3.4 });\n\nconst sx = H.map(Math.sin(t * 0.9), -1, 1, -H.PI + 0.2, H.PI - 0.2);\nv.dot(sx, four(sx), { r: 6, fill: H.colors.yellow });\nv.text(\"sample\", sx + 0.05, four(sx) + 0.22, { color: H.colors.sub, size: 12 });\n\nH.text(\"Fourier series -> square wave\", 24, 32, { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"Adding odd sine harmonics (4/pi) sin(kx)/k builds the square wave.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"harmonics N = \" + odd.length + \" highest k = \" + odd[odd.length - 1], 24, 78, { color: H.colors.accent, size: 14, weight: 600 });\nH.text(\"f(\" + sx.toFixed(2) + \") = \" + four(sx).toFixed(3), 24, 98, { color: H.colors.sub, size: 13 });\n\nH.legend([\n { label: \"partial sum S_N(x)\", color: H.colors.accent2 },\n { label: \"individual harmonics\", color: H.hsl(214, 70, 60) },\n { label: \"ideal square wave\", color: H.colors.grid },\n], 24, h - 70);" + }, + { + "id": "two-source-interference-field", + "title": "Two-Source Interference (Ripple Field)", + "tag": "Waves & Optics", + "dimension": "3D", + "equation": "u(x,z,t) = A cos(k r1 - wt) + A cos(k r2 - wt)", + "summary": "Two coherent point sources emit circular waves over a plane. Their height fields add, carving the classic interference pattern of constructive ridges and destructive nodal lines into a lit, depth-sorted 3D surface you can orbit.", + "keywords": [ + "interference", + "two source", + "double slit", + "ripple tank", + "coherent sources", + "constructive", + "destructive", + "wave field", + "superposition", + "nodal lines", + "fringes", + "diffraction", + "huygens", + "circular waves" + ], + "bullets": [ + "Where the two path lengths differ by a whole wavelength the crests add (constructive ridges); a half-wavelength difference cancels them (nodal valleys).", + "Each source radiates expanding circular wavefronts; the spheres mark the two coherent emitters bobbing in phase.", + "The fixed pattern of ridges and troughs is set by the source spacing and the wavenumber k, while the whole field oscillates at angular frequency w." + ], + "student_prompts": [ + "How does the spacing between the two sources change the number of bright fringes?", + "What is the path-difference condition for a constructive vs destructive point?", + "How does this 2D ripple pattern connect to Young's double-slit experiment?" + ], + "code": "H.background();\nconst cam = H.cam3d({ scale: 30, dist: 17, pitch: -0.62, cy: H.H * 0.56 });\ncam.yaw = 0.28 * t;\n\nconst k = 2.2;\nconst omega = 3.0;\nconst sx1 = -2.0, sz1 = 0.0;\nconst sx2 = 2.0, sz2 = 0.0;\nconst amp = 0.9;\n\ncam.grid(6, 1.5);\n\nH.surface3d(cam, (x, z) => {\n const r1 = Math.sqrt((x - sx1) * (x - sx1) + (z - sz1) * (z - sz1)) + 1e-6;\n const r2 = Math.sqrt((x - sx2) * (x - sx2) + (z - sz2) * (z - sz2)) + 1e-6;\n const w1 = amp * Math.cos(k * r1 - omega * t) / (1 + 0.35 * r1);\n const w2 = amp * Math.cos(k * r2 - omega * t) / (1 + 0.35 * r2);\n return (w1 + w2) * 1.6;\n}, { xMin: -6, xMax: 6, yMin: -6, yMax: 6, nx: 44, ny: 44, hueMin: 205, hueMax: 320 });\n\ncam.axes(6);\n\nconst s1 = [sx1, amp * Math.cos(-omega * t) * 1.6 + 0.2, sz1];\nconst s2 = [sx2, amp * Math.cos(-omega * t) * 1.6 + 0.2, sz2];\n[{ p: s1, c: H.colors.yellow }, { p: s2, c: H.colors.good }]\n .map(o => ({ p: o.p, c: o.c, d: cam.project(o.p).depth }))\n .sort((a, b) => b.d - a.d)\n .forEach(o => cam.sphere(o.p, 0.28, { color: o.c }));\n\nconst dt = (2 * H.PI) / omega;\nH.text(\"Two-source interference\", 24, 32, { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"Two coherent sources -> crests reinforce, troughs cancel.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"k = \" + k.toFixed(1) + \" period T = \" + dt.toFixed(2) + \" s\", 24, 78, { color: H.colors.accent, size: 14, weight: 600 });\nH.text(\"phase = \" + ((omega * t) % (2 * H.PI)).toFixed(2) + \" rad\", 24, 98, { color: H.colors.sub, size: 13 });\n\nH.legend([\n { label: \"source A\", color: H.colors.yellow },\n { label: \"source B\", color: H.colors.good },\n { label: \"combined wave field\", color: H.hsl(260, 72, 60) },\n], 24, H.H - 70);" + }, + { + "id": "wave-packet-phase-group-velocity", + "title": "Wave Packet: Phase vs Group Velocity", + "tag": "Waves & Optics", + "dimension": "2D", + "equation": "u(x,t) = exp(-(x-vg t)^2 / 2s^2) * cos(k0 x - w0 t)", + "summary": "A Gaussian-enveloped wave packet travels to the right. A tracked carrier crest races forward at the phase velocity vp = w/k while the whole envelope (and the energy it carries) drifts more slowly at the group velocity vg, making the distinction concrete.", + "keywords": [ + "wave packet", + "wavepacket", + "phase velocity", + "group velocity", + "dispersion", + "carrier", + "envelope", + "gaussian envelope", + "modulation", + "beat", + "pulse", + "quantum wave packet", + "de broglie", + "vp vg" + ], + "bullets": [ + "The fast pink dot is a single carrier crest moving at the phase velocity vp = w0/k0; individual crests outrun the pulse.", + "The green dot tracks the envelope peak, which moves at the slower group velocity vg = dw/dk and carries the packet's energy.", + "Crests visibly appear at the back of the envelope and vanish at the front because the carrier and the envelope travel at different speeds." + ], + "student_prompts": [ + "Why does the energy of the pulse travel at the group velocity and not the phase velocity?", + "What happens to the packet's shape over time when the medium is dispersive?", + "How do phase and group velocity relate to a de Broglie matter wave in quantum mechanics?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\n\nconst k0 = 6.0;\nconst omega0 = 9.0;\nconst vg = 0.8;\nconst vp = omega0 / k0;\nconst sigma = 1.4;\n\nconst v = H.plot2d({ xMin: -9, xMax: 9, yMin: -1.5, yMax: 1.5, pad: 54 });\nv.grid({ stepX: 3 });\nv.axes({ stepX: 3, stepY: 0.5 });\n\nconst span = 16;\nlet xc = (-7 + vg * t) % span;\nif (xc > 9) xc -= span;\n\nconst env = (x) => Math.exp(-((x - xc) * (x - xc)) / (2 * sigma * sigma));\nconst packet = (x) => env(x) * Math.cos(k0 * x - omega0 * t);\n\nv.fn(x => env(x), { color: H.hsl(150, 70, 55, 0.45), width: 2 });\nv.fn(x => -env(x), { color: H.hsl(150, 70, 55, 0.45), width: 2 });\nv.fn(packet, { color: H.colors.accent, width: 3 });\n\nconst m = Math.round((k0 * xc - omega0 * t) / (2 * H.PI));\nconst crestX = (2 * H.PI * m + omega0 * t) / k0;\nif (crestX > -9 && crestX < 9) {\n v.dot(crestX, packet(crestX), { r: 6, fill: H.colors.warn });\n v.text(\"crest (vp)\", crestX + 0.2, packet(crestX) + 0.28, { color: H.colors.warn, size: 12 });\n}\nv.dot(xc, 1.0, { r: 6, fill: H.colors.good });\nv.text(\"envelope (vg)\", xc + 0.2, 1.18, { color: H.colors.good, size: 12 });\n\nH.text(\"Wave packet: phase vs group velocity\", 24, 32, { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"Carrier crests slide through a slower-moving Gaussian envelope.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"vp = w/k = \" + vp.toFixed(2) + \" vg = \" + vg.toFixed(2), 24, 78, { color: H.colors.accent, size: 14, weight: 600 });\nH.text(\"envelope center xc = \" + xc.toFixed(2), 24, 98, { color: H.colors.sub, size: 13 });\n\nH.legend([\n { label: \"wave packet\", color: H.colors.accent },\n { label: \"Gaussian envelope\", color: H.hsl(150, 70, 55) },\n { label: \"carrier crest (vp)\", color: H.colors.warn },\n { label: \"envelope peak (vg)\", color: H.colors.good },\n], 24, h - 90);" + }, + { + "id": "bubble-sort-bars", + "title": "Bubble Sort on Bars", + "tag": "Algorithms", + "dimension": "2D", + "equation": "if a[j] > a[j+1] then swap; O(n^2) comparisons", + "summary": "A bar chart of 9 values runs the full bubble sort one comparison at a time. Adjacent bars are highlighted as they are compared, flash red on a swap, and the already-sorted tail locks in green as larger values bubble to the right.", + "keywords": [ + "bubble sort", + "sorting algorithm", + "insertion sort", + "sort bars", + "comparison swap", + "adjacent elements", + "array sorting", + "o(n^2)", + "quadratic sort", + "ascending order", + "in-place sort", + "passes", + "swap animation" + ], + "bullets": [ + "Each pass compares neighbors left to right; the largest unsorted value bubbles to its final spot on the right.", + "Yellow = comparing, red = swapping, green = locked into the sorted tail.", + "The comparison counter and pass number make the O(n^2) cost concrete as the array converges." + ], + "student_prompts": [ + "Why is bubble sort O(n^2) in the worst case but O(n) on an already-sorted array?", + "How does insertion sort differ from bubble sort step by step?", + "Which sorting algorithm would you use for nearly-sorted data and why?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst base = [5, 2, 8, 1, 9, 4, 7, 3, 6];\nconst n = base.length;\nconst steps = [];\nconst a = base.slice();\nsteps.push({ arr: a.slice(), i: -1, j: -1, swap: false, done: false });\nfor (let i = 0; i < n - 1; i++) {\n for (let j = 0; j < n - 1 - i; j++) {\n const swap = a[j] > a[j + 1];\n if (swap) { const tmp = a[j]; a[j] = a[j + 1]; a[j + 1] = tmp; }\n steps.push({ arr: a.slice(), i: i, j: j, swap: swap, done: false });\n }\n}\nsteps.push({ arr: a.slice(), i: n, j: -1, swap: false, done: true });\nconst total = steps.length;\nconst period = total * 0.55 + 2.2;\nconst tt = t % period;\nlet idx = Math.min(total - 1, Math.floor(tt / 0.55));\nif (tt > total * 0.55) idx = total - 1;\nconst step = steps[idx];\nconst arr = step.arr;\nconst maxV = 9;\nconst box = { x: 60, y: 110, w: w - 120, h: h - 190 };\nconst bw = box.w / n;\nconst sortedFrom = step.done ? 0 : n - 1 - step.i;\nfor (let k = 0; k < n; k++) {\n const val = arr[k];\n const bh = H.map(val, 0, maxV, 8, box.h);\n const bx = box.x + k * bw + bw * 0.12;\n const by = box.y + box.h - bh;\n const bwi = bw * 0.76;\n let fill = H.colors.accent;\n if (!step.done && (k === step.j || k === step.j + 1)) {\n fill = step.swap ? H.colors.warn : H.colors.yellow;\n } else if (k >= sortedFrom || step.done) {\n fill = H.colors.good;\n }\n H.rect(bx, by, bwi, bh, { fill: fill, stroke: H.colors.bg, width: 1.5, radius: 5 });\n H.text(String(val), bx + bwi / 2, by - 8,\n { color: H.colors.ink, size: 14, align: \"center\", weight: 600 });\n H.text(String(k), bx + bwi / 2, box.y + box.h + 18,\n { color: H.colors.sub, size: 11, align: \"center\" });\n}\nif (!step.done && step.j >= 0) {\n const j = step.j;\n const x1 = box.x + j * bw + bw * 0.5;\n const x2 = box.x + (j + 1) * bw + bw * 0.5;\n const yb = box.y + box.h + 30;\n H.line(x1, yb, x2, yb, { color: step.swap ? H.colors.warn : H.colors.yellow, width: 2 });\n H.text(step.swap ? \"swap\" : \"keep\", (x1 + x2) / 2, yb + 16,\n { color: step.swap ? H.colors.warn : H.colors.yellow, size: 12, align: \"center\" });\n}\nH.text(\"Bubble sort\", 24, 36, { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"Adjacent pairs are compared; the larger bubbles right each pass.\",\n 24, 58, { color: H.colors.sub, size: 13 });\nconst passNum = step.done ? n - 1 : step.i + 1;\nconst compares = idx;\nH.text(\"pass \" + passNum + \" / \" + (n - 1) + \" comparisons: \" + compares +\n (step.done ? \" SORTED\" : \"\"), 24, 84,\n { color: step.done ? H.colors.good : H.colors.accent2, size: 13, weight: 600 });\nH.legend([\n { label: \"comparing\", color: H.colors.yellow },\n { label: \"swapping\", color: H.colors.warn },\n { label: \"sorted\", color: H.colors.good },\n], w - 150, 120);" + }, + { + "id": "bfs-graph-traversal", + "title": "BFS Graph Traversal", + "tag": "Algorithms", + "dimension": "2D", + "equation": "frontier expands level by level; depth d(v) = d(u) + 1", + "summary": "Breadth-first search runs on a small 8-node graph from node 0. Nodes light up one at a time in queue order, the expanding node pulses, BFS tree edges thicken, and each node is annotated with its discovery rank and shortest-path depth.", + "keywords": [ + "bfs", + "breadth first search", + "graph traversal", + "dfs", + "depth first search", + "queue", + "fifo", + "shortest path", + "level order", + "graph algorithm", + "adjacency list", + "node visit order", + "frontier", + "tree edges" + ], + "bullets": [ + "A FIFO queue guarantees nodes are discovered in increasing distance from the source, so depth d equals the shortest hop count.", + "The thick blue edges form the BFS tree; thin gray edges are non-tree (cross) edges.", + "The #rank label is queue/visit order while d= is the level — BFS explores all of one level before the next." + ], + "student_prompts": [ + "How would the visit order change if this were DFS with a stack instead of BFS with a queue?", + "Why does BFS find shortest paths in unweighted graphs but not in weighted ones?", + "What is the time complexity of BFS in terms of vertices V and edges E?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst nodes = [\n { id: 0, x: -7, y: 3 },\n { id: 1, x: -3, y: 5 },\n { id: 2, x: -3, y: 1 },\n { id: 3, x: 1, y: 5 },\n { id: 4, x: 1, y: 1 },\n { id: 5, x: 5, y: 3.5 },\n { id: 6, x: 5, y: 0 },\n { id: 7, x: 8, y: 2 },\n];\nconst edges = [\n [0, 1], [0, 2], [1, 3], [2, 4], [3, 5], [4, 5], [4, 6], [5, 7], [6, 7],\n];\nconst adj = nodes.map(() => []);\nedges.forEach(([u, v]) => { adj[u].push(v); adj[v].push(u); });\nadj.forEach((l) => l.sort((p, q) => p - q));\nconst start = 0;\nconst visited = nodes.map(() => false);\nconst depth = nodes.map(() => -1);\nconst order = [];\nconst queue = [start];\nvisited[start] = true; depth[start] = 0;\nlet guard = 0;\nwhile (queue.length && guard++ < 200) {\n const u = queue.shift();\n order.push(u);\n for (const v of adj[u]) {\n if (!visited[v]) { visited[v] = true; depth[v] = depth[u] + 1; queue.push(v); }\n }\n}\nconst total = order.length;\nconst period = total * 0.7 + 2.5;\nconst tt = t % period;\nconst revealed = Math.min(total, Math.floor(tt / 0.7) + 1);\nconst frontIdx = revealed - 1;\nconst frontNode = order[Math.max(0, Math.min(total - 1, frontIdx))];\nconst visOrder = new Array(nodes.length).fill(-1);\nfor (let k = 0; k < revealed; k++) visOrder[order[k]] = k;\nconst view = H.plot2d({ xMin: -9, xMax: 10, yMin: -1.5, yMax: 6.5, pad: 40 });\nview.grid({ color: H.colors.grid });\nview.axes({ ticks: true });\nedges.forEach(([u, v]) => {\n const tree = visOrder[u] >= 0 && visOrder[v] >= 0 &&\n (depth[u] + 1 === depth[v] || depth[v] + 1 === depth[u]);\n view.line(nodes[u].x, nodes[u].y, nodes[v].x, nodes[v].y,\n { color: tree ? H.colors.accent : H.colors.grid, width: tree ? 2.6 : 1.4 });\n});\nnodes.forEach((nd) => {\n const k = visOrder[nd.id];\n let fill = H.colors.panel;\n let ink = H.colors.sub;\n if (k >= 0) {\n fill = nd.id === frontNode ? H.colors.accent2 : H.colors.accent;\n ink = H.colors.bg;\n }\n const px = view.X(nd.x), py = view.Y(nd.y);\n let r = 18;\n if (nd.id === frontNode && k >= 0) r = 18 + 3 * Math.sin(t * 6);\n H.circle(px, py, r, { fill: fill, stroke: H.colors.bg, width: 2 });\n H.text(String(nd.id), px, py + 5, { color: ink, size: 15, align: \"center\", weight: 700 });\n if (k >= 0) {\n H.text(\"d=\" + depth[nd.id], px, py - r - 6,\n { color: H.colors.sub, size: 11, align: \"center\" });\n H.text(\"#\" + (k + 1), px, py + r + 14,\n { color: H.colors.violet, size: 11, align: \"center\" });\n }\n});\nH.text(\"Breadth-first search (BFS)\", 24, 36, { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"A FIFO queue explores the graph level by level from node 0.\",\n 24, 58, { color: H.colors.sub, size: 13 });\nconst visitedStr = order.slice(0, revealed).join(\" → \");\nH.text(\"visit order: \" + visitedStr, 24, 84,\n { color: H.colors.accent2, size: 13, weight: 600 });\nconst maxD = revealed > 0 ? depth[frontNode] : 0;\nH.text(\"discovered \" + revealed + \" / \" + nodes.length +\n \" nodes current depth: \" + maxD, 24, h - 22,\n { color: H.colors.sub, size: 12 });\nH.legend([\n { label: \"expanding\", color: H.colors.accent2 },\n { label: \"discovered\", color: H.colors.accent },\n { label: \"unseen\", color: H.colors.panel },\n], w - 150, 120);" + }, + { + "id": "recursion-tree-fibonacci", + "title": "Recursion Tree: fib(5)", + "tag": "Algorithms", + "dimension": "2D", + "equation": "fib(n) = fib(n-1) + fib(n-2), fib(0)=0, fib(1)=1", + "summary": "The full call tree of naive recursive Fibonacci fib(5) is drawn, then animated in post-order so you watch leaf values return and sums bubble up the tree. Repeated identical subtrees make the exponential blow-up of naive recursion visible.", + "keywords": [ + "recursion tree", + "recursion", + "fibonacci", + "call stack", + "divide and conquer", + "post-order", + "tree traversal", + "exponential time", + "memoization", + "dynamic programming", + "base case", + "subproblems", + "recursive calls", + "call tree" + ], + "bullets": [ + "Each node is a call fib(n); the value returned appears only once the call resolves, mirroring how recursion unwinds bottom-up.", + "Returns happen in post-order: both children must finish before a parent can sum them.", + "Notice fib(2) and fib(3) appear in multiple places — recomputing them is exactly why naive recursion is exponential and memoization helps." + ], + "student_prompts": [ + "How many total calls does naive fib(n) make, and why is it roughly the golden-ratio raised to n?", + "How would memoization or dynamic programming change this recursion tree?", + "What is the maximum depth of the call stack while computing fib(5)?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nlet counter = 0;\nfunction build(n, depth) {\n const node = { n: n, depth: depth, id: counter++, children: [], val: 0 };\n if (n < 2) { node.val = n; return node; }\n node.left = build(n - 1, depth + 1);\n node.right = build(n - 2, depth + 1);\n node.children = [node.left, node.right];\n node.val = node.left.val + node.right.val;\n return node;\n}\nconst root = build(5, 0);\nconst all = [];\nlet leafX = 0;\nfunction layout(node) {\n if (node.children.length === 0) { node.ux = leafX++; }\n else {\n node.children.forEach(layout);\n node.ux = (node.left.ux + node.right.ux) / 2;\n }\n all.push(node);\n}\nlayout(root);\nconst maxDepth = all.reduce((m, nd) => Math.max(m, nd.depth), 0);\nconst leaves = leafX;\nconst finishOrder = [];\nfunction postorder(node) {\n node.children.forEach(postorder);\n finishOrder.push(node.id);\n}\npostorder(root);\nconst total = finishOrder.length;\nconst period = total * 0.45 + 2.4;\nconst tt = t % period;\nconst resolved = Math.min(total, Math.floor(tt / 0.45) + 1);\nconst doneSet = new Array(counter).fill(false);\nfor (let k = 0; k < resolved; k++) doneSet[finishOrder[k]] = true;\nconst activeId = finishOrder[Math.max(0, Math.min(total - 1, resolved - 1))];\nconst box = { x: 50, y: 120, w: w - 100, h: h - 180 };\nconst px = (ux) => box.x + (leaves <= 1 ? box.w / 2 : H.map(ux, 0, leaves - 1, 0, box.w));\nconst py = (d) => box.y + (maxDepth === 0 ? 0 : H.map(d, 0, maxDepth, 0, box.h));\nall.forEach((nd) => {\n nd.children.forEach((c) => {\n const lit = doneSet[c.id];\n H.line(px(nd.ux), py(nd.depth), px(c.ux), py(c.depth),\n { color: lit ? H.colors.good : H.colors.grid, width: lit ? 2.4 : 1.3 });\n });\n});\nall.forEach((nd) => {\n const x = px(nd.ux), y = py(nd.depth);\n const done = doneSet[nd.id];\n let fill = H.colors.panel;\n let ink = H.colors.sub;\n if (done) { fill = H.colors.good; ink = H.colors.bg; }\n if (nd.id === activeId && done) { fill = H.colors.accent2; ink = H.colors.bg; }\n let r = 16;\n if (nd.id === activeId && done) r = 16 + 2.5 * Math.sin(t * 6);\n H.circle(x, y, r, { fill: fill, stroke: H.colors.bg, width: 2 });\n H.text(\"f\" + nd.n, x, y + 1, { color: ink, size: 13, align: \"center\", weight: 700 });\n if (done) {\n H.text(\"=\" + nd.val, x, y + r + 13,\n { color: H.colors.accent, size: 11, align: \"center\", weight: 600 });\n }\n});\nH.text(\"Recursion tree: fib(5)\", 24, 36, { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"fib(n) = fib(n-1) + fib(n-2). Leaves return; values bubble up post-order.\",\n 24, 58, { color: H.colors.sub, size: 13 });\nconst rootVal = doneSet[root.id] ? root.val : \"...\";\nH.text(\"calls resolved: \" + resolved + \" / \" + total +\n \" fib(5) = \" + rootVal, 24, 84,\n { color: doneSet[root.id] ? H.colors.good : H.colors.accent2, size: 13, weight: 600 });\nH.text(\"Repeated subtrees (e.g. fib(2)) show why naive recursion is exponential.\",\n 24, h - 22, { color: H.colors.sub, size: 12 });\nH.legend([\n { label: \"returning now\", color: H.colors.accent2 },\n { label: \"resolved\", color: H.colors.good },\n { label: \"pending\", color: H.colors.panel },\n], w - 160, 120);" + }, + { + "id": "projectile-motion-velocity-vectors", + "title": "Projectile Motion: Velocity Vectors", + "tag": "Classical Mechanics", + "dimension": "2D", + "equation": "x = v0 cos(theta) t, y = v0 sin(theta) t - 1/2 g t^2", + "summary": "A ball is launched at a fixed speed and angle and flies along a parabola. Live arrows show the constant horizontal velocity, the shrinking-then-growing vertical velocity, and their resultant as the ball traces the trajectory.", + "keywords": [ + "projectile", + "projectile motion", + "parabola", + "parabolic trajectory", + "velocity vector", + "velocity components", + "kinematics", + "launch angle", + "range", + "apex", + "gravity", + "ballistics", + "horizontal velocity", + "vertical velocity" + ], + "bullets": [ + "Horizontal velocity vx stays constant for the whole flight; only vy changes.", + "vy decreases, hits zero at the apex, then reverses sign on the way down.", + "The resultant speed |v| is minimum at the top and equal at matching heights." + ], + "student_prompts": [ + "How does the launch angle that maximizes range come out to 45 degrees?", + "What happens to the trajectory if I double the initial speed?", + "Why does air resistance break the symmetry of the parabola?" + ], + "code": "H.background();\n const g = 9.8;\n const v0 = 22;\n const ang = 58 * Math.PI / 180;\n const vx0 = v0 * Math.cos(ang);\n const vy0 = v0 * Math.sin(ang);\n const tFlight = (2 * vy0) / g;\n const range = vx0 * tFlight;\n const apex = (vy0 * vy0) / (2 * g);\n const xMax = Math.max(range * 1.12, 1);\n const yMax = Math.max(apex * 1.45, 1);\n const view = H.plot2d({ xMin: -xMax * 0.05, xMax: xMax, yMin: -yMax * 0.08, yMax: yMax, pad: 52 });\n view.grid();\n view.axes();\n\n const arcPts = [];\n const steps = 90;\n for (let i = 0; i <= steps; i++) {\n const tt = (i / steps) * tFlight;\n const x = vx0 * tt;\n const y = vy0 * tt - 0.5 * g * tt * tt;\n if (y >= -1e-6) arcPts.push([x, y]);\n }\n view.path(arcPts, { color: H.colors.grid, width: 2, dash: [6, 6] });\n\n const period = tFlight + 0.6;\n const tau = (t % period);\n const ct = Math.min(tau, tFlight);\n const px = vx0 * ct;\n const py = Math.max(0, vy0 * ct - 0.5 * g * ct * ct);\n const cvx = vx0;\n const cvy = vy0 - g * ct;\n const speed = Math.sqrt(cvx * cvx + cvy * cvy);\n\n const trail = [];\n for (let i = 0; i <= 40; i++) {\n const tt = ct * (i / 40);\n const x = vx0 * tt;\n const y = Math.max(0, vy0 * tt - 0.5 * g * tt * tt);\n trail.push([x, y]);\n }\n view.path(trail, { color: H.colors.accent, width: 3 });\n\n const vscale = (range * 0.012);\n view.arrow(px, py, px + cvx * vscale, py, { color: H.colors.good, width: 2.4, head: 9 });\n view.arrow(px, py, px, py + cvy * vscale, { color: H.colors.warn, width: 2.4, head: 9 });\n view.arrow(px, py, px + cvx * vscale, py + cvy * vscale, { color: H.colors.yellow, width: 2.8, head: 10 });\n\n view.line(-xMax * 0.05, 0, xMax, 0, { color: H.colors.axis, width: 2 });\n view.dot(range * 0.5, apex, { r: 4, fill: H.colors.violet, stroke: H.colors.bg });\n view.text(\"apex \" + apex.toFixed(1) + \" m\", range * 0.5, apex + yMax * 0.06, { color: H.colors.violet, size: 12, align: \"center\" });\n\n view.circle(px, py, 8, { fill: H.colors.accent2, stroke: H.colors.bg, width: 2 });\n\n H.text(\"Projectile Motion: velocity vectors\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\n H.text(\"v0 = \" + v0 + \" m/s at \" + (ang * 180 / Math.PI).toFixed(0) + \" deg. vx is constant; vy flips sign at the top.\", 24, 52, { color: H.colors.sub, size: 13 });\n H.text(\"t = \" + ct.toFixed(2) + \" s |v| = \" + speed.toFixed(1) + \" m/s\", 24, 76, { color: H.colors.sub, size: 13 });\n H.text(\"vx = \" + cvx.toFixed(1) + \" vy = \" + cvy.toFixed(1) + \" m/s range = \" + range.toFixed(1) + \" m\", 24, 94, { color: H.colors.sub, size: 13 });\n\n H.legend([\n { label: \"vx (horizontal)\", color: H.colors.good },\n { label: \"vy (vertical)\", color: H.colors.warn },\n { label: \"v (resultant)\", color: H.colors.yellow }\n ], H.W - 190, 84);\n\n view.text(\"x (m)\", xMax * 0.92, -yMax * 0.04, { color: H.colors.sub, size: 12 });\n view.text(\"y (m)\", xMax * 0.015, yMax * 0.95, { color: H.colors.sub, size: 12 });" + }, + { + "id": "simple-harmonic-motion-spring", + "title": "Simple Harmonic Motion: Mass on a Spring", + "tag": "Classical Mechanics", + "dimension": "2D", + "equation": "x(t) = A cos(omega t), omega = sqrt(k/m)", + "summary": "A mass oscillates on a frictionless spring while a synchronized x(t) cosine curve and energy bars show kinetic and spring potential energy trading off so the total stays constant.", + "keywords": [ + "simple harmonic motion", + "shm", + "mass on spring", + "spring oscillator", + "hooke's law", + "oscillation", + "sinusoidal motion", + "angular frequency", + "period", + "amplitude", + "kinetic energy", + "potential energy", + "energy conservation", + "restoring force" + ], + "bullets": [ + "The restoring force F = -kx gives sinusoidal motion x = A cos(omega t).", + "Angular frequency omega = sqrt(k/m): stiffer spring or lighter mass means faster.", + "Energy continuously swaps between spring PE and kinetic KE while the total E is fixed." + ], + "student_prompts": [ + "Why is the period independent of the amplitude in ideal SHM?", + "How would adding damping change the x(t) curve over time?", + "Where in the cycle are the speed and the acceleration each largest?" + ], + "code": "H.background();\n const w = H.W, h = H.H;\n\n const A = 1.6;\n const k = 8;\n const m = 1.2;\n const omega = Math.sqrt(k / m);\n const x = A * Math.cos(omega * t);\n const vel = -A * omega * Math.sin(omega * t);\n const PE = 0.5 * k * x * x;\n const KE = 0.5 * m * vel * vel;\n const E = PE + KE;\n\n const wallX = w * 0.10;\n const eqX = w * 0.42;\n const pxPerM = (w * 0.20);\n const massY = h * 0.40;\n const massX = eqX + x * pxPerM;\n\n H.rect(wallX - 14, massY - 70, 14, 140, { fill: H.colors.panel, stroke: H.colors.axis, width: 1.5 });\n for (let i = 0; i < 7; i++) {\n H.line(wallX - 14, massY - 70 + i * 22, wallX, massY - 70 + i * 22 + 12, { color: H.colors.axis, width: 1 });\n }\n\n const coils = 14;\n const sp = [];\n sp.push([wallX, massY]);\n for (let i = 0; i <= coils; i++) {\n const s = i / coils;\n const sx = wallX + s * (massX - wallX);\n const sy = massY + (i === 0 || i === coils ? 0 : (i % 2 === 0 ? -14 : 14));\n sp.push([sx, sy]);\n }\n sp.push([massX, massY]);\n H.path(sp, { color: H.colors.accent, width: 2.4 });\n\n H.line(eqX, massY - 78, eqX, massY + 78, { color: H.colors.grid, width: 1.5, dash: [5, 5] });\n H.text(\"x = 0\", eqX, massY + 96, { color: H.colors.sub, size: 12, align: \"center\" });\n\n const va = vel * pxPerM * 0.18;\n if (Math.abs(va) > 1) H.arrow(massX, massY - 44, massX + va, massY - 44, { color: H.colors.good, width: 2.4, head: 9 });\n\n H.rect(massX - 24, massY - 24, 48, 48, { fill: H.colors.accent2, stroke: H.colors.bg, width: 2, radius: 6 });\n H.text(\"m\", massX, massY + 6, { color: H.colors.bg, size: 16, weight: 700, align: \"center\" });\n\n H.arrow(eqX, massY + 64, massX, massY + 64, { color: H.colors.violet, width: 2, head: 8 });\n H.text(\"x = \" + x.toFixed(2) + \" m\", (eqX + massX) / 2, massY + 58, { color: H.colors.violet, size: 12, align: \"center\" });\n\n const gx = w * 0.66, gy = h * 0.16, gw = w * 0.30, gh = h * 0.34;\n const view = H.plot2d({ box: { x: gx, y: gy, w: gw, h: gh }, xMin: 0, xMax: 8, yMin: -A * 1.3, yMax: A * 1.3 });\n view.grid({ stepX: 2, stepY: 1 });\n view.axes({ stepX: 2, stepY: 1 });\n view.fn((tt) => A * Math.cos(omega * (t - (8 - tt))), { color: H.colors.accent, width: 2.4 });\n view.dot(8, x, { r: 5, fill: H.colors.accent2, stroke: H.colors.bg });\n H.text(\"x(t) = A cos(omega t)\", gx, gy - 8, { color: H.colors.sub, size: 12 });\n\n const bx = w * 0.66, by = h * 0.62, bw = w * 0.30, bh = h * 0.22;\n H.rect(bx, by, bw, bh, { stroke: H.colors.grid, width: 1 });\n const maxE = 0.5 * k * A * A + 1e-9;\n const peW = (PE / maxE) * bw;\n const keW = (KE / maxE) * bw;\n H.rect(bx, by + 6, peW, bh * 0.36, { fill: H.colors.warn, radius: 3 });\n H.rect(bx, by + bh * 0.52, keW, bh * 0.36, { fill: H.colors.good, radius: 3 });\n H.text(\"PE = \" + PE.toFixed(2) + \" J\", bx + 6, by + bh * 0.30, { color: H.colors.ink, size: 12 });\n H.text(\"KE = \" + KE.toFixed(2) + \" J\", bx + 6, by + bh * 0.78, { color: H.colors.ink, size: 12 });\n\n const T = (2 * Math.PI) / omega;\n H.text(\"Simple Harmonic Motion: mass on a spring\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\n H.text(\"Frictionless oscillator. Energy sloshes between spring PE and kinetic KE; total stays fixed.\", 24, 52, { color: H.colors.sub, size: 13 });\n H.text(\"omega = \" + omega.toFixed(2) + \" rad/s T = \" + T.toFixed(2) + \" s\", 24, 76, { color: H.colors.sub, size: 13 });\n H.text(\"x = \" + x.toFixed(2) + \" m v = \" + vel.toFixed(2) + \" m/s E = \" + E.toFixed(2) + \" J\", 24, 94, { color: H.colors.sub, size: 13 });\n\n H.legend([\n { label: \"spring PE\", color: H.colors.warn },\n { label: \"kinetic KE\", color: H.colors.good },\n { label: \"velocity\", color: H.colors.good }\n ], 24, h - 70);" + }, + { + "id": "pendulum-phase-space", + "title": "Pendulum Phase Space", + "tag": "Classical Mechanics", + "dimension": "2D", + "equation": "theta'' = -(g/L) sin(theta)", + "summary": "A large-amplitude pendulum swings on the right while its state (angle, angular velocity) traces a closed energy orbit in the phase plane on the left, showing how one conserved energy pins the motion to a single loop.", + "keywords": [ + "pendulum", + "phase space", + "phase portrait", + "phase plane", + "angular velocity", + "nonlinear oscillator", + "energy orbit", + "theta omega", + "state space", + "conservation of energy", + "anharmonic", + "simple pendulum", + "dynamical system", + "limit cycle" + ], + "bullets": [ + "A point in phase space is a full state: angle theta and angular velocity omega.", + "Conserved energy confines the pendulum to one closed loop (libration orbit).", + "At large angles the motion is anharmonic: the orbit is not a perfect ellipse." + ], + "student_prompts": [ + "What does the phase orbit look like if the pendulum has enough energy to go over the top?", + "How does adding friction turn the closed loop into an inward spiral?", + "Why does the period grow as the swing amplitude increases?" + ], + "code": "H.background();\n const w = H.W, h = H.H;\n\n const g = 9.8, L = 1.0;\n const w2 = g / L;\n const theta0 = 2.3;\n let th = theta0, om = 0;\n const dt = 1 / 240;\n const N = Math.min(Math.floor(t * 240), 6000);\n for (let i = 0; i < N; i++) {\n const a1 = -w2 * Math.sin(th);\n const omh = om + a1 * dt * 0.5;\n const thh = th + om * dt * 0.5;\n const a2 = -w2 * Math.sin(thh);\n om = om + a2 * dt;\n th = th + omh * dt;\n }\n let thW = th;\n while (thW > Math.PI) thW -= 2 * Math.PI;\n while (thW < -Math.PI) thW += 2 * Math.PI;\n\n const gx = w * 0.07, gy = h * 0.16, gw = w * 0.50, gh = h * 0.72;\n const omMax = Math.sqrt(2 * w2 * (1 - Math.cos(theta0))) * 1.25 + 0.5;\n const view = H.plot2d({ box: { x: gx, y: gy, w: gw, h: gh }, xMin: -Math.PI, xMax: Math.PI, yMin: -omMax, yMax: omMax });\n view.grid({ stepX: 1, stepY: 1 });\n view.axes({ stepX: 1, stepY: 1 });\n\n const upper = [], lower = [];\n const M = 160;\n for (let i = 0; i <= M; i++) {\n const a = -theta0 + (2 * theta0) * (i / M);\n const val = 2 * w2 * (Math.cos(a) - Math.cos(theta0));\n const omv = val > 0 ? Math.sqrt(val) : 0;\n upper.push([a, omv]);\n lower.push([a, -omv]);\n }\n view.path(upper, { color: H.colors.violet, width: 1.6 });\n view.path(lower, { color: H.colors.violet, width: 1.6 });\n\n view.dot(thW, om, { r: 6, fill: H.colors.accent2, stroke: H.colors.bg });\n view.arrow(thW, om, thW + (om * 0.06), om + (-w2 * Math.sin(thW)) * 0.06, { color: H.colors.good, width: 2, head: 8 });\n\n H.text(\"theta (rad)\", gx + gw - 70, gy + gh + 26, { color: H.colors.sub, size: 12 });\n H.text(\"omega (rad/s)\", gx - 4, gy - 8, { color: H.colors.sub, size: 12 });\n\n const pivotX = w * 0.80, pivotY = h * 0.30;\n const Lpx = h * 0.34;\n const bobX = pivotX + Lpx * Math.sin(thW);\n const bobY = pivotY + Lpx * Math.cos(thW);\n H.line(pivotX, pivotY, pivotX, pivotY + Lpx, { color: H.colors.grid, width: 1.5, dash: [5, 5] });\n const arcPts = [];\n for (let i = 0; i <= 24; i++) {\n const a = (thW) * (i / 24);\n arcPts.push([pivotX + Lpx * 0.32 * Math.sin(a), pivotY + Lpx * 0.32 * Math.cos(a)]);\n }\n H.path(arcPts, { color: H.colors.yellow, width: 2 });\n H.line(pivotX, pivotY, bobX, bobY, { color: H.colors.sub, width: 3 });\n H.circle(pivotX, pivotY, 5, { fill: H.colors.axis });\n H.circle(bobX, bobY, 15, { fill: H.colors.accent, stroke: H.colors.bg, width: 2 });\n H.text(\"theta = \" + (thW * 180 / Math.PI).toFixed(0) + \" deg\", pivotX, pivotY - 16, { color: H.colors.sub, size: 12, align: \"center\" });\n\n const KE = 0.5 * L * L * om * om;\n const PE = g * L * (1 - Math.cos(thW));\n const E = KE + PE;\n\n H.text(\"Pendulum Phase Space\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\n H.text(\"Each point is a state (theta, omega). The pendulum traces one closed loop = one conserved energy.\", 24, 52, { color: H.colors.sub, size: 13 });\n H.text(\"theta = \" + thW.toFixed(2) + \" rad omega = \" + om.toFixed(2) + \" rad/s\", 24, 76, { color: H.colors.sub, size: 13 });\n H.text(\"KE = \" + KE.toFixed(2) + \" PE = \" + PE.toFixed(2) + \" E = \" + E.toFixed(2) + \" (per unit mass)\", 24, 94, { color: H.colors.sub, size: 13 });\n\n H.legend([\n { label: \"energy orbit\", color: H.colors.violet },\n { label: \"current state\", color: H.colors.accent2 },\n { label: \"phase velocity\", color: H.colors.good }\n ], gx, gy + gh + 40);" + }, + { + "id": "derivative-tangent-slope-secant-limit", + "title": "Derivative as the Slope of the Tangent Line", + "tag": "Calculus", + "dimension": "2D", + "equation": "f'(a) = lim_{h->0} (f(a+h) - f(a)) / h", + "summary": "A point of tangency sweeps along a curve while the tangent line tracks the exact derivative. A second secant line, drawn through a and a+h, shrinks its step h toward zero so you watch the secant slope converge to the tangent slope.", + "keywords": [ + "derivative", + "tangent line", + "slope", + "secant line", + "rate of change", + "instantaneous rate", + "limit definition", + "difference quotient", + "f prime", + "calculus", + "differentiation", + "gradient of curve" + ], + "bullets": [ + "The orange dashed line is the tangent at x=a; its slope equals f'(a)=a-cos(a) for this curve.", + "The purple secant through a and a+h has slope (f(a+h)-f(a))/h, shown with a rise/run triangle.", + "As h shrinks the secant slope readout approaches the tangent slope readout: the limit definition in action." + ], + "student_prompts": [ + "Why does the secant slope approach the tangent slope as h goes to 0?", + "How would the tangent line look at a point where f'(a) = 0?", + "What does a negative derivative tell us about the shape of the curve there?" + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -3.4, xMax: 3.4, yMin: -3.2, yMax: 5.2, pad: 50 });\nv.grid(); v.axes();\nconst f = (x) => 0.5 * x * x - Math.sin(x) + 0.5;\nconst df = (x) => x - Math.cos(x);\nv.fn(f, { color: H.colors.accent, width: 3 });\nconst a = 2.8 * Math.sin(t * 0.55);\nconst fa = f(a);\nconst m = df(a);\nconst tx = (x) => fa + m * (x - a);\nv.line(v.xMin, tx(v.xMin), v.xMax, tx(v.xMax), { color: H.colors.accent2, width: 2.4, dash: [7, 6] });\nconst dh = 1.2 + 1.0 * Math.cos(t * 0.55);\nconst xb = a + dh;\nconst fb = f(xb);\nconst secSlope = Math.abs(xb - a) > 1e-6 ? (fb - fa) / (xb - a) : m;\nv.line(a, fa, xb, fb, { color: H.colors.violet, width: 2 });\nv.line(a, fa, xb, fa, { color: H.colors.sub, width: 1.4, dash: [3, 4] });\nv.line(xb, fa, xb, fb, { color: H.colors.sub, width: 1.4, dash: [3, 4] });\nv.dot(a, fa, { r: 6, fill: H.colors.accent2 });\nv.dot(xb, fb, { r: 5, fill: H.colors.violet });\nv.text(\"f(x)\", a - 2.4, f(a - 2.4) + 0.9, { color: H.colors.accent, size: 14 });\nv.text(\"a\", a, fa - 0.55, { color: H.colors.ink, size: 13, align: \"center\" });\nv.text(\"a+h\", xb, fb + 0.7, { color: H.colors.violet, size: 12, align: \"center\" });\nH.text(\"Derivative = slope of the tangent line\", 22, 28, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"As h -> 0 the secant (purple) becomes the tangent (orange).\", 22, 50, { color: H.colors.sub, size: 13 });\nH.text(\"a = \" + a.toFixed(2) + \" f'(a) = \" + m.toFixed(2), 22, 78, { color: H.colors.accent2, size: 14, weight: 600 });\nH.text(\"h = \" + dh.toFixed(2) + \" secant slope = \" + secSlope.toFixed(2), 22, 98, { color: H.colors.violet, size: 14, weight: 600 });\nH.legend([\n { label: \"f(x)\", color: H.colors.accent },\n { label: \"tangent (f')\", color: H.colors.accent2 },\n { label: \"secant\", color: H.colors.violet },\n], 22, 126);" + }, + { + "id": "riemann-sum-accumulating-integral-area", + "title": "Definite Integral as Accumulating Riemann Area", + "tag": "Calculus", + "dimension": "2D", + "equation": "∫_a^b f(x) dx = lim_{N->∞} Σ f(x_i*) Δx", + "summary": "Midpoint-rule rectangles fill the area under a curve, and their count climbs from a couple to dozens. The running rectangle sum and the exact integral are shown side by side, with the gap between them visibly shrinking as N grows.", + "keywords": [ + "integral", + "definite integral", + "riemann sum", + "area under curve", + "accumulation", + "midpoint rule", + "rectangles", + "antiderivative", + "fundamental theorem", + "integration", + "numerical integration", + "summation" + ], + "bullets": [ + "Each rectangle has width Δx=(b-a)/N and midpoint height f(x_i*); their total approximates the area.", + "The function is kept nonnegative on [0,6], so every bar height and the accumulated area stay positive.", + "As N rises the sum readout converges to the exact integral and the error readout drops toward zero." + ], + "student_prompts": [ + "Why does the midpoint rule usually beat left or right Riemann sums?", + "What happens to the error if I double the number of rectangles?", + "How is this Riemann sum connected to the antiderivative of f?" + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -0.4, xMax: 6.6, yMin: -0.4, yMax: 3.0, pad: 52 });\nv.grid(); v.axes();\nconst f = (x) => 0.9 + 0.8 * Math.sin(0.65 * x) + 0.25 * x;\nconst A = 0, B = 6;\nconst cyc = (t * 0.12) % 1;\nconst N = Math.round(H.lerp(2, 60, H.ease(cyc)));\nconst dx = (B - A) / N;\nlet area = 0;\nfor (let i = 0; i < N; i++) {\n const xl = A + i * dx;\n const xm = xl + dx / 2;\n const hh = Math.max(0, f(xm));\n area += hh * dx;\n const shade = H.hsl(205 - 120 * (i / Math.max(1, N - 1)), 70, 58, 0.5);\n v.rect(xl, 0, dx, hh, { fill: shade, stroke: \"rgba(10,14,30,0.45)\", width: 0.8 });\n}\nv.fn(f, { color: H.colors.accent2, width: 3 });\nlet exact = 0;\nconst M = 600;\nfor (let i = 0; i < M; i++) {\n const xm = A + (i + 0.5) * (B - A) / M;\n exact += Math.max(0, f(xm)) * (B - A) / M;\n}\nconst err = Math.abs(exact - area);\nv.line(A, 0, A, f(A), { color: H.colors.sub, width: 1.4, dash: [4, 4] });\nv.line(B, 0, B, f(B), { color: H.colors.sub, width: 1.4, dash: [4, 4] });\nv.text(\"a=0\", A, -0.18, { color: H.colors.sub, size: 12, align: \"center\" });\nv.text(\"b=6\", B, -0.18, { color: H.colors.sub, size: 12, align: \"center\" });\nv.text(\"y = f(x)\", 4.4, f(4.4) + 0.35, { color: H.colors.accent2, size: 14 });\nH.text(\"Definite integral as accumulating area\", 22, 28, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Midpoint Riemann sum: more rectangles -> exact area under f.\", 22, 50, { color: H.colors.sub, size: 13 });\nH.text(\"N = \" + N + \" rectangles sum ~ \" + area.toFixed(3), 22, 78, { color: H.colors.accent, size: 14, weight: 600 });\nH.text(\"exact integral = \" + exact.toFixed(3) + \" error = \" + err.toFixed(3), 22, 98, { color: H.colors.good, size: 14, weight: 600 });" + }, + { + "id": "taylor-series-convergence-error-landscape", + "title": "Taylor Series Convergence Landscape", + "tag": "Calculus", + "dimension": "3D", + "equation": "sin(x) = Σ_{k=0}^{∞} (-1)^k x^{2k+1} / (2k+1)!", + "summary": "A lit 3D surface where horizontal position is x, depth is the number of Taylor terms N, and height is the approximation error |sin(x) - T_N(x)|. As more terms switch on, the ridges collapse toward the floor, making convergence of the Maclaurin series for sine literally a flattening landscape.", + "keywords": [ + "taylor series", + "maclaurin series", + "convergence", + "polynomial approximation", + "power series", + "sine expansion", + "error", + "remainder", + "truncation error", + "series", + "3d surface", + "approximation order" + ], + "bullets": [ + "The up axis is the error |sin(x)-T_N(x)|, which is clamped nonnegative so the surface never dips below the floor.", + "Moving back along the ground axis adds Taylor terms; the tall error ridges far from x=0 shrink as N increases.", + "The live readout tracks the error at x=pi, a hard spot far from the expansion center, falling as terms are added." + ], + "student_prompts": [ + "Why does the Taylor approximation get worse far from x=0 for a fixed number of terms?", + "How fast does the error at x=pi shrink as I add more terms?", + "What is the radius of convergence for the Taylor series of sin(x)?" + ], + "code": "H.background();\nconst cam = H.cam3d({ scale: 30, dist: 17, pitch: -0.5, cy: H.H * 0.56 });\ncam.yaw = 0.3 * t;\ncam.grid(6, 2);\nconst factorial = (n) => { let p = 1; for (let k = 2; k <= n; k++) p *= k; return p; };\nconst Nmax = 9;\nconst grow = (Math.sin(t * 0.35) * 0.5 + 0.5);\nconst frontier = 1 + Math.floor(grow * (Nmax - 1));\nconst taylorSin = (x, terms) => {\n let s = 0;\n for (let k = 0; k < terms; k++) {\n const n = 2 * k + 1;\n s += (k % 2 === 0 ? 1 : -1) * Math.pow(x, n) / factorial(n);\n }\n return s;\n};\nH.surface3d(cam, (u, w) => {\n const tt = 1 + Math.round(H.map(w, -6, 6, 0, frontier - 1));\n const approx = taylorSin(u, tt);\n let e = Math.abs(Math.sin(u) - approx);\n if (!Number.isFinite(e)) e = 6;\n return Math.min(e, 6) * 0.9;\n}, { xMin: -6, xMax: 6, yMin: -6, yMax: 6, nx: 38, ny: 24, hueMin: 25, hueMax: 200 });\ncam.axes(7);\nconst xProbe = Math.PI;\nconst errProbe = Math.abs(Math.sin(xProbe) - taylorSin(xProbe, frontier));\nH.text(\"Taylor series convergence of sin(x)\", 22, 28, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Height = |sin(x) - T_N(x)| error. As terms N grow the surface flattens to 0.\", 22, 50, { color: H.colors.sub, size: 13 });\nH.text(\"active terms N = \" + frontier + \" (degree \" + (2 * frontier - 1) + \")\", 22, 78, { color: H.colors.accent2, size: 14, weight: 600 });\nH.text(\"error at x = pi : \" + errProbe.toFixed(4), 22, 98, { color: H.colors.good, size: 14, weight: 600 });\nH.text(\"x axis -> x, ground axis -> term count N, up -> error\", 22, 118, { color: H.colors.sub, size: 12 });" + }, + { + "id": "particle-in-a-box-wavefunctions", + "title": "Particle in a Box: Stationary States", + "tag": "Quantum Mechanics", + "dimension": "2D", + "equation": "psi_n(x) = sqrt(2/L) sin(n pi x / L), E_n = n^2 pi^2 hbar^2 / (2 m L^2)", + "summary": "An infinite square well showing the n-th energy eigenstate: the real part of the wavefunction oscillates in time while its probability density stays steady, and n cycles 1 to 4 so you watch nodes appear and the energy climb as n^2.", + "keywords": [ + "particle in a box", + "infinite square well", + "wavefunction", + "probability density", + "psi squared", + "quantum well", + "standing wave", + "energy levels", + "eigenstate", + "quantum number", + "nodes", + "stationary state", + "schrodinger", + "quantum mechanics" + ], + "bullets": [ + "The wavefunction must vanish at both walls, which forces only whole-number half-wavelengths to fit — that quantization gives energies E_n proportional to n^2.", + "|psi_n(x)|^2 (the filled green curve) is the probability of finding the particle at x; it has n-1 interior nodes where the particle is never found.", + "Re[psi e^{-i w t}] (blue) oscillates in time, but the measurable probability density |psi|^2 is time-independent — that is why these are called stationary states." + ], + "student_prompts": [ + "Why does the energy grow like n^2 instead of linearly with n?", + "What happens to the spacing between energy levels if I make the box L wider?", + "How does the number of nodes relate to the quantum number n, and why?" + ], + "code": "H.background();\nconst L = 1; // box width (nm), well from x=0..L\nconst n = 1 + Math.floor((t * 0.25) % 4); // quantum number cycles 1..4\nconst hbar = 1.0545718e-34, me = 9.109e-31; // SI for energy readout\nconst Ln = L * 1e-9;\nconst E_J = (n * n * Math.PI * Math.PI * hbar * hbar) / (2 * me * Ln * Ln);\nconst E_eV = E_J / 1.602e-19;\nconst omega = 2 + 0.6 * n;\nconst phase = Math.cos(omega * t);\nconst A = Math.sqrt(2 / L); // normalization\nconst psi = (x) => A * Math.sin((n * Math.PI * x) / L);\nconst v = H.plot2d({ xMin: -0.12, xMax: 1.12, yMin: -2.4, yMax: 3.0, pad: 52 });\nv.grid({ stepX: 0.25, stepY: 1 });\nv.axes({ stepX: 0.25, stepY: 1 });\nv.line(0, -2.4, 0, 3.0, { color: H.colors.warn, width: 3 });\nv.line(L, -2.4, L, 3.0, { color: H.colors.warn, width: 3 });\nv.text(\"V=inf\", 0.0, 2.85, { color: H.colors.warn, size: 12, align: \"center\" });\nv.text(\"V=inf\", L, 2.85, { color: H.colors.warn, size: 12, align: \"center\" });\nv.text(\"V = 0\", 0.5, 0.2, { color: H.colors.sub, size: 12, align: \"center\" });\nconst fillPts = [[0, 0]];\nconst dens = [];\nconst NS = 120;\nfor (let i = 0; i <= NS; i++) {\n const x = (i / NS) * L;\n const p = psi(x);\n const d = p * p; // |psi|^2 >= 0\n dens.push([x, d]);\n fillPts.push([x, d]);\n}\nfillPts.push([L, 0]);\nv.path(fillPts, { color: \"none\", fill: \"rgba(103,232,176,0.18)\", close: true });\nv.path(dens, { color: H.colors.good, width: 2.4 });\nconst wavePts = [];\nfor (let i = 0; i <= NS; i++) {\n const x = (i / NS) * L;\n wavePts.push([x, psi(x) * phase]);\n}\nv.path(wavePts, { color: H.colors.accent, width: 3 });\nconst xb = 0.5 * L + 0.45 * L * Math.sin(t * 0.9);\nconst xbc = H.clamp(xb, 0, L);\nv.dot(xbc, psi(xbc) * phase, { r: 6, fill: H.colors.accent2 });\nconst nodes = n - 1;\nH.text(\"Particle in a box: stationary states\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Infinite square well -- psi_n(x) = sqrt(2/L)*sin(n pi x/L)\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"n = \" + n + \" nodes = \" + nodes + \" E_n = \" + E_eV.toFixed(2) + \" eV\", 24, 76, { color: H.colors.yellow, size: 14, weight: 600 });\nH.text(\"Re[psi e^{-i w t}] oscillates; |psi|^2 (filled) is steady\", 24, 96, { color: H.colors.sub, size: 12 });\nH.legend([\n { label: \"psi_n(x) real part\", color: H.colors.accent },\n { label: \"|psi_n(x)|^2 probability density\", color: H.colors.good },\n { label: \"infinite walls\", color: H.colors.warn },\n], 24, H.H - 76);" + }, + { + "id": "hydrogen-orbital-3d-shape", + "title": "Hydrogen Orbital Shapes (3D)", + "tag": "Quantum Mechanics", + "dimension": "3D", + "equation": "psi_nlm(r,theta,phi) = R_nl(r) Y_lm(theta,phi); surface radius proportional to |Y_lm|", + "summary": "A rotating solid surface whose radius in each direction is proportional to the angular probability amplitude |Y_lm|, cycling through 1s, 2p_z, 3d_z2 and 3d_xy so you see how the electron cloud reshapes as the quantum numbers l and m change.", + "keywords": [ + "hydrogen orbital", + "atomic orbital", + "spherical harmonic", + "electron cloud", + "s orbital", + "p orbital", + "d orbital", + "probability cloud", + "quantum numbers", + "angular momentum", + "wavefunction shape", + "y_lm", + "mesh3d", + "3d orbital" + ], + "bullets": [ + "The distance from the nucleus to the surface in any direction shows how likely the electron is to be found heading that way — round for s, two-lobed for p, more structured for d.", + "Angular nodes (planes where the amplitude is zero) increase with the orbital angular momentum l: 0 for s, 1 for p, 2 for d.", + "The quantum number m sets how the lobes are oriented in space; the d_z2 and d_xy shapes share l=2 but point very differently." + ], + "student_prompts": [ + "What is the difference between the radial part R_nl and the angular part Y_lm of an orbital?", + "Why does a p orbital have exactly one nodal plane while a d orbital has two?", + "How does this angular shape relate to the full 3D probability cloud that includes the radial distance?" + ], + "code": "H.background();\nconst cam = H.cam3d({ scale: 46, dist: 16, pitch: -0.32, cy: H.H * 0.54 });\ncam.yaw = 0.3 * t;\ncam.grid(5, 1);\nconst orbitals = [\n { name: \"1s (l=0)\", desc: \"spherical, no angular nodes\" },\n { name: \"2pz (l=1, m=0)\", desc: \"two lobes along z, one nodal plane\" },\n { name: \"3dz2 (l=2, m=0)\", desc: \"torus + two axial lobes\" },\n { name: \"3dxy (l=2, m=2)\", desc: \"four lobes, two nodal planes\" },\n];\nconst idx = Math.floor((t / 5) % orbitals.length);\nconst orb = orbitals[idx];\nfunction angular(theta, phi) {\n if (idx === 0) return 1; // s\n if (idx === 1) return Math.abs(Math.cos(theta)); // p_z ~ cos(theta)\n if (idx === 2) { // d_z^2 ~ 3cos^2-1\n return Math.abs(3 * Math.cos(theta) * Math.cos(theta) - 1) / 2;\n }\n return Math.abs(Math.sin(theta) * Math.sin(theta) * Math.sin(2 * phi));\n}\nconst breathe = 0.9 + 0.12 * Math.sin(t * 1.6);\nconst Rmax = 3.4;\nH.mesh3d(cam, (u, v) => {\n const r = Rmax * (0.12 + 0.88 * angular(v, u)) * breathe;\n const y = r * Math.cos(v);\n const s = r * Math.sin(v);\n const x = s * Math.cos(u);\n const z = s * Math.sin(u);\n return [x, y, z];\n}, {\n uMin: 0, uMax: H.TAU, vMin: 0, vMax: Math.PI,\n nu: 40, nv: 30,\n hueMin: 280, hueMax: 200, alpha: 0.9,\n});\ncam.sphere([0, 0, 0], 0.16, { color: H.colors.warn });\ncam.axes(4);\nconst extent = (Rmax * breathe).toFixed(2);\nH.text(\"Hydrogen orbital -- angular shape |Y_lm|\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Surface radius proportional to amplitude in each direction. Drag to orbit.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"orbital: \" + orb.name, 24, 78, { color: H.colors.yellow, size: 15, weight: 600 });\nH.text(orb.desc, 24, 98, { color: H.colors.sub, size: 12 });\nH.text(\"lobe extent ~ \" + extent + \" a0 (breathing pulse)\", 24, 118, { color: H.colors.accent, size: 12 });\nH.legend([\n { label: \"nucleus (proton)\", color: H.colors.warn },\n { label: \"electron cloud lobe\", color: \"#c4a7ff\" },\n], 24, H.H - 56);" + }, + { + "id": "harmonic-oscillator-levels-collapse", + "title": "Harmonic Oscillator + Collapse", + "tag": "Quantum Mechanics", + "dimension": "2D", + "equation": "E_n = (n + 1/2) hbar omega; V(x) = (1/2) m omega^2 x^2", + "summary": "The quantum harmonic oscillator's evenly-spaced energy ladder sits inside its parabolic well; the system shimmers as a superposition of levels, then a periodic 'measurement' sweeps through and collapses it to a single randomly-chosen eigenstate whose probability hump brightens.", + "keywords": [ + "quantum harmonic oscillator", + "energy levels", + "energy ladder", + "quantized energy", + "wavefunction collapse", + "measurement", + "superposition", + "eigenstate", + "hermite polynomial", + "parabolic potential", + "zero point energy", + "quantum mechanics", + "probability density", + "collapse" + ], + "bullets": [ + "Unlike the box, the oscillator's levels E_n = (n + 1/2) hbar omega are equally spaced, and the lowest one sits at 1/2 hbar omega above the bottom — the zero-point energy.", + "Before measurement the system is a superposition: several |psi_n|^2 humps coexist and shimmer, meaning the energy is genuinely undetermined.", + "A measurement (the sweeping line) collapses the superposition to one eigenstate; its probability hump brightens and fills while the others vanish." + ], + "student_prompts": [ + "Why are the harmonic oscillator's energy levels equally spaced while the box's grow like n^2?", + "What is zero-point energy and why can't the oscillator have exactly E = 0?", + "What does it physically mean for the wavefunction to 'collapse' when we measure energy?" + ], + "code": "H.background();\nconst v = H.plot2d({ xMin: -5, xMax: 5, yMin: -0.4, yMax: 7.2, pad: 54 });\nv.grid({ stepX: 1, stepY: 1 });\nv.axes({ stepX: 1, stepY: 1 });\nv.fn((x) => 0.5 * x * x, { color: H.colors.sub, width: 2 });\nv.text(\"V(x)=0.5 x^2\", -4.2, 6.6, { color: H.colors.sub, size: 13 });\nfunction herm(n, x) {\n if (n === 0) return 1;\n if (n === 1) return 2 * x;\n let hm1 = 1, h = 2 * x;\n for (let k = 1; k < n; k++) {\n const hp = 2 * x * h - 2 * k * hm1;\n hm1 = h; h = hp;\n }\n return h;\n}\nconst fact = [1, 1, 2, 6, 24];\nfunction psi2(n, x) {\n const Hn = herm(n, x);\n const norm = 1 / Math.sqrt(Math.pow(2, n) * fact[n] * Math.sqrt(Math.PI));\n const val = norm * Hn * Math.exp(-x * x / 2);\n return val * val; // |psi|^2 >= 0\n}\nconst NMAX = 5;\nfor (let n = 0; n < NMAX; n++) {\n const E = n + 0.5;\n const xt = Math.sqrt(2 * E); // classical turning points where V=E\n v.line(-xt, E, xt, E, { color: H.colors.grid, width: 1.4, dash: [5, 5] });\n v.text(\"n=\" + n, xt + 0.15, E, { color: H.colors.sub, size: 11 });\n}\nconst cycle = 6;\nconst tc = t % cycle;\nconst measuring = tc > 3;\nconst measured = Math.floor((t / cycle)) % NMAX;\nconst collapse = measuring ? H.ease((tc - 3) / 1.2) : 0;\nfor (let n = 0; n < NMAX; n++) {\n const E = n + 0.5;\n const isPicked = n === measured;\n let amp;\n if (!measuring) {\n amp = 0.45 + 0.25 * Math.sin(t * 1.3 + n);\n amp = Math.abs(amp);\n } else {\n amp = isPicked ? (0.5 + 0.5 * collapse) : (0.5 * (1 - collapse));\n }\n amp = H.clamp(amp, 0, 1);\n if (amp < 0.02) continue;\n const col = isPicked && measuring ? H.colors.accent2 : H.colors.accent;\n const pts = [];\n const NS = 90;\n for (let i = 0; i <= NS; i++) {\n const x = -5 + (10 * i) / NS;\n const y = E + 2.2 * psi2(n, x) * amp;\n pts.push([x, y]);\n }\n const rgba = isPicked && measuring\n ? \"rgba(244,162,89,\" + (0.18 + 0.5 * collapse).toFixed(3) + \")\"\n : \"rgba(124,196,255,\" + (0.10 * amp).toFixed(3) + \")\";\n if (isPicked && measuring) {\n const fillp = [[-5, E]].concat(pts).concat([[5, E]]);\n v.path(fillp, { color: \"none\", fill: rgba, close: true });\n }\n v.path(pts, { color: col, width: isPicked && measuring ? 3 : 1.6 });\n}\nif (measuring) {\n const mx = -4 + 8 * H.ease((tc - 3) / 3);\n v.line(mx, -0.4, mx, 7.2, { color: H.colors.violet, width: 1.4, dash: [3, 6] });\n}\nconst Emeas = (measured + 0.5);\nH.text(\"Quantum harmonic oscillator + collapse\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"E_n = (n + 0.5) hbar omega -- equally spaced ladder in V(x)=0.5 m omega^2 x^2\", 24, 52, { color: H.colors.sub, size: 13 });\nconst stateTxt = measuring\n ? \"MEASURED -> collapsed to n=\" + measured + \", E=\" + Emeas.toFixed(1) + \" hbar omega\"\n : \"SUPERPOSITION of levels (shimmering)\";\nH.text(stateTxt, 24, 76, { color: measuring ? H.colors.accent2 : H.colors.violet, size: 14, weight: 600 });\nH.text(\"phase: \" + (measuring ? \"after measurement\" : \"before measurement\"), 24, 96, { color: H.colors.sub, size: 12 });\nH.legend([\n { label: \"potential V(x)\", color: H.colors.sub },\n { label: \"|psi_n|^2 superposition\", color: H.colors.accent },\n { label: \"collapsed eigenstate\", color: H.colors.accent2 },\n], 24, H.H - 76);" + }, + { + "id": "unit-circle-sine-cosine", + "title": "Unit Circle Generating Sine & Cosine", + "tag": "Trigonometry", + "dimension": "2D", + "equation": "x = cos(theta), y = sin(theta), sin^2(theta) + cos^2(theta) = 1", + "summary": "A point sweeps around the unit circle while its vertical height unwraps into the sine curve and its horizontal shadow unwraps into the cosine curve, with a dashed tie-line linking the rotating point to the moving graph dots.", + "keywords": [ + "unit circle", + "sine", + "cosine", + "sin", + "cos", + "trigonometry", + "trig", + "angle", + "theta", + "radians", + "wave", + "sinusoid", + "periodic", + "circular motion" + ], + "bullets": [ + "The height of the point on the circle IS sin(theta); its horizontal shadow IS cos(theta).", + "As theta grows past 2pi the same values repeat, which is why sine and cosine are periodic.", + "The orange (cos) and green (sin) legs form a right triangle whose hypotenuse is the radius 1, so sin^2 + cos^2 = 1 every frame." + ], + "student_prompts": [ + "Why does cosine lead sine by a quarter turn on the graph?", + "How would the curves change if the circle had radius 2 instead of 1?", + "Show me how tan(theta) relates to this same picture." + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nH.text(\"Unit Circle - generating sine & cosine\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"As the angle theta sweeps, the point's height traces sin and its shadow traces cos.\", 24, 54, { color: H.colors.sub, size: 13 });\n\nconst theta = (t * 0.7) % H.TAU;\nconst cosT = Math.cos(theta);\nconst sinT = Math.sin(theta);\n\nconst cx = w * 0.26;\nconst cy = h * 0.56;\nconst R = Math.min(w * 0.2, h * 0.34);\n\nH.line(cx - R - 18, cy, cx + R + 18, cy, { color: H.colors.grid, width: 1.4 });\nH.line(cx, cy - R - 18, cx, cy + R + 18, { color: H.colors.grid, width: 1.4 });\nH.text(\"cos\", cx + R + 6, cy + 16, { color: H.colors.sub, size: 11 });\nH.text(\"sin\", cx + 6, cy - R - 6, { color: H.colors.sub, size: 11 });\n\nH.circle(cx, cy, R, { stroke: H.colors.axis, width: 2 });\n\nconst px = cx + cosT * R;\nconst py = cy - sinT * R;\n\nctx.save();\nctx.beginPath();\nctx.strokeStyle = H.colors.violet;\nctx.lineWidth = 3;\nctx.arc(cx, cy, R * 0.32, 0, -theta, true);\nctx.stroke();\nctx.restore();\nH.text(\"theta = \" + theta.toFixed(2) + \" rad\", cx - R, cy + R + 30, { color: H.colors.violet, size: 12 });\n\nH.line(cx, cy, px, py, { color: H.colors.ink, width: 2 });\nH.line(cx, cy, px, cy, { color: H.colors.accent2, width: 3 });\nH.line(px, cy, px, py, { color: H.colors.good, width: 3 });\nH.circle(px, py, 6, { fill: H.colors.warn, stroke: H.colors.bg, width: 2 });\nH.text(\"(\" + cosT.toFixed(2) + \", \" + sinT.toFixed(2) + \")\", px + 8, py - 8, { color: H.colors.ink, size: 12 });\n\nconst v = H.plot2d({\n box: { x: w * 0.5, y: h * 0.2, w: w * 0.44, h: h * 0.6 },\n xMin: 0, xMax: H.TAU, yMin: -1.25, yMax: 1.25,\n});\nv.grid({ stepX: Math.PI / 2 });\nv.axes({ stepX: Math.PI / 2, stepY: 0.5 });\nv.fn((x) => Math.sin(x), { color: H.colors.good, width: 3 });\nv.fn((x) => Math.cos(x), { color: H.colors.accent2, width: 3 });\n\nconst sx = v.X(theta);\nv.line(theta, 0, theta, sinT, { color: H.colors.grid, width: 1.2, dash: [4, 4] });\nv.dot(theta, sinT, { r: 6, fill: H.colors.good });\nv.dot(theta, cosT, { r: 6, fill: H.colors.accent2 });\nH.line(px, py, sx, v.Y(sinT), { color: H.colors.grid, width: 1.2, dash: [3, 5] });\nH.text(\"y = sin(theta)\", v.X(0.15), v.Y(1.18), { color: H.colors.good, size: 12 });\nH.text(\"y = cos(theta)\", v.X(3.4), v.Y(1.18), { color: H.colors.accent2, size: 12 });\n\nH.text(\"sin(theta) = \" + sinT.toFixed(3), 24, 80, { color: H.colors.good, size: 13 });\nH.text(\"cos(theta) = \" + cosT.toFixed(3), 24, 100, { color: H.colors.accent2, size: 13 });\nH.text(\"sin^2 + cos^2 = \" + (sinT * sinT + cosT * cosT).toFixed(3), 24, 120, { color: H.colors.sub, size: 13 });\n\nH.legend([\n { label: \"sin theta (height)\", color: H.colors.good },\n { label: \"cos theta (shadow)\", color: H.colors.accent2 },\n { label: \"swept angle theta\", color: H.colors.violet },\n], 24, h - 70);" + }, + { + "id": "conic-sections-slicing-cone", + "title": "Conic Sections - Slicing a Cone", + "tag": "Geometry", + "dimension": "3D", + "equation": "cutting plane y = m x intersecting cone x^2 + z^2 = (r/R)^2 y^2", + "summary": "A lit double cone is sliced by a cutting plane whose tilt steps through four stages, and the intersection curve traced in space morphs from a circle to an ellipse to a parabola to a hyperbola while the camera slowly orbits.", + "keywords": [ + "conic sections", + "conics", + "cone", + "slice", + "circle", + "ellipse", + "parabola", + "hyperbola", + "cutting plane", + "intersection", + "geometry", + "double cone", + "nappe", + "eccentricity" + ], + "bullets": [ + "Every conic is just the curve where a flat plane cuts a cone - the tilt of the plane decides which one you get.", + "A horizontal cut gives a circle; tilting it gently gives an ellipse, parallel to the side gives a parabola, and steeper still cuts both nappes into a hyperbola.", + "The highlighted curve is computed by solving the cone and plane equations together, so it sits exactly on the cone's surface." + ], + "student_prompts": [ + "What exact plane angle turns the ellipse into a parabola?", + "Why does the hyperbola need both halves (nappes) of the cone?", + "How does eccentricity connect these four shapes into one family?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\nconst cam = H.cam3d({ scale: 30, dist: 16, pitch: -0.42, cy: H.H * 0.56 });\ncam.yaw = 0.3 * t;\n\nconst stageNames = [\"Circle\", \"Ellipse\", \"Parabola\", \"Hyperbola\"];\nconst stage = Math.floor((t % 16) / 4);\nconst slope = [0.0, 0.42, 1.0, 1.55][stage];\nconst planeY0 = 0.0;\n\ncam.grid(5, 1, { color: H.colors.grid });\n\nconst R = 3.2;\nconst coneR = 2.4;\nH.mesh3d(cam, (u, vv) => {\n const y = (vv - 0.5) * 2 * R;\n const rad = (Math.abs(y) / R) * coneR;\n return [rad * Math.cos(u), y, rad * Math.sin(u)];\n}, { uMin: 0, uMax: H.TAU, vMin: 0, vMax: 1, nu: 40, nv: 28, hue: 205, alpha: 0.5, wire: true });\n\nconst S = 3.6;\nconst planeAt = (x, z) => planeY0 + slope * x;\nconst corners = [\n [-S, planeAt(-S, -S), -S],\n [ S, planeAt( S, -S), -S],\n [ S, planeAt( S, S), S],\n [-S, planeAt(-S, S), S],\n];\ncam.poly(corners, { fill: \"rgba(124,196,255,0.16)\", stroke: H.colors.accent, width: 1.4 });\n\nconst k = coneR / R;\nconst curve = [];\nconst N = 220;\nfor (let i = 0; i <= N; i++) {\n const ang = (i / N) * H.TAU;\n const cax = Math.cos(ang), saz = Math.sin(ang);\n const A = 1 - k * k * slope * slope * cax * cax;\n const B = -2 * k * k * slope * planeY0 * cax;\n const C = -k * k * planeY0 * planeY0;\n let r;\n if (Math.abs(A) < 1e-6) {\n r = Math.abs(B) > 1e-9 ? -C / B : NaN;\n } else {\n const disc = B * B - 4 * A * C;\n if (disc < 0) { r = NaN; }\n else {\n const sq = Math.sqrt(disc);\n const r1 = (-B + sq) / (2 * A);\n const r2 = (-B - sq) / (2 * A);\n const ok1 = Number.isFinite(r1) && Math.abs(planeY0 + slope * r1 * cax) <= R + 0.05;\n const ok2 = Number.isFinite(r2) && Math.abs(planeY0 + slope * r2 * cax) <= R + 0.05;\n r = ok1 ? r1 : (ok2 ? r2 : NaN);\n }\n }\n if (Number.isFinite(r) && r >= 0 && r < 12) {\n curve.push([r * cax, planeY0 + slope * r * cax, r * saz]);\n } else {\n if (curve.length > 1) cam.path(curve.splice(0), { color: H.colors.warn, width: 3.5 });\n else curve.length = 0;\n }\n}\nif (curve.length > 1) cam.path(curve, { color: H.colors.warn, width: 3.5 });\n\ncam.axes(3.6);\n\nH.text(\"Conic Sections - slicing a cone\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Tilt the cutting plane and the intersection curve changes its type.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"Current slice: \" + stageNames[stage], 24, 84, { color: H.colors.warn, size: 15, weight: 700 });\nH.text(\"plane slope = \" + slope.toFixed(2), 24, 106, { color: H.colors.accent, size: 13 });\nH.text(\"y = \" + slope.toFixed(2) + \" x (cutting plane)\", 24, 126, { color: H.colors.sub, size: 12 });\n\nH.legend([\n { label: \"double cone\", color: H.hsl(205, 72, 55) },\n { label: \"cutting plane\", color: H.colors.accent },\n { label: \"conic section\", color: H.colors.warn },\n], 24, h - 70);" + }, + { + "id": "pythagorean-theorem-squares", + "title": "Pythagorean Theorem - Squares on the Sides", + "tag": "Geometry", + "dimension": "2D", + "equation": "a^2 + b^2 = c^2", + "summary": "A right triangle with breathing legs carries a colored square on each side, and the live readout shows the two leg-squares' areas summing to exactly the hypotenuse-square's area every frame.", + "keywords": [ + "pythagorean theorem", + "pythagoras", + "right triangle", + "hypotenuse", + "legs", + "squares on sides", + "a squared plus b squared", + "a2 b2 c2", + "geometry", + "area", + "right angle", + "proof", + "euclid", + "distance" + ], + "bullets": [ + "Each side of the right triangle carries a square whose area equals that side length squared.", + "As the legs a and b grow and shrink, the green and orange leg-squares always add up to the violet hypotenuse-square.", + "The right-angle marker at the corner is what makes the identity a^2 + b^2 = c^2 hold - it fails for non-right triangles." + ], + "student_prompts": [ + "Show me a rearrangement proof that the two small squares fill the big one.", + "What happens to a^2 + b^2 vs c^2 if the angle is not exactly 90 degrees?", + "How is the distance formula just the Pythagorean theorem in disguise?" + ], + "code": "H.background();\nconst w = H.W, h = H.H;\n\nconst a = 2.4 + 1.1 * (0.5 + 0.5 * Math.sin(t * 0.6));\nconst b = 3.4 + 1.0 * (0.5 + 0.5 * Math.cos(t * 0.45));\nconst c = Math.sqrt(a * a + b * b);\n\nconst v = H.plot2d({\n box: { x: 60, y: 70, w: w - 120, h: h - 150 },\n xMin: -c - 1, xMax: b + 1.5, yMin: -1.5, yMax: a + c + 1,\n});\nv.grid({ stepX: 2, stepY: 2 });\nv.axes({ stepX: 2, stepY: 2 });\n\nconst A = [0, 0], B = [b, 0], C = [0, a];\n\nv.path([[0, 0], [b, 0], [b, -b], [0, -b]],\n { color: H.colors.accent2, width: 2, fill: \"rgba(244,162,89,0.18)\", close: true });\nv.text(\"b^2 = \" + (b * b).toFixed(2), b / 2, -b / 2, { color: H.colors.accent2, size: 13, align: \"center\" });\n\nv.path([[0, 0], [0, a], [-a, a], [-a, 0]],\n { color: H.colors.good, width: 2, fill: \"rgba(103,232,176,0.18)\", close: true });\nv.text(\"a^2 = \" + (a * a).toFixed(2), -a / 2, a / 2, { color: H.colors.good, size: 13, align: \"center\" });\n\nconst dx = (B[0] - C[0]) / c, dy = (B[1] - C[1]) / c;\nconst nx = -dy, ny = dx;\nconst hSq = [\n [C[0], C[1]],\n [B[0], B[1]],\n [B[0] + nx * c, B[1] + ny * c],\n [C[0] + nx * c, C[1] + ny * c],\n];\nv.path(hSq, { color: H.colors.violet, width: 2, fill: \"rgba(196,167,255,0.18)\", close: true });\nv.text(\"c^2 = \" + (c * c).toFixed(2),\n (hSq[0][0] + hSq[2][0]) / 2, (hSq[0][1] + hSq[2][1]) / 2,\n { color: H.colors.violet, size: 13, align: \"center\" });\n\nv.path([A, B, C], { color: H.colors.ink, width: 3, fill: \"rgba(124,196,255,0.22)\", close: true });\n\nv.path([[0.32, 0], [0.32, 0.32], [0, 0.32]], { color: H.colors.sub, width: 1.6 });\n\nv.text(\"a = \" + a.toFixed(2), -0.45, a / 2, { color: H.colors.good, size: 13, align: \"right\" });\nv.text(\"b = \" + b.toFixed(2), b / 2, 0.35, { color: H.colors.accent2, size: 13, align: \"center\" });\nv.text(\"c = \" + c.toFixed(2), (C[0] + B[0]) / 2 + 0.25, (C[1] + B[1]) / 2 + 0.1, { color: H.colors.violet, size: 13 });\n\nconst s = 0.5 + 0.5 * Math.sin(t * 1.2);\nv.dot(C[0] + (B[0] - C[0]) * s, C[1] + (B[1] - C[1]) * s, { r: 5, fill: H.colors.warn });\n\nH.text(\"Pythagorean Theorem - squares on the sides\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"The two leg-squares' areas always sum to the hypotenuse-square's area.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"a^2 + b^2 = \" + (a * a).toFixed(2) + \" + \" + (b * b).toFixed(2) + \" = \" + (a * a + b * b).toFixed(2),\n 24, h - 52, { color: H.colors.ink, size: 14 });\nH.text(\"c^2 = \" + (c * c).toFixed(2) + \" (match!)\", 24, h - 30, { color: H.colors.good, size: 14, weight: 700 });\n\nH.legend([\n { label: \"a^2 (leg)\", color: H.colors.good },\n { label: \"b^2 (leg)\", color: H.colors.accent2 },\n { label: \"c^2 (hypotenuse)\", color: H.colors.violet },\n], w - 220, 92);" + } +] \ No newline at end of file diff --git a/tests/test_main.py b/tests/test_main.py index 794df00..b9e0635 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -540,9 +540,11 @@ def test_mode_is_part_of_key(self): class LibraryMatchTests(unittest.TestCase): def test_strong_match(self): + # The library may hold more than one Fourier scene (hand-written + + # workflow-generated); any of them is a correct match for this prompt. sc, score = main.library_match("show a fourier series building a square wave", "auto") self.assertIsNotNone(sc) - self.assertEqual(sc["id"], "fourier-square-wave") + self.assertIn("fourier", sc["id"]) self.assertGreaterEqual(score, 3.0) def test_no_match_for_unrelated(self): From 4116ad6b4da15db820d0900892ebf67f51558a68 Mon Sep 17 00:00:00 2001 From: Maxpeng59 Date: Mon, 15 Jun 2026 19:34:14 +0700 Subject: [PATCH 06/17] Validator catches motion that drifts off-screen and never loops Watched the local model write a Doppler scene with pos = t*4 (unbounded): the source and all wavefronts sail off the right edge within seconds, leaving only a static observer line. It passed validation because early frames (t<=3) still had content on-screen. Now the validator samples a late frame (t=30) and tracks the FINAL frame's on-screen fraction: if <15% of the last frame's content is on canvas, the scene "drifted off and never loops" -> fatal, repaired with a hint to bound/loop the motion (t % period, Math.sin(t)). All 50 library scenes still pass; 71 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.py | 14 +- stem-viz-plugin/.claude-plugin/plugin.json | 22 + stem-viz-plugin/README.md | 86 ++ stem-viz-plugin/skills/stem-viz/SKILL.md | 216 ++++ .../skills/stem-viz/references/concepts.md | 117 ++ .../skills/stem-viz/references/examples.md | 236 ++++ .../skills/stem-viz/references/helper-api.md | 140 +++ .../skills/stem-viz/references/repair.md | 55 + .../skills/stem-viz/runtime/README.md | 65 + .../skills/stem-viz/runtime/host.html | 137 +++ .../skills/stem-viz/runtime/sandbox-worker.js | 1080 +++++++++++++++++ .../skills/stem-viz/scripts/validate_scene.js | 206 ++++ tests/test_main.py | 19 + validate_scene.js | 22 +- 14 files changed, 2405 insertions(+), 10 deletions(-) create mode 100644 stem-viz-plugin/.claude-plugin/plugin.json create mode 100644 stem-viz-plugin/README.md create mode 100644 stem-viz-plugin/skills/stem-viz/SKILL.md create mode 100644 stem-viz-plugin/skills/stem-viz/references/concepts.md create mode 100644 stem-viz-plugin/skills/stem-viz/references/examples.md create mode 100644 stem-viz-plugin/skills/stem-viz/references/helper-api.md create mode 100644 stem-viz-plugin/skills/stem-viz/references/repair.md create mode 100644 stem-viz-plugin/skills/stem-viz/runtime/README.md create mode 100644 stem-viz-plugin/skills/stem-viz/runtime/host.html create mode 100644 stem-viz-plugin/skills/stem-viz/runtime/sandbox-worker.js create mode 100644 stem-viz-plugin/skills/stem-viz/scripts/validate_scene.js diff --git a/main.py b/main.py index 70ec7b7..0e3388d 100644 --- a/main.py +++ b/main.py @@ -1389,11 +1389,15 @@ def evaluate_scene(code: str) -> dict: return { "fatal": True, "error": ( - "Everything was drawn OFF-SCREEN — you mixed coordinate spaces. " - "H.line/H.circle/H.text take PIXEL coords (0..H.W, 0..H.H). The " - "plot2d view's methods (v.line/v.text/v.dot/v.path/v.circle) take " - "DATA coords and map them for you — do NOT wrap their arguments in " - "v.X()/v.Y(). Pick one space per call and keep points on-canvas." + "The content ended up OFF-SCREEN. Two common causes: (1) mixing " + "coordinate spaces — H.line/H.circle/H.text take PIXEL coords " + "(0..H.W, 0..H.H), while the plot2d view methods " + "(v.line/v.text/v.dot/v.path) take DATA coords and map them for " + "you, so never wrap their args in v.X()/v.Y(); (2) UNBOUNDED " + "motion that drifts away — e.g. pos = t*4 sails off the edge. " + "Make motion LOOP: use t % period, Math.sin(t), or keep the " + "moving subject within the visible range. Keep the main content " + "on-canvas the whole time." ), "problems": [], } diff --git a/stem-viz-plugin/.claude-plugin/plugin.json b/stem-viz-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..c6156e6 --- /dev/null +++ b/stem-viz-plugin/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "stem-viz", + "version": "1.0.0", + "description": "Generate, validate, and run animated 2D/3D STEM visualizations — sandboxed-JavaScript scene(ctx, t, H) animations of math, physics, ML, and CS concepts. Bundles the rendering contract, helper API, a concept→visual playbook, a self-repair protocol, a headless validator, and the portable runtime.", + "author": { + "name": "VisualLM" + }, + "homepage": "https://github.com/Maxpeng59/VisualLM", + "license": "MIT", + "keywords": [ + "visualization", + "animation", + "stem", + "education", + "math", + "physics", + "machine-learning", + "canvas", + "3d", + "skill" + ] +} diff --git a/stem-viz-plugin/README.md b/stem-viz-plugin/README.md new file mode 100644 index 0000000..f45e6d9 --- /dev/null +++ b/stem-viz-plugin/README.md @@ -0,0 +1,86 @@ +# stem-viz — a portable STEM-visualization skill + plugin + +A self-contained capability pack that teaches any AI to turn a STEM question into +an **animated 2D/3D explanation**, and gives it the tools to run and verify the +result. Extracted from [VisualLM](https://github.com/Maxpeng59/VisualLM) so the +capability travels — as a **Claude Skill** (portable across Claude Code, the Agent +SDK, claude.ai, the Skills API) and as a **Claude Code plugin** (this folder). + +## What's inside + +``` +stem-viz-plugin/ +├── .claude-plugin/plugin.json # plugin manifest (Claude Code installs this) +└── skills/stem-viz/ # the skill (portable on its own) + ├── SKILL.md # the contract + workflow + when to use + ├── references/ + │ ├── helper-api.md # full H / plot2d / cam3d / surface3d / mesh3d API + output schema + │ ├── examples.md # 8 complete, validator-passing scenes (2D + 3D) + │ ├── concepts.md # concept → visualization playbook (ML/STEM topics → recipes) + │ └── repair.md # error-class → minimal-change self-repair protocol + ├── runtime/ + │ ├── sandbox-worker.js # the real rendering engine (Web Worker + OffscreenCanvas + H) + │ ├── host.html # minimal no-build page: paste a scene, watch it render + │ └── README.md # how to embed the runtime in any app + └── scripts/ + └── validate_scene.js # headless Node validator (run before shipping a scene) +``` + +The skill does three things, end to end: +1. **Generate** — write `scene(ctx, t, H)` animation code from a STEM prompt. +2. **Self-repair** — fix code that throws, by error class, with minimal edits. +3. **Teach** — map concepts (gradient descent, Fourier series, projectile motion…) + to proven visual recipes. + +…and bundles the **runtime** so generated scenes actually run outside VisualLM, +plus a **headless validator** so an agent can verify a scene in ~1s without a +browser. + +## Use it as a Claude Code plugin + +Add the marketplace (the repo or a local clone) and install: + +``` +/plugin marketplace add Maxpeng59/VisualLM # or: add /path/to/VisualLM +/plugin install stem-viz +``` + +Once installed, the `stem-viz` skill triggers automatically when you ask to +visualize/animate/illustrate a STEM idea. (Claude Code discovers the skill under +`skills/` from the plugin manifest.) + +## Use it as a standalone Skill + +The `skills/stem-viz/` folder is a complete, portable skill — drop it into any +skills directory (Claude Code `~/.claude/skills/`, an Agent SDK skills path, +claude.ai, or the Skills API). Nothing in it depends on the plugin wrapper. + +## Try the runtime right now + +```bash +cd skills/stem-viz/runtime +python3 -m http.server 8000 +# open http://localhost:8000/host.html — paste a scene from references/examples.md +``` + +## Validate a scene headlessly + +```bash +node skills/stem-viz/scripts/validate_scene.js <<'SCENE' +H.background(); +const v = H.plot2d({ xMin: -6, xMax: 6, yMin: -2, yMax: 2 }); +v.grid(); v.axes(); +v.fn(x => Math.sin(x + t), { color: H.colors.accent, width: 3 }); +H.text("sine", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +SCENE +# → {"ok":true,"error":null,"painted":true,"text":true,"paint":N,"onscreen":true} +``` + +## Keeping it in sync + +`runtime/sandbox-worker.js` (the engine), `scripts/validate_scene.js` (its mock), +and `references/helper-api.md` (the docs) describe the same `H` API. If you extend +the helper library, update all three together. + +## License +MIT (matches VisualLM). diff --git a/stem-viz-plugin/skills/stem-viz/SKILL.md b/stem-viz-plugin/skills/stem-viz/SKILL.md new file mode 100644 index 0000000..5a76d7c --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/SKILL.md @@ -0,0 +1,216 @@ +--- +name: stem-viz +description: >- + Generate animated 2D/3D STEM visualizations as self-contained sandboxed-JavaScript + scene(ctx, t, H) code — math, physics, chemistry, biology, CS/algorithms, and + machine-learning concepts rendered as something you can watch move. Use this + whenever the user wants to visualize, animate, illustrate, simulate, or "show" + a STEM concept, equation, function, algorithm, or data idea (e.g. "animate + gradient descent", "visualize a Fourier series", "show projectile motion", + "plot z = f(x,y) as a 3D surface", "explain the derivative with a moving + tangent line", "draw the electric field of a dipole"), even when they don't say + the word "animation". Also use it to RUN a scene (bundled browser runtime), to + VALIDATE scene code headlessly before shipping (bundled Node validator), and to + REPAIR scene code that throws. Prefer this skill over hand-rolling canvas code. +--- + +# STEM Visualization — scene generator + runtime + +This skill produces **animated explanations of STEM ideas**. You write the body of +a `scene(ctx, t, H)` function in JavaScript; a small sandboxed renderer (bundled +in `runtime/`) calls it ~60×/second and paints the result on a canvas. The same +code can be validated headlessly (`scripts/validate_scene.js`) and run for real +(`runtime/host.html`) — so a scene you generate here is portable: it runs anywhere +the runtime goes, with no server and no dependencies beyond a browser (to render) +or Node (to validate). + +## Workflow + +1. **Understand the concept** and what should *move*. The goal is teaching, not + decoration — decide the one mechanism the animation should reveal (a sweeping + tangent, a propagating wave, a descending optimizer, a rotating molecule). +2. **Pick 2D or 3D.** 3D when the idea lives in space (surfaces `z=f(x,y)`, + orbits, molecules, fields in space, multivariable calculus, rotations); 2D for + single-variable functions, time series, circuits, graphs/algorithms, planar + geometry. Honor an explicit user preference. +3. **Check the playbook.** For common topics, `references/concepts.md` maps the + concept to a proven visual recipe (and links a complete worked scene). Reuse + the recipe rather than inventing one. +4. **Write the scene body** following the contract below. Keep it a pure function + of `(ctx, t)` — drive *all* motion from `t`, never keep outer state. +5. **Validate before you ship.** Run `scripts/validate_scene.js` (see below). It + catches throws, blank output, missing labels, and off-screen drawing in ~1s + without a browser. +6. **If it throws or is blank, repair it** using `references/repair.md` — make the + *smallest* change that fixes the reported error; don't rewrite a working scene. +7. **Emit the result** in the output format below (or just the `code` body if the + caller only wants runnable code). + +## The rendering contract + +`code` is the BODY of a function with this exact signature — provide only the +statements that go *inside* it, do not write `function scene(...)` yourself: + +```js +function scene(ctx, t) { + // your code here +} +``` + +- `ctx` — a Canvas 2D context. The canvas is cleared before each call. +- `t` — elapsed time in **seconds** (a float; respects pause/speed). Drive all + motion from `t` so the animation loops smoothly. `scene` is called ~60×/s and + must be a **pure function of (ctx, t)** — no frame counters, no outer-state + mutation. +- `H` — the helper library, available as a **global** in the renderer scope. + Reference it directly (`H.text(...)`); never declare it. +- `H.W`, `H.H` — logical width/height of the drawing area (pixels). +- Only these globals exist: `Math`, `Number`, `Array`, `Object`, `JSON`, + `console`, and `H`. Call math through `Math.*` (`Math.sin(x)`, not `sin(x)`). +- **Never** use: unbounded loops, `while(true)`, `setTimeout`/`setInterval`/ + `requestAnimationFrame`, network, DOM, `import`, or `eval`. Keep every loop + finite and cheap (≤ a few hundred iterations per frame). + +### Three hard rules (code that breaks these is treated as a failed scene) + +**Rule 0 — every scene MUST PAINT.** The #1 failure is code that computes but +never draws. +1. Call `H.background()` (or `H.clear()`) on the **first line**. +2. Call **≥ 3 drawing helpers per frame** (a `for` loop calling one counts): + `H.text/line/path/circle/rect/arrow/legend/surface3d/mesh3d`, any `plot2d` + view method (`.grid/.axes/.fn/.dot`), or any `cam` method + (`.line/.path/.poly/.sphere/.grid/.axes`). +3. Use real **pixel** coordinates in `[0, H.W] × [0, H.H]`. Do **not** draw at + math coordinates like `(-3, 0.5)` directly — go through `H.plot2d` (which maps + for you) or scale with `H.map(...)`. Mixing the two (e.g. `v.line(v.X(x), ...)`) + double-transforms and draws off-screen — the validator flags this as + `onscreen: false`. + +**Rule 1 — every scene MUST MOVE.** It only animates if your code reads `t`. +Drive at least one primary element from `t`. If the concept is static (a +structure, a proof), animate the *explanation*: sweep a highlight, pulse the +region under discussion, orbit the camera (`cam.yaw = 0.3 * t`), or step through +stages with `const phase = Math.floor(t % 9 / 3);`. + +**Rule 2 — every scene MUST BE LABELED with real values.** A picture without +numbers teaches nothing. +- A title (`H.text`, size 18, weight 700) top-left + a one-line caption under it + (size 13, `H.colors.sub`). +- `view.axes()` (2D) and `cam.axes(len)` (3D) auto-draw numeric ticks — use them + whenever the scene has coordinates. +- At least one **live readout** that changes with `t`, e.g. + `H.text("E = " + E.toFixed(2) + " J", 24, 76, { color: H.colors.sub, size: 13 });` +- With 2+ colored elements, add `H.legend([{label, color}, ...], x, y)`. + +### Minimal complete skeleton + +```js +H.background(); +const v = H.plot2d({ xMin: -6, xMax: 6, yMin: -2, yMax: 2 }); +v.grid(); v.axes(); +v.fn(x => Math.sin(x + t), { color: H.colors.accent, width: 3 }); +H.text("Your title", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("one-line caption", 24, 52, { color: H.colors.sub, size: 13 }); +``` + +## Helper essentials + +Enough to write most 2D scenes; **full API in `references/helper-api.md`** (read it +for `plot2d` data-space methods, all `cam3d`/`surface3d`/`mesh3d` options, and the +exact option bags). + +- **Constants:** `H.W`, `H.H`, `H.TAU` (2π), `H.PI`, `H.colors`, `H.palette`. +- **Colors:** `H.colors.{bg,panel,ink,sub,grid,axis,accent,accent2,good,warn,violet,yellow}`. + For anything else use a CSS string (`"#ffaa00"`) or `H.hsl(h,s,l,a)` / `H.color(i)`. +- **Math:** `H.clamp`, `H.lerp`, `H.map(x,inMin,inMax,outMin,outMax)`, `H.ease`. +- **Draw (pixels):** `H.background()`, `H.text(s,x,y,opts)`, `H.line(x1,y1,x2,y2,opts)`, + `H.path([[x,y],...],opts)`, `H.circle(x,y,r,opts)`, `H.rect(x,y,w,h,opts)`, + `H.arrow(x1,y1,x2,y2,opts)`, `H.legend(items,x,y)`. +- **2D graph:** `const v = H.plot2d({xMin,xMax,yMin,yMax,pad})` → `v.grid()`, + `v.axes()`, `v.fn(x=>..., opts)`, `v.dot(x,y,opts)`, `v.X(v)`/`v.Y(v)`, plus + data-space `v.line/v.arrow/v.text/v.circle/v.path/v.rect` (args in **math** + units). +- **3D:** `const cam = H.cam3d({yaw,pitch,scale,dist,cx,cy})`; set `cam.yaw = 0.3*t`; + `cam.grid()`, `cam.axes(len)`, `cam.line/path/poly/sphere`; and the **solid** + surfaces `H.surface3d(cam, (x,y)=>height, opts)` and `H.mesh3d(cam, (u,v)=>[x,y,z], opts)`. + Make 3D solid (lit, depth-sorted) — never a cloud of flat dots. + +## Validate before shipping + +The bundled validator runs the scene against a faithful mock of `H` in a locked +V8 sandbox at several values of `t` and reports JSON — no browser needed: + +```bash +node scripts/validate_scene.js <<'SCENE' +H.background(); +const v = H.plot2d({ xMin: -6, xMax: 6, yMin: -2, yMax: 2 }); +v.grid(); v.axes(); +v.fn(x => Math.sin(x + t), { color: H.colors.accent, width: 3 }); +H.text("sine", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +SCENE +# → {"ok":true,"error":null,"painted":true,"text":true,"paint":N,"onscreen":true} +``` + +A scene is ready to ship only when `ok:true`, `painted:true`, `text:true`, and +`onscreen:true`. If `ok:false`, read `error` and repair (next). If `painted` or +`text` is false, you broke Rule 0/2. If `onscreen:false`, you mixed data and +pixel coordinates (Rule 0.3). + +## If it throws: repair + +`references/repair.md` maps each error class to a targeted fix and the guiding +principle: **make the smallest change that fixes the reported error — keep +everything that already works.** Re-validate after every repair. Most failures +are a tiny set: an undeclared name, an invented helper, a property read off +`undefined`, or a syntax slip from truncation. + +## Run it for real + +`runtime/` is the actual rendering engine, portable and dependency-free: +- `runtime/sandbox-worker.js` — the Web Worker that compiles and runs scene code + in an OffscreenCanvas sandbox (no DOM/network; watchdog kills runaways). It + exposes the real `H` library. +- `runtime/host.html` — a minimal standalone page: paste a scene, watch it render, + drag to orbit 3D. Open it in a browser, no build step. +- `runtime/README.md` — how to embed the runtime in your own app. + +## Output format + +When the caller wants a full scene record (the VisualLM shape), emit these fields +(JSON schema in `references/helper-api.md`): + +- `title` — short scene title (≤ 60 chars) +- `tag` — subject label ("Calculus", "Electromagnetism", "Algorithms") +- `dimension` — `"2D"` or `"3D"` +- `equation` — the central equation in plain text, or `""` +- `summary` — one or two sentences on what the animation shows +- `bullets` — exactly 3 short teaching points tied to what's on screen +- `student_prompts` — 3 good follow-up questions +- `code` — the `scene` body: self-contained, runnable, validated + +If the caller only asked for "an animation of X", returning just the validated +`code` body is fine. + +## Correctness rules that are easy to get wrong + +- **Defensive code.** Guard against divide-by-zero, NaN, and out-of-range values. + The animation must never throw — `+1e-6` denominators, `Math.max(0, ...)` for + physical floors, `H.clamp` for bounded quantities. +- **Physical quantities stay physical.** Lengths, radii, masses, probabilities, + energies must never display negative. Don't animate them with a bare + `Math.sin(t)` — use `2 + Math.sin(t)` or `Math.abs(...)`. +- **Numbers must match the picture.** If a readout says `a = 3.0`, the drawn + length must be 3 units. +- **No fake interactivity.** Generated code receives no mouse/keyboard input. + Never claim "drag the vertices" / "click to…" in code, summary, or bullets. The + only built-in interaction is camera orbit on 3D scenes (automatic). + +## Reference files + +| File | Read it when | +|---|---| +| `references/helper-api.md` | You need the full helper API — `plot2d` data-space methods, every `cam3d`/`surface3d`/`mesh3d` option, the color list, the output JSON schema. | +| `references/concepts.md` | The user named a common STEM topic — get a proven concept→visual recipe and a link to a complete worked scene. | +| `references/examples.md` | You want complete, validated end-to-end scenes (2D and 3D) to adapt. | +| `references/repair.md` | A generated scene threw or came back blank — error-class → minimal-change fix. | +| `runtime/README.md` | You need to actually render/host scenes, or embed the engine elsewhere. | diff --git a/stem-viz-plugin/skills/stem-viz/references/concepts.md b/stem-viz-plugin/skills/stem-viz/references/concepts.md new file mode 100644 index 0000000..41b0ab1 --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/references/concepts.md @@ -0,0 +1,117 @@ +# Concept → visualization playbook + +How to turn a STEM/ML topic into a scene that *teaches*. The hard part is rarely +the code — it's choosing **what should move** so the animation reveals the +mechanism. This file gives proven recipes for common topics and a general method +for anything not listed. + +## The general method (use this for any concept) + +1. **Name the mechanism.** What is the one idea? ("the derivative is the tangent's + slope", "adding harmonics sharpens the wave", "the optimizer follows the + negative gradient"). The animation exists to show *that*. +2. **Choose the moving variable.** Map the mechanism to something driven by `t`: + a swept parameter, an accumulating sum, a propagating front, an integrated + trajectory, a rotating viewpoint. +3. **Pick the representation:** + - one-variable function / time series / signal → **2D `plot2d` + `fn`** + - geometry, vectors, planar fields, circuits, graphs → **2D pixel or `plot2d` + data-space** + - `z = f(x,y)`, optimization landscapes → **3D `surface3d`** + - parametric shapes (sphere, torus, tube, helix) → **3D `mesh3d`** + - discrete bodies in space (atoms, planets, particles) → **3D `cam.sphere`, + depth-sorted** +4. **Make the invisible legible.** Add a live readout of the key quantity, a + legend for multiple series, axes with units. If the steady-state is static, + animate the *construction* (sweep, accumulate, step through stages). +5. **Adapt the nearest worked scene** in `references/examples.md` rather than + starting blank. + +## Recipes by topic + +Each row: the visual idea, the technique, and the closest example to adapt. + +### Calculus & analysis +- **Derivative / tangent / rate of change** → plot `f`, sweep the point of + tangency, draw the tangent in data space, print the live slope. → example 1. +- **Integral / area under a curve** → plot `f`, fill Riemann rectangles whose + count grows with `t`, print the running sum vs the true integral. +- **Limit / secant → tangent** → draw a secant between `a` and `a+h`, shrink `h` + with `t`, show the slope converging. +- **Taylor / power series** → plot the target faintly, overlay the partial sum + with a breathing term count `N` (same shape as Fourier, example 2). +- **Multivariable / partial derivatives / gradient** → `surface3d` for `f(x,y)`, + draw the gradient arrow on the surface or a contour slice. → example 7. + +### Signals & linear algebra +- **Fourier series / harmonics / decomposition** → partial sums of sines with a + breathing `N`, target behind, legend. → example 2. +- **Sine/cosine / phase / radians** → unit circle with dropped perpendiculars + + linked sin/cos plot. → example 3. +- **Vectors / dot & cross product / projection** → 2D arrows from origin, show the + projection foot and the angle; animate one vector rotating. +- **Matrix transform / eigenvectors** → a grid of dots under `A`, interpolate from + identity to `A` with `t`; eigenvectors stay on their line. + +### Mechanics & physics +- **Projectile / kinematics / trajectory** → faint full path + moving point + + velocity arrow; floor physical quantities at 0. → example 4. +- **Oscillation / SHM / pendulum / spring** → position `= A*Math.cos(ω*t)`, draw + the body + a phase plot; keep amplitude positive. +- **Waves / interference / standing waves** → 2D `fn` of `sin(kx − ωt)`, or two + sources with summed displacement; for 3D ripples use `surface3d` (example 7's + cousin: `sin(r − t)/r`). +- **Orbits / gravity / planetary motion** → 3D `cam.path` ellipse + a `cam.sphere` + body moving along it, central sphere for the star. + +### Electromagnetism +- **Field lines / dipole / point charges** → a `field(x,y)` function, streamlines + by integration, a test charge advected with `t`. → example 5. +- **RC / RL / circuits / time constant** → `plot2d` of the exponential + a drawn + component whose fill/level tracks the value. → example 6. +- **EM wave** → 3D: oscillating E (one axis) and B (perpendicular) along a + propagation axis, both driven by `sin(kz − ωt)` via `cam.path`. + +### Machine learning & optimization +- **Gradient descent / loss surface / training** → `surface3d` loss + a sphere + stepping down the negative gradient, re-released each cycle, live loss readout. + → example 7. (Too-large learning rate = make the steps overshoot for a variant.) +- **Linear/logistic regression fit** → 2D scatter (fixed seed via a formula, not + randomness) + a line/curve whose parameters animate toward the fit; show the + loss dropping. +- **Neural net / perceptron** → 2D nodes as circles, edges as lines with + width ∝ |weight|, a pulse traveling forward; label the activation. +- **k-means / clustering** → 2D points colored by nearest centroid; move centroids + to the mean each cycle. + +### CS & algorithms +- **Graph traversal (BFS/DFS)** → fixed node positions, edges as lines, a frontier + that expands by stage `Math.floor(t * rate)`; color visited vs frontier; legend. +- **Sorting** → bars (`H.rect`) whose heights are a fixed array; animate compares/ + swaps by stage; highlight the active pair. +- **Recursion / trees** → draw a tree by depth; reveal levels with `t`. + +### Chemistry & biology +- **Molecules / crystal structure / DNA** → 3D `cam.sphere` atoms, depth-sorted, + `cam.line` bonds, slow spin. → example 8. +- **Reaction / rate / equilibrium** → 2D concentrations vs time as `plot2d` curves + approaching equilibrium; live readout of the ratio. + +### Probability & statistics +- **Distributions / CLT / sampling** → 2D histogram (`H.rect` bars from a + closed-form density, not RNG) + the limiting curve overlaid; grow the sample + with `t`. +- **Bayes / conditional probability** → area/tree diagram with regions sized to + probabilities; highlight the conditioning event. + +## Notes that keep scenes correct + +- **No randomness.** `scene` must be a pure function of `t` and is called every + frame, so `Math.random()` would jitter. Use deterministic formulas, or a fixed + hash like `frac(Math.sin(i*12.9898)*43758.5)` for "scattered" points. +- **Stage with `t`.** For discrete steps, `const stage = Math.floor(t % period / step)` + cycles cleanly and loops. +- **Keep physical quantities physical** (lengths, probabilities, energies ≥ 0) and + make displayed numbers match the drawing. See SKILL.md → "Correctness rules". +- **3D must look 3D.** Prefer `surface3d`/`mesh3d`/`cam.sphere` (lit, depth-sorted) + over flat dot clouds, add `cam.grid()` + `cam.axes()` + a slow `cam.yaw = 0.3*t`. diff --git a/stem-viz-plugin/skills/stem-viz/references/examples.md b/stem-viz-plugin/skills/stem-viz/references/examples.md new file mode 100644 index 0000000..ec80980 --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/references/examples.md @@ -0,0 +1,236 @@ +# Worked scenes (complete, validated) + +Eight end-to-end scenes across domains. Every one passes +`scripts/validate_scene.js` (`ok`, `painted`, `text`, `onscreen` all true) and +follows the three hard rules. Adapt the closest one rather than starting from a +blank file — match the *technique* (function plot, vector field, 3D surface, +sphere cloud) to your concept. + +Each block is the `scene` body — paste it straight into the validator or +`runtime/host.html`. + +--- + +## 1. Derivative as a moving tangent line — 2D, `plot2d` + data-space +Technique: plot `y=f(x)`, sweep the point of tangency with `t`, draw the tangent +in data space, print the live slope. + +```js +H.background(); +const v = H.plot2d({ xMin: -3.2, xMax: 3.2, yMin: -3, yMax: 5, pad: 50 }); +v.grid(); v.axes(); +const f = (x) => 0.15 * x * x * x - x; +const df = (x) => 0.45 * x * x - 1; +v.fn(f, { color: H.colors.accent, width: 3 }); +const a = 2.6 * Math.sin(t * 0.6); +const slope = df(a); +const tx = (x) => f(a) + slope * (x - a); +v.line(-3.2, tx(-3.2), 3.2, tx(3.2), { color: H.colors.accent2, width: 2.4, dash: [7, 6] }); +v.dot(a, f(a), { r: 7 }); +H.text("Derivative = slope of the tangent", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("a = " + a.toFixed(2) + " f'(a) = " + slope.toFixed(2), 24, 52, { color: H.colors.sub, size: 14 }); +``` + +## 2. Fourier series → square wave — 2D, partial sums + legend +Technique: a breathing harmonic count `N`, a partial-sum function, the target +drawn faintly behind it, a legend. + +```js +H.background(); +const v = H.plot2d({ xMin: -Math.PI, xMax: Math.PI, yMin: -1.5, yMax: 1.5, pad: 50 }); +v.grid(); v.axes(); +const N = 1 + Math.floor((1 + Math.sin(t * 0.5)) * 5); // 1..11 harmonics, breathing +const partial = (x) => { + let s = 0; + for (let k = 1; k <= N; k++) s += Math.sin((2 * k - 1) * x) / (2 * k - 1); + return (4 / Math.PI) * s; +}; +v.fn((x) => Math.sign(Math.sin(x)) || 0, { color: H.colors.sub, width: 1.5 }); +v.fn(partial, { color: H.colors.accent, width: 3 }); +H.text("Fourier series of a square wave", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("harmonics N = " + N, 24, 52, { color: H.colors.accent, size: 14 }); +H.legend([{ label: "target square", color: H.colors.sub }, { label: "partial sum", color: H.colors.accent }], H.W - 170, 28); +``` + +## 3. Unit circle generates sine & cosine — 2D, two linked panels +Technique: a pixel-space circle with dropped perpendiculars on the left, a +`plot2d` of sin/cos on the right, sharing the angle `th`. + +```js +H.background(); +const cx = H.W * 0.28, cy = H.H * 0.52, R = Math.min(H.W, H.H) * 0.28; +const th = t * 0.8; +H.circle(cx, cy, R, { stroke: H.colors.grid, width: 1.5 }); +H.line(cx - R - 10, cy, cx + R + 10, cy, { color: H.colors.axis, width: 1 }); +H.line(cx, cy - R - 10, cx, cy + R + 10, { color: H.colors.axis, width: 1 }); +const px = cx + R * Math.cos(th), py = cy - R * Math.sin(th); +H.line(cx, cy, px, py, { color: H.colors.accent2, width: 2 }); +H.line(px, py, px, cy, { color: H.colors.good, width: 2, dash: [4, 4] }); +H.line(px, py, cx, py, { color: H.colors.accent, width: 2, dash: [4, 4] }); +H.circle(px, py, 6, { fill: H.colors.warn }); +const v = H.plot2d({ xMin: 0, xMax: 12, yMin: -1.3, yMax: 1.3, box: { x: H.W * 0.56, y: 70, w: H.W * 0.4, h: H.H - 150 } }); +v.grid(); v.axes(); +v.fn((x) => Math.sin(x), { color: H.colors.good, width: 2.5 }); +v.fn((x) => Math.cos(x), { color: H.colors.accent, width: 2.5 }); +v.dot(th % 12, Math.sin(th)); +H.text("Unit circle → sine & cosine", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("theta = " + (th % (2 * Math.PI)).toFixed(2) + " rad", 24, 52, { color: H.colors.sub, size: 14 }); +H.legend([{ label: "sin", color: H.colors.good }, { label: "cos", color: H.colors.accent }], H.W * 0.56, 50); +``` + +## 4. Projectile motion — 2D, trajectory + velocity vector + live readout +Technique: precompute the full parabola as a faint dashed `path`, animate the +moving point and its velocity `arrow`, keep physical quantities non-negative +(`Math.max(0, y)`). + +```js +H.background(); +const g = 9.8, v0 = 22, ang = 58 * Math.PI / 180; +const flight = 2 * v0 * Math.sin(ang) / g; +const tau = (t % (flight + 1)); +const xr = v0 * Math.cos(ang) * tau; +const yr = Math.max(0, v0 * Math.sin(ang) * tau - 0.5 * g * tau * tau); +const v = H.plot2d({ xMin: 0, xMax: 60, yMin: 0, yMax: 26, pad: 50 }); +v.grid(); v.axes(); +const path = []; +for (let i = 0; i <= 60; i++) { + const tt = i / 60 * flight; + path.push([v0 * Math.cos(ang) * tt, v0 * Math.sin(ang) * tt - 0.5 * g * tt * tt]); +} +v.path(path, { color: H.colors.sub, width: 1.5, dash: [6, 5] }); +const vx = v0 * Math.cos(ang), vy = v0 * Math.sin(ang) - g * tau; +v.arrow(xr, yr, xr + vx * 0.25, yr + vy * 0.25, { color: H.colors.good, width: 2 }); +v.dot(xr, yr, { r: 7, fill: H.colors.warn }); +H.text("Projectile: 22 m/s at 58 deg", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("x = " + xr.toFixed(1) + " m y = " + yr.toFixed(1) + " m", 24, 52, { color: H.colors.sub, size: 14 }); +``` + +## 5. Electric field of a dipole — 2D, vector field + streamlines (pixel space) +Technique: a `field(x,y)` function, streamlines integrated by following the field, +a test charge advected with `t`. Pure pixel space (no `plot2d`) because the field +is over the whole canvas. + +```js +H.background(); +const w = H.W, h = H.H; +const q1 = { x: w * 0.38, y: h * 0.52, s: +1 }; +const q2 = { x: w * 0.62, y: h * 0.52, s: -1 }; +function field(x, y) { + let ex = 0, ey = 0; + for (const q of [q1, q2]) { + const dx = x - q.x, dy = y - q.y; + const r2 = dx * dx + dy * dy + 80; + const r = Math.sqrt(r2); + const e = q.s / r2; + ex += e * dx / r; ey += e * dy / r; + } + return [ex, ey]; +} +for (let k = 0; k < 16; k++) { + const a0 = (k / 16) * H.TAU; + let x = q1.x + 16 * Math.cos(a0), y = q1.y + 16 * Math.sin(a0); + const pts = [[x, y]]; + for (let i = 0; i < 220; i++) { + const [ex, ey] = field(x, y); + const m = Math.hypot(ex, ey) + 1e-9; + x += 3 * ex / m; y += 3 * ey / m; + if (x < 0 || x > w || y < 0 || y > h) break; + if (Math.hypot(x - q2.x, y - q2.y) < 14) break; + pts.push([x, y]); + } + H.path(pts, { color: H.colors.accent, width: 1.4 }); +} +const tt = (t % 6) / 6; +let tx = H.lerp(q1.x, q2.x, 0.15), ty = q1.y - 70; +for (let i = 0; i < Math.floor(tt * 200); i++) { + const [ex, ey] = field(tx, ty); const m = Math.hypot(ex, ey) + 1e-9; + tx += 2.4 * ex / m; ty += 2.4 * ey / m; +} +H.circle(tx, ty, 6, { fill: H.colors.yellow, stroke: H.colors.bg, width: 2 }); +H.circle(q1.x, q1.y, 13, { fill: H.colors.warn }); +H.circle(q2.x, q2.y, 13, { fill: H.colors.accent2 }); +H.text("+", q1.x - 5, q1.y + 5, { color: H.colors.bg, size: 16, weight: 700 }); +H.text("-", q2.x - 4, q2.y + 5, { color: H.colors.bg, size: 18, weight: 700 }); +H.text("Electric field of a dipole", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("test charge along the field, t = " + (t % 6).toFixed(1) + "s", 24, 52, { color: H.colors.sub, size: 13 }); +``` + +## 6. RC circuit charging a capacitor — 2D, plot + schematic with live fill +Technique: a `plot2d` of the exponential on the left, a drawn capacitor whose fill +height tracks `V` on the right, a charge/discharge cycle from `t`. + +```js +H.background(); +const RC = 1.6, V0 = 5; +const cycle = t % 8; +const charging = cycle < 5; +const tau = charging ? cycle : cycle - 5; +const V = charging ? V0 * (1 - Math.exp(-tau / RC)) : V0 * Math.exp(-tau / RC); +const v = H.plot2d({ xMin: 0, xMax: 5, yMin: 0, yMax: 5.5, box: { x: 70, y: 70, w: H.W * 0.5, h: H.H - 150 } }); +v.grid(); v.axes(); +v.fn((x) => V0 * (1 - Math.exp(-x / RC)), { color: H.colors.sub, width: 1.5 }); +v.line(RC, 0, RC, V0, { color: H.colors.violet, width: 1.5, dash: [5, 5] }); +v.dot(tau, V, { r: 7, fill: H.colors.good }); +v.text("t = RC", RC + 0.1, 0.5, { color: H.colors.violet, size: 12 }); +const bx = H.W * 0.68, by = 120, bw = 220, bh = 260; +H.rect(bx, by, bw, bh, { stroke: H.colors.axis, width: 2, radius: 8 }); +H.text("battery", bx + 10, by + 24, { color: H.colors.sub, size: 12 }); +const capX = bx + bw - 60, capY = by + 60, capH = 140; +H.rect(capX, capY, 36, capH, { stroke: H.colors.accent, width: 2 }); +H.rect(capX + 3, capY + capH - capH * (V / V0) + 3, 30, capH * (V / V0) - 6, { fill: H.colors.accent }); +H.text("Q", capX + 44, capY + capH / 2, { color: H.colors.accent, size: 14 }); +H.text("RC circuit: charging a capacitor", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text((charging ? "charging" : "discharging") + " V = " + V.toFixed(2) + " V", 24, 52, { color: H.colors.sub, size: 14 }); +``` + +## 7. Gradient descent on a loss surface — 3D, `surface3d` + descending sphere +Technique: `H.surface3d` for the loss, an optimizer point re-released from a corner +each cycle and stepped along the negative gradient, `cam.yaw = 0.3*t` spin. + +```js +H.background(); +const cam = H.cam3d({ scale: 70, dist: 16, pitch: -0.5, cy: H.H * 0.56 }); +cam.yaw = 0.3 * t; +const loss = (x, y) => 0.6 * (x * x + y * y) - 1.1 * Math.exp(-((x - 1) ** 2 + (y - 1) ** 2)); +cam.grid(3, 1); +H.surface3d(cam, loss, { xMin: -2.6, xMax: 2.6, yMin: -2.6, yMax: 2.6, nx: 34, ny: 34, alpha: 0.9 }); +let px = 2.2, py = 2.2; +const steps = Math.floor((t % 6) * 14); +for (let i = 0; i < steps; i++) { + const gx = 1.2 * px + 1.1 * 2 * (px - 1) * Math.exp(-((px - 1) ** 2 + (py - 1) ** 2)); + const gy = 1.2 * py + 1.1 * 2 * (py - 1) * Math.exp(-((px - 1) ** 2 + (py - 1) ** 2)); + px -= 0.06 * gx; py -= 0.06 * gy; +} +cam.sphere([px, loss(px, py) + 0.15, py], 0.18, { color: H.colors.warn }); +cam.axes(3); +H.text("Gradient descent on a loss surface", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("loss = " + loss(px, py).toFixed(3), 24, 52, { color: H.colors.sub, size: 14 }); +``` + +## 8. DNA double helix — 3D, sphere cloud + depth sort + bonds +Technique: build two antiparallel strands of spheres, **depth-sort descending** +before drawing so near atoms occlude far ones, bonds via `cam.line`. + +```js +H.background(); +const cam = H.cam3d({ scale: 34, dist: 18, pitch: -0.2 }); +cam.yaw = 0.5 * t; +const balls = []; +const N = 34; +for (let i = 0; i < N; i++) { + const s = i / (N - 1); + const ang = s * H.TAU * 2.1 + t * 0.3; + const ypos = (s - 0.5) * 9; + const a = [2.1 * Math.cos(ang), ypos, 2.1 * Math.sin(ang)]; + const b = [-2.1 * Math.cos(ang), ypos, -2.1 * Math.sin(ang)]; + balls.push({ p: a, color: H.colors.accent, r: 0.32 }); + balls.push({ p: b, color: H.colors.accent2, r: 0.32 }); + if (i % 3 === 0) cam.line(a, b, { color: H.colors.sub, width: 1.6 }); +} +balls + .map((o) => ({ ...o, depth: cam.project(o.p).depth })) + .sort((a, b) => b.depth - a.depth) + .forEach((o) => cam.sphere(o.p, o.r, { color: o.color })); +H.text("DNA double helix", 24, 30, { color: H.colors.ink, size: 18, weight: 700 }); +H.text("two antiparallel strands joined by base pairs", 24, 52, { color: H.colors.sub, size: 13 }); +``` diff --git a/stem-viz-plugin/skills/stem-viz/references/helper-api.md b/stem-viz-plugin/skills/stem-viz/references/helper-api.md new file mode 100644 index 0000000..7672681 --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/references/helper-api.md @@ -0,0 +1,140 @@ +# Helper API (`H`) — full reference + +The renderer hands your `scene(ctx, t)` body a global `H`. Everything below is a +method or constant on `H` (or on the `view`/`cam` objects it returns). Coordinates +are **pixels**, origin top-left, **y grows downward**, unless a method says +otherwise. This mirrors `runtime/sandbox-worker.js` exactly — if you change the +runtime, update this file. + +## Contents +- [Constants](#constants) +- [Math helpers](#math-helpers) +- [2D drawing (pixel space)](#2d-drawing-pixel-space) +- [2D graphing — `H.plot2d`](#2d-graphing--hplot2d) +- [3D camera — `H.cam3d`](#3d-camera--hcam3d) +- [Solid 3D surfaces — `H.surface3d` / `H.mesh3d`](#solid-3d-surfaces) +- [Resilience: invented helpers no-op](#resilience) +- [Output JSON schema](#output-json-schema) + +## Constants +- `H.W`, `H.H` — logical canvas width / height in pixels. +- `H.TAU` = 2π, `H.PI` = π. +- `H.colors` — themed palette. Use ONLY these names: `bg`, `panel`, `ink` (bright + text), `sub` (dim text), `grid`, `axis`, `accent`, `accent2`, `good`, `warn`, + `violet`, `yellow`. For any other color use a CSS string (`"#ffaa00"`), + `H.hsl(h,s,l,a)`, or `H.color(i)`. +- `H.palette` — array of 8 distinct CSS colors; `H.color(i)` indexes it (wraps). + +## Math helpers +- `H.clamp(x, lo, hi)` — constrain x to `[lo, hi]`. +- `H.lerp(a, b, t)` — linear blend; `t=0`→a, `t=1`→b. +- `H.map(x, inMin, inMax, outMin, outMax)` — remap a range (used for data→pixel). +- `H.ease(t)` — smoothstep on `[0,1]` (eased 0→1), good for transitions. +- `H.color(i)` — pick palette color i (wraps). +- `H.hsl(h, s, l, a?)` — `"hsla(h,s%,l%,a)"` string; h in degrees, s/l in percent. + +## 2D drawing (pixel space) +Every option bag is optional; sensible defaults apply. +- `H.clear(color?)` — fill the whole canvas with a solid color. +- `H.background(top?, bottom?)` — vertical gradient background. **Call this first.** +- `H.text(str, x, y, {size, color, align, baseline, weight, maxWidth, font})`. +- `H.line(x1, y1, x2, y2, {color, width, dash, cap})`. +- `H.path(points, {color, width, fill, close, dash})` — `points = [[x,y], ...]`. + Set `color:"none"` to fill only; `fill:"#.."` fills the polygon. +- `H.circle(x, y, r, {fill, stroke, width})`. +- `H.rect(x, y, w, h, {fill, stroke, width, radius})` — `radius` rounds corners. +- `H.arrow(x1, y1, x2, y2, {color, width, head})` — line with an arrowhead at the + end; `head` sets arrowhead size. +- `H.legend([{label, color}, ...], x, y)` — color-swatch legend; counts as labels. + +## 2D graphing — `H.plot2d` +`const view = H.plot2d({ xMin, xMax, yMin, yMax, pad, box })` returns a `view` +that maps DATA coordinates to pixels for you. Defaults: x∈[-10,10], y∈[-6,6], +`pad:46`. Pass an explicit `box:{x,y,w,h}` (pixels) to place multiple plots. + +**Scaffold + functions:** +- `view.grid()` — light reference grid. +- `view.axes()` — x/y axes **with numeric tick labels** (satisfies Rule 2 for + coordinates). +- `view.fn(f, {color, width, steps})` — plot `y = f(x)` across the domain. +- `view.dot(x, y, {r, fill, stroke})` — marker at a **data** point. +- `view.X(v)` / `view.Y(v)` — map a single data x/y to a pixel; `view.box` is the + pixel box. + +**Data-space drawing** (arguments are in MATH units; the view maps them): +- `view.line(x1, y1, x2, y2, opts)` +- `view.arrow(x1, y1, x2, y2, opts)` +- `view.text(str, x, y, opts)` +- `view.circle(x, y, rPx, opts)` — center in data units, radius in **pixels**. +- `view.path([[x,y], ...], opts)` +- `view.rect(x, y, w, h, opts)` — lower-left corner + size, all in data units. + +> **Coordinate trap:** with a `view`, pass RAW math coords to the data-space +> methods — `view.line(x1, y1, x2, y2)`, NOT `view.line(view.X(x1), ...)`. Double +> transforming draws far off-screen (validator → `onscreen:false`). Use the +> pixel-space `H.*` methods only for chrome that isn't tied to graph coordinates. + +Methods chain: `const v = H.plot2d({xMin:-6,xMax:6}); v.grid(); v.axes(); v.fn(Math.sin);` + +## 3D camera — `H.cam3d` +`const cam = H.cam3d({ yaw, pitch, scale, dist, cx, cy })`. Convention: **+y is UP** +on screen; the ground plane is x/z. Larger `depth` = farther from camera, so for +correct overlap **sort polygons/spheres DESCENDING by depth and draw far first**. +The user can drag to orbit and scroll to zoom automatically — still set a slow +default spin `cam.yaw = 0.3 * t` so it reads as 3D before they touch it. + +- `cam.project([x, y, z])` → `{ x, y, depth, f }` (screen point + depth + scale). +- `cam.yaw`, `cam.pitch` — settable; drive with `t` to rotate. +- `cam.line(a, b, opts)` — segment between two `[x,y,z]` points. +- `cam.path(points, opts)` — 3D polyline (orbits, trajectories, curves). +- `cam.poly(points, {fill, stroke, width})` — filled 3D polygon (you depth-sort). +- `cam.sphere([x,y,z], r, {color})` — **shaded** ball, world-unit radius, any CSS + color. Returns its projection. For many: sort by `cam.project(p).depth` + descending, then draw (atoms, planets, particles). +- `cam.grid(size, step)` — ground-plane grid at y=0 (depth perception). +- `cam.axes(len)` — labeled x/y/z axes. + +## Solid 3D surfaces +Use these instead of point clouds — they render filled, light-shaded, +depth-sorted meshes that genuinely look 3D. + +- `H.surface3d(cam, (x, y) => height, { xMin, xMax, yMin, yMax, nx, ny, alpha, wire, hueMin, hueMax })` + — THE way to draw any height function `z = f(x,y)`. The returned height is drawn + along the screen-up axis; color maps from height (override with `hueMin`/`hueMax`). +- `H.mesh3d(cam, (u, v) => [x, y, z], { uMin, uMax, vMin, vMax, nu, nv, hue, alpha, wire })` + — parametric surface: spheres, tori, cylinders, tubes, ribbons, Möbius strips. + `u`/`v` default to `[0, TAU]`; use a fixed `hue` (0–360). + Sphere of radius R: `(u, v) => [Math.cos(u)*Math.sin(v)*R, Math.cos(v)*R, Math.sin(u)*Math.sin(v)*R]` + with `vMin: 0, vMax: Math.PI`. + +Keep `nx`/`ny`/`nu`/`nv` ≤ ~40 each (hard-capped at 64) for smooth framerate. + +## Resilience +The runtime wraps `H`, every `view`, and every `cam` in a proxy: calling a helper +that does NOT exist returns a **chainable no-op** instead of throwing, so a single +typo (`H.spinner()`, `cam.glow()`) degrades to "that one thing didn't draw" rather +than blanking the frame. Don't rely on this — use only the documented helpers — +but it means an invented method is a quality bug, not a crash. + +## Output JSON schema +When emitting a full scene record, this is the exact shape (all fields required): + +```json +{ + "type": "object", + "additionalProperties": false, + "properties": { + "title": { "type": "string" }, + "tag": { "type": "string" }, + "dimension": { "type": "string", "enum": ["2D", "3D"] }, + "equation": { "type": "string" }, + "summary": { "type": "string" }, + "bullets": { "type": "array", "items": { "type": "string" } }, + "student_prompts": { "type": "array", "items": { "type": "string" } }, + "code": { "type": "string" } + }, + "required": ["title", "tag", "dimension", "equation", "summary", "bullets", "student_prompts", "code"] +} +``` +`bullets` should be exactly 3; `student_prompts` 3. `code` is the validated +`scene` body. diff --git a/stem-viz-plugin/skills/stem-viz/references/repair.md b/stem-viz-plugin/skills/stem-viz/references/repair.md new file mode 100644 index 0000000..60aeb49 --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/references/repair.md @@ -0,0 +1,55 @@ +# Repair protocol — fixing a scene that throws or comes back blank + +When `scripts/validate_scene.js` (or the live runtime) reports a problem, fix it +with the **smallest change that addresses the reported error**. The most common +mistake is rewriting the whole scene — that usually trades the original bug for a +new one. Keep everything that already works; touch only the failing line. + +## Loop +1. Run `node scripts/validate_scene.js <<'SCENE' … SCENE`. +2. Read the JSON: `ok`, `error`, `painted`, `text`, `onscreen`. +3. Apply the targeted fix for that signal (tables below). +4. **Re-validate.** Repeat only while it's making progress — if two repairs hit + the *same* error, stop and rethink the approach rather than looping. + +## `ok:false` — the scene threw. Fix by error class. + +| Error contains | What it means | Minimal fix | +|---|---|---| +| `is not defined` | A name was used before it was declared (usually a typo). | Declare it with `const`/`let`, or fix the misspelling. Do **not** add other new names. | +| `is not a function` | You called something that isn't a real helper (invented method, or a non-function value). | Use only the documented helpers (`references/helper-api.md`). Delete or replace the invalid call. | +| `Cannot read properties of undefined` / `… of null` | You read a property off `undefined`/`null` — an out-of-range array index, or a value that wasn't created. | Guard the access: check the value exists and any index is in range before use. | +| `is not iterable` | You spread or looped over a non-array. | Ensure the value is an array (default to `[]`) before iterating. | +| `Unexpected token` / `SyntaxError` | The code didn't parse — usually an unbalanced bracket or a statement cut off mid-way. | Return complete, valid JavaScript; check brackets/quotes balance. | +| `hung` / `timed out` | An unbounded or huge loop. | Bound every loop; drive animation from `t`, never a `while`-until-condition. Cap mesh `nx/ny/nu/nv` ≤ 40. | + +The error message names *what* threw; if you also have a line (the live runtime +reports `where: "line N: "`), fix exactly that line. + +## `painted:false` — nothing drew (Rule 0) +The body computed but never issued a content draw. Ensure `H.background()` is the +first line **and** at least three real drawing calls run every frame (a `for` loop +calling one counts). Setup-only code (`const`s, math, no `H.*` draw) renders a +blank canvas. + +## `text:false` — no labels (Rule 2) +Add a title (`H.text`, size 18, weight 700), a caption, and at least one live +readout. `view.axes()`/`cam.axes()` also count as labels. + +## `onscreen:false` — drawn off-canvas (the coordinate trap) +Content was drawn but none landed on the canvas — almost always **data vs pixel +coordinate mixing**. Symptoms and fixes: +- You called a `view` *data-space* method with already-mapped pixels: + `v.line(v.X(x1), v.Y(y1), …)` double-transforms. Pass **raw math coords**: + `v.line(x1, y1, x2, y2)`. +- You drew with pixel-space `H.line/H.circle` using math coordinates like + `(-3, 0.5)`. Either go through a `view`, or map with `H.map(...)` / `v.X`/`v.Y` + first. +- Your data range doesn't contain what you're plotting — widen `xMin/xMax`, + `yMin/yMax` to include the values. + +## When stuck +If the same error survives two minimal fixes, the approach is wrong, not the line. +Re-read the relevant recipe in `references/concepts.md`, or adapt the nearest +working scene in `references/examples.md` instead of patching further. A correct +scene built from a known-good template beats a heavily-patched broken one. diff --git a/stem-viz-plugin/skills/stem-viz/runtime/README.md b/stem-viz-plugin/skills/stem-viz/runtime/README.md new file mode 100644 index 0000000..dc72019 --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/runtime/README.md @@ -0,0 +1,65 @@ +# Runtime — render scenes anywhere + +This folder is the actual VisualLM rendering engine, extracted so generated +scenes run **without the VisualLM server** and with **no dependencies** beyond a +browser. It is what makes a scene from this skill *portable*: ship `runtime/` +alongside the `code` and any host can render it. + +## Files +- **`sandbox-worker.js`** — a Web Worker that compiles a `scene(ctx, t, H)` body + and runs it ~60×/s in an **OffscreenCanvas sandbox**. It defines the real `H` + helper library (the one `references/helper-api.md` documents). Hardened: no DOM, + no network, a watchdog kills runaway frames, invented helpers degrade to chainable + no-ops, and a transient throwing frame doesn't kill a running scene. +- **`host.html`** — a minimal, self-contained host page: paste a scene, press Run, + watch it render; drag to orbit 3D and scroll to zoom. No build step. + +## Quick start +Serve the folder over HTTP (workers don't load from `file://`) and open the host: + +```bash +cd runtime +python3 -m http.server 8000 +# open http://localhost:8000/host.html +``` + +## Embed in your own app +The worker speaks a small message protocol. Wire it to a canvas: + +```js +const canvas = document.querySelector("canvas"); +const offscreen = canvas.transferControlToOffscreen(); +const worker = new Worker("./sandbox-worker.js"); + +worker.onmessage = (e) => { + const m = e.data; + if (m.type === "ready") worker.postMessage({ type: "run", code: sceneBody, resetTime: true }); + if (m.type === "heartbeat") {/* a frame rendered — scene is live */} + if (m.type === "runtime-error" || m.type === "compile-error") { + console.error(m.message, m.where); // m.where = "line N: " when available + } +}; + +const dpr = Math.min(2, devicePixelRatio || 1); +worker.postMessage({ type: "init", canvas: offscreen, width: 900, height: 560, dpr }, [offscreen]); +``` + +**Messages you send:** +| type | payload | effect | +|---|---|---| +| `init` | `{canvas, width, height, dpr}` (transfer `canvas`) | one-time setup | +| `run` | `{code, resetTime}` | compile + run a scene body | +| `pause` / `resume` | — | freeze / continue | +| `speed` | `{value}` | time multiplier | +| `resize` | `{width, height, dpr}` | re-fit the canvas | +| `orbit` | `{dyaw, dpitch, dzoom}` | nudge the 3D camera (drag/scroll) | +| `orbit-reset` | — | reset the camera | + +**Messages you receive:** `ready`, `running`, `heartbeat` (every ~20 frames), +`compile-error` / `runtime-error` (`{message, stack, where}`). + +## Headless validation +To check a scene *without* a browser (CI, an agent loop), use +`../scripts/validate_scene.js` (Node only) — see SKILL.md → "Validate before +shipping". The validator mirrors this runtime's `H` surface; if you add a helper +to `sandbox-worker.js`, mirror it in the validator's mock too. diff --git a/stem-viz-plugin/skills/stem-viz/runtime/host.html b/stem-viz-plugin/skills/stem-viz/runtime/host.html new file mode 100644 index 0000000..d1e3d0a --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/runtime/host.html @@ -0,0 +1,137 @@ + + + + + + STEM-viz runtime — scene host + + + +
+
+

STEM-viz scene host

+

Paste a scene(ctx, t, H) body, press Run. Drag the canvas to orbit 3D, scroll to zoom.

+
+ +
+ + + loading… +
+
+ +
+
+
drag = orbit · scroll = zoom · double-click = reset
+
+ + + + diff --git a/stem-viz-plugin/skills/stem-viz/runtime/sandbox-worker.js b/stem-viz-plugin/skills/stem-viz/runtime/sandbox-worker.js new file mode 100644 index 0000000..85eb0a2 --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/runtime/sandbox-worker.js @@ -0,0 +1,1080 @@ +/* + * VisualLM sandbox worker. + * + * Runs AI-generated animation code inside a dedicated Web Worker with an + * OffscreenCanvas. The worker has no DOM access and no same-origin window, so + * generated code cannot touch the page, cookies, or storage. Network access is + * additionally blocked by the page CSP (connect-src 'none'). If generated code + * hangs (e.g. `while (true)`), the worker stops emitting heartbeats and the main + * thread terminates and recreates it. + * + * Contract for generated code: it is the *body* of + * function scene(ctx, t, H) { ... } + * called once per frame. `t` is elapsed seconds (honoring pause + speed). The + * function must fully redraw each frame. `H` is the helper library below. + */ + +// Defense in depth: strip network / module APIs from the worker scope before +// any generated code runs. The worker already has no DOM and no same-origin +// window; removing these closes the remaining exfiltration paths. +try { + self.fetch = undefined; + self.XMLHttpRequest = undefined; + self.WebSocket = undefined; + self.EventSource = undefined; + self.indexedDB = undefined; + // Block sub-workers: without this, generated code could spawn a sub-worker + // from a Blob URL (`new Worker(URL.createObjectURL(new Blob([...])))`) and + // get a fresh global scope with fetch/XHR re-enabled — escaping the strip + // we just did. The page also has no CSP `worker-src`, so the browser would + // not block it on its own. + self.Worker = undefined; + self.SharedWorker = undefined; + // sendBeacon and WebTransport are alternate POST/UDP channels available in + // worker scope; BroadcastChannel can leak to other same-origin tabs. + self.WebTransport = undefined; + self.BroadcastChannel = undefined; + if (self.navigator) { + try { + self.navigator.sendBeacon = undefined; + } catch (e) { + /* ignore */ + } + } + self.importScripts = function () { + throw new Error("importScripts is disabled in the sandbox."); + }; +} catch (e) { + /* ignore */ +} + +let canvas = null; +let ctx = null; +let dpr = 1; +let logicalW = 0; +let logicalH = 0; + +let sceneFn = null; +let running = false; +let paused = false; +let speed = 1; + +// User-driven camera orbit, applied on top of whatever yaw/pitch the scene +// sets. Updated by "orbit" messages from the main thread (canvas drag/wheel), +// reset when a new scene loads. Every cam3d instance reads these, so dragging +// rotates any 3D scene without the generated code having to cooperate. +const orbit = { yaw: 0, pitch: 0, zoom: 1 }; + +let simTime = 0; // accumulated, speed-scaled seconds passed to scene() +let lastWall = 0; // last wall-clock timestamp (ms) +let frameCount = 0; +// Per-frame error tolerance. A scene that has rendered at least one clean frame +// (everRendered) is kept alive through an occasional throwing frame rather than +// being killed and shipped to the slow repair loop. Only a first-frame failure +// or a sustained run of throwing frames escalates to a real runtime-error. +let consecutiveErrors = 0; +let everRendered = false; +let lastCode = ""; // raw source of the running scene, for offending-line lookup +let loopTimer = null; + +const FRAME_MS = 1000 / 60; + +function post(msg) { + self.postMessage(msg); +} + +/* ------------------------------------------------------------------ */ +/* Helper library handed to generated code as `H`. */ +/* ------------------------------------------------------------------ */ + +const TAU = Math.PI * 2; + +const COLORS = { + bg: "#0e1525", + panel: "#16203a", + ink: "#eef2ff", + sub: "#9fb0d4", + grid: "#26314f", + axis: "#566087", + accent: "#7cc4ff", + accent2: "#f4a259", + good: "#67e8b0", + warn: "#ff8aa0", + violet: "#c4a7ff", + yellow: "#ffe08a", +}; + +const PALETTE = [ + "#7cc4ff", + "#f4a259", + "#67e8b0", + "#c4a7ff", + "#ff8aa0", + "#ffe08a", + "#5eead4", + "#fca5f1", +]; + +function clamp(x, lo, hi) { + return x < lo ? lo : x > hi ? hi : x; +} +function lerp(a, b, t) { + return a + (b - a) * t; +} +function map(x, inMin, inMax, outMin, outMax) { + if (inMax === inMin) return outMin; + return outMin + ((x - inMin) * (outMax - outMin)) / (inMax - inMin); +} +function ease(t) { + t = clamp(t, 0, 1); + return t * t * (3 - 2 * t); +} + +function makeHelpers() { + const H = { + TAU, + PI: Math.PI, + colors: COLORS, + palette: PALETTE, + clamp, + lerp, + map, + ease, + get W() { + return logicalW; + }, + get H() { + return logicalH; + }, + clear(color) { + ctx.save(); + ctx.fillStyle = color || COLORS.bg; + ctx.fillRect(0, 0, logicalW, logicalH); + ctx.restore(); + }, + background(top, bottom) { + const g = ctx.createLinearGradient(0, 0, 0, logicalH); + g.addColorStop(0, top || "#101a31"); + g.addColorStop(1, bottom || COLORS.bg); + ctx.save(); + ctx.fillStyle = g; + ctx.fillRect(0, 0, logicalW, logicalH); + ctx.restore(); + }, + text(str, x, y, opts) { + opts = opts || {}; + ctx.save(); + const size = opts.size || 16; + const weight = opts.weight || 500; + const family = + opts.font || "'Inter', system-ui, -apple-system, sans-serif"; + ctx.font = `${weight} ${size}px ${family}`; + ctx.fillStyle = opts.color || COLORS.ink; + ctx.textAlign = opts.align || "left"; + ctx.textBaseline = opts.baseline || "alphabetic"; + if (opts.maxWidth) ctx.fillText(String(str), x, y, opts.maxWidth); + else ctx.fillText(String(str), x, y); + ctx.restore(); + }, + line(x1, y1, x2, y2, opts) { + opts = opts || {}; + ctx.save(); + ctx.strokeStyle = opts.color || COLORS.axis; + ctx.lineWidth = opts.width || 1.5; + ctx.lineCap = opts.cap || "round"; + if (opts.dash) ctx.setLineDash(opts.dash); + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + ctx.restore(); + }, + path(points, opts) { + if (!points || points.length < 2) return; + opts = opts || {}; + ctx.save(); + ctx.strokeStyle = opts.color || COLORS.accent; + ctx.lineWidth = opts.width || 2.5; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + if (opts.dash) ctx.setLineDash(opts.dash); + ctx.beginPath(); + ctx.moveTo(points[0][0], points[0][1]); + for (let i = 1; i < points.length; i++) + ctx.lineTo(points[i][0], points[i][1]); + if (opts.close) ctx.closePath(); + if (opts.fill) { + ctx.fillStyle = opts.fill; + ctx.fill(); + } + if (opts.color !== "none") ctx.stroke(); + ctx.restore(); + }, + circle(x, y, r, opts) { + opts = opts || {}; + ctx.save(); + ctx.beginPath(); + ctx.arc(x, y, Math.max(0, r), 0, TAU); + if (opts.fill) { + ctx.fillStyle = opts.fill; + ctx.fill(); + } + if (opts.stroke) { + ctx.strokeStyle = opts.stroke; + ctx.lineWidth = opts.width || 2; + ctx.stroke(); + } + ctx.restore(); + }, + rect(x, y, w, h, opts) { + opts = opts || {}; + ctx.save(); + const r = opts.radius || 0; + ctx.beginPath(); + if (r > 0) { + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + } else { + ctx.rect(x, y, w, h); + } + if (opts.fill) { + ctx.fillStyle = opts.fill; + ctx.fill(); + } + if (opts.stroke) { + ctx.strokeStyle = opts.stroke; + ctx.lineWidth = opts.width || 1.5; + ctx.stroke(); + } + ctx.restore(); + }, + arrow(x1, y1, x2, y2, opts) { + opts = opts || {}; + const color = opts.color || COLORS.accent; + const width = opts.width || 2.5; + const head = opts.head || 9; + ctx.save(); + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = width; + ctx.lineCap = "round"; + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + const ang = Math.atan2(y2 - y1, x2 - x1); + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo( + x2 - head * Math.cos(ang - 0.4), + y2 - head * Math.sin(ang - 0.4) + ); + ctx.lineTo( + x2 - head * Math.cos(ang + 0.4), + y2 - head * Math.sin(ang + 0.4) + ); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + }, + // Map an HSL-ish index to a palette color. + color(i) { + return PALETTE[((i % PALETTE.length) + PALETTE.length) % PALETTE.length]; + }, + hsl(h, s, l, a) { + return `hsla(${h}, ${s}%, ${l}%, ${a == null ? 1 : a})`; + }, + + /* 2D plotting view. Maps data coordinates to pixels with a padded box. */ + plot2d(o) { + o = o || {}; + const pad = o.pad == null ? 46 : o.pad; + const box = o.box || { + x: pad, + y: pad * 0.6, + w: logicalW - pad * 2, + h: logicalH - pad * 1.6, + }; + const xMin = o.xMin == null ? -10 : o.xMin; + const xMax = o.xMax == null ? 10 : o.xMax; + const yMin = o.yMin == null ? -6 : o.yMin; + const yMax = o.yMax == null ? 6 : o.yMax; + const X = (v) => box.x + map(v, xMin, xMax, 0, box.w); + const Y = (v) => box.y + map(v, yMin, yMax, box.h, 0); + let view = { + box, + xMin, + xMax, + yMin, + yMax, + X, + Y, + grid(opts) { + opts = opts || {}; + const stepX = opts.stepX || niceStep(xMax - xMin); + const stepY = opts.stepY || niceStep(yMax - yMin); + ctx.save(); + ctx.strokeStyle = opts.color || COLORS.grid; + ctx.lineWidth = 1; + ctx.fillStyle = COLORS.sub; + ctx.font = "12px 'Inter', sans-serif"; + for (let gx = Math.ceil(xMin / stepX) * stepX; gx <= xMax + 1e-9; gx += stepX) { + ctx.beginPath(); + ctx.moveTo(X(gx), box.y); + ctx.lineTo(X(gx), box.y + box.h); + ctx.stroke(); + } + for (let gy = Math.ceil(yMin / stepY) * stepY; gy <= yMax + 1e-9; gy += stepY) { + ctx.beginPath(); + ctx.moveTo(box.x, Y(gy)); + ctx.lineTo(box.x + box.w, Y(gy)); + ctx.stroke(); + } + ctx.restore(); + return view; + }, + axes(opts) { + opts = opts || {}; + const x0 = clamp(0, xMin, xMax); + const y0 = clamp(0, yMin, yMax); + ctx.save(); + ctx.strokeStyle = opts.color || COLORS.axis; + ctx.lineWidth = 1.6; + ctx.beginPath(); + ctx.moveTo(box.x, Y(y0)); + ctx.lineTo(box.x + box.w, Y(y0)); + ctx.moveTo(X(x0), box.y); + ctx.lineTo(X(x0), box.y + box.h); + ctx.stroke(); + // Numeric tick labels. Scenes without axis values read as a vague + // picture instead of a graph, so these are on by default + // (opts.ticks === false disables them for stylized scenes). + if (opts.ticks !== false) { + const stepX = opts.stepX || niceStep(xMax - xMin); + const stepY = opts.stepY || niceStep(yMax - yMin); + const fmt = (v) => + Math.abs(v) >= 1000 || (Math.abs(v) < 0.01 && v !== 0) + ? v.toExponential(0) + : +v.toFixed(2) + ""; + ctx.fillStyle = opts.tickColor || COLORS.sub; + ctx.font = "11px 'Inter', sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + for (let gx = Math.ceil(xMin / stepX) * stepX; gx <= xMax + 1e-9; gx += stepX) { + if (Math.abs(gx) < stepX * 1e-6) continue; // skip 0 (origin clutter) + ctx.fillText(fmt(gx), X(gx), Y(y0) + 5); + } + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + for (let gy = Math.ceil(yMin / stepY) * stepY; gy <= yMax + 1e-9; gy += stepY) { + if (Math.abs(gy) < stepY * 1e-6) continue; + ctx.fillText(fmt(gy), X(x0) - 6, Y(gy)); + } + } + ctx.restore(); + return view; + }, + fn(f, opts) { + opts = opts || {}; + const steps = opts.steps || 240; + const pts = []; + for (let i = 0; i <= steps; i++) { + const xv = lerp(xMin, xMax, i / steps); + let yv; + try { + yv = f(xv); + } catch (e) { + yv = NaN; + } + if (Number.isFinite(yv) && yv >= yMin - 2 && yv <= yMax + 2) { + pts.push([X(xv), Y(yv)]); + } else if (pts.length) { + H.path(pts.splice(0), { color: opts.color || COLORS.accent, width: opts.width || 2.6 }); + } + } + if (pts.length) + H.path(pts, { color: opts.color || COLORS.accent, width: opts.width || 2.6 }); + return view; + }, + dot(xv, yv, opts) { + H.circle(X(xv), Y(yv), (opts && opts.r) || 5, { + fill: (opts && opts.fill) || COLORS.accent2, + stroke: (opts && opts.stroke) || COLORS.bg, + width: 2, + }); + return view; + }, + /* Data-space drawing. Models naturally write `v.line(x1,y1,x2,y2)` + * meaning math coordinates — these make that correct instead of a + * fatal "not a function". */ + line(x1, y1, x2, y2, opts) { + H.line(X(x1), Y(y1), X(x2), Y(y2), opts); + return view; + }, + arrow(x1, y1, x2, y2, opts) { + H.arrow(X(x1), Y(y1), X(x2), Y(y2), opts); + return view; + }, + text(str, xv, yv, opts) { + H.text(str, X(xv), Y(yv), opts); + return view; + }, + circle(xv, yv, r, opts) { + H.circle(X(xv), Y(yv), r, opts); // r stays in pixels + return view; + }, + path(points, opts) { + if (!points || !points.length) return view; + H.path(points.map((p) => [X(p[0]), Y(p[1])]), opts); + return view; + }, + rect(xv, yv, w, h, opts) { + // (xv, yv) is the lower-left corner in data coords; w/h in data units. + H.rect(X(xv), Y(yv + h), X(xv + w) - X(xv), Y(yv) - Y(yv + h), opts); + return view; + }, + }; + // Same tolerance as H itself: an invented view method becomes a no-op + // instead of killing the whole frame with "v.foo is not a function". + // Reassign the `view` binding to the proxy so every `return view` inside + // the methods above yields the wrapped object — that keeps invented + // helpers no-op-able even mid-chain (e.g. view.fn(...).annotate(...)). + view = wrapHelpers(view); + return view; + }, + + /* 3D camera. project([x,y,z]) -> {x, y, depth, f}. Larger depth = farther + * from the camera, so painter's algorithm = sort DESCENDING by depth and + * draw in order. User drag/zoom (the `orbit` worker global) is layered on + * top of the scene's own yaw/pitch automatically. Convention: y is UP. */ + cam3d(o) { + o = o || {}; + let yaw = o.yaw || 0; + let pitch = o.pitch == null ? -0.5 : o.pitch; + const scale = o.scale || 60; + const dist = o.dist || 9; + const cx = o.cx == null ? logicalW / 2 : o.cx; + const cy = o.cy == null ? logicalH / 2 : o.cy; + let cam = { + set yaw(v) { + yaw = v; + }, + get yaw() { + return yaw; + }, + set pitch(v) { + pitch = v; + }, + get pitch() { + return pitch; + }, + project(p) { + let x = p[0], + y = p[1], + z = p[2]; + const ya = yaw + orbit.yaw; + const pa = clamp(pitch + orbit.pitch, -1.45, 1.45); + // yaw about Y + let xz = x * Math.cos(ya) - z * Math.sin(ya); + let zz = x * Math.sin(ya) + z * Math.cos(ya); + x = xz; + z = zz; + // pitch about X + let yz = y * Math.cos(pa) - z * Math.sin(pa); + let zp = y * Math.sin(pa) + z * Math.cos(pa); + y = yz; + z = zp; + const f = (dist / (dist + z)) * orbit.zoom; + return { x: cx + x * scale * f, y: cy - y * scale * f, depth: z, f }; + }, + /* Straight 3D segment. */ + line(a, b, opts) { + const p1 = cam.project(a); + const p2 = cam.project(b); + H.line(p1.x, p1.y, p2.x, p2.y, opts); + return cam; + }, + /* Polyline through 3D points: [[x,y,z], ...]. */ + path(points, opts) { + if (!points || points.length < 2) return cam; + const px = []; + for (let i = 0; i < points.length; i++) { + const p = cam.project(points[i]); + px.push([p.x, p.y]); + } + H.path(px, opts); + return cam; + }, + /* Filled/stroked 3D polygon (caller is responsible for depth order). */ + poly(points, opts) { + if (!points || points.length < 3) return cam; + opts = opts || {}; + const px = []; + for (let i = 0; i < points.length; i++) { + const p = cam.project(points[i]); + px.push([p.x, p.y]); + } + H.path(px, { + color: opts.stroke || opts.color || "none", + width: opts.width || 1, + fill: opts.fill, + close: true, + }); + return cam; + }, + /* Shaded ball at a 3D point. Radius is in WORLD units (scales with + * perspective). Works with any CSS base color. Returns the projected + * point so callers can depth-sort before drawing. */ + sphere(p, r, opts) { + opts = opts || {}; + const q = cam.project(p); + const rr = Math.max(0.5, r * scale * q.f); + const base = opts.color || COLORS.accent; + ctx.save(); + ctx.beginPath(); + ctx.arc(q.x, q.y, rr, 0, TAU); + ctx.fillStyle = base; + ctx.fill(); + // Highlight toward the light, darkened rim away from it. Layered + // gradients shade any base color without parsing it. + const hx = q.x - rr * 0.35; + const hy = q.y - rr * 0.4; + let g = ctx.createRadialGradient(hx, hy, rr * 0.05, hx, hy, rr * 1.25); + g.addColorStop(0, "rgba(255,255,255,0.65)"); + g.addColorStop(0.45, "rgba(255,255,255,0.08)"); + g.addColorStop(1, "rgba(8,10,24,0.55)"); + ctx.fillStyle = g; + ctx.fill(); + if (opts.stroke) { + ctx.strokeStyle = opts.stroke; + ctx.lineWidth = opts.width || 1; + ctx.stroke(); + } + ctx.restore(); + return q; + }, + /* Ground-plane grid at y=0 for depth perception. Inputs come from + * generated code, so both are sanitized: a zero/negative/NaN step or + * a huge size would otherwise hang the worker. */ + grid(size, step, opts) { + size = Number.isFinite(size) && size > 0 ? Math.min(size, 1000) : 4; + step = Number.isFinite(step) && step > 0 ? step : 1; + if (size / step > 80) step = size / 80; // cap at 161 lines per axis + opts = opts || {}; + const color = opts.color || COLORS.grid; + const width = opts.width || 1; + for (let v = -size; v <= size + 1e-9; v += step) { + cam.line([v, 0, -size], [v, 0, size], { color, width }); + cam.line([-size, 0, v], [size, 0, v], { color, width }); + } + return cam; + }, + axes(len, opts) { + len = Number.isFinite(len) && len > 0 ? Math.min(len, 1000) : 3; + opts = opts || {}; + const o0 = cam.project([0, 0, 0]); + const ax = [ + [[len, 0, 0], COLORS.accent, "x"], + [[0, len, 0], COLORS.good, "y"], + [[0, 0, len], COLORS.accent2, "z"], + ]; + ax.forEach(([v, c, label]) => { + const p = cam.project(v); + H.arrow(o0.x, o0.y, p.x, p.y, { color: c, width: 2 }); + H.text(label, p.x + 4, p.y - 4, { color: c, size: 13 }); + }); + // Unit tick marks + numbers so 3D scenes have a readable scale. + // Skipped for long axes (labels would smear together). + if (opts.ticks !== false && len <= 12) { + const step = len > 6 ? 2 : 1; + for (let v = step; v <= len - step * 0.5; v += step) { + ax.forEach(([dir, c]) => { + const u = [ + (dir[0] / len) * v, + (dir[1] / len) * v, + (dir[2] / len) * v, + ]; + const p = cam.project(u); + H.circle(p.x, p.y, 1.6, { fill: c }); + H.text(String(v), p.x + 3, p.y - 3, { + color: COLORS.sub, + size: 10, + }); + }); + } + } + return cam; + }, + }; + // Invented cam methods degrade to no-ops, like H and plot2d views. + // Reassign the `cam` binding to the proxy so every `return cam` inside the + // methods above yields the wrapped object — chains keep working in any + // order (e.g. cam.grid().glow(), cam.line(...).spiral(...)). + cam = wrapHelpers(cam); + return cam; + }, + + /* Solid, lit, depth-sorted height surface: screenHeight = f(x, y). + * THE way to draw z = f(x, y) surfaces. `f` receives the two ground-plane + * coordinates and returns the height drawn along the screen-up axis. */ + surface3d(cam, f, opts) { + opts = opts || {}; + const xMin = opts.xMin == null ? -3 : opts.xMin; + const xMax = opts.xMax == null ? 3 : opts.xMax; + const yMin = opts.yMin == null ? -3 : opts.yMin; + const yMax = opts.yMax == null ? 3 : opts.yMax; + const nx = clamp(Math.round(opts.nx || 36), 4, 64); + const ny = clamp(Math.round(opts.ny || 36), 4, 64); + // Sample the grid once; reuse corner samples between quads. + const pts = []; + let hMin = Infinity; + let hMax = -Infinity; + for (let j = 0; j <= ny; j++) { + const row = []; + for (let i = 0; i <= nx; i++) { + const x = map(i, 0, nx, xMin, xMax); + const y = map(j, 0, ny, yMin, yMax); + let h; + try { + h = f(x, y); + } catch (e) { + h = NaN; + } + if (Number.isFinite(h)) { + if (h < hMin) hMin = h; + if (h > hMax) hMax = h; + row.push([x, h, y]); + } else { + row.push(null); + } + } + pts.push(row); + } + if (hMin > hMax) return; // nothing finite to draw + const quads = []; + for (let j = 0; j < ny; j++) { + for (let i = 0; i < nx; i++) { + const a = pts[j][i]; + const b = pts[j][i + 1]; + const c = pts[j + 1][i + 1]; + const d = pts[j + 1][i]; + if (!a || !b || !c || !d) continue; + const value = + hMax > hMin + ? ((a[1] + b[1] + c[1] + d[1]) / 4 - hMin) / (hMax - hMin) + : 0.5; + quads.push({ pts: [a, b, c, d], value }); + } + } + drawShadedQuads(cam, quads, opts); + }, + + /* Solid, lit, depth-sorted parametric surface: fn(u, v) -> [x, y, z]. + * Spheres, tori, cylinders, tubes, ribbons, Möbius strips, orbitals... */ + mesh3d(cam, fn, opts) { + opts = opts || {}; + const uMin = opts.uMin == null ? 0 : opts.uMin; + const uMax = opts.uMax == null ? TAU : opts.uMax; + const vMin = opts.vMin == null ? 0 : opts.vMin; + const vMax = opts.vMax == null ? TAU : opts.vMax; + const nu = clamp(Math.round(opts.nu || 32), 3, 64); + const nv = clamp(Math.round(opts.nv || 18), 3, 64); + const pts = []; + let hMin = Infinity; + let hMax = -Infinity; + for (let j = 0; j <= nv; j++) { + const row = []; + for (let i = 0; i <= nu; i++) { + const u = map(i, 0, nu, uMin, uMax); + const v = map(j, 0, nv, vMin, vMax); + let p; + try { + p = fn(u, v); + } catch (e) { + p = null; + } + if ( + p && + Number.isFinite(p[0]) && + Number.isFinite(p[1]) && + Number.isFinite(p[2]) + ) { + if (p[1] < hMin) hMin = p[1]; + if (p[1] > hMax) hMax = p[1]; + row.push(p); + } else { + row.push(null); + } + } + pts.push(row); + } + if (hMin > hMax) return; + const quads = []; + for (let j = 0; j < nv; j++) { + for (let i = 0; i < nu; i++) { + const a = pts[j][i]; + const b = pts[j][i + 1]; + const c = pts[j + 1][i + 1]; + const d = pts[j + 1][i]; + if (!a || !b || !c || !d) continue; + const value = + hMax > hMin + ? ((a[1] + b[1] + c[1] + d[1]) / 4 - hMin) / (hMax - hMin) + : 0.5; + quads.push({ pts: [a, b, c, d], value }); + } + } + drawShadedQuads(cam, quads, opts); + }, + + // Convenience: draw a soft legend chip. + legend(items, x, y) { + ctx.save(); + let cy = y; + items.forEach((it) => { + H.circle(x + 6, cy - 4, 5, { fill: it.color }); + H.text(it.label, x + 18, cy, { size: 13, color: COLORS.sub }); + cy += 20; + }); + ctx.restore(); + }, + }; + return H; +} + +/* Shared core of surface3d / mesh3d: project quads, Lambert-shade them from a + * fixed light, sort far-to-near, and fill. Color options: + * hue — fixed hue (parametric meshes default to 210) + * hueMin/hueMax — height-mapped hue ramp (surfaces default to 215 → 25) + * colorFn(value, lambert) — full custom CSS color escape hatch + * alpha, wire (stroke the quad edges, default true), shade (default true) + */ +const LIGHT_DIR = (() => { + const v = [0.45, 0.85, 0.35]; + const n = Math.hypot(v[0], v[1], v[2]); + return [v[0] / n, v[1] / n, v[2] / n]; +})(); + +function drawShadedQuads(cam, quads, opts) { + opts = opts || {}; + const alpha = opts.alpha == null ? 0.96 : clamp(opts.alpha, 0.05, 1); + const wire = opts.wire !== false; + const shade = opts.shade !== false; + const hueFixed = opts.hue; + const hueMin = opts.hueMin == null ? 215 : opts.hueMin; + const hueMax = opts.hueMax == null ? 25 : opts.hueMax; + const polys = []; + for (let k = 0; k < quads.length; k++) { + const q = quads[k]; + const [a, b, c, d] = q.pts; + // Normal from the diagonals — stable even for non-planar quads. + const u = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; + const v = [d[0] - b[0], d[1] - b[1], d[2] - b[2]]; + let nx = u[1] * v[2] - u[2] * v[1]; + let ny = u[2] * v[0] - u[0] * v[2]; + let nz = u[0] * v[1] - u[1] * v[0]; + const nl = Math.hypot(nx, ny, nz) || 1; + nx /= nl; + ny /= nl; + nz /= nl; + // abs(): quad winding is arbitrary, light both faces. + const lambert = Math.abs( + nx * LIGHT_DIR[0] + ny * LIGHT_DIR[1] + nz * LIGHT_DIR[2] + ); + const pr = [ + cam.project(a), + cam.project(b), + cam.project(c), + cam.project(d), + ]; + polys.push({ + pr, + depth: (pr[0].depth + pr[1].depth + pr[2].depth + pr[3].depth) / 4, + lambert: shade ? 0.25 + 0.75 * lambert : 1, + value: q.value, + }); + } + // Painter's algorithm: larger depth = farther; draw far first. + polys.sort((p1, p2) => p2.depth - p1.depth); + ctx.save(); + ctx.lineJoin = "round"; + for (let k = 0; k < polys.length; k++) { + const p = polys[k]; + let fill; + if (typeof opts.colorFn === "function") { + try { + fill = opts.colorFn(p.value, p.lambert); + } catch (e) { + fill = null; + } + } + if (!fill) { + const hue = + hueFixed == null ? lerp(hueMin, hueMax, p.value) : hueFixed; + const lightness = clamp(18 + 44 * p.lambert, 8, 78); + fill = `hsla(${hue}, 72%, ${lightness}%, ${alpha})`; + } + ctx.beginPath(); + ctx.moveTo(p.pr[0].x, p.pr[0].y); + ctx.lineTo(p.pr[1].x, p.pr[1].y); + ctx.lineTo(p.pr[2].x, p.pr[2].y); + ctx.lineTo(p.pr[3].x, p.pr[3].y); + ctx.closePath(); + ctx.fillStyle = fill; + ctx.fill(); + if (wire) { + ctx.strokeStyle = "rgba(10, 14, 30, 0.35)"; + ctx.lineWidth = 0.7; + ctx.stroke(); + } + } + ctx.restore(); +} + +function niceStep(range) { + const raw = range / 8; + const mag = Math.pow(10, Math.floor(Math.log10(raw))); + const norm = raw / mag; + let step; + if (norm < 1.5) step = 1; + else if (norm < 3) step = 2; + else if (norm < 7) step = 5; + else step = 10; + return step * mag; +} + +let HELP = null; + +/* Safety net for a common model mistake: referencing `cam` / `view` / `v` + * without creating them first (previously a fatal ReferenceError that burned + * the whole repair budget). These globals provide sane defaults; code that + * properly declares `const cam = H.cam3d({...})` shadows them cleanly, same + * as the `H` global. */ +function seedConvenienceGlobals() { + if (!HELP) return; + self.cam = HELP.cam3d({}); + self.view = HELP.plot2d({}); + self.v = self.view; +} + +/* Wrap the helper object in a Proxy that returns a harmless no-op for any + * helper the model invents but we don't ship. Without this, a single + * `H.spinner()`-style typo blanks the whole frame; with it, the rest of the + * scene still renders and the model just doesn't get that specific helper. */ +function wrapHelpers(h) { + if (typeof Proxy === "undefined") return h; + const proxy = new Proxy(h, { + get(target, key) { + // Real helpers and any symbol access (Symbol.toPrimitive, iterators, …) + // pass straight through — intercepting symbols would break coercion. + if (key in target || typeof key === "symbol") return target[key]; + // An invented helper (`H.spinner()`, `cam.spiral()`, `view.heatmap()`). + // Return a no-op that RETURNS THE HOST so chains keep flowing: + // `cam.invented(...).line(...)` used to throw "Cannot read properties of + // undefined (reading 'line')" and burn the whole repair budget. Now the + // invented call does nothing and `.line(...)` resolves on the real host. + // A bare `H.invented(...)` still just no-ops. + return function chainableNoop() { + return proxy; + }; + }, + }); + return proxy; +} + +/* ------------------------------------------------------------------ */ +/* Frame loop */ +/* ------------------------------------------------------------------ */ + +function compile(code) { + // Important shadowing fix: we used to pass `H` as a function parameter, + // which made `const H = H.H` in the generated code throw a SyntaxError + // ("Identifier 'H' has already been declared") and the whole scene died. + // + // Solution: expose H as a *worker global* instead. Generated code that + // references bare `H` still resolves it through the global scope chain, + // BUT a `const H = ...` declaration in the function body now shadows the + // global cleanly — no syntax error, and the local `H` overrides for the + // rest of that block, which is what the model intended anyway. + // + // ctx is kept as a parameter (we never see the model redeclare it). + self.H = HELP; + /* eslint-disable no-new-func */ + const factory = new Function( + "ctx", + "t", + '"use strict";\n' + code + "\n" + ); + return factory; +} + +// A scene that throws every frame from t=0 is broken and must go to repair. +// One that renders cleanly then throws on a single frame (a NaN at one value of +// `t`, a transient out-of-range index) should NOT — killing it wastes a full +// repair round-trip on a scene that's 99% working. We escalate only when the +// scene never produced a clean frame, or has thrown continuously for ~half a +// second (state is corrupted, not a one-frame blip). +const MAX_CONSECUTIVE_ERRORS = 30; // ~0.5s at 60fps + +// Best-effort: pull the offending source line out of a V8 `new Function` stack +// frame (`:LINE:COL`). The compiled wrapper adds 3 lines ahead of the +// user's code (the `function(ctx,t)` header, the `) {` line, and the injected +// `"use strict";`), so user line = anonLine - 3. Non-V8 engines format stacks +// differently, miss the regex, and yield "" — the repair model then falls back +// to the message + code alone. Handing the model the exact failing line cuts +// repair rounds, especially for terse errors like "Cannot read properties of +// undefined" that don't say *which* access broke. +function offendingLine(stack, code) { + try { + if (!stack || !code) return ""; + const m = /:(\d+):\d+/.exec(stack); + if (!m) return ""; + const lineNo = parseInt(m[1], 10) - 3; // 1-based line within `code` + const lines = code.split("\n"); + if (lineNo < 1 || lineNo > lines.length) return ""; + const text = String(lines[lineNo - 1]).trim(); + return text ? "line " + lineNo + ": " + text : ""; + } catch (e) { + return ""; + } +} + +function tick() { + if (!running) return; + const now = performance.now(); + if (!paused) { + const dt = Math.min(0.05, (now - lastWall) / 1000); // clamp big gaps + simTime += dt * speed; + } + lastWall = now; + + if (sceneFn) { + try { + ctx.clearRect(0, 0, logicalW, logicalH); + sceneFn(ctx, simTime); // H is a global (see compile()) + everRendered = true; + consecutiveErrors = 0; + } catch (err) { + consecutiveErrors++; + if (!everRendered || consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { + running = false; + post({ + type: "runtime-error", + message: String((err && err.message) || err), + stack: String((err && err.stack) || ""), + where: offendingLine(err && err.stack, lastCode), + }); + return; + } + // Transient bad frame: skip drawing it, keep the loop alive. + } + } + + frameCount++; + // Heartbeat on the first clean frame (so the main thread's run() promise + // resolves ~immediately rather than after ~20 frames), then every 20 frames. + if (everRendered && (frameCount === 1 || frameCount % 20 === 0)) { + post({ type: "heartbeat", frame: frameCount, t: simTime }); + } + + loopTimer = setTimeout(tick, FRAME_MS); +} + +/* ------------------------------------------------------------------ */ +/* Message handling */ +/* ------------------------------------------------------------------ */ + +self.onmessage = (e) => { + const m = e.data || {}; + switch (m.type) { + case "init": { + canvas = m.canvas; + dpr = m.dpr || 1; + logicalW = m.width; + logicalH = m.height; + canvas.width = Math.round(logicalW * dpr); + canvas.height = Math.round(logicalH * dpr); + ctx = canvas.getContext("2d"); + ctx.scale(dpr, dpr); + HELP = wrapHelpers(makeHelpers()); + seedConvenienceGlobals(); + post({ type: "ready" }); + break; + } + case "resize": { + if (!canvas) return; + logicalW = m.width; + logicalH = m.height; + // Honor the new DPR when the user drags the window across displays — + // otherwise text/strokes go blurry on a switch into Retina (or aliased + // when going back). + if (typeof m.dpr === "number" && m.dpr > 0) dpr = m.dpr; + canvas.width = Math.round(logicalW * dpr); + canvas.height = Math.round(logicalH * dpr); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.scale(dpr, dpr); + seedConvenienceGlobals(); // defaults capture canvas center/box at creation + break; + } + case "run": { + try { + const fn = compile(m.code); + sceneFn = fn; + lastCode = typeof m.code === "string" ? m.code : ""; + if (m.resetTime !== false) { + simTime = 0; + orbit.yaw = 0; + orbit.pitch = 0; + orbit.zoom = 1; + } + frameCount = 0; + consecutiveErrors = 0; + everRendered = false; + paused = false; + lastWall = performance.now(); + if (!running) { + running = true; + tick(); + } + post({ type: "running" }); + } catch (err) { + post({ + type: "compile-error", + message: String((err && err.message) || err), + }); + } + break; + } + case "pause": + paused = true; + break; + case "resume": + paused = false; + lastWall = performance.now(); + break; + case "speed": + speed = m.value; + break; + case "orbit": + // Camera drag/zoom from the main thread. Applied inside cam3d.project, + // so it affects every 3D scene without the generated code's cooperation. + orbit.yaw += m.dyaw || 0; + orbit.pitch = clamp(orbit.pitch + (m.dpitch || 0), -1.45, 1.45); + if (m.dzoom) orbit.zoom = clamp(orbit.zoom * m.dzoom, 0.35, 4); + break; + case "orbit-reset": + orbit.yaw = 0; + orbit.pitch = 0; + orbit.zoom = 1; + break; + case "stop": + running = false; + if (loopTimer) clearTimeout(loopTimer); + break; + default: + break; + } +}; diff --git a/stem-viz-plugin/skills/stem-viz/scripts/validate_scene.js b/stem-viz-plugin/skills/stem-viz/scripts/validate_scene.js new file mode 100644 index 0000000..c46a3be --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/scripts/validate_scene.js @@ -0,0 +1,206 @@ +#!/usr/bin/env node +/* + * Headless validator for VisualLM scene code. + * + * Reads a scene body (the inside of `function scene(ctx, t) { ... }`) on stdin + * and runs it against a BEHAVIOR-FAITHFUL mock of the worker's `H` helper + * library + a counting 2D context, at several values of `t`. Reports JSON on + * stdout: + * ok — did the body run without throwing at every sampled frame? + * error — first throw's message (null if ok) + * painted — did it issue any content draw call (background/clear excluded)? + * text — did it draw any text (title/labels/readouts)? + * paint — content draw-call count + * + * This lets the Python server validate (and drive repairs) WITHOUT a browser + * round-trip — the slow part of the old loop. + * + * SECURITY: the scene is AI-generated, hence untrusted. It runs in a FRESH, + * EMPTY V8 context via `vm` — no `process`, `require`, `console`, timers, or + * dynamic `import` reach it, and NO host object is passed in (the classic + * `obj.constructor.constructor("return process")()` escape needs a host object + * on the prototype chain; we hand in nothing and read results back only as a + * primitive JSON string). A hard `timeout` kills infinite loops. + * + * The mock mirrors the helper API SURFACE (return shapes + chaining), not the + * pixel internals: legitimate scenes never false-fail, and genuine + * SyntaxError/ReferenceError/TypeError throws are caught exactly as the browser + * sandbox would catch them. Keep the method list in sync with + * sandbox-worker.js's makeHelpers() when helpers are added. + */ +"use strict"; + +const vm = require("vm"); + +// The mock helper library, as source evaluated INSIDE the sandbox context so +// every object's prototype chain stays inside the sandbox (no host leak). No +// backticks in here — this whole thing is a template literal. `var`-declared +// names (H/ctx/cam/view/v/paint/text) persist on the context global. +const SANDBOX_SRC = ` +var paint = 0, text = 0, conscr = 0; +var console = { log: function(){}, warn: function(){}, error: function(){}, info: function(){} }; +var W = 900, H_ = 560, TAU = Math.PI * 2; +// On-screen test (generous margin). A draw whose points all land far outside +// the canvas is invisible — the tell-tale of mixing data and pixel coords +// (e.g. v.line(v.X(x), ...) double-transforms). 'paint' counts CONTENT draws +// (not background/grid/axes scaffold); 'conscr' counts those that land +// on-screen. paint>0 with conscr===0 means "everything was drawn off-screen". +function inb(x, y){ return typeof x === "number" && typeof y === "number" && isFinite(x) && isFinite(y) && x >= -120 && x <= W + 120 && y >= -120 && y <= H_ + 120; } +function onAny(pairs){ for (var i = 0; i < pairs.length; i++) if (inb(pairs[i][0], pairs[i][1])) return true; return false; } +function content(on){ paint++; if (on) conscr++; } +var COLORS = { + bg:"#0e1525", panel:"#16203a", ink:"#eef2ff", sub:"#9fb0d4", grid:"#26314f", + axis:"#566087", accent:"#7cc4ff", accent2:"#f4a259", good:"#67e8b0", + warn:"#ff8aa0", violet:"#c4a7ff", yellow:"#ffe08a" +}; +var PALETTE = ["#7cc4ff","#f4a259","#67e8b0","#c4a7ff","#ff8aa0","#ffe08a","#5eead4","#fca5f1"]; +function clamp(x,lo,hi){ return xhi?hi:x; } +function lerp(a,b,t){ return a+(b-a)*t; } +function map(x,a,b,c,d){ return b===a?c:c+((x-a)*(d-c))/(b-a); } +function ease(t){ t=clamp(t,0,1); return t*t*(3-2*t); } +function wrap(obj){ + return new Proxy(obj, { get: function(t,k){ if(k in t) return t[k]; return function(){ return undefined; }; } }); +} +function makeCtx(){ + var grad = { addColorStop: function(){} }; + var PAINT = { fill:1, stroke:1, fillRect:1, strokeRect:1, fillText:1, strokeText:1, drawImage:1, putImageData:1 }; + var TEXTOP = { fillText:1, strokeText:1 }; + var target = { + canvas: { width: W, height: H_ }, + createLinearGradient: function(){ return grad; }, + createRadialGradient: function(){ return grad; }, + createPattern: function(){ return null; }, + measureText: function(){ return { width: 8 }; }, + getImageData: function(){ return { data: [], width: 0, height: 0 }; }, + setLineDash: function(){}, getLineDash: function(){ return []; } + }; + return new Proxy(target, { + get: function(t,k){ + if(k in t) return t[k]; + if(typeof k !== "string") return undefined; + return function(){ if(PAINT[k]) paint++; if(TEXTOP[k]) text++; return undefined; }; + }, + set: function(){ return true; } + }); +} +function makeH(){ + var H = { + TAU: TAU, PI: Math.PI, colors: COLORS, palette: PALETTE, + clamp: clamp, lerp: lerp, map: map, ease: ease, W: W, H: H_, + clear: function(){}, background: function(){}, + text: function(s,x,y){ text++; content(inb(x,y)); }, + line: function(x1,y1,x2,y2){ content(onAny([[x1,y1],[x2,y2]])); }, + path: function(p){ if(p && p.length>=2) content(onAny(p)); }, + circle: function(x,y){ content(inb(x,y)); }, + rect: function(x,y,w,h){ content(onAny([[x,y],[x+w,y+h]])); }, + arrow: function(x1,y1,x2,y2){ content(onAny([[x1,y1],[x2,y2]])); }, + legend: function(items){ (items||[]).forEach(function(){ text++; }); content(true); }, + color: function(i){ return PALETTE[((i%PALETTE.length)+PALETTE.length)%PALETTE.length]; }, + hsl: function(h,s,l,a){ return "hsla("+h+","+s+"%,"+l+"%,"+(a==null?1:a)+")"; }, + plot2d: function(o){ + o=o||{}; + var xMin=o.xMin==null?-10:o.xMin, xMax=o.xMax==null?10:o.xMax; + var yMin=o.yMin==null?-6:o.yMin, yMax=o.yMax==null?6:o.yMax; + var pad=o.pad==null?46:o.pad; + var box=o.box||{ x:pad, y:pad*0.6, w:W-pad*2, h:H_-pad*1.6 }; + var X=function(v){ return box.x+map(v,xMin,xMax,0,box.w); }; + var Y=function(v){ return box.y+map(v,yMin,yMax,box.h,0); }; + // grid/axes are scaffold (don't count as content); fn maps internally so + // it's reliably on-screen. The data-space methods convert via X/Y then + // check bounds, so a scene that wraps args in v.X()/v.Y() (double map) + // shows up as off-screen content. + var view={ + box:box, xMin:xMin, xMax:xMax, yMin:yMin, yMax:yMax, X:X, Y:Y, + grid:function(){ return view; }, + axes:function(){ text++; return view; }, + fn:function(f){ for(var i=0;i<=12;i++){ try{ f(lerp(xMin,xMax,i/12)); }catch(e){} } content(true); return view; }, + dot:function(x,y){ content(inb(X(x),Y(y))); return view; }, + line:function(x1,y1,x2,y2){ content(onAny([[X(x1),Y(y1)],[X(x2),Y(y2)]])); return view; }, + arrow:function(x1,y1,x2,y2){ content(onAny([[X(x1),Y(y1)],[X(x2),Y(y2)]])); return view; }, + text:function(s,x,y){ text++; content(inb(X(x),Y(y))); return view; }, + circle:function(x,y){ content(inb(X(x),Y(y))); return view; }, + path:function(p){ if(p && p.length){ var q=[]; for(var i=0;i=2){ var q=[]; for(var i=0;i=3){ var q=[]; for(var i=0;i { + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (c) => (data += c)); + process.stdin.on("end", () => resolve(data)); + }); +} + +(async () => { + const code = await readStdin(); + let result = { ok: false, error: null, painted: false, text: false, paint: 0, onscreen: true }; + + // The runner: define the scene as the body of a function (so a scene's own + // `const v = ...` shadows the global v instead of colliding with a + // parameter), call it at several frames, and RETURN a JSON string. Building + // it with string concatenation means the scene's own backticks/quotes need + // no escaping. A `};` injection only lands the attacker back in this same + // empty sandbox — no escalation. + const runner = + SANDBOX_SRC + + "\n;(function(){var __ok=false,__err=null;try{var __s=function(ctx,t){\n" + + code + + "\n};var __ts=[0,0.4,1.3,3.0];for(var __i=0;__i<__ts.length;__i++){__s(ctx,__ts[__i]);}__ok=true;}" + + "catch(e){__ok=false;__err=(e&&e.message)?String(e.message):String(e);}" + + "return JSON.stringify({ok:__ok,error:__err,paint:paint,text:text,conscr:conscr});})()"; + + try { + const context = vm.createContext(Object.create(null)); + const out = vm.runInContext(runner, context, { timeout: 2000 }); + const parsed = JSON.parse(out); + result.ok = !!parsed.ok; + result.error = parsed.error || null; + result.paint = parsed.paint || 0; + result.painted = (parsed.paint || 0) > 0; + result.text = (parsed.text || 0) > 0; + // Content was drawn, but none of it landed on the canvas → the scene is + // effectively blank (almost always a data-vs-pixel coordinate mixup). + result.onscreen = !((parsed.paint || 0) > 0 && (parsed.conscr || 0) === 0); + } catch (err) { + const msg = err && err.message ? String(err.message) : String(err); + if (/timed out|execution timed/i.test(msg)) { + result.error = "The scene hung (possible infinite loop)."; + } else { + result.error = msg; // SyntaxError etc. — compile failed before running + } + } + process.stdout.write(JSON.stringify(result)); +})(); diff --git a/tests/test_main.py b/tests/test_main.py index b9e0635..6101fa6 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -456,6 +456,25 @@ def test_onscreen_content_passes(self): ) self.assertTrue(r["onscreen"]) + def test_unbounded_drift_off_screen_detected(self): + # pos = t*4 with no loop: by the late frame the moving content has + # sailed off the canvas and never returns. + r = main.headless_validate( + "const v = H.plot2d({xMin:-10,xMax:10,yMin:-5,yMax:5}); v.grid(); v.axes();" + " const pos = t*4; for (let i=-5;i<=5;i++){ v.dot(pos+i, 0, {}); }" + " v.text('x='+pos.toFixed(1), v.X(pos), v.Y(2), {});" + ) + self.assertTrue(r["ok"]) + self.assertFalse(r["onscreen"]) + + def test_looping_motion_stays_on_screen(self): + r = main.headless_validate( + "const v = H.plot2d({xMin:-10,xMax:10,yMin:-5,yMax:5}); v.grid(); v.axes();" + " const pos = ((t*4+10)%20)-10; for (let i=-3;i<=3;i++){ v.dot(pos+i*0.3, 0, {}); }" + " H.text('looping', 24, 30, {});" + ) + self.assertTrue(r["onscreen"]) + def test_host_escape_is_contained(self): # process must be unreachable inside the sandbox. r = main.headless_validate( diff --git a/validate_scene.js b/validate_scene.js index c46a3be..d077d9f 100644 --- a/validate_scene.js +++ b/validate_scene.js @@ -174,13 +174,20 @@ function readStdin() { // it with string concatenation means the scene's own backticks/quotes need // no escaping. A `};` injection only lands the attacker back in this same // empty sandbox — no escalation. + // Sample early frames AND a late one (t=30). The late sample catches the + // "drifts off and never loops" bug — e.g. a source at pos = t*4 that has + // sailed off the right edge by the time anyone watches. We record the LAST + // frame's content draws (__lc) and how many landed on-canvas (__ls): if most + // of the final frame's content is off-screen, the animation doesn't hold its + // subject in view. (The scene contract requires looping, bounded motion.) const runner = SANDBOX_SRC + "\n;(function(){var __ok=false,__err=null;try{var __s=function(ctx,t){\n" + code + - "\n};var __ts=[0,0.4,1.3,3.0];for(var __i=0;__i<__ts.length;__i++){__s(ctx,__ts[__i]);}__ok=true;}" + + "\n};var __ts=[0,0.4,1.3,3.0,8.0,30.0];var __lc=0,__ls=0;" + + "for(var __i=0;__i<__ts.length;__i++){var __p0=paint,__s0=conscr;__s(ctx,__ts[__i]);__lc=paint-__p0;__ls=conscr-__s0;}__ok=true;}" + "catch(e){__ok=false;__err=(e&&e.message)?String(e.message):String(e);}" + - "return JSON.stringify({ok:__ok,error:__err,paint:paint,text:text,conscr:conscr});})()"; + "return JSON.stringify({ok:__ok,error:__err,paint:paint,text:text,conscr:conscr,lc:__lc,ls:__ls});})()"; try { const context = vm.createContext(Object.create(null)); @@ -191,9 +198,14 @@ function readStdin() { result.paint = parsed.paint || 0; result.painted = (parsed.paint || 0) > 0; result.text = (parsed.text || 0) > 0; - // Content was drawn, but none of it landed on the canvas → the scene is - // effectively blank (almost always a data-vs-pixel coordinate mixup). - result.onscreen = !((parsed.paint || 0) > 0 && (parsed.conscr || 0) === 0); + // off-screen if: nothing ever landed on-canvas (a data-vs-pixel mixup), OR + // the final frame drew content but <15% of it is on-canvas (content drifted + // off and never loops back). A frame that draws no content isn't penalized. + const lc = parsed.lc || 0; + const ls = parsed.ls || 0; + const neverOnScreen = (parsed.paint || 0) > 0 && (parsed.conscr || 0) === 0; + const driftedOff = lc > 0 && ls * 100 < lc * 15; + result.onscreen = !(neverOnScreen || driftedOff); } catch (err) { const msg = err && err.message ? String(err.message) : String(err); if (/timed out|execution timed/i.test(msg)) { From 883202f157c14ddcbf00b59ed9805cc97b768d38 Mon Sep 17 00:00:00 2001 From: Maxpeng59 Date: Mon, 15 Jun 2026 19:43:52 +0700 Subject: [PATCH 07/17] Catch drifting-subject motion + steer the model away from it up front MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live re-run exposed the subtler case: the local model wrote xSource = -speed*t again, and fixed labels (title, origin marker) kept the last-frame on-screen fraction just above 15%, so the moving subject sailed off-screen undetected. - Validator now also flags a COLLAPSE: content abundant on-screen early (>=5 draws in some early frame) but the late frame retains <40% of that peak => the subject drifted away while only fixed annotations remain. Catches the exact generated Doppler; no false-flags across the 50-scene library. - System prompt Rule 1 now bans `x = speed*t` outright and requires looping/ bounded motion (Math.sin(t), t % period) so moving subjects stay in frame — fixing the root cause for every generation, not just repairs. - 72 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.py | 7 +++++++ tests/test_main.py | 15 +++++++++++++++ validate_scene.js | 18 +++++++++++------- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index 0e3388d..3e0dc5f 100644 --- a/main.py +++ b/main.py @@ -420,6 +420,13 @@ def claude_available() -> dict: the EXPLANATION: sweep the highlight through the parts, pulse the region being discussed, orbit the camera, or step through stages with `const phase = Math.floor(t % 9 / 3);`. + - KEEP MOVING SUBJECTS IN FRAME. Motion must LOOP, never drift away. Do NOT + write `x = speed * t` (the object sails off-screen and never comes back). + Instead oscillate — `x = A * Math.sin(t)` — or wrap — `x = (t * v) % span` + — or reset a phase with `t % period`. A car passing an observer should + loop back and pass again; a wave should keep propagating across the SAME + visible window. If after a few seconds your main subject would leave the + canvas, the scene is rejected. ### Rule 2 — every scene MUST BE LABELED with real values diff --git a/tests/test_main.py b/tests/test_main.py index 6101fa6..2e94041 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -475,6 +475,21 @@ def test_looping_motion_stays_on_screen(self): ) self.assertTrue(r["onscreen"]) + def test_drifting_subject_with_fixed_labels_detected(self): + # The hard case from the live Doppler run: a moving subject + # (xSource = -3*t) drifts off, but fixed annotations (title, origin + # marker) remain. On-screen content COLLAPSES from abundant early to + # sparse late — caught even though a few fixed elements stay visible. + r = main.headless_validate( + "const v = H.plot2d({xMin:-10,xMax:10,yMin:-5,yMax:5}); v.grid(); v.axes();" + " const xs = -3*t; v.dot(0,0,{});" + " for (let i=-3;i<=3;i++){ const x=xs+i*2; if(x>=-10&&x<=10) v.line(x,-0.5,x,0.5,{}); }" + " v.dot(xs,0,{}); v.text('x='+xs.toFixed(1), v.X(xs), v.Y(-0.5), {});" + " H.text('Doppler', 24, 30, {});" + ) + self.assertTrue(r["ok"]) + self.assertFalse(r["onscreen"]) + def test_host_escape_is_contained(self): # process must be unreachable inside the sandbox. r = main.headless_validate( diff --git a/validate_scene.js b/validate_scene.js index d077d9f..cd1316e 100644 --- a/validate_scene.js +++ b/validate_scene.js @@ -184,10 +184,10 @@ function readStdin() { SANDBOX_SRC + "\n;(function(){var __ok=false,__err=null;try{var __s=function(ctx,t){\n" + code + - "\n};var __ts=[0,0.4,1.3,3.0,8.0,30.0];var __lc=0,__ls=0;" + - "for(var __i=0;__i<__ts.length;__i++){var __p0=paint,__s0=conscr;__s(ctx,__ts[__i]);__lc=paint-__p0;__ls=conscr-__s0;}__ok=true;}" + + "\n};var __ts=[0,0.4,1.3,3.0,8.0,30.0];var __lc=0,__ls=0,__em=0;" + + "for(var __i=0;__i<__ts.length;__i++){var __p0=paint,__s0=conscr;__s(ctx,__ts[__i]);__lc=paint-__p0;__ls=conscr-__s0;if(__i<__ts.length-1&&(conscr-__s0)>__em)__em=conscr-__s0;}__ok=true;}" + "catch(e){__ok=false;__err=(e&&e.message)?String(e.message):String(e);}" + - "return JSON.stringify({ok:__ok,error:__err,paint:paint,text:text,conscr:conscr,lc:__lc,ls:__ls});})()"; + "return JSON.stringify({ok:__ok,error:__err,paint:paint,text:text,conscr:conscr,lc:__lc,ls:__ls,em:__em});})()"; try { const context = vm.createContext(Object.create(null)); @@ -198,14 +198,18 @@ function readStdin() { result.paint = parsed.paint || 0; result.painted = (parsed.paint || 0) > 0; result.text = (parsed.text || 0) > 0; - // off-screen if: nothing ever landed on-canvas (a data-vs-pixel mixup), OR - // the final frame drew content but <15% of it is on-canvas (content drifted - // off and never loops back). A frame that draws no content isn't penalized. + // off-screen if any of: (a) nothing ever landed on-canvas (a data-vs-pixel + // mixup); (b) the final frame drew content but <15% is on-canvas; or (c) the + // on-screen content COLLAPSED — abundant early (>=5 on-screen draws in some + // early frame) but the late frame keeps under 40% of that peak, i.e. the + // moving subject drifted away while only fixed labels remain. const lc = parsed.lc || 0; const ls = parsed.ls || 0; + const em = parsed.em || 0; const neverOnScreen = (parsed.paint || 0) > 0 && (parsed.conscr || 0) === 0; const driftedOff = lc > 0 && ls * 100 < lc * 15; - result.onscreen = !(neverOnScreen || driftedOff); + const collapsed = em >= 5 && ls * 10 < em * 4; + result.onscreen = !(neverOnScreen || driftedOff || collapsed); } catch (err) { const msg = err && err.message ? String(err.message) : String(err); if (/timed out|execution timed/i.test(msg)) { From 191e34f97e9dc2d7f1edcee4d9febffb75e98ec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Mon, 15 Jun 2026 19:49:46 +0700 Subject: [PATCH 08/17] Pedagogy: teach the mechanism, don't solve the student's problem Bake the product's teaching philosophy into the generator. Scenes must build understanding of the mechanism (sweep the parameter, explain in labels, provoke prediction) rather than just computing and displaying the answer to a student's specific problem. Applied consistently to SCENE_SYSTEM_PROMPT ("Style and pedagogy") and the stem-viz skill (SKILL.md + concepts.md). Guarded against over-correction: scenes stay concrete and labeled, and a live readout of a CHANGING quantity remains good teaching (it makes the relationship visible). The line is illuminate-the-mechanism vs. hand-over-the-answer. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.py | 20 +++++++++++++++ stem-viz-plugin/skills/stem-viz/SKILL.md | 25 +++++++++++++++++++ .../skills/stem-viz/references/concepts.md | 5 ++++ 3 files changed, 50 insertions(+) diff --git a/main.py b/main.py index 3e0dc5f..0a4978b 100644 --- a/main.py +++ b/main.py @@ -525,6 +525,26 @@ def claude_available() -> dict: ## Style and pedagogy + ### Teach the mechanism — do not just solve their problem + + The point of every scene is to help the learner UNDERSTAND, not to be an + answer key. If the prompt is really a specific problem ("a ball thrown at + 22 m/s at 58 deg, find the range"), do NOT just compute the number and show + it as the answer — that does the student's work for them. Instead reveal the + MECHANISM that produces it: the parabola forming, the velocity components, + why it peaks where it does, so the learner can see the structure and reason + to the result themselves. Prefer the general relationship over a single + instance — SWEEP the parameter (let the point `a`, the angle, or the + harmonic count vary with `t`) so they watch how it behaves across cases, not + only at their one value. Make labels EXPLAIN ("slope = f'(a): watch it flip + sign at the peak"), not just state a result; use the bullets and + student_prompts to provoke prediction, not to spoon-feed the solution. + + This is NOT a license to be vague. Scenes stay concrete, runnable, and + labeled, and a live readout of a CHANGING quantity is good teaching — it + makes the relationship visible. The line: illuminate the mechanism (teach) + vs. hand over the one specific answer to their assigned problem (solve). + - Teach the idea. Label axes, key points, and quantities with `H.text`. - Animate the MECHANISM (a moving particle, sweeping angle, growing sum, propagating wave, rotating object), not just a static picture. diff --git a/stem-viz-plugin/skills/stem-viz/SKILL.md b/stem-viz-plugin/skills/stem-viz/SKILL.md index 5a76d7c..341b452 100644 --- a/stem-viz-plugin/skills/stem-viz/SKILL.md +++ b/stem-viz-plugin/skills/stem-viz/SKILL.md @@ -24,11 +24,36 @@ code can be validated headlessly (`scripts/validate_scene.js`) and run for real the runtime goes, with no server and no dependencies beyond a browser (to render) or Node (to validate). +## Teach the mechanism, don't solve their problem + +This is the goal above all the rules below. These animations exist to help a +learner **understand** a concept — to make it *visible* — not to act as an +answer key. When a prompt is really a specific problem ("a ball thrown at +22 m/s at 58°, find the range"), don't just compute the number and display it as +*the answer* — that does the student's work for them. Show the **mechanism** +that produces it (the parabola forming, the velocity components, why it peaks +where it does) so the learner can see the structure and reason to the result. + +- **Show the relationship, not one instance.** Sweep a parameter with `t` (the + point of tangency, the angle, the harmonic count) so they watch *how* it + behaves across cases — not just the value at their number. +- **Make labels explain, not just state.** `"slope = f'(a): watch it flip sign + at the peak"` teaches; a bare `"answer = 41.2"` doesn't. +- **Provoke, don't spoon-feed.** Use `bullets` and `student_prompts` to invite + prediction ("where is the slope zero?"), not to hand over the solution. + +Not a license to be vague: scenes stay concrete, runnable, and labeled, and a +**live readout of a changing quantity is good** — it makes the relationship +visible. The line is between *illuminating the mechanism* (teach) and +*delivering the one specific answer to their assigned problem* (solve). + ## Workflow 1. **Understand the concept** and what should *move*. The goal is teaching, not decoration — decide the one mechanism the animation should reveal (a sweeping tangent, a propagating wave, a descending optimizer, a rotating molecule). + Reveal that mechanism — don't just compute the prompt's specific answer (see + *Teach the mechanism, don't solve their problem* above). 2. **Pick 2D or 3D.** 3D when the idea lives in space (surfaces `z=f(x,y)`, orbits, molecules, fields in space, multivariable calculus, rotations); 2D for single-variable functions, time series, circuits, graphs/algorithms, planar diff --git a/stem-viz-plugin/skills/stem-viz/references/concepts.md b/stem-viz-plugin/skills/stem-viz/references/concepts.md index 41b0ab1..31cc22e 100644 --- a/stem-viz-plugin/skills/stem-viz/references/concepts.md +++ b/stem-viz-plugin/skills/stem-viz/references/concepts.md @@ -5,6 +5,11 @@ the code — it's choosing **what should move** so the animation reveals the mechanism. This file gives proven recipes for common topics and a general method for anything not listed. +Throughout, the goal is **understanding, not an answer key**: reveal the +*mechanism* and the *general relationship* (sweep the parameter with `t`) rather +than just computing the one specific number a prompt asks for. See *Teach the +mechanism, don't solve their problem* in SKILL.md. + ## The general method (use this for any concept) 1. **Name the mechanism.** What is the one idea? ("the derivative is the tangent's From 64e8118a8f430844f31c03eae9686245f16f6f27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Mon, 15 Jun 2026 19:57:16 +0700 Subject: [PATCH 09/17] Run VisualLM as an app: one-click launcher + native-window option Make it runnable without touching the terminal after a one-time key setup. - launch.py: loads .env, picks a free port, starts main.py, waits until /api/health is green, then opens the UI in a chrome-less app window (Chrome/Edge/Brave --app, else the default browser). Flags: --no-browser, --smoke. - VisualLM.command: macOS double-click wrapper (chmod +x). - desktop.py: optional TRUE native window via pywebview (one pip install). - .env.example: API-key template (.env stays gitignored). - README: new "Run it (as an app)" section + Files entries. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 19 ++++++ README.md | 40 +++++++++---- VisualLM.command | 5 ++ desktop.py | 59 +++++++++++++++++++ launch.py | 148 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 261 insertions(+), 10 deletions(-) create mode 100644 .env.example create mode 100755 VisualLM.command create mode 100755 desktop.py create mode 100755 launch.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7db9fcb --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Copy this file to ".env" (same folder) and fill in your key(s). +# The launcher (launch.py / VisualLM.command / desktop.py) loads it automatically. +# Format is one KEY=VALUE per line. Anything already set in your shell wins. + +# Best-quality animations (recommended). Get a key at https://console.anthropic.com +ANTHROPIC_API_KEY=sk-ant-... + +# Optional alternatives / fallbacks (used only if Anthropic isn't set): +# OPENAI_API_KEY=sk-... +# GEMINI_API_KEY=... + +# No cloud key? VisualLM falls back to a local Ollama model if one is running +# (install from https://ollama.com, then e.g. ollama pull qwen2.5:7b). + +# Optional: pin the port (default 4173). +# VISUALLM_PORT=4173 + +# Optional: require a shared access code to generate (for a published instance). +# VISUALLM_ACCESS_CODE=letmein diff --git a/README.md b/README.md index 88074dc..3320e21 100644 --- a/README.md +++ b/README.md @@ -101,22 +101,36 @@ Generation tries providers in this order — the first one with a key wins: All cloud providers are called over plain REST — no extra SDKs required. -## Run locally +## Run it (as an app) + +The easy way — after a one-time setup, **no terminal needed**: + +1. **Set a key (once).** Copy `.env.example` to `.env` and paste your key: + `ANTHROPIC_API_KEY=sk-ant-...`. For Claude also run `pip install -r requirements.txt`. + No cloud key? Install [Ollama](https://ollama.com) and `ollama pull qwen2.5:7b` + for a local fallback. +2. **Launch it.** + - **macOS:** double-click **`VisualLM.command`** in Finder (first time: right-click → Open to clear the macOS warning). + - **Any OS:** `python3 launch.py` + + The launcher loads `.env`, starts the server on a free port, waits until it's + healthy, and opens VisualLM in its own app-style window. Keep that window open; + Ctrl+C (or closing it) stops everything. + +**Prefer a true native window** (no browser chrome at all)? `pip install pywebview` +then `python3 desktop.py`. + +### Plain manual start + +Equivalent to what the launcher does, if you'd rather drive it yourself: ```bash -# 1. (Recommended) enable at least one cloud generator pip install -r requirements.txt # only needed for Claude export ANTHROPIC_API_KEY=sk-ant-... # and/or OPENAI_API_KEY / GEMINI_API_KEY - -# 2. (Optional) local fallback + tutor: run Ollama with a model -ollama pull qwen2.5:7b - -# 3. Start the app -python3 main.py +ollama pull qwen2.5:7b # optional local fallback + tutor +python3 main.py # then open http://127.0.0.1:4173 ``` -Then open . - ## Deploy to the web The repo is deploy-ready for any Docker host. The server reads `PORT` and @@ -165,6 +179,12 @@ HTTP round-trips against every endpoint (with stubbed generators). - `app.js` — orchestration: sandbox runner, generate→run→repair loop, orbit controls, playback, tutor chat, resources, status. - `index.html` / `styles.css` — app structure and visual design. +- `launch.py` / `VisualLM.command` / `desktop.py` — run VisualLM as an app: + the one-click launcher (loads `.env`, starts the server on a free port, opens + an app-style window), the macOS double-click wrapper, and the optional + native-window version (pywebview). `.env.example` is the key template. +- `stem-viz-plugin/` — the scene-generation capability packaged as a portable + Claude skill + plugin (drop into any AI); see its own README. - `Dockerfile` / `render.yaml` — production deployment (Docker image bundles Node for the validator). - `tests/` — stdlib-only test suite (covers the validator, auto-fixer, diff --git a/VisualLM.command b/VisualLM.command new file mode 100755 index 0000000..13aac0b --- /dev/null +++ b/VisualLM.command @@ -0,0 +1,5 @@ +#!/bin/bash +# Double-click this file in Finder to launch VisualLM like an app. +# (macOS may ask once to confirm running it — right-click → Open the first time.) +cd "$(dirname "$0")" || exit 1 +exec python3 launch.py diff --git a/desktop.py b/desktop.py new file mode 100755 index 0000000..9cb549f --- /dev/null +++ b/desktop.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Run VisualLM as a NATIVE desktop window (no browser chrome at all). + +This is the most "real app" option. It needs one extra package: + + pip install pywebview + python3 desktop.py + +It starts the local server in the background and opens VisualLM in a native OS +window (WKWebView on macOS, WebView2 on Windows, GTK/Qt on Linux). API keys via +.env work exactly as in launch.py. If you don't want the extra dependency, use +`python3 launch.py` instead — it opens an app-style browser window. +""" +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import launch # reuse .env loading, free_port, and the health check + +HERE = Path(__file__).resolve().parent + + +def main() -> int: + try: + import webview # pywebview + except ImportError: + print("This needs pywebview: pip install pywebview") + print("(or just run: python3 launch.py — opens an app-style browser window)") + return 1 + + launch.load_dotenv(HERE / ".env") + forced = os.environ.get("VISUALLM_PORT") + port = int(forced) if forced else launch.free_port(4173) + os.environ["VISUALLM_PORT"] = str(port) + os.environ.setdefault("VISUALLM_HOST", "127.0.0.1") + url = f"http://127.0.0.1:{port}" + + server = subprocess.Popen([sys.executable, str(HERE / "main.py")], cwd=str(HERE)) + try: + if not launch.wait_healthy(f"{url}/api/health"): + print("✗ The server did not start — see output above.") + return 1 + webview.create_window("VisualLM", url, width=1280, height=820, min_size=(900, 600)) + webview.start() # blocks until the window closes (must run on main thread) + finally: + if server.poll() is None: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/launch.py b/launch.py new file mode 100755 index 0000000..79d0278 --- /dev/null +++ b/launch.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""One-click launcher for VisualLM. + +Run it: + python3 launch.py # start the server + open the app in a window + ./VisualLM.command # macOS: just double-click this in Finder + +It loads API keys from a `.env` file next to this script (if present), starts the +local server (main.py), waits until it's healthy, then opens the UI — preferring +a chrome-less "app window" (Chrome / Edge / Brave --app mode) and falling back to +your default browser. Leave the launcher running; Ctrl+C stops everything. + +Flags: + --no-browser start the server but don't open a window + --smoke start, confirm healthy, stop, exit 0 (used for testing) +""" +from __future__ import annotations + +import os +import shutil +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request +import webbrowser +from pathlib import Path + +HERE = Path(__file__).resolve().parent + + +def load_dotenv(path: Path) -> None: + """Load simple KEY=VALUE lines from `.env` into the environment. + + Does NOT override variables already set in the real environment, so an + explicit `export ANTHROPIC_API_KEY=...` still wins over the file. + """ + if not path.exists(): + return + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, val = line.partition("=") + os.environ.setdefault(key.strip(), val.strip().strip('"').strip("'")) + + +def free_port(preferred: int) -> int: + """Return `preferred` if it's bindable, otherwise an OS-chosen free port.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(("127.0.0.1", preferred)) + return preferred + except OSError: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def wait_healthy(url: str, timeout: float = 25.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=1.5) as r: + if r.status == 200: + return True + except (urllib.error.URLError, OSError): + pass + time.sleep(0.25) + return False + + +def open_app_window(url: str) -> None: + """Open `url` in a chrome-less app window if a Chromium browser is present, + else fall back to the default browser.""" + candidates = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + shutil.which("google-chrome"), + shutil.which("google-chrome-stable"), + shutil.which("chromium"), + shutil.which("chromium-browser"), + shutil.which("microsoft-edge"), + shutil.which("brave-browser"), + ] + for c in candidates: + if c and Path(c).exists(): + try: + subprocess.Popen( + [c, f"--app={url}", "--new-window"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return + except OSError: + pass + webbrowser.open(url) + + +def main() -> int: + load_dotenv(HERE / ".env") + + forced = os.environ.get("VISUALLM_PORT") + port = int(forced) if forced else free_port(4173) + os.environ["VISUALLM_PORT"] = str(port) + os.environ.setdefault("VISUALLM_HOST", "127.0.0.1") + url = f"http://127.0.0.1:{port}" + no_browser = "--no-browser" in sys.argv or "--smoke" in sys.argv + smoke = "--smoke" in sys.argv + + if not any(os.environ.get(k) for k in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY")): + print("• No cloud API key found. VisualLM will try a local Ollama model, and") + print(" otherwise show a fallback. For the best animations, put") + print(" ANTHROPIC_API_KEY=sk-ant-... in a .env file next to launch.py") + print(" (copy .env.example to .env). See README for details.\n") + + print(f"• Starting VisualLM on {url} …") + server = subprocess.Popen([sys.executable, str(HERE / "main.py")], cwd=str(HERE)) + try: + if not wait_healthy(f"{url}/api/health"): + print("✗ The server did not become healthy in time — see output above.") + return 1 + print("✓ VisualLM is up.") + if smoke: + print("✓ Smoke check passed.") + return 0 + if no_browser: + print(f" Open {url} in your browser.") + else: + print("• Opening the app window …") + open_app_window(url) + print("\nVisualLM is running. Keep this window open. Press Ctrl+C to stop.\n") + server.wait() + except KeyboardInterrupt: + print("\n• Stopping VisualLM …") + finally: + if server.poll() is None: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 8af5deb9207d4ce240487690e026140325a18c9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Mon, 15 Jun 2026 20:47:05 +0700 Subject: [PATCH 10/17] Fix Pylance type error: narrow node path before subprocess.run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shutil.which("node") is `str | None`, and pyright/Pylance can't carry the non-None proof across the separate node_validator_available() helper (it's a module global), so the subprocess.run arg list was typed `list[str | None]` (reportCallIssue + reportArgumentType). Rebind to a local and guard it inline so it narrows to `str`. Behavior-identical — the inline check is De Morgan-equivalent to the old `not node_validator_available()`. pyright now reports 0 errors on main.py. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 0a4978b..d6af838 100644 --- a/main.py +++ b/main.py @@ -1159,11 +1159,16 @@ def headless_validate(code: str, timeout: float = 6.0) -> dict | None: validator is unavailable or itself failed (so callers skip the gate rather than wrongly reject a scene). """ - if not code or not code.strip() or not node_validator_available(): + # Rebind to a local so the guard narrows it to `str` for the type checker: + # Pylance/pyright can't carry the non-None proof across the separate + # node_validator_available() function (it's a module global). This inline + # check is De Morgan-equivalent to `not node_validator_available()`. + node_bin = _NODE_BIN + if not code or not code.strip() or not node_bin or not _VALIDATOR_PATH.exists(): return None try: proc = subprocess.run( - [_NODE_BIN, str(_VALIDATOR_PATH)], + [node_bin, str(_VALIDATOR_PATH)], input=code.encode("utf-8"), stdout=subprocess.PIPE, stderr=subprocess.PIPE, From 82df16d7af45243e634f3d5cd98197f05b545998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Tue, 16 Jun 2026 18:42:16 +0700 Subject: [PATCH 11/17] Build VisualLM as a real macOS app (double-click .app + custom icon) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make_app.sh assembles VisualLM.app — a proper bundle (Info.plist + a generated multi-resolution .icns from make_icon.py, a dependency-free stdlib PNG writer). Its launcher resolves the repo by the bundle's own location and opens VisualLM in a native window (pywebview if installed) or an app-style browser window otherwise. The python3 path is baked at build time so a Finder launch — which gets a minimal PATH — still finds the right interpreter. The .app is a git-ignored build artifact; run ./make_app.sh to (re)build. A fully self-contained PyInstaller bundle is the next step (in progress). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 9 +++++ make_app.sh | 81 ++++++++++++++++++++++++++++++++++++++++++++ make_icon.py | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100755 make_app.sh create mode 100755 make_icon.py diff --git a/.gitignore b/.gitignore index 18df511..87036b9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,12 @@ __pycache__/ .env .venv/ venv/ + +# macOS app build artifacts (run ./make_app.sh to regenerate) +VisualLM.app/ +VisualLM.iconset/ +VisualLM.png +VisualLM.icns +build/ +dist/ +*.spec.bak diff --git a/make_app.sh b/make_app.sh new file mode 100755 index 0000000..5a060a2 --- /dev/null +++ b/make_app.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# Build VisualLM.app — a real, double-clickable macOS app for VisualLM. +# +# ./make_app.sh +# +# It generates an icon, then assembles a .app bundle that (when double-clicked) +# starts the local server and opens VisualLM in a native window if pywebview is +# installed (`pip install pywebview`), otherwise an app-style browser window. +# +# The bundle lives next to this script and resolves the repo by its own +# location, so keep VisualLM.app inside the repo folder. It's a build artifact +# (git-ignored); re-run this script to rebuild. macOS only (needs iconutil/sips). +set -euo pipefail +cd "$(dirname "$0")" + +APP="VisualLM.app" +PYBIN="$(command -v python3 || true)" +[ -n "$PYBIN" ] || { echo "python3 not found on PATH"; exit 1; } + +echo "• Generating icon …" +python3 make_icon.py VisualLM.png >/dev/null +ICONSET="VisualLM.iconset"; rm -rf "$ICONSET"; mkdir "$ICONSET" +# Exact names iconutil expects (logical size + @2x retina variant). +gen() { sips -z "$2" "$2" VisualLM.png --out "$ICONSET/$1" >/dev/null; } +gen icon_16x16.png 16 +gen icon_16x16@2x.png 32 +gen icon_32x32.png 32 +gen icon_32x32@2x.png 64 +gen icon_128x128.png 128 +gen icon_128x128@2x.png 256 +gen icon_256x256.png 256 +gen icon_256x256@2x.png 512 +gen icon_512x512.png 512 +gen icon_512x512@2x.png 1024 +iconutil -c icns "$ICONSET" -o VisualLM.icns + +echo "• Assembling $APP …" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp VisualLM.icns "$APP/Contents/Resources/VisualLM.icns" + +cat > "$APP/Contents/Info.plist" < + + + + CFBundleNameVisualLM + CFBundleDisplayNameVisualLM + CFBundleIdentifiercom.visuallm.app + CFBundleVersion1.0 + CFBundleShortVersionString1.0 + CFBundlePackageTypeAPPL + CFBundleExecutableVisualLM + CFBundleIconFileVisualLM + NSHighResolutionCapable + LSMinimumSystemVersion10.13 + + +PLIST + +# The launcher. PYBIN is baked at build time because a Finder-launched app gets +# a minimal PATH that usually won't include a pyenv/framework python3. +cat > "$APP/Contents/MacOS/VisualLM" <> "\$LOG" +# Native window via pywebview if available; otherwise an app-style browser window. +"\$PY" desktop.py >> "\$LOG" 2>&1 || "\$PY" launch.py >> "\$LOG" 2>&1 +LAUNCH +chmod +x "$APP/Contents/MacOS/VisualLM" + +rm -rf "$ICONSET" VisualLM.png VisualLM.icns +touch "$APP" # nudge Finder to refresh the icon + +echo "✓ Built $APP" +echo " Double-click it in Finder. First launch: right-click → Open (clears the" +echo " macOS 'unidentified developer' warning once). For a true native window," +echo " 'pip install pywebview' first; otherwise it opens an app-style window." diff --git a/make_icon.py b/make_icon.py new file mode 100755 index 0000000..110bd12 --- /dev/null +++ b/make_icon.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Generate the VisualLM app icon (1024x1024 PNG) with only the standard library. + +Design echoes the app's "breathing circle" motif: a dark gradient rounded square +with two concentric accent rings and a bright center dot. Output: VisualLM.png +(make_app.sh downsamples it into an .iconset and runs iconutil -> .icns). + +Usage: python3 make_icon.py [out.png] +""" +from __future__ import annotations + +import math +import struct +import sys +import zlib + +SIZE = 1024 + + +def _hex(h: str) -> tuple[int, int, int]: + h = h.lstrip("#") + return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + + +BG_TOP, BG_BOT = _hex("#16203a"), _hex("#0b1120") +ACCENT, ACCENT2, INK = _hex("#7cc4ff"), _hex("#f4a259"), _hex("#eef2ff") + + +def _lerp(a: float, b: float, t: float) -> float: + return a + (b - a) * t + + +def _png(width: int, height: int, raw: bytes) -> bytes: + def chunk(typ: bytes, data: bytes) -> bytes: + body = typ + data + return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF) + + ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) # 8-bit RGBA + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", zlib.compress(raw, 9)) + + chunk(b"IEND", b"") + ) + + +def build() -> bytes: + half = SIZE / 2.0 + cr = SIZE * 0.235 # corner radius + flat = half - cr # half-extent of the straight edges + r1, w1 = SIZE * 0.300, SIZE * 0.024 # outer ring radius / half-width + r2, w2 = SIZE * 0.200, SIZE * 0.020 # inner ring + rdot = SIZE * 0.072 # center dot radius + rows = bytearray() + for y in range(SIZE): + rows.append(0) # PNG filter byte (None) per scanline + ty = y / (SIZE - 1) + bg = ( + _lerp(BG_TOP[0], BG_BOT[0], ty), + _lerp(BG_TOP[1], BG_BOT[1], ty), + _lerp(BG_TOP[2], BG_BOT[2], ty), + ) + dyc = y - half + for x in range(SIZE): + # Rounded-square mask (hard edge; sips downscaling anti-aliases it). + ex = abs(x - half) - flat + ey = abs(dyc) - flat + ex = ex if ex > 0 else 0.0 + ey = ey if ey > 0 else 0.0 + if ex * ex + ey * ey > cr * cr: + rows += b"\x00\x00\x00\x00" + continue + r, g, b = bg + d = math.hypot(x - half, dyc) + a1 = 1.0 - abs(d - r1) / w1 + if a1 > 0: + r = _lerp(r, ACCENT[0], a1); g = _lerp(g, ACCENT[1], a1); b = _lerp(b, ACCENT[2], a1) + a2 = 1.0 - abs(d - r2) / w2 + if a2 > 0: + r = _lerp(r, ACCENT2[0], a2); g = _lerp(g, ACCENT2[1], a2); b = _lerp(b, ACCENT2[2], a2) + if d < rdot: + r, g, b = INK + rows += bytes((int(r), int(g), int(b), 255)) + return _png(SIZE, SIZE, bytes(rows)) + + +def main() -> int: + out = sys.argv[1] if len(sys.argv) > 1 else "VisualLM.png" + with open(out, "wb") as f: + f.write(build()) + print(f"wrote {out} ({SIZE}x{SIZE})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From ef63a25251a9a5e71b6f9c0e1f8f444d8582521d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Tue, 16 Jun 2026 18:50:10 +0700 Subject: [PATCH 12/17] App foundation: in-process entry point + frozen-aware asset paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app_main.py runs the VisualLM server IN-PROCESS (server in a daemon thread, window on the main thread) instead of spawning `python main.py` — required for a PyInstaller bundle, where sys.executable is the app binary, not a Python interpreter. main.py's BASE_DIR now resolves to sys._MEIPASS when frozen so the static assets + validate_scene.js load from the bundle (no-op unfrozen). Opens a native pywebview window if installed, else an app-style browser window. Verified: `app_main.py --smoke` serves /api/health; 72/72 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- app_main.py | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++ main.py | 9 +++++- 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100755 app_main.py diff --git a/app_main.py b/app_main.py new file mode 100755 index 0000000..2709d5c --- /dev/null +++ b/app_main.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""VisualLM native-app entry point — runs the server IN-PROCESS and opens a window. + +This is what PyInstaller freezes into VisualLM.app, and it also works unfrozen +(`python3 app_main.py`). Unlike launch.py / desktop.py — which spawn `python +main.py` as a subprocess for the dev workflow — this imports the server and runs +it in a background thread, because a frozen app's `sys.executable` is the app +binary, not a Python interpreter, so spawning a subprocess can't work once bundled. + +Flags: + --no-window start the server only (print the URL, keep serving) + --smoke start, confirm /api/health, stop, exit 0 (for testing) +""" +from __future__ import annotations + +import os +import sys +import threading +from http.server import ThreadingHTTPServer +from pathlib import Path + +import launch # reuse load_dotenv / free_port / wait_healthy / open_app_window + +HERE = Path(__file__).resolve().parent + + +def main() -> int: + launch.load_dotenv(HERE / ".env") + forced = os.environ.get("VISUALLM_PORT") + port = int(forced) if forced else launch.free_port(4173) + os.environ["VISUALLM_PORT"] = str(port) + os.environ.setdefault("VISUALLM_HOST", "127.0.0.1") + url = f"http://127.0.0.1:{port}" + + # Importing the server module is side-effect-free (its server start is under + # an `if __name__ == "__main__"` guard); we drive it ourselves below. + import main as server_mod + + httpd = ThreadingHTTPServer(("127.0.0.1", port), server_mod.VisualLMHandler) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + + smoke = "--smoke" in sys.argv + no_window = smoke or "--no-window" in sys.argv + try: + if not launch.wait_healthy(f"{url}/api/health"): + print("✗ Server did not become healthy.") + return 1 + print(f"✓ VisualLM is up on {url}") + if smoke: + print("✓ Smoke check passed.") + return 0 + if no_window: + print(f" Open {url} in your browser. Ctrl+C to stop.") + _block() + return 0 + try: + import webview # pywebview → true native window + except ImportError: + print("• pywebview not installed — opening an app-style browser window.") + print(" (pip install pywebview for a true native window.)") + launch.open_app_window(url) + _block() + return 0 + webview.create_window("VisualLM", url, width=1280, height=820, min_size=(900, 600)) + webview.start() # blocks on the main thread until the window closes + return 0 + finally: + httpd.shutdown() + + +def _block() -> None: + try: + threading.Event().wait() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/main.py b/main.py index d6af838..fc42db1 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ import re import shutil import subprocess +import sys import textwrap import threading import time @@ -21,7 +22,13 @@ from pathlib import Path -BASE_DIR = Path(__file__).resolve().parent +# When bundled by PyInstaller, the static assets + validate_scene.js live in the +# unpacked bundle dir (sys._MEIPASS), not beside a source file. Harmless when +# not frozen (falls back to this file's directory). +if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + BASE_DIR = Path(sys._MEIPASS) +else: + BASE_DIR = Path(__file__).resolve().parent # Hard caps for the in-memory resource store (single-user local app). MAX_RESOURCES = 12 From ad559a3b0550cba84f820e28ea1be95b10e1e54f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Tue, 16 Jun 2026 19:00:39 +0700 Subject: [PATCH 13/17] Self-contained app: PyInstaller bundle (dist/VisualLM.app) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a committed PyInstaller spec + build_app.sh that freeze app_main.py into a standalone double-click VisualLM.app — bundles Python, the static assets, validate_scene.js, and the scene library; runs the server in-process and serves from the bundle (BASE_DIR -> sys._MEIPASS). Verified: the frozen binary's `--smoke` boots the server and serves index.html from the bundle (~22 MB app). Also: app_main.py --smoke now confirms static assets serve (the real bundle-path test), and the packaged app reads .env from next to VisualLM.app or ~/.visuallm/ (when frozen, its __file__ is inside the bundle). Build artifacts (build/, dist/) stay git-ignored. 72/72 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 7 +++++ VisualLM.spec | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++ app_main.py | 31 ++++++++++++++++++--- build_app.sh | 38 ++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 VisualLM.spec create mode 100755 build_app.sh diff --git a/README.md b/README.md index 3320e21..8afcecb 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,13 @@ The easy way — after a one-time setup, **no terminal needed**: **Prefer a true native window** (no browser chrome at all)? `pip install pywebview` then `python3 desktop.py`. +**Want a real, self-contained app** (bundles Python — no terminal, no repo needed +to run)? `./build_app.sh` produces **`dist/VisualLM.app`** via PyInstaller; move it +to /Applications and double-click. It runs the server in-process (`app_main.py`) +and serves bundled assets. For Claude inside the bundle, `pip install anthropic` +before building; for a native window, `pip install pywebview` before building. +The packaged app reads a `.env` placed next to `VisualLM.app` or in `~/.visuallm/`. + ### Plain manual start Equivalent to what the launcher does, if you'd rather drive it yourself: diff --git a/VisualLM.spec b/VisualLM.spec new file mode 100644 index 0000000..0eaf99f --- /dev/null +++ b/VisualLM.spec @@ -0,0 +1,74 @@ +# -*- mode: python ; coding: utf-8 -*- +# PyInstaller spec — freezes VisualLM into a self-contained, double-click macOS app. +# +# ./build_app.sh # regenerates the icon, then runs `pyinstaller VisualLM.spec` +# open dist/VisualLM.app +# +# The frozen app runs the server in-process (app_main.py) and serves the bundled +# static assets via main.py's BASE_DIR -> sys._MEIPASS. It opens a native +# pywebview window if pywebview was importable at build time, else an app-style +# browser window. To include Claude, `pip install anthropic` before building; +# otherwise the bundle uses OpenAI / Gemini / Ollama (all stdlib HTTP). +from pathlib import Path + +# Static + data files the server reads at runtime, placed at the bundle root. +_ASSETS = [ + "index.html", "app.js", "sandbox-worker.js", "styles.css", + "validate_scene.js", "scene_library.py", "scene_library_generated.json", +] +datas = [(a, ".") for a in _ASSETS if Path(a).exists()] + +# pywebview is optional: include its backend only if it's installed, so the +# build works without it (app_main.py falls back to a browser window). +hiddenimports = ["main", "launch"] +try: + import webview # noqa: F401 + hiddenimports.append("webview") +except ImportError: + pass + +icon = "VisualLM.icns" if Path("VisualLM.icns").exists() else None + +a = Analysis( + ["app_main.py"], + pathex=["."], + binaries=[], + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + runtime_hooks=[], + excludes=[], + noarchive=False, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="VisualLM", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, # windowed app — no terminal + argv_emulation=False, + icon=icon, +) +coll = COLLECT(exe, a.binaries, a.datas, strip=False, upx=False, name="VisualLM") + +app = BUNDLE( + coll, + name="VisualLM.app", + icon=icon, + bundle_identifier="com.visuallm.app", + info_plist={ + "CFBundleName": "VisualLM", + "CFBundleDisplayName": "VisualLM", + "CFBundleShortVersionString": "1.0", + "CFBundleVersion": "1.0", + "NSHighResolutionCapable": True, + "LSMinimumSystemVersion": "10.13", + }, +) diff --git a/app_main.py b/app_main.py index 2709d5c..01330cc 100755 --- a/app_main.py +++ b/app_main.py @@ -24,8 +24,24 @@ HERE = Path(__file__).resolve().parent +def _env_files() -> list[Path]: + """Where to look for a .env. When frozen (inside VisualLM.app) __file__ is + in the bundle, so also check next to the .app and a per-user config dir — + that's where a user can drop ANTHROPIC_API_KEY=... for the packaged app.""" + if getattr(sys, "frozen", False): + out: list[Path] = [] + exe = Path(sys.executable).resolve() + # .../VisualLM.app/Contents/MacOS/VisualLM -> the folder holding the .app + if len(exe.parents) >= 4: + out.append(exe.parents[3] / ".env") + out.append(Path.home() / ".visuallm" / ".env") + return out + return [HERE / ".env"] + + def main() -> int: - launch.load_dotenv(HERE / ".env") + for env_file in _env_files(): + launch.load_dotenv(env_file) forced = os.environ.get("VISUALLM_PORT") port = int(forced) if forced else launch.free_port(4173) os.environ["VISUALLM_PORT"] = str(port) @@ -47,8 +63,17 @@ def main() -> int: return 1 print(f"✓ VisualLM is up on {url}") if smoke: - print("✓ Smoke check passed.") - return 0 + # Also confirm static assets serve — the real test that bundled data + # files (index.html etc.) resolve via BASE_DIR when frozen. + import urllib.request + try: + with urllib.request.urlopen(url + "/", timeout=3) as r: + served = r.status == 200 and b"VisualLM" in r.read() + except Exception: # noqa: BLE001 + served = False + print("✓ static assets served (index.html)" if served else "✗ static assets NOT served") + print("✓ Smoke check passed." if served else "✗ Smoke check FAILED.") + return 0 if served else 1 if no_window: print(f" Open {url} in your browser. Ctrl+C to stop.") _block() diff --git a/build_app.sh b/build_app.sh new file mode 100755 index 0000000..497e017 --- /dev/null +++ b/build_app.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Build a SELF-CONTAINED, double-click VisualLM.app with PyInstaller. +# +# ./build_app.sh +# open dist/VisualLM.app +# +# Unlike make_app.sh (a lightweight wrapper that needs the repo + system Python), +# this produces a standalone bundle that includes Python and all dependencies. +# To bundle Claude support, `pip install anthropic` first; otherwise the app +# uses OpenAI / Gemini / Ollama. For a true native window, `pip install pywebview` +# before building; otherwise it opens an app-style browser window. +# +# macOS only (uses sips/iconutil for the icon). Output: dist/VisualLM.app +set -euo pipefail +cd "$(dirname "$0")" + +echo "• Ensuring PyInstaller is available …" +python3 -c "import PyInstaller" 2>/dev/null || python3 -m pip install --disable-pip-version-check pyinstaller + +echo "• Generating icon …" +python3 make_icon.py VisualLM.png >/dev/null +ICONSET="VisualLM.iconset"; rm -rf "$ICONSET"; mkdir "$ICONSET" +gen() { sips -z "$2" "$2" VisualLM.png --out "$ICONSET/$1" >/dev/null; } +gen icon_16x16.png 16; gen icon_16x16@2x.png 32 +gen icon_32x32.png 32; gen icon_32x32@2x.png 64 +gen icon_128x128.png 128; gen icon_128x128@2x.png 256 +gen icon_256x256.png 256; gen icon_256x256@2x.png 512 +gen icon_512x512.png 512; gen icon_512x512@2x.png 1024 +iconutil -c icns "$ICONSET" -o VisualLM.icns +rm -rf "$ICONSET" VisualLM.png + +echo "• Freezing app with PyInstaller …" +python3 -m PyInstaller --noconfirm --clean VisualLM.spec + +rm -f VisualLM.icns +echo "✓ Built dist/VisualLM.app" +echo " Verify: dist/VisualLM.app/Contents/MacOS/VisualLM --smoke" +echo " Run: open dist/VisualLM.app" From c5e56926986929fa08822866f8f0820703e73d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Tue, 16 Jun 2026 19:08:12 +0700 Subject: [PATCH 14/17] Test coverage for the app launcher (launch.py + app_main) Add tests/test_launcher.py (stdlib, headless): .env loading (quote-stripping, skipping comments/junk lines, never overriding an existing shell env var, missing-file no-op), free_port (returns the preferred port when free; falls back to a bindable port when it's taken), wait_healthy (true against a live 200 server, false on a dead port within the timeout), and app_main._env_files (unfrozen -> local .env; frozen -> alongside VisualLM.app + ~/.visuallm). The new launcher/app code was previously untested. Suite 72 -> 81, green. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_launcher.py | 137 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 tests/test_launcher.py diff --git a/tests/test_launcher.py b/tests/test_launcher.py new file mode 100644 index 0000000..5f84f99 --- /dev/null +++ b/tests/test_launcher.py @@ -0,0 +1,137 @@ +"""Tests for the app launcher (launch.py) and native-app entry (app_main.py). + +Stdlib only, headless: covers .env loading, free-port selection, the health +poll, and the frozen-vs-source .env search-path logic — the glue that runs +`python3 launch.py`, the VisualLM.app wrapper, and the PyInstaller bundle. +""" +from __future__ import annotations + +import os +import socket +import sys +import tempfile +import threading +import unittest +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import app_main # noqa: E402 +import launch # noqa: E402 + + +class LoadDotenvTests(unittest.TestCase): + def setUp(self): + self._saved = dict(os.environ) + + def tearDown(self): + os.environ.clear() + os.environ.update(self._saved) + + def test_loads_keys_strips_quotes_skips_junk(self): + with tempfile.TemporaryDirectory() as d: + p = Path(d) / ".env" + p.write_text('# comment\nFOO_T=bar\nQUOTED="hello world"\n\nNO_EQUALS\n') + for k in ("FOO_T", "QUOTED"): + os.environ.pop(k, None) + launch.load_dotenv(p) + self.assertEqual(os.environ.get("FOO_T"), "bar") + self.assertEqual(os.environ.get("QUOTED"), "hello world") + self.assertNotIn("NO_EQUALS", os.environ) + + def test_does_not_override_existing_env(self): + with tempfile.TemporaryDirectory() as d: + p = Path(d) / ".env" + p.write_text("FOO_T2=fromfile\n") + os.environ["FOO_T2"] = "preset" + launch.load_dotenv(p) + self.assertEqual(os.environ["FOO_T2"], "preset") # shell wins + + def test_missing_file_is_noop(self): + launch.load_dotenv(Path("/no/such/.env")) # must not raise + + +class FreePortTests(unittest.TestCase): + @staticmethod + def _an_unused_port() -> int: + s = socket.socket() + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + return port + + def test_returns_preferred_when_free(self): + port = self._an_unused_port() + self.assertEqual(launch.free_port(port), port) + + def test_falls_back_when_preferred_is_taken(self): + held = socket.socket() + held.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + held.bind(("127.0.0.1", 0)) + held.listen(1) + taken = held.getsockname()[1] + try: + got = launch.free_port(taken) + self.assertNotEqual(got, taken) + probe = socket.socket() # the returned port must be bindable + probe.bind(("127.0.0.1", got)) + probe.close() + finally: + held.close() + + +class WaitHealthyTests(unittest.TestCase): + def test_true_when_server_responds_200(self): + class _H(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b"ok") + + def log_message(self, *a): + pass + + srv = HTTPServer(("127.0.0.1", 0), _H) + port = srv.server_address[1] + threading.Thread(target=srv.serve_forever, daemon=True).start() + try: + self.assertTrue(launch.wait_healthy(f"http://127.0.0.1:{port}/health", timeout=5)) + finally: + srv.shutdown() + srv.server_close() + + def test_false_when_nothing_listens(self): + s = socket.socket() + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + self.assertFalse(launch.wait_healthy(f"http://127.0.0.1:{port}/health", timeout=1.0)) + + +class EnvFilesTests(unittest.TestCase): + def test_unfrozen_uses_local_env(self): + self.assertFalse(getattr(sys, "frozen", False)) + self.assertEqual(app_main._env_files(), [app_main.HERE / ".env"]) + + def test_frozen_searches_alongside_app_and_user_dir(self): + had_frozen = hasattr(sys, "frozen") + orig_frozen = getattr(sys, "frozen", None) + orig_exe = sys.executable + sys.frozen = True + sys.executable = str(Path(tempfile.gettempdir()) / "X/VisualLM.app/Contents/MacOS/VisualLM") + try: + files = app_main._env_files() + finally: + sys.executable = orig_exe + if had_frozen: + sys.frozen = orig_frozen + else: + del sys.frozen + self.assertEqual(len(files), 2) + self.assertEqual(files[0].name, ".env") + self.assertIn(Path.home() / ".visuallm" / ".env", files) + + +if __name__ == "__main__": + unittest.main() From c01b413f54a3c2862ae0fe05dd59358846286506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Tue, 16 Jun 2026 19:18:41 +0700 Subject: [PATCH 15/17] Fix auto-fixer corrupting scenes that declare their own PI/TAU/math-fn names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit autofix_code blindly prefixed every bare PI / TAU / Math-fn name. A scene that aliases one — `const PI = Math.PI` or `const TAU = 6.28` — was rewritten to `const Math.PI = ...` (a SYNTAX error), and a local `function log(){}` / `const log = ...` became `Math.log`. Now we collect the names the scene declares itself (const/let/var/function) and never rewrite those; genuinely bare calls and constants are still fixed. Regression test added; suite 81 -> 82, green. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.py | 31 ++++++++++++++++++++++++------- tests/test_main.py | 18 ++++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index fc42db1..afffc79 100644 --- a/main.py +++ b/main.py @@ -1112,25 +1112,42 @@ def _apply_outside_strings(code: str, transform) -> str: return "".join(out) -def _autofix_math(segment: str) -> str: - segment = _MATH_FN_RE.sub(r"Math.\1\2", segment) - segment = _BARE_PI_RE.sub("Math.PI", segment) - segment = _BARE_TAU_RE.sub("H.TAU", segment) +def _autofix_math(segment: str, declared: frozenset = frozenset()) -> str: + # Never rewrite a name the scene declares itself: a local `const PI = ...` + # must not become `const Math.PI = ...` (a syntax error), and a local + # `function log(){}` must not be rewritten to `Math.log`. + def _fn(m): + name = m.group(1) + return m.group(0) if name in declared else "Math." + name + m.group(2) + + segment = _MATH_FN_RE.sub(_fn, segment) + if "PI" not in declared: + segment = _BARE_PI_RE.sub("Math.PI", segment) + if "TAU" not in declared: + segment = _BARE_TAU_RE.sub("H.TAU", segment) return segment +# Names the scene declares for itself — excluded from the bare-Math rewrite so +# we never corrupt an alias like `const PI = Math.PI` or a local `function sin`. +_DECLARED_RE = re.compile(r"\b(?:const|let|var|function)\s+([A-Za-z_$][\w$]*)") + + def autofix_code(code: str) -> str: """Mechanically fix high-confidence errors without a model round-trip. Currently: prefix bare Math functions/constants (the dominant "X is not defined" cause). Applied outside strings/comments so labels and - comments are never corrupted. Idempotent — running it on already-correct - code is a no-op. + comments are never corrupted, and never to a name the scene declares itself + (so a local `const PI`/`const TAU`/`function log` is left intact rather than + rewritten into invalid syntax or the wrong call). Idempotent — running it on + already-correct code is a no-op. """ if not code: return code try: - return _apply_outside_strings(code, _autofix_math) + declared = frozenset(_DECLARED_RE.findall(code)) + return _apply_outside_strings(code, lambda seg: _autofix_math(seg, declared)) except re.error: return code diff --git a/tests/test_main.py b/tests/test_main.py index 2e94041..5ccb5ac 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -400,6 +400,24 @@ def test_idempotent(self): once = main.autofix_code("r = sqrt(2);") self.assertEqual(once, main.autofix_code(once)) + def test_does_not_corrupt_locally_declared_names(self): + # A scene that declares its own PI/TAU/function must be left intact — + # rewriting `const PI` -> `const Math.PI` is a syntax error, and a local + # `log(...)` is the scene's function, not Math.log. + self.assertEqual( + main.autofix_code("const PI = Math.PI; const r = PI * 2;"), + "const PI = Math.PI; const r = PI * 2;", + ) + self.assertEqual( + main.autofix_code("const TAU = 6.28; const a = TAU;"), + "const TAU = 6.28; const a = TAU;", + ) + out = main.autofix_code("const log = (x) => x + 1; const y = log(5);") + self.assertNotIn("Math.log", out) + # ...but a genuinely bare call/constant (no local decl) is still fixed. + self.assertIn("Math.sin(", main.autofix_code("r = sin(t);")) + self.assertIn("Math.PI", main.autofix_code("a = 2 * PI;")) + def test_sanitize_runs_autofix(self): self.assertIn("Math.sin", main.sanitize_code("H.background(); const y = sin(t);")) From 0a346906e9b1e5c735b9e9c9f8ee97c78b619bb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Tue, 16 Jun 2026 19:28:21 +0700 Subject: [PATCH 16/17] Fix sanitize_code corrupting t/H/ctx used as call or array values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _rewrite_declared_names renamed a reserved binding (H/ctx/t) after ANY comma in a const/let/var span — including expression commas inside ()/[]/{}. So a very common pattern like `const y = H.lerp(a, b, t);` became `H.lerp(a, b, _t)` (an undefined reference), and `const p = [Math.cos(t), t]` lost its `t` — silently feeding the server-side repair loop and burning round-trips. The docstring already said "top level of the statement" but the regex never enforced bracket depth. Replaced the naive regex with a depth-aware scan that renames only genuine declarators (at depth 0, right after const/let/var or a top-level comma). Real redeclarations still rename (`const W = H.W, H = H.H` -> `_H`; `let H, ctx` -> `let _H, _ctx`). Also removed a SyntaxWarning (a `\s` in a non-raw docstring). 2 regression tests; suite 82 -> 84, green. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.py | 69 +++++++++++++++++++++++++++++++++++++++++----- tests/test_main.py | 17 ++++++++++++ 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index afffc79..1225f86 100644 --- a/main.py +++ b/main.py @@ -1230,14 +1230,69 @@ def _rewrite_declared_names(span: str) -> str: const W = H.W, H = H.H; (the multi-decl that causes TDZ) const W = H.W,\n H = H.H; (multi-line) - A binding identifier is one that appears either: - - right after `const|let|var ` (the first declarator), or - - right after a comma at the top level of the statement. + A binding is a reserved name at bracket depth 0 that is either the first + declarator (right after const/let/var) or follows a top-level comma. This is + DEPTH-AWARE on purpose: a reserved name used as a VALUE inside ()/[]/{} — + `H.lerp(a, b, t)`, `[x, t]`, `H.map(x, 0, 10, t)` — is NOT a declarator and + must be left alone, or it becomes an undefined reference (`_t`). The old + comma-matching regex renamed those and silently broke very common scenes. """ - pattern = re.compile( - r"(\b(?:const|let|var)\s+|,\s*)(" + "|".join(_RESERVED_BINDINGS) + r")\b" - ) - return pattern.sub(lambda m: f"{m.group(1)}_{m.group(2)}", span) + m = re.match(r"\s*(?:const|let|var)\b", span) + if not m: + return span + reserved = set(_RESERVED_BINDINGS) + out = [span[: m.end()]] + i, n = m.end(), len(span) + depth = 0 + quote = None # active string/template delimiter, or None + expect = True # the next depth-0 identifier is a declarator binding + while i < n: + ch = span[i] + if quote is not None: + out.append(ch) + if ch == "\\" and i + 1 < n: + out.append(span[i + 1]) + i += 2 + continue + if ch == quote: + quote = None + i += 1 + continue + if ch in "\"'`": + quote = ch + out.append(ch) + i += 1 + continue + if ch in "([{": + depth += 1 + expect = False + out.append(ch) + i += 1 + continue + if ch in ")]}": + depth -= 1 + out.append(ch) + i += 1 + continue + if ch == "," and depth == 0: + expect = True + out.append(ch) + i += 1 + continue + if expect and depth == 0 and (ch.isalpha() or ch in "_$"): + j = i + while j < n and (span[j].isalnum() or span[j] in "_$"): + j += 1 + ident = span[i:j] + out.append("_" + ident if ident in reserved else ident) + expect = False + i = j + continue + if not ch.isspace(): + expect = False + out.append(ch) + i += 1 + return "".join(out) def sanitize_code(code: object) -> str: diff --git a/tests/test_main.py b/tests/test_main.py index 5ccb5ac..2e6fef7 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -40,6 +40,23 @@ def test_rewrites_reserved_bindings(self): self.assertIn("_H = H.H", out) self.assertNotIn(", H =", out) + def test_keeps_real_redeclarations_renamed(self): + # Genuine TDZ/shadow declarations must still be renamed. + self.assertEqual(main.sanitize_code("const W = H.W, H = H.H;"), "const W = H.W, _H = H.H;") + self.assertEqual(main.sanitize_code("let H, ctx;"), "let _H, _ctx;") + + def test_does_not_rewrite_reserved_used_as_values(self): + # t/H/ctx used as a VALUE inside a declaration's expression (after a + # comma within ()/[]) is NOT a declarator — renaming it to _t/_H would + # create an undefined reference and break a very common scene pattern. + for code in ( + "const y = H.lerp(a, b, t);", + "const p = [Math.cos(t), t];", + "const v = H.map(x, 0, 10, t);", + "const r = Math.sin(t);", + ): + self.assertEqual(main.sanitize_code(code), code) + def test_strips_import_lines(self): out = main.sanitize_code("import x from 'y';\nH.background();") self.assertNotIn("import", out) From 46434de83f97896217d42d53d27d27a78b7459eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E6=95=AC=E8=BF=9B?= Date: Tue, 16 Jun 2026 19:35:27 +0700 Subject: [PATCH 17/17] Lock sanitize_code unwrap behavior with edge-case tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `function scene(...) { ... }` unwrap (rfind-based) and the fence strip run on every generated scene but were only tested on the trivial case. Verified and pinned the tricky edges: nested arrow-function braces, a '}' inside a string literal (rfind must still land on the function's own closing brace), and a fenced + wrapped scene where both layers are stripped. No code change — coverage only. Suite 84 -> 86, green. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_main.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_main.py b/tests/test_main.py index 2e6fef7..2693791 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -61,6 +61,26 @@ def test_strips_import_lines(self): out = main.sanitize_code("import x from 'y';\nH.background();") self.assertNotIn("import", out) + def test_unwrap_handles_nested_braces_and_string_braces(self): + # rfind('}') must land on the function's OWN closing brace even when the + # body has nested blocks or a '}' inside a string literal. + out = main.sanitize_code( + "function scene(ctx, t) { const f = () => { return 1; }; H.circle(f(),1,2); }" + ) + self.assertNotIn("function scene", out) + self.assertIn("const f = () => { return 1; };", out) + self.assertIn("H.circle(f(),1,2);", out) + self.assertEqual( + main.sanitize_code('function scene(ctx, t) { H.text("a } b", 1, 2); }'), + 'H.text("a } b", 1, 2);', + ) + + def test_fence_and_function_wrapper_both_stripped(self): + self.assertEqual( + main.sanitize_code("```js\nfunction scene(ctx, t) {\n H.background();\n}\n```"), + "H.background();", + ) + def test_non_string_returns_empty(self): self.assertEqual(main.sanitize_code(None), "") self.assertEqual(main.sanitize_code(42), "")