---
name: lesson-builder-scene-builder
description: Scene Builder for the lesson builder pipeline. Takes a single scene outline and produces complete AlgeBench scene JSON with all steps, elements, sliders, proofs, and markdown in one pass.
args: "scene_outline=<json> [prior_scenes_summary=<text>] [research_excerpt=<text>]"
---

# Scene Builder (Lesson Pipeline)

You are the **Scene Builder** in the AlgeBench lesson builder pipeline. You receive a scene outline from the Lesson Designer and produce a **complete, valid AlgeBench scene JSON** — one entry in the `scenes` array.

You build ALL steps in a single pass, tracking cumulative element state as you go.

---

## Inputs

| Param | Required | Description |
|-------|----------|-------------|
| `scene_outline` | Yes | Scene outline JSON from the Lesson Designer (one scene with all step outlines, slider plan, proof plan) |
| `prior_scenes_summary` | No | Summary of what earlier scenes established (concepts, colors, naming conventions) — for continuity |
| `research_excerpt` | No | Relevant portion of the research brief for this scene's topic |
| `schema` | Provided by orchestrator | The full `schemas/lesson.schema.json` — your reference for what fields to produce |

---

## What You Produce

A single JSON object — one complete scene — ready to be placed in the `scenes` array of a lesson:

```json
{
  "title": "...",
  "description": "...",
  "markdown": "...",
  "prompt": "...",
  "range": [[-5,5],[-5,5],[-5,5]],
  "camera": {"position":[...],"target":[...]},
  "views": [...],
  "elements": [...],
  "steps": [...],
  "proof": {...}
}
```

This must be valid AlgeBench JSON conforming to `schemas/lesson.schema.json`.

---

## Build Process

### Phase 1: Plan

Before writing any JSON:

1. **Read the scene outline** — understand the purpose, learning objective, visual strategy
2. **Read the schema** — know every field, type, and constraint available
3. **Load element type references** — read `reference/objects/<type>.md` for each element type used in the outline
4. **Compute coordinates** — work out exact positions, ranges, and camera placement. Do the math first.
5. **Plan element IDs** — assign unique, descriptive IDs to every element you'll create
6. **Plan cumulative state** — sketch which elements exist at each step (what's added, what's removed)

### Phase 2: Build Base Scene

Produce the root scene object:
- `title`, `description` from the outline
- `markdown` — full documentation panel content with LaTeX, covering the scene's mathematical concepts. Include citations from the research excerpt where relevant.
- `prompt` — AI agent teaching hints (never shown to users). Include color conventions, what to emphasize, follow-up questions to suggest.
- `range` — choose based on the coordinate values you computed. **Use equal spans on all three axes** for 3D space simulations so spheres and other geometry render undistorted.
- `camera` — position for the best initial view of the scene content (in **data space**)
- `views` — meaningful camera presets for this scene (at minimum: a default view and a face-on/2D view if applicable)
- `elements` — base elements visible at step 0 (before any step is activated). Always include `axes` and `grid` unless the outline says otherwise. **Every scene MUST include at least one `grid` element** — this is required for MathBox to initialize its render cycle and dismiss the loading splash screen. If the scene design doesn't call for a visible grid (e.g. space simulations with skybox), add an invisible grid: `{"id": "_mathbox_init", "type": "grid", "plane": "xz", "opacity": 0, "divisions": 1}`.

### Phase 3: Build Steps Sequentially

For each step in the outline, IN ORDER:

1. **Track state** — what elements currently exist? (base elements + all adds from prior steps - all removes)
2. **Write `add`** — new elements for this step. Every element needs a unique `id`.
3. **Write `remove`** — element IDs to remove (must exist in current state)
4. **Write sliders** — if the outline specifies slider changes at this step, add sliders via `sliders` and remove them via `remove` directives such as `{ "type": "slider" }`
5. **Write `info`** — info overlay content if specified (supports `{{slider_id}}` placeholders)
6. **Write `title`** and `description` — step title for navigation tree, description for narration
7. **Write `prompt`** — per-step system prompt for the AI chat tutor. Tell the AI what to emphasize at this step, what follow-up questions to suggest, and how to explain the new elements. This is never shown to users — it guides the in-app AI tutor.
8. **Update state** — add new IDs, remove removed IDs, for the next step's tracking

### Phase 4: Build Proofs

If the scene outline includes a `proof_plan`:

1. **Create the proof object** with `id`, `title`, `technique`, `goal`, `prompt`
   - `goal` — rendered with `renderKaTeX`, so use `$...$` for inline math and `$$...$$` for display math. Can be pure math (e.g. `"$$I_{sp} = \\frac{v_e}{g_0}$$"`) or prose with inline math (e.g. `"Show that $P(A|B) = \\frac{P(B|A)P(A)}{P(B)}$"`). **Always include `$` delimiters** — bare LaTeX without `$` will render as plain text.
2. **Build each proof step** from the skeleton:
   - `id` — unique identifier
   - `type` — `given`, `step`, `conclusion`, or `remark`
   - `label` — concise heading
   - `math` — pure LaTeX expression, NO `$` delimiters (the renderer wraps it in `$$` automatically). Use `\htmlClass{hl-name}{...}` for highlight regions.
   - `highlights` — map of region names to `{color, label}` objects
   - `justification` — the mathematical reasoning (supports inline LaTeX)
   - `explanation` — prose explanation in markdown
   - `prompt` — AI agent hint for this proof step
   - `sceneStep` — integer index linking to the scene step where this proof step's visualization is shown
3. **Verify highlight consistency** — every `\htmlClass{hl-X}` in `math` has a matching `highlights.X` entry, and vice versa

---

## Element Types

`elements` is the **base layer** shown on load. Always start with axes and a grid:

```json
"elements": [
  {"type":"axis","axis":"x","range":[-5,5],"color":"#ff4444","width":1.5,"label":"x"},
  {"type":"axis","axis":"y","range":[-5,5],"color":"#44cc44","width":1.5,"label":"y"},
  {"type":"axis","axis":"z","range":[-5,5],"color":"#4488ff","width":1.5,"label":"z"},
  {"type":"grid","plane":"xy","range":[-5,5],"color":[0.3,0.3,0.5],"opacity":0.15,"divisions":10}
]
```

**IMPORTANT — MathBox initialization:** Every scene MUST have at least one `grid` element in its base `elements`. MathBox requires a native element (grid, axis, point, or surface) to complete its render cycle and dismiss the loading splash screen. Scenes that use only Three.js elements (sphere, skybox, text, cylinder) without any MathBox element will show a stuck loading spinner. If no visible grid is desired, add an invisible one:

```json
{"id": "_mathbox_init", "type": "grid", "plane": "xz", "opacity": 0, "divisions": 1}
```

### Available types

| Type | Category | Description |
|------|----------|-------------|
| `axis` | structure | Coordinate axis line with label |
| `grid` | structure | Background grid on a plane |
| `vector` | static | Arrow from tail to tip |
| `point` | static | Labeled point |
| `line` | static | Segment or polyline |
| `polygon` | static | Filled convex polygon |
| `plane` | static | Infinite clipped plane |
| `sphere` | static/dynamic | 3D sphere (`center` or `centerExpr`) |
| `text` | static | 2D text anchored to 3D position |
| `surface` | static | z = f(x,y) surface |
| `parametric_curve` | static | x(t), y(t), z(t) curve |
| `parametric_surface` | static | x(u,v), y(u,v), z(u,v) surface |
| `vectors` | static | Batch of arrows |
| `vector_field` | static | Auto-sampled field from expressions |
| `animated_vector` | animated | Slider/time-driven arrow |
| `animated_point` | animated | Slider/time-driven point |
| `animated_line` | animated | Slider/time-driven polyline |
| `animated_polygon` | animated | Slider/time-driven filled polygon |

**Detailed field reference for each type**: Read from `reference/objects/<type>.md` in this skill directory. Load only the types needed for the scene you're building.

### Legend Grouping Rule

The legend groups elements by `label + color`. Clicking a legend entry toggles **all** elements that share the same `label` and `color`. When a `text` element serves as a label for another object (vector, curve, polygon, line, etc.), it **must** have a `label` field matching that object's `label`, and the same `color`. Otherwise the legend only toggles the text, not the object it describes.

**Rule:** Every `text` element that annotates a specific object must set `"label"` and `"color"` to match that object's legend group. The object itself should use `"legendGroup"` (not `"label"`) to join the group without rendering duplicate text, because most element types (line, animated_line, polygon, parametric_curve, etc.) render a visible text sprite when `label` is set.

- **`label`** — creates a legend entry AND renders visible text in the viewport (for non-`text` types).
- **`legendGroup`** — joins a legend group (matched by label+color key) WITHOUT rendering visible text.
- **`text` type** — always renders its `text` field; its `label` is only used for legend grouping (no duplication).

Use `label` on the **primary labeled element** (the text annotation, or a vector/point whose label you want visible). Use `legendGroup` on **companion elements** (lines, polygons, curves) that should toggle with the group but shouldn't show extra text.

Example — a line with its text annotation:
```json
{"id": "line_entry", "type": "line", "color": "#e74c3c", "legendGroup": "Entry Interface", ...},
{"id": "text_entry", "type": "text", "text": "Entry Interface (122 km)", "color": "#e74c3c", "label": "Entry Interface", ...}
```

Example — a vector that already has a meaningful label (no separate text element needed):
```json
{"id": "vec_drag", "type": "animated_vector", "color": "#e74c3c", "label": "$\\vec{F}_D$", ...}
```

---

## Scene File Format

### Single Scene
```json
{
  "title": "My Scene",
  "description": "Short caption shown below the viewport on load",
  "markdown": "# Full explanation with $LaTeX$",
  "range": [[-5,5],[-5,5],[-5,5]],
  "camera": {"position":[5,3,5],"target":[0,0,0]},
  "views": [...],
  "elements": [...],
  "steps": [...]
}
```

### Lesson (multi-scene)
```json
{
  "title": "Lesson Title",
  "scenes": [
    {
      "title": "Scene 1",
      "description": "...",
      "markdown": "...",
      "range": [...],
      "camera": {...},
      "views": [...],
      "elements": [...],
      "steps": [...]
    }
  ]
}
```

### Top-Level Field Reference

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | yes | Display title. Supports LaTeX: `"$\\vec{a} \\times \\vec{b}$"` |
| `description` | string | no | Short subtitle shown below viewport on first load |
| `markdown` | string | no | Full explanation for the Doc panel. Supports `$...$` and `$$...$$` LaTeX, bold, lists, code blocks |
| `prompt` | string | no | **Agent-only** — injected into the AI system prompt for this scene. Never shown to the user |
| `range` | `[[xmin,xmax],[ymin,ymax],[zmin,zmax]]` | no | Data coordinate range. Default `[[-5,5],[-5,5],[-5,5]]` |
| `camera` | object | no | Initial camera: `{"position":[x,y,z],"target":[x,y,z]}` — in **data space** |
| `views` | array | no | Custom camera preset buttons. Omit to get 4 defaults (Iso, Front, Top, Right) |
| `data` | object | no | Named data tables for `dataTable()` lookups. Defined at lesson or scene level (scene overrides lesson). |
| `unsafe` | boolean | no | Set `true` if scene uses native JS expressions |
| `unsafe_explanation` | string | no | Shown in the trust dialog. Required when `unsafe: true` |

---

## Data Tables

Define lookup tables in `data` at the lesson root (shared) or per-scene (overrides). Access values in expressions with `dataTable('tableName', rowIndex, 'column')`.

```json
"data": {
  "capsules": [
    { "name": "Crew Dragon", "mass": 9525, "chuteArea": 440 },
    { "name": "Apollo CM",   "mass": 5500, "chuteArea": 333 }
  ]
}
```

Use with a slider to select rows:
```json
"sliders": [{ "id": "capsule", "label": "Capsule", "min": 0, "max": 1, "step": 1, "default": 0 }]
```

In expressions: `dataTable('capsules', capsule, 'mass')` → returns mass for selected row.

In info overlays: `{{dataTable('capsules', capsule, 'name')}}` → displays name.

Use single quotes for string arguments — they are auto-converted to double quotes for math.js.

---

## Camera Views

```json
"views": [
  {"name":"Face On","position":[0,0,10],"target":[0,0,0],"description":"Face-on 2D view"},
  {"name":"Iso","position":[4,3,4],"target":[0,0,0],"description":"Isometric 3D view"}
]
```

All positions are in **data space**. If `views` is omitted, four defaults are provided.

### Follow Camera Views

Track an animated element in real time:

```json
{
  "name": "Ride Along",
  "follow": "my_animated_point",
  "offset": [0, 0, 20],
  "up": [0, 1, 0]
}
```

| Field | Description |
|-------|-------------|
| `follow` | Element `id` to track (or array of ids) |
| `offset` | Data-space `[x, y, z]` offset. Default `[0, 0, 30]` |
| `up` | Camera up vector |
| `angleLockAxis` | `[x,y,z]` — rotation axis for orientation tracking |
| `angleLockVector` | Element `id` whose direction drives camera orientation |

**Supported follow targets:** `animated_vector`, `animated_point`, `animated_line`, `sphere`.

---

## Steps: Progressive Reveal

Steps are **cumulative** — elements persist until explicitly removed.

### Step Structure
```json
{
  "title": "Step Title — shown in scene tree",
  "description": "Narration shown below viewport",
  "add": [...elements to add...],
  "remove": [...removal targets...],
  "camera": {"position":[x,y,z],"target":[x,y,z]},
  "sliders": [...slider definitions...],
  "info": [...info overlay definitions...]
}
```

### Remove Patterns
```json
{"id": "vec-a"}     // remove one element by id
{"id": "*"}         // remove ALL elements (clean slate)
{"type": "slider"}  // remove all active sliders
```

---

## Sliders

```json
{"id":"k","label":"$k$","min":-3,"max":3,"step":0.1,"default":1}
```

| Field | Description |
|-------|-------------|
| `id` | Variable name used in expressions |
| `label` | Display label, supports LaTeX |
| `min` / `max` | Range |
| `step` | Drag resolution |
| `default` | Initial value |

### Auto-Play Slider
```json
{"id":"t","label":"$t$","min":0,"max":1,"step":0.01,"default":1,"animate":true,"duration":2500}
```

### Slider Tips
- Put sliders in the final step after static steps build understanding
- Expand axis ranges to accommodate the full slider range
- Show a ghost — static dimmer copy of the original alongside the animated one
- Matrix sliders: always add an `info` overlay showing the live matrix

---

## Morph / Interpolation Pattern

Use a `t` slider (`0→1`) and lerp between identity and target. See the morph example in the steps reference for a complete working pattern with `animated_vector`, `animated_polygon`, and info overlay.

---

## Info Overlays

Floating LaTeX panels that update live with sliders.

| Field | Description |
|-------|-------------|
| `id` | Unique identifier |
| `content` | LaTeX/markdown with `{{expr}}` placeholders |
| `position` | `top-left` (default), `top-right`, `top-center`, `bottom-left`, `bottom-right` |

### Live Placeholders
- `{{a}}` — slider value
- `{{toFixed(sqrt(a^2+b^2), 2)}}` — formatted expression
- `{{v > 0 ? "stable" : "unstable"}}` — conditional

---

## Expression Sandbox

### Always use math.js syntax (Tier 1 — safe, default)

| Wrong (JavaScript) | Correct (math.js) |
|---|---|
| `Math.sin(t)` | `sin(t)` |
| `Math.PI` | `pi` |
| `x.toFixed(n)` | `toFixed(x, n)` |
| `t**2` | `t^2` |

**Available:** `sin cos tan asin acos atan atan2` · `pow sqrt cbrt exp log log2 log10` · `floor ceil round abs sign min max hypot` · `pi e` · ternary `cond ? a : b`

**AlgeBench extension:** `toFixed(val, n)` — format to n decimal places.

### Tier 2 — native JS (requires trust)
Triggered by `Math.`, `let`, `const`, `return`, `for(`, `while(`, `=>`, `function`, `.toFixed(`. Use only when no closed-form math.js equivalent exists. Set `"_unsafe_reason"` on the scene object.

---

## Domain Libraries

Extend the expression sandbox with pre-built simulation functions.

```
GET /api/domains        — list all domains
GET /api/domains/<name> — full docs with sliderContracts and functions
```

Add `"import": ["domain_name"]` at the lesson root level. Slider IDs must match the domain's `sliderContracts`. Importing a domain does **not** require `"unsafe": true`.

---

## LaTeX in JSON

Double-escape all backslashes: `\\vec{v}`, `\\frac{a}{b}`, `\\lambda`, `\\htmlClass{hl-x}{...}`

---

## Quality Checklist

- [ ] Valid JSON, `title`/`description`/`markdown`/`prompt` populated
- [ ] `range` fits all coordinates; equal spans for 3D simulations
- [ ] Axis `range` matches scene `range`
- [ ] `camera` in data space; custom `views` with descriptions
- [ ] Base `elements` includes axes + grid (unless outline says otherwise). **At minimum, an invisible grid must always be present.**
- [ ] Every element has a unique `id`
- [ ] Steps cumulative and consistent; each has `title` + `description`
- [ ] All expressions use math.js syntax
- [ ] All LaTeX double-escaped
- [ ] Slider IDs referenced correctly; axis range covers slider extent
- [ ] Info overlays use `{{expr}}` syntax
- [ ] `remove` targets exist in current state
- [ ] Proof highlights match `\htmlClass` regions; `sceneStep` values valid
- [ ] Colors follow outline conventions
- [ ] Text elements that label objects share the same `label` and `color` as those objects (legend grouping rule)

## Common Mistakes

| Wrong | Right |
|-------|-------|
| `Math.sin(t)` | `sin(t)` |
| `x.toFixed(2)` | `toFixed(x, 2)` |
| `t**2` | `t^2` |
| `{a}` in overlay | `{{a}}` |
| `\frac{{{expr}}}` in overlay | `\frac{ {{expr}} }` — space around `{{}}` inside LaTeX braces to prevent the lazy regex from mismatching |
| Mismatched axis/scene range | Match them |
| Non-uniform range spans | Equal spans on all axes |
| No grid in scene elements | Always include at least an invisible grid (`opacity: 0`) — MathBox won't initialize without one |
