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/.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/README.md b/README.md index 88074dc..8afcecb 100644 --- a/README.md +++ b/README.md @@ -101,22 +101,43 @@ 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`. + +**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: ```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 +186,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/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 new file mode 100755 index 0000000..01330cc --- /dev/null +++ b/app_main.py @@ -0,0 +1,104 @@ +#!/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 _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: + 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) + 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: + # 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() + 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/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" 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()) diff --git a/main.py b/main.py index 70ec7b7..1225f86 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 @@ -420,6 +427,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 @@ -518,6 +532,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. @@ -1078,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 @@ -1132,11 +1183,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, @@ -1174,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: @@ -1389,11 +1500,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/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()) 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..341b452 --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/SKILL.md @@ -0,0 +1,241 @@ +--- +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). + +## 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 + 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..31cc22e --- /dev/null +++ b/stem-viz-plugin/skills/stem-viz/references/concepts.md @@ -0,0 +1,122 @@ +# 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. + +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 + 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_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() diff --git a/tests/test_main.py b/tests/test_main.py index b9e0635..2693791 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -40,10 +40,47 @@ 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) + 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), "") @@ -400,6 +437,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);")) @@ -456,6 +511,40 @@ 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_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 c46a3be..cd1316e 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,__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});})()"; + "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)); @@ -191,9 +198,18 @@ 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 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; + 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)) {