-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
2252 lines (1974 loc) · 92.1 KB
/
Copy pathmain.py
File metadata and controls
2252 lines (1974 loc) · 92.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
from __future__ import annotations
import argparse
import hmac
import json
import os
import re
import shutil
import subprocess
import textwrap
import threading
import time
import traceback
import urllib.error
import urllib.parse
import urllib.request
import uuid
from collections import defaultdict, deque
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
# Hard caps for the in-memory resource store (single-user local app).
MAX_RESOURCES = 12
MAX_RESOURCE_CHARS = 60_000 # per file
MAX_TOTAL_RESOURCE_CHARS = 240_000 # across all files combined
MAX_CONTEXT_PER_RESOURCE = 18_000 # chars used per generation/chat turn
# Largest POST body we'll read off the wire. The biggest legitimate request
# is a resource upload of ~1.5 MB; 4 MB gives plenty of headroom while
# preventing a malicious `Content-Length: 999999999` from hanging a worker
# thread on `rfile.read(huge_n)` (which would either OOM or wait forever).
MAX_REQUEST_BODY_BYTES = 4 * 1024 * 1024
# --- Ollama (local) is used for the tutor chat and as an offline fallback. ---
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434").rstrip("/")
PREFERRED_MODELS = (
"qwen2.5:7b",
"llama3.2",
"llama3.1:8b",
"phi4-mini",
)
# --- Claude (cloud) is the primary brain for generating animation code. ---
ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-opus-4-8")
# Generation depth/latency lever. Opus 4.8 effort levels: low|medium|high|xhigh|max.
# Default "high": code-gen for novel STEM scenes is intelligence-sensitive, and
# correct first-try code is precisely what AVOIDS the slow client-side repair
# loop. Set VISUALLM_CLAUDE_EFFORT=medium to trade a little quality for speed.
ANTHROPIC_EFFORT = os.environ.get("VISUALLM_CLAUDE_EFFORT", "high").strip() or "high"
# Hard per-response ceiling (the model isn't aware of it). A scene is a few KB of
# code plus short text, so 32k is generous headroom for thinking + output; lower
# it only if you need to cap cost. Too low risks truncated JSON -> a parse error
# that the caller counts as a failed generation.
ANTHROPIC_MAX_TOKENS = int(os.environ.get("VISUALLM_CLAUDE_MAX_TOKENS", "32000"))
# --- OpenAI / Gemini are optional cloud fallbacks (key = enabled). ---
OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/")
OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o")
GEMINI_BASE_URL = os.environ.get(
"GEMINI_BASE_URL", "https://generativelanguage.googleapis.com/v1beta"
).rstrip("/")
GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.0-flash")
# ===================================================================== #
# Abuse protection (the app may be exposed to the public internet) #
# ===================================================================== #
#
# Every /api POST burns either local compute (Ollama) or the operator's paid
# API credits (Claude/OpenAI/Gemini), so when published we (a) rate-limit per
# client IP with a sliding 60 s window and (b) optionally require a shared
# access code (VISUALLM_ACCESS_CODE) so only people you invite can generate.
RATE_LIMIT_PER_MIN = int(os.environ.get("VISUALLM_RATE_LIMIT", "20"))
ACCESS_CODE = os.environ.get("VISUALLM_ACCESS_CODE", "").strip()
_rate_lock = threading.Lock()
_rate_hits: dict[str, deque] = defaultdict(deque)
def rate_limit_ok(ip: str, now: float | None = None) -> bool:
"""Record a hit for `ip` and return False once it exceeds the window."""
if RATE_LIMIT_PER_MIN <= 0: # 0 disables limiting (local dev)
return True
now = time.time() if now is None else now
with _rate_lock:
hits = _rate_hits[ip]
while hits and hits[0] <= now - 60.0:
hits.popleft()
if len(hits) >= RATE_LIMIT_PER_MIN:
return False
hits.append(now)
# Bound memory: drop idle IPs once the table gets large.
if len(_rate_hits) > 5000:
for stale in [k for k, v in _rate_hits.items() if not v]:
del _rate_hits[stale]
return True
def access_code_ok(supplied: object) -> bool:
if not ACCESS_CODE:
return True
if not isinstance(supplied, str):
return False
return hmac.compare_digest(supplied.strip(), ACCESS_CODE)
# ===================================================================== #
# HTTP plumbing #
# ===================================================================== #
def read_json_body(handler: SimpleHTTPRequestHandler) -> dict:
raw_header = handler.headers.get("Content-Length", "0")
try:
content_length = int(raw_header)
except ValueError as err:
# A non-numeric Content-Length used to ValueError out of do_POST and
# drop the connection (curl saw HTTP 000). Surface a clean 400 by
# raising a JSONDecodeError, which do_POST already catches.
raise json.JSONDecodeError(
f"Invalid Content-Length: {raw_header!r}", "", 0
) from err
if content_length < 0:
raise json.JSONDecodeError(
f"Negative Content-Length: {content_length}", "", 0
)
if content_length > MAX_REQUEST_BODY_BYTES:
raise json.JSONDecodeError(
f"Body too large: {content_length} > {MAX_REQUEST_BODY_BYTES}", "", 0
)
raw_body = handler.rfile.read(content_length) if content_length else b"{}"
if not raw_body:
return {}
return json.loads(raw_body.decode("utf-8"))
def send_json(handler: SimpleHTTPRequestHandler, status: HTTPStatus, payload: dict) -> None:
data = json.dumps(payload).encode("utf-8")
try:
handler.send_response(status)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Content-Length", str(len(data)))
handler.end_headers()
handler.wfile.write(data)
except (BrokenPipeError, ConnectionError):
# Client disconnected mid-response (common when the user navigates away
# during a slow generation). There's no one to send to — swallow it
# rather than let it surface as an unhandled traceback in the log.
pass
# ===================================================================== #
# Ollama bridge #
# ===================================================================== #
def ollama_request(path: str, payload: dict | None = None, timeout: float = 60.0) -> dict:
body = None if payload is None else json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
f"{OLLAMA_URL}{path}",
data=body,
headers={"Content-Type": "application/json"},
method="POST" if body is not None else "GET",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
details = error.read().decode("utf-8", errors="replace").strip()
raise RuntimeError(details or f"Ollama returned HTTP {error.code}.") from error
except urllib.error.URLError as error:
reason = getattr(error, "reason", error)
raise RuntimeError(f"Could not reach Ollama at {OLLAMA_URL}. {reason}") from error
def fetch_ollama_models() -> list[dict]:
response = ollama_request("/api/tags", timeout=10.0)
return response.get("models", [])
def choose_ollama_model(models: list[dict]) -> str:
configured = os.environ.get("OLLAMA_MODEL", "").strip()
if configured:
return configured
available = [m.get("name", "") for m in models]
for candidate in PREFERRED_MODELS:
if candidate in available:
return candidate
return available[0] if available else PREFERRED_MODELS[0]
def ollama_available() -> tuple[bool, str | None]:
try:
fetch_ollama_models()
return True, None
except RuntimeError as error:
return False, str(error)
# ===================================================================== #
# Resource store (uploaded reference material) #
# ===================================================================== #
#
# Single-user, in-memory. Files are kept as text so both the generator
# (Claude) and the tutor (Ollama/Claude) can ground their output in them.
# Binary files are rejected; users upload notes, problem sets, lecture
# excerpts, code, datasets — anything text-ish.
_resources_lock = threading.Lock()
_resources: list[dict] = []
def _resources_total_chars() -> int:
return sum(len(r["content"]) for r in _resources)
def list_resources() -> list[dict]:
with _resources_lock:
return [
{
"id": r["id"],
"name": r["name"],
"size": len(r["content"]),
"uploaded_at": r["uploaded_at"],
}
for r in _resources
]
def add_resource(name: str, content: str) -> dict:
if not isinstance(name, str) or not name.strip():
raise ValueError("Resource name is required.")
if not isinstance(content, str) or not content.strip():
raise ValueError("Resource content is empty or not text.")
# Light binary detection: reject if too many NULs / control bytes appear.
sample = content[:4000]
non_text = sum(
1
for ch in sample
if ch not in "\n\r\t" and (ord(ch) < 32 or ord(ch) == 127)
)
if sample and non_text / max(1, len(sample)) > 0.05:
raise ValueError(
"That file looks binary. Upload text-based files (.txt, .md, "
".csv, .json, .py, .js, etc.)."
)
trimmed = content[:MAX_RESOURCE_CHARS]
with _resources_lock:
if len(_resources) >= MAX_RESOURCES:
raise ValueError(
f"You can keep at most {MAX_RESOURCES} resources. Delete one first."
)
if _resources_total_chars() + len(trimmed) > MAX_TOTAL_RESOURCE_CHARS:
raise ValueError(
"Total resource size would exceed the limit. Delete a larger "
"resource first."
)
entry = {
"id": uuid.uuid4().hex[:12],
"name": name.strip()[:200],
"content": trimmed,
"uploaded_at": time.time(),
}
_resources.append(entry)
return {
"id": entry["id"],
"name": entry["name"],
"size": len(entry["content"]),
"uploaded_at": entry["uploaded_at"],
"truncated": len(content) > MAX_RESOURCE_CHARS,
}
def delete_resource(resource_id: str) -> bool:
with _resources_lock:
for i, r in enumerate(_resources):
if r["id"] == resource_id:
_resources.pop(i)
return True
return False
def resources_context_block() -> str:
"""Format every resource as a labeled text block for prompts.
Returns "" when there are no resources, so callers can cheaply skip
appending it.
"""
with _resources_lock:
if not _resources:
return ""
parts = ["The student has uploaded reference material. Use it to ground "
"your explanation, examples, and any equations or notation:"]
for r in _resources:
snippet = r["content"][:MAX_CONTEXT_PER_RESOURCE]
note = ""
if len(r["content"]) > MAX_CONTEXT_PER_RESOURCE:
note = f" (truncated; showing first {MAX_CONTEXT_PER_RESOURCE} chars)"
parts.append(f"\n--- BEGIN RESOURCE: {r['name']}{note} ---\n{snippet}\n--- END RESOURCE ---")
return "\n".join(parts)
# ===================================================================== #
# Anthropic / Claude bridge #
# ===================================================================== #
def anthropic_client():
"""Return an Anthropic client, or None if unavailable."""
if not os.environ.get("ANTHROPIC_API_KEY"):
return None
try:
# Lazy import — keeps the app runnable without the SDK installed.
# Pylance/Pyright can't see this is intentional, so suppress the warning.
import anthropic # type: ignore[reportMissingImports]
except ImportError:
return None
try:
return anthropic.Anthropic()
except Exception: # noqa: BLE001 - any construction failure means "unavailable"
return None
def claude_available() -> dict:
has_key = bool(os.environ.get("ANTHROPIC_API_KEY"))
try:
import anthropic # type: ignore[reportMissingImports] # noqa: F401
has_sdk = True
except ImportError:
has_sdk = False
return {
"available": has_key and has_sdk,
"has_key": has_key,
"has_sdk": has_sdk,
"model": ANTHROPIC_MODEL,
}
# The renderer contract + helper API + worked examples. This is the heart of
# the product: it teaches the model exactly how to write animation code that the
# sandbox can run. It is static, so we cache it as a prompt prefix.
SCENE_SYSTEM_PROMPT = textwrap.dedent(
r"""
You are VisualLM, an engine that turns any STEM question, equation, or idea
into a clear, animated 2D or 3D explanation. You do this by WRITING
JavaScript animation code that runs in a sandboxed canvas renderer.
Your output is consumed by a program, not a human. Return ONLY the structured
fields requested. The most important field is `code`.
## The rendering contract
`code` is the BODY of a function with this exact signature:
function scene(ctx, t) {
// your code here
}
- `ctx` is a Canvas 2D context. The canvas is already cleared before each
call, but you may also paint a background.
- `t` is elapsed time in SECONDS as a float (it respects pause and speed).
Drive ALL motion from `t` so the animation loops smoothly. Do not keep your
own frame counter or mutate outer state — `scene` is called ~60 times per
second and must be a pure function of (ctx, t).
- `H` is a helper library, available as a global in the worker scope.
Just reference it directly (e.g. `H.text(...)`); do not declare it.
- Use `H.W` and `H.H` for the logical width/height of the drawing area.
- NEVER use: loops without bounds, `while(true)`, `setTimeout`, `setInterval`,
`requestAnimationFrame`, network calls, DOM, `import`, or `eval`. Keep every
loop finite and cheap (a few hundred iterations max per frame).
- Do NOT define `function scene(...)` yourself and do NOT wrap the code in a
function — provide only the statements that go INSIDE the body.
## Hard rules — code that violates these will be REJECTED
### Rule 0 — every scene MUST PAINT
The single biggest failure mode is code that computes but never draws.
EVERY scene you emit MUST do all three of these:
1. Call `H.background()` (or `H.clear(...)`) on the FIRST line of the
body. This guarantees the canvas isn't transparent / stuck black.
2. Call at least THREE drawing helpers per frame from this set:
`H.text`, `H.line`, `H.path`, `H.circle`, `H.rect`, `H.arrow`,
`H.legend`, `H.surface3d`, `H.mesh3d`, any `H.plot2d` view method
(`.grid()`, `.axes()`, `.fn()`, `.dot()`), or any `cam` method
(`.line()`, `.path()`, `.poly()`, `.sphere()`, `.grid()`, `.axes()`).
A `for` loop that calls one of them counts.
3. Use real on-screen pixel coordinates between 0 and `H.W`/`H.H`. Do
NOT draw at math coordinates like (-3, 0.5) directly — either go
through `H.plot2d` (which maps for you) or scale with `H.map(...)`.
Minimal complete skeleton (use this as a starting point):
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 scene title", 24, 30,
{ color: H.colors.ink, size: 18, weight: 700 });
If your scene doesn't contain `H.background` AND at least 3 other `H.*`
drawing calls, the renderer treats it as a failed generation.
### Rule 1 — every scene MUST MOVE
`scene` is called ~60 times per second; the picture only animates if your
code reads `t`. Scenes that never use `t` are rejected as static images.
- Drive at least one PRIMARY element from `t`: a moving particle, a
propagating wave, a sweeping tangent/angle, a growing sum, a rotating
camera (`cam.yaw = 0.3 * t`), oscillating values.
- If the concept itself is static (a structure, a proof, a shape), animate
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);`.
### Rule 2 — every scene MUST BE LABELED with real values
A picture without numbers teaches nothing. Scenes with zero `H.text`
calls are rejected.
- Title (H.text, size 18, weight 700) at the top-left + a one-line caption
under it (size 13, H.colors.sub).
- `view.axes()` (2D) and `cam.axes(len)` (3D) draw numeric tick values
automatically — use them whenever the scene has coordinates.
- Label the key quantities ON the drawing (axis names, point coordinates,
vector names, units).
- Include at least one LIVE READOUT that changes with `t`, e.g.
`H.text("E = " + energy.toFixed(2) + " J", 24, 76, { color: H.colors.sub, size: 13 });`
- With 2+ colored elements, add `H.legend([{label, color}, ...], x, y)`.
### Other hard rules — code that violates these will be REJECTED
The following patterns break the sandbox. Do not produce them under any
circumstance:
- Always call math functions through `Math.*` — write `Math.sin(x)`, not
`sin(x)`. The only globals available are `Math`, `Number`, `Array`,
`Object`, `JSON`, `console`, and `H` (the helper library).
- Use `H.colors.<name>` only for these exact names: bg, panel, ink, sub,
grid, axis, accent, accent2, good, warn, violet, yellow. For any other
color, use a CSS string ("#ffaa00") or `H.hsl(h,s,l)` / `H.color(i)`.
- If you need a local name for `H.W` / `H.H` (canvas width / height), use
different identifiers — e.g. `const w = H.W, h = H.H`. Don't pick `H`
as your local variable name.
- Don't access properties off `undefined`. Every helper call must use one
of the documented helpers below.
## Helper library `H`
Constants: `H.W`, `H.H`, `H.TAU` (2π), `H.PI`, `H.colors`, `H.palette` (array).
`H.colors` has: bg, panel, ink (bright text), sub (dim text), grid, axis,
accent, accent2, good, warn, violet, yellow.
Math: `H.clamp(x,lo,hi)`, `H.lerp(a,b,t)`, `H.map(x,inMin,inMax,outMin,outMax)`,
`H.ease(t)` (smoothstep 0..1), `H.color(i)` (palette pick), `H.hsl(h,s,l,a)`.
Drawing (all coordinates in pixels, origin top-left, y grows downward):
- `H.clear(color?)` — fill the whole canvas.
- `H.background(top?, bottom?)` — vertical gradient background.
- `H.text(str, x, y, {size,color,align,baseline,weight,maxWidth})`.
- `H.line(x1,y1,x2,y2,{color,width,dash,cap})`.
- `H.path(points,{color,width,fill,close,dash})` — points is `[[x,y],...]`.
- `H.circle(x,y,r,{fill,stroke,width})`.
- `H.rect(x,y,w,h,{fill,stroke,width,radius})`.
- `H.arrow(x1,y1,x2,y2,{color,width,head})` — line with an arrowhead.
- `H.legend([{label,color},...], x, y)`.
2D graphing — `H.plot2d({xMin,xMax,yMin,yMax,pad})` returns a `view`:
- `view.X(v)` / `view.Y(v)` — map data coords to pixels.
- `view.grid()` — light grid. `view.axes()` — x/y axes. `view.box` — pixel box.
- `view.fn(f, {color,width,steps})` — plot `y=f(x)` across the domain.
- `view.dot(x,y,{r,fill,stroke})` — a data-space marker.
- Data-space drawing (coordinates in MATH units, not pixels):
`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)`,
`view.path([[x,y],...],opts)`, `view.rect(x,y,w,h,opts)` (lower-left
corner + size in data units). Use these for geometry, annotations, and
shapes tied to graph coordinates.
Chain them: `const v = H.plot2d({xMin:-6,xMax:6,yMin:-3,yMax:3}); v.grid(); v.axes(); v.fn(Math.sin);`
`view.axes()` draws numeric tick values automatically.
3D — `H.cam3d({yaw,pitch,scale,dist,cx,cy})` returns a `cam`. Convention:
+y is UP on screen; the ground plane is x/z. Larger `depth` = farther away,
so painter's algorithm = sort DESCENDING by depth, draw far first.
- `cam.project([x,y,z])` -> `{x,y,depth,f}` (screen point + depth).
- `cam.yaw`/`cam.pitch` are settable (rotate the view with `t`).
- `cam.line(a, b, opts)` — 3D 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 the projection. For many spheres: depth-sort by
`cam.project(p).depth` descending, then draw (atoms, planets, particles).
- `cam.grid(size, step)` — ground-plane grid at y=0 (gives depth perception).
- `cam.axes(len)` — labeled x/y/z axes.
SOLID 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 is mapped
from height automatically (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).
Example sphere: `(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 at or below ~40 each (they're capped at 64).
The user can DRAG the canvas to orbit any 3D scene and scroll to zoom (this
is automatic). Still give the scene a slow default spin (`cam.yaw = 0.3*t`)
so it reads as 3D even before they touch it.
## Style and pedagogy
- 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.
- Use color from `H.colors`/`H.palette`; keep it readable on the dark bg.
- Title the scene at the top-left and add a one-line caption if helpful.
- Make it loop or breathe so it looks alive even when the concept is static.
- Choosing 2D vs 3D: pick 3D whenever the concept lives in space — surfaces
z=f(x,y), orbits and trajectories, molecules and crystal structures,
vector fields in space, electromagnetic waves, multivariable calculus,
rotations. Pick 2D for single-variable functions, time series, circuits,
graphs/algorithms, and planar geometry. Honor the user's preference when
given.
- When you do 3D, make it SOLID: use `H.surface3d` / `H.mesh3d` /
`cam.sphere` (lit, filled, depth-sorted). Do NOT draw 3D as a cloud of
flat dots — that reads as 2D. Add `cam.grid()` and `cam.axes()` so depth
is legible, and a slow `cam.yaw = 0.3 * t` spin.
## Worked example 1 — "derivative of x^2 as a moving tangent line" (2D)
code:
const v = H.plot2d({ xMin: -3.2, xMax: 3.2, yMin: -1, yMax: 9, pad: 50 });
v.grid(); v.axes();
const f = (x) => x * x;
v.fn(f, { color: H.colors.accent, width: 3 });
// sweep the point of tangency with time
const a = 3 * Math.sin(t * 0.6);
const slope = 2 * a; // derivative of x^2
const tx = (x) => f(a) + slope * (x - a);
H.line(v.X(-3.2), v.Y(tx(-3.2)), v.X(3.2), v.Y(tx(3.2)),
{ color: H.colors.accent2, width: 2.4, dash: [7, 6] });
v.dot(a, f(a), { r: 6 });
H.text("y = x^2", v.X(-3.0), v.Y(8.4), { color: H.colors.accent, size: 16 });
H.text("slope = 2a = " + slope.toFixed(2), 24, 30,
{ color: H.colors.ink, size: 18, weight: 700 });
H.text("The tangent line's slope IS the derivative.", 24, 52,
{ color: H.colors.sub, size: 13 });
## Worked example 2 — "rotating 3D surface z = sin(r)/r" (3D, SOLID)
code:
H.background();
const cam = H.cam3d({ scale: 34, dist: 16, pitch: -0.55, cy: H.H * 0.54 });
cam.yaw = 0.35 * t;
cam.grid(6, 2);
H.surface3d(cam, (x, y) => {
const r = Math.sqrt(x * x + y * y) + 1e-6;
return (Math.sin(r - t) / r) * 4; // ripple that propagates with t
}, { xMin: -6, xMax: 6, yMin: -6, yMax: 6, nx: 40, ny: 40 });
cam.axes(7);
H.text("z = sin(r - t) / r", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });
H.text("A circular wave radiating from the origin. Drag to orbit.", 24, 52,
{ color: H.colors.sub, size: 13 });
## Worked example 3 — "DNA double helix" (3D, spheres + bonds)
code:
H.background();
const cam = H.cam3d({ scale: 36, 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;
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) // far first
.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 });
## Output fields
- title: short scene title (<= 60 chars).
- tag: short subject label, e.g. "Calculus", "Electromagnetism", "Algorithms".
- dimension: "2D" or "3D".
- equation: the central equation in plain text (e.g. "y = sin(x)"), or "" if none.
- summary: one or two sentences on what the animation shows.
- bullets: exactly 3 short teaching points tied to what is on screen.
- student_prompts: 3 good follow-up questions a student could ask the tutor.
- code: the function body described above. Self-contained, runnable, robust.
Make the code correct and defensive: guard against division by zero, NaN, and
values outside the visible range. The animation must never throw.
Correctness rules that are easy to get wrong:
- Physically nonnegative quantities (lengths, areas, radii, masses,
probabilities, concentrations) must NEVER display as negative. Don't
animate them with a bare Math.sin(t) — use a form that stays positive,
e.g. `2 + Math.sin(t)` or `Math.abs(...)`, and sanity-check every live
readout you print.
- Generated code receives NO mouse or keyboard input. Never claim the
scene is interactive ("drag the vertices", "click to...") in the code,
summary, or bullets. The ONLY interactivity is the built-in camera
orbit on 3D scenes, which the app provides automatically.
- Make sure the numbers you display are consistent with the picture: if
the readout says a = 3.0, the drawn length must actually be 3 units.
"""
).strip()
SCENE_SCHEMA = {
"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",
],
}
def _mode_hint(preferred_mode: str) -> str:
return {
"2d": "The user prefers a 2D scene if it fits the concept well.",
"3d": "The user prefers a 3D scene if it fits the concept well.",
}.get(preferred_mode, "Pick 2D or 3D, whichever explains the idea best.")
def claude_call_scene(user_blocks: list[dict]) -> dict:
"""Run one structured Claude generation and return the parsed scene dict."""
client = anthropic_client()
if client is None:
raise RuntimeError("Claude is not configured.")
with client.messages.stream(
model=ANTHROPIC_MODEL,
max_tokens=ANTHROPIC_MAX_TOKENS,
thinking={"type": "adaptive"},
output_config={
"effort": ANTHROPIC_EFFORT,
"format": {"type": "json_schema", "schema": SCENE_SCHEMA},
},
system=[
{
"type": "text",
"text": SCENE_SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"},
}
],
messages=[{"role": "user", "content": user_blocks}],
) as stream:
message = stream.get_final_message()
text = next((b.text for b in message.content if b.type == "text"), "")
if not text:
raise RuntimeError("Claude returned no visualization.")
plan = json.loads(text)
plan["model"] = ANTHROPIC_MODEL
plan["engine"] = "claude"
return plan
def generate_with_claude(prompt: str, preferred_mode: str) -> dict:
resources = resources_context_block()
user_text = (
f"Create a STEM visualization for this request:\n\n{prompt}\n\n"
f"{_mode_hint(preferred_mode)}"
)
if resources:
user_text += "\n\n" + resources
return claude_call_scene([{"type": "text", "text": user_text}])
def _repair_hint(error: str) -> str:
"""Turn a sandbox error into a targeted fix instruction.
The repair loop's worst failure mode is the model *rewriting the whole
scene* and introducing a different bug — so every hint ends with a
minimal-change directive. The error-class prefix points the model straight
at the fault. Substring match: sandbox error messages aren't structured.
"""
e = (error or "").lower()
if "is not defined" in e:
specific = (
"A name was used before it was declared (usually a typo). Declare it "
"with const/let, or fix the misspelling. Do NOT add other new names."
)
elif "is not a function" in e:
specific = (
"You called something that isn't a real helper. Use ONLY the helpers "
"from the contract above (H.*, the plot2d view methods, the cam3d "
"methods). Delete or replace the invalid call."
)
elif "cannot read" in e and ("undefined" in e or "null" in e):
specific = (
"You read a property of undefined/null. Guard the access — confirm "
"the value exists and any array index is in range before using it."
)
elif "is not iterable" in e:
specific = (
"You looped over or spread a non-array. Ensure the value is an array "
"(default to []) before iterating."
)
elif "unexpected" in e or "syntaxerror" in e or "token" in e:
specific = (
"The code did not parse — likely an unbalanced bracket or an "
"unfinished statement. Return complete, valid JavaScript."
)
else:
specific = "Identify exactly what threw, then fix that specific cause."
return (
specific
+ " Make the SMALLEST change that fixes the error: keep every part that "
"already works, do not rewrite the whole scene, and do not change the "
"teaching intent."
)
def repair_with_claude(prompt: str, code: str, error: str) -> dict:
user_text = (
"The animation code you wrote threw an error in the sandbox. Fix it and "
"return the full corrected scene.\n\n"
f"Original request:\n{prompt}\n\n"
f"Error:\n{error}\n\n"
f"How to fix it:\n{_repair_hint(error)}\n\n"
f"Broken code (function body of scene(ctx, t, H)):\n{code}"
)
return claude_call_scene([{"type": "text", "text": user_text}])
# --- Ollama fallback generation (best-effort; lower quality than Claude). ---
OLLAMA_SCENE_PROMPT = (
SCENE_SYSTEM_PROMPT
+ "\n\nReturn STRICT JSON only with keys: title, tag, dimension, equation, "
"summary, bullets (array of 3 strings), student_prompts (array of 3 strings), "
"code (string). No markdown, no commentary."
)
def _extract_json_object(raw_text: str) -> dict:
text = raw_text.strip()
try:
parsed = json.loads(text)
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
pass
# Brace-balanced walk: find each complete top-level {...} segment and try
# to parse it. Handles "prose {a} more {b}" (find+rfind would have sliced
# both objects into one unparseable blob) and braces inside JSON string
# values. Raises json.JSONDecodeError so callers (e.g. generate_with_ollama)
# can decide whether to retry.
depth = 0
start = -1
in_str = False
escape = False
for i, ch in enumerate(text):
if in_str:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_str = False
continue
if ch == '"':
in_str = True
elif ch == "{":
if depth == 0:
start = i
depth += 1
elif ch == "}":
if depth > 0:
depth -= 1
if depth == 0 and start != -1:
try:
parsed = json.loads(text[start : i + 1])
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
pass # try the next balanced segment
start = -1
raise json.JSONDecodeError("No parseable JSON object in model output", text, 0)
def generate_with_ollama(prompt: str, preferred_mode: str, fix: dict | None = None) -> dict:
models = fetch_ollama_models()
model_name = choose_ollama_model(models)
if fix:
user = (
"Your animation code threw an error. Fix it and return the full scene "
"as strict JSON.\n\nRequest:\n" + prompt + "\n\nError:\n" + fix["error"]
+ "\n\nHow to fix it:\n" + _repair_hint(fix["error"])
+ "\n\nBroken code:\n" + fix["code"]
)
else:
user = (
"Create a STEM visualization for this request:\n\n"
+ prompt
+ "\n\n"
+ _mode_hint(preferred_mode)
)
resources = resources_context_block()
if resources:
user += "\n\n" + resources
payload = {
"model": model_name,
"stream": False,
"format": "json",
"messages": [
{"role": "system", "content": OLLAMA_SCENE_PROMPT},
{"role": "user", "content": user},
],
"options": {"temperature": 0.2},
# Tell Ollama to keep the model resident for a while after the
# call returns. The next visualize/repair won't pay the cold-load.
"keep_alive": "30m",
}
# Retry once on JSON parse failure: `format: json` produces valid JSON
# most of the time, but sampling occasionally emits unterminated strings
# / unescaped quotes that break json.loads. A single re-sample at the
# same temperature usually succeeds and is much cheaper than surfacing
# a hard "Could not generate" to the user.
last_parse_err: json.JSONDecodeError | None = None
for attempt in range(2):
response = ollama_request(
"/api/chat",
payload=payload,
# Generous timeout: a cold load of a 7B model + a complex JSON-mode
# generation can take well over 2 minutes the first time. Subsequent
# calls run in 10-30s.
timeout=420.0,
)
content = response.get("message", {}).get("content")
if not isinstance(content, str):
raise RuntimeError("Ollama returned no visualization.")
try:
plan = _extract_json_object(content)
except json.JSONDecodeError as err:
last_parse_err = err
continue
plan["model"] = model_name
plan["engine"] = "ollama"
return plan
# Both attempts produced unparseable JSON — surface the latest parse error.
raise RuntimeError(
f"Ollama produced invalid JSON twice in a row: {last_parse_err}"
)
# ===================================================================== #
# OpenAI / Gemini bridges (optional cloud fallbacks) #
# ===================================================================== #
#
# Both speak plain REST via urllib (no SDK needed) and reuse the strict-JSON
# variant of the scene prompt, so adding a key is the only setup required.
def _http_post_json(url: str, payload: dict, headers: dict, timeout: float = 180.0) -> dict:
request = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json", **headers},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
details = error.read().decode("utf-8", errors="replace").strip()
# API error bodies are JSON with a useful "message" — surface just that.
try:
parsed = json.loads(details)
msg = parsed.get("error", {}).get("message") or details
except (json.JSONDecodeError, AttributeError):
msg = details
raise RuntimeError(f"HTTP {error.code}: {msg[:500]}") from error
except urllib.error.URLError as error:
raise RuntimeError(f"Could not reach {url.split('/', 3)[2]}: {getattr(error, 'reason', error)}") from error
def openai_available() -> dict:
return {
"available": bool(os.environ.get("OPENAI_API_KEY")),
"model": OPENAI_MODEL,
}
def gemini_available() -> dict:
return {
"available": bool(os.environ.get("GEMINI_API_KEY")),
"model": GEMINI_MODEL,
}
def _openai_chat(system: str, messages: list[dict], json_mode: bool) -> str:
payload: dict = {
"model": OPENAI_MODEL,
"messages": [{"role": "system", "content": system}] + messages,
"temperature": 0.3,
}
if json_mode:
payload["response_format"] = {"type": "json_object"}
response = _http_post_json(
f"{OPENAI_BASE_URL}/chat/completions",
payload,
{"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"},
)
choices = response.get("choices") or []
content = (choices[0].get("message", {}) or {}).get("content") if choices else None
if not isinstance(content, str) or not content.strip():
raise RuntimeError("OpenAI returned no content.")
return content
def _gemini_generate(system: str, turns: list[dict], json_mode: bool) -> str:
# Like Claude, Gemini rejects conversations that open with a model turn
# (the UI seeds chat with one assistant message) — drop leading ones.
while turns and turns[0].get("role") != "user":
turns = turns[1:]
contents = [
{
"role": "model" if m["role"] == "assistant" else "user",
"parts": [{"text": m["content"]}],
}
for m in turns
]
payload: dict = {
"system_instruction": {"parts": [{"text": system}]},
"contents": contents,
"generationConfig": {"temperature": 0.3, "maxOutputTokens": 8192},
}
if json_mode:
payload["generationConfig"]["responseMimeType"] = "application/json"
response = _http_post_json(
f"{GEMINI_BASE_URL}/models/{GEMINI_MODEL}:generateContent",
payload,
{"x-goog-api-key": os.environ["GEMINI_API_KEY"]},
)
candidates = response.get("candidates") or []
parts = (candidates[0].get("content", {}) or {}).get("parts", []) if candidates else []
text = "".join(p.get("text", "") for p in parts if isinstance(p, dict))
if not text.strip():
raise RuntimeError("Gemini returned no content.")
return text
def _scene_user_text(prompt: str, preferred_mode: str, fix: dict | None) -> str:
if fix:
user = (
"Your animation code threw an error. Fix it and return the full scene "
"as strict JSON.\n\nRequest:\n" + prompt + "\n\nError:\n" + fix["error"]
+ "\n\nHow to fix it:\n" + _repair_hint(fix["error"])
+ "\n\nBroken code:\n" + fix["code"]
)
else:
user = (
"Create a STEM visualization for this request:\n\n"
+ prompt
+ "\n\n"
+ _mode_hint(preferred_mode)
)
resources = resources_context_block()
if resources:
user += "\n\n" + resources