Jelajahi Sumber

refactor(skills): Extract iso-studio to standalone repo

The app outgrew the skill: an app with a versioned roadmap and a growing
asset library is a product, not a skill resource, and plugin installs
should not carry it to every machine. iso-studio (index.html, server.mjs,
scene-schema.json, palettes copy, manual, screenshot) now lives at
X:/Forge/iso-studio (github.com/0xDarkMatter/iso-studio) with its own
17-assertion test suite; the app's palette fetch and server simplified
for the flat layout.

The skill keeps the knowledge layer and routes to the app:
references/iso-studio.md is now a quickstart pointer, SKILL.md Route E /
section 5 and the assets table point at the external repo, and the skill
test suite (62 assertions) covers scripts only. Follows the
shotcraft/musecraft/cutcraft extraction precedent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0xDarkMatter 2 minggu lalu
induk
melakukan
fb7b1558a1

+ 10 - 6
CHANGELOG.md

@@ -23,12 +23,16 @@ feature releases live in the README "Recent Updates" section.
   generator, per-tool transform recipes), `tile-validate.py` (AI-tile QA gate:
   halo/bleed/anchor/palette), `sheet-pack.py` (spritesheet + JSON atlas), and a
   `check-iso-facts.py` §7 staleness verifier (`--offline` gates PR CI on the
-  canonical constants; `--live` resolves cited npm packages weekly). Ships
-  **iso-studio**, a zero-dependency browser scene composer (snap-to-grid
-  staging, footprint-aware y-sort, control palettes, PNG/SVG/scene-JSON export,
-  and blockout-to-ControlNet depth/lineart export). Built by a file-partitioned
-  Opus/Sonnet agent workflow with per-file adversarial review against a
-  pinned-constants build brief.
+  canonical constants; `--live` resolves cited npm packages weekly). The
+  companion **iso-studio** app — a zero-dependency browser scene composer
+  (snap-to-grid staging, footprint-aware y-sort, control palettes,
+  PNG/SVG/scene-JSON export, and blockout-to-ControlNet depth/lineart export)
+  — was built alongside and immediately extracted to its own repository
+  (github.com/0xDarkMatter/iso-studio) because an app with a roadmap and an
+  asset library is a product, not a skill resource; the skill routes to it via
+  `references/iso-studio.md`. Built by a file-partitioned Opus/Sonnet agent
+  workflow with per-file adversarial review against a pinned-constants build
+  brief.
 
 ## [3.4.0] - 2026-06-23
 

+ 2 - 2
README.md

@@ -23,7 +23,7 @@ From Python async patterns to Rust ownership models, from AWS Fargate deployment
 ## Recent Updates
 
 **v3.5.0** (July 2026)
-- 📐 **`isometric-ops` skill** — isometric illustrative assets end to end: creation, refinement, composition, and export for websites and games. **14 references** anchor the exact projection math (true isometric 30° vs **2:1 dimetric 26.565°** — the mislabel that breaks tilesets — with every constant derived and machine-checked by a §7 staleness verifier), coordinate transforms + y-sort depth doctrine, the tile-spec discipline, SVG/CSS/three.js generation, Aseprite pixel-art workflow, dual Blender ortho rigs (60° dimetric vs 54.736° true iso), engine tilemaps (Godot 4 / Unity / Phaser 3), and the full AI pipeline — Recraft/Midjourney/Flux+LoRA generation under **ControlNet depth/MLSD structure control**, upscale + vectorization ladders, and licence discipline that checks AI-training clauses. Scripts: `iso-math.py` (constants/transforms/grid generator), `tile-validate.py` (AI-tile QA: halo, bleed, anchor, palette), `sheet-pack.py` (spritesheet + atlas). Headlined by **iso-studio** — a bundled zero-dependency browser scene composer with snap-to-grid staging, footprint-aware y-sort, docked control palettes, PNG/SVG/scene-JSON export, and a blockout mode that exports **depth + lineart maps straight into ControlNet conditioning**.
+- 📐 **`isometric-ops` skill** — isometric illustrative assets end to end: creation, refinement, composition, and export for websites and games. **14 references** anchor the exact projection math (true isometric 30° vs **2:1 dimetric 26.565°** — the mislabel that breaks tilesets — with every constant derived and machine-checked by a §7 staleness verifier), coordinate transforms + y-sort depth doctrine, the tile-spec discipline, SVG/CSS/three.js generation, Aseprite pixel-art workflow, dual Blender ortho rigs (60° dimetric vs 54.736° true iso), engine tilemaps (Godot 4 / Unity / Phaser 3), and the full AI pipeline — Recraft/Midjourney/Flux+LoRA generation under **ControlNet depth/MLSD structure control**, upscale + vectorization ladders, and licence discipline that checks AI-training clauses. Scripts: `iso-math.py` (constants/transforms/grid generator), `tile-validate.py` (AI-tile QA: halo, bleed, anchor, palette), `sheet-pack.py` (spritesheet + atlas). Headlined by **iso-studio** — a zero-dependency browser scene composer with snap-to-grid staging, footprint-aware y-sort, docked control palettes, PNG/SVG/scene-JSON export, and a blockout mode that exports **depth + lineart maps straight into ControlNet conditioning** — built alongside the skill and extracted to [its own repo](https://github.com/0xDarkMatter/iso-studio) (an app with a roadmap and asset library is a product, not a skill resource).
 
 **v3.4.0** (June 2026)
 - 📊 **`r-ops` skill** — the set's first data-science skill: a tidyverse-first, current-best-practice reference for modern R (2024+). `SKILL.md` routes an import → tidy → transform → visualize → model → communicate workflow across **9 reference files (~115 KB)** — tidyverse-core, import-io, strings-dates-factors, visualization, iteration-functional, modeling-stats, data-table, time-series, workflow-tooling. Leads with current idioms (native `|>`, dplyr `.by=`, the `\(x)` lambda, `across()`, `list_rbind`, `slice_*`, tidymodels, the tidyverts `tsibble`/`fable` stack, Quarto + renv) and names base R / `data.table` where they win. Ships a 43-assertion offline self-test plus a `check-r-facts.py` §7 staleness verifier (`--offline` asserts every catalogued CRAN package is still named in the prose and the currency note carries a year; `--live` resolves each package on CRAN) so the modern-stack claim is **machine-enforced, not asserted**. Salvaged and freshened from the stale stacked PR #6 (which also duplicated the already-shipped supply-chain-defense), re-landed clean off current `main`.
@@ -218,7 +218,7 @@ See [skill-creator](skills/skill-creator/) for the complete guide.
 | [genart-ops](skills/genart-ops/) | Generative art - three.js scenes, p5.js sketches, SVG generation, GLSL shaders, procedural algorithms, colour theory |
 | [threejs-ops](skills/threejs-ops/) | App/game-scale three.js - import maps + ES-module reality, GLTF pipeline (DRACO/KTX2/meshopt, gltf-transform), AnimationMixer crossfades, fixed-timestep loops, rapier/cannon-es physics, R3F + drei, InstancedMesh/LOD/disposal discipline, boids/steering actors; npm staleness verifier |
 | [mapbox-ops](skills/mapbox-ops/) | Advanced Mapbox GL JS (web v3) - custom markers, thematic dataviz, 3D/terrain, cinematic camera, style composition, expressions, performance, gotchas; headless Playwright map verifier |
-| [isometric-ops](skills/isometric-ops/) | Isometric asset creation end-to-end - exact projection math (true iso vs 2:1 dimetric), SVG/CSS/three.js generation, pixel-art + Blender pre-render pipelines, engine tilemaps, AI generation with ControlNet structure control, tile QA + atlas packing scripts, and the bundled zero-dependency iso-studio scene composer (snap-to-grid, y-sort, blockout-to-ControlNet export) |
+| [isometric-ops](skills/isometric-ops/) | Isometric asset creation end-to-end - exact projection math (true iso vs 2:1 dimetric), SVG/CSS/three.js generation, pixel-art + Blender pre-render pipelines, engine tilemaps, AI generation with ControlNet structure control, tile QA + atlas packing scripts; routes to the companion [iso-studio](https://github.com/0xDarkMatter/iso-studio) scene composer (snap-to-grid, y-sort, blockout-to-ControlNet export) |
 | [unfold-admin](skills/unfold-admin/) | Django Unfold admin theme - ModelAdmin, dashboards, filters, widgets, theming |
 
 #### Python Skills

+ 21 - 19
skills/isometric-ops/SKILL.md

@@ -1,6 +1,6 @@
 ---
 name: isometric-ops
-description: "Create, refine, compose, and export isometric illustrative assets for websites and games. Covers projection math (true isometric 30° vs 2:1 dimetric vs pixel 1:2), SVG/CSS/three.js generation, pixel-art workflow, Blender pre-render rigs, engine tilemaps (Godot/Unity/Phaser), AI generation with ControlNet structure control, asset sourcing and licences, and the bundled iso-studio scene composer (snap-to-grid staging, y-sort, blockout-to-ControlNet export). Use for: isometric, dimetric, axonometric, isometric illustration, isometric icon, isometric city, isometric room, isometric map, iso grid, isometric tiles, 2:1 tiles, tile spec, tileset, tilemap, y-sort, depth sorting, orthographic camera, SSR method, scale shear rotate, isometric CSS, isometric SVG, iso-studio, snap to grid, sprite sheet, spritesheet, atlas packing, isometric pixel art, Aseprite isometric, Blender isometric render, isometric AI generation, isometric LoRA, ControlNet isometric, isometric asset pack, Kenney isometric, isometric export."
+description: "Create, refine, compose, and export isometric illustrative assets for websites and games. Covers projection math (true isometric 30° vs 2:1 dimetric vs pixel 1:2), SVG/CSS/three.js generation, pixel-art workflow, Blender pre-render rigs, engine tilemaps (Godot/Unity/Phaser), AI generation with ControlNet structure control, asset sourcing and licences, and the companion iso-studio scene composer (standalone app: snap-to-grid staging, y-sort, blockout-to-ControlNet export). Use for: isometric, dimetric, axonometric, isometric illustration, isometric icon, isometric city, isometric room, isometric map, iso grid, isometric tiles, 2:1 tiles, tile spec, tileset, tilemap, y-sort, depth sorting, orthographic camera, SSR method, scale shear rotate, isometric CSS, isometric SVG, iso-studio, snap to grid, sprite sheet, spritesheet, atlas packing, isometric pixel art, Aseprite isometric, Blender isometric render, isometric AI generation, isometric LoRA, ControlNet isometric, isometric asset pack, Kenney isometric, isometric export."
 license: MIT
 allowed-tools: "Read Write Bash"
 metadata:
@@ -14,7 +14,8 @@ Create, refine, compose, and export isometric illustrative assets for websites a
 games — end to end. This skill covers the exact projection math, the vector/CSS/SVG and
 three.js generation routes, the pixel-art and Blender pre-render pipelines, engine
 tilemap integration, the AI-generation-with-structure-control workflow, asset sourcing
-with licence discipline, and the bundled **iso-studio** scene composer. Every constant is
+with licence discipline, and the companion **iso-studio** scene composer (a standalone
+app that grew out of this skill). Every constant is
 derived and machine-checked; every workflow is runnable; every claim is sourced in its
 reference file.
 
@@ -196,9 +197,10 @@ three.js scene.
 
 ### Route E — Compose a scene (iso-studio)
 
-The bundled **iso-studio** scene composer stages assets on a snap-to-grid isometric canvas
-with automatic depth sorting and a blockout-to-ControlNet export path. See §5 below for the
-launch command and status.
+The companion **iso-studio** scene composer (standalone app, local checkout
+`X:\Forge\iso-studio`) stages assets on a snap-to-grid isometric canvas with automatic
+depth sorting and a blockout-to-ControlNet export path. See §5 below for the launch
+command and status.
 
 1. **Launch** the app (§5), pick a projection, set tile width and grid extent.
 2. **Import** PNG/SVG/WebP by drag-drop, paste, or file picker; assets land in the tray.
@@ -207,7 +209,7 @@ launch command and status.
 4. **Depth** sorts automatically by `(tileX + tileY)`, then elevation, then zBias, across
    ground / props / overlay layers.
 5. **Export** PNG at 1×/2×/4× (transparent, cropped) or save the scene as JSON conforming
-   to [`assets/scene-schema.json`](assets/scene-schema.json) (version "1.0").
+   to the app repo's `scene-schema.json` (version "1.0").
 6. **Blockout → ControlNet** (v2 feature): place flat-shaded grey primitives and export a
    depth-map / lineart render that conditions the AI pipeline (Route C, step 3).
 
@@ -247,18 +249,18 @@ Pure-stdlib scripts run with `python`; Pillow scripts use PEP 723 inline metadat
 | [`palettes/three-tone-presets.json`](assets/palettes/three-tone-presets.json) | 8 three-tone presets (`kenney-prototype-grey`, `pastel-dollhouse`, `industrial-muted`, `cyberpunk-teal-violet`, `blueprint`, `earthy-game`, `mono-ink`, `brand-neutral`); top-lightest verified by WCAG luminance | Route A/B, [`style-guide.md`](references/style-guide.md) |
 | [`grids/`](assets/grids/) | Pre-generated `true-iso-{32,64,128}.svg` and `dimetric-2to1-{32,64,128}.svg` (line slope 0.5 dimetric / tan30° true iso) | Route A, backdrops |
 | [`blender-iso-rig.py`](assets/blender-iso-rig.py) | Headless Blender ortho-rig + N-direction sprite baker + optional depth/normal passes | Route D, step 2 |
-| [`scene-schema.json`](assets/scene-schema.json) | Draft-07 JSON Schema (v1.0) the iso-studio scene files validate against | Route E, save/load |
-| [`iso-studio/`](assets/iso-studio/) | The zero-dependency scene composer (`index.html` + `server.mjs`); full manual: [`iso-studio.md`](references/iso-studio.md) | Route E, §5 |
+| **iso-studio** (external) | The zero-dependency scene composer — standalone repo at `X:\Forge\iso-studio` (github.com/0xDarkMatter/iso-studio), owns `scene-schema.json` + the asset library; pointer: [`iso-studio.md`](references/iso-studio.md) | Route E, §5 |
 
-## 5. iso-studio — the scene composer
+## 5. iso-studio — the scene composer (standalone app)
 
-**iso-studio** is a zero-dependency, no-build isometric scene composer bundled with this
-skill at `assets/iso-studio/` (`index.html` + `server.mjs`, no npm deps). Launch it, then
-work the docked palettes:
+**iso-studio** is a zero-dependency, no-build isometric scene composer that grew out of
+this skill and now lives in its own repository — local checkout `X:\Forge\iso-studio`,
+remote `github.com/0xDarkMatter/iso-studio` (`index.html` + `server.mjs`, no npm deps).
+Launch it, then work the docked palettes:
 
 ```
-node assets/iso-studio/server.mjs        # then open http://localhost:4323
-PORT=8080 node assets/iso-studio/server.mjs
+node X:\Forge\iso-studio\server.mjs      # then open http://localhost:4323
+PORT=8080 node X:\Forge\iso-studio\server.mjs
 ```
 
 - **Canvas + Grid** — projection selector (2:1 dimetric / true isometric / custom angle),
@@ -273,8 +275,7 @@ PORT=8080 node assets/iso-studio/server.mjs
 - **Inspector, Scene, Export palettes** — anchor/footprint/elevation/scale/flip/zBias
   editing; background/checkerboard/canvas size; PNG export at 1×/2×/4× (crop-to-content,
   transparent), SVG export (gated — every placed asset must be SVG-sourced), and scene
-  JSON save/load conforming to [`assets/scene-schema.json`](assets/scene-schema.json)
-  (version "1.0").
+  JSON save/load conforming to the app repo's `scene-schema.json` (version "1.0").
 - **Blockout mode (signature feature)** — place flat-shaded three-tone grey primitives
   (box / slab / ramp / cylinder) and export a **depth map** and a **lineart** render sized
   to the canvas; both condition the ControlNet step of the AI pipeline
@@ -285,9 +286,10 @@ PORT=8080 node assets/iso-studio/server.mjs
 The full manual — workspace tour, projection/snap configuration, anchor-at-feet
 discipline, the complete hotkey table, the scene-JSON schema walkthrough, the
 blockout → depth/lineart → ControlNet round trip step by step, and a "known limits"
-section (depth export is per-instance flat grey, not per-face; elevation is a sort
-tiebreaker, not a depth-normalization input; `flipX` has no effect on primitives) — lives
-in [`references/iso-studio.md`](references/iso-studio.md).
+section (depth export is per-instance flat grey, elevation-aware but not per-face;
+`flipX` mirrors a ramp's slope, no-op on symmetric primitives) — lives in the app repo
+at `docs/MANUAL.md`; this skill's [`references/iso-studio.md`](references/iso-studio.md)
+is the quickstart pointer.
 
 ---
 

+ 0 - 1861
skills/isometric-ops/assets/iso-studio/index.html

@@ -1,1861 +0,0 @@
-<!doctype html>
-<html lang="en">
-<head>
-<meta charset="utf-8" />
-<meta name="viewport" content="width=device-width, initial-scale=1" />
-<title>iso-studio — isometric scene composer</title>
-<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 1 15 4.5 8 8 1 4.5Z' fill='%23c8cdd4'/%3E%3Cpath d='M1 4.5 8 8v7L1 11.5Z' fill='%238a919b'/%3E%3Cpath d='M15 4.5 8 8v7l7-3.5Z' fill='%23565e69'/%3E%3C/svg%3E" />
-<style>
-  /* ---------------------------------------------------------------------------
-     iso-studio — a zero-dependency isometric scene composer (isometric-ops).
-     Docked control-palette aesthetic borrowed from tools/svg-brand-tuner, scaled up.
-     All state, math, render, input, palettes, and IO live in the single <script>
-     at the bottom, split into clearly commented sections for the v2 agent.
-  --------------------------------------------------------------------------- */
-  /* Quiet neutral palette, one accent. No gradients; the faintest 1px borders are the only chrome. */
-  :root {
-    --ink:#1a2229; --accent:#3d7f99; --accent-ink:#f7fafb;
-    --canvas:#f4f6f7; --card:#ffffff; --panel:#fafbfc;
-    --border:#e6eaed; --muted:#7a858c; --sel:#c8892a; --sel-soft:rgba(200,137,42,.14);
-    --danger:#b34a3f;
-  }
-  * { box-sizing: border-box; }
-  html, body { height:100%; }
-  /* Manrope renders when installed locally; the stack degrades gracefully to Inter / the
-     system UI font. No web-font <link> or data-URI embed — the app stays fully offline. */
-  body { margin:0; font-family:Manrope,"Manrope",Inter,system-ui,-apple-system,sans-serif;
-         color:var(--ink); background:var(--canvas); overflow:hidden; user-select:none;
-         font-size:12px; }
-  .app { display:flex; height:100vh; }
-
-  /* ---- left rail: asset tray ---- */
-  .tray { width:190px; flex:none; background:var(--card); border-right:1px solid var(--border);
-          display:flex; flex-direction:column; height:100vh; }
-  .tray .head { padding:14px 14px 8px; }
-  .tray h1 { font-size:15px; margin:0; letter-spacing:-.01em; }
-  .tray .ver { font-size:10px; color:var(--muted); font-weight:600; }
-  .tray .sub { font-size:11px; color:var(--muted); margin:2px 0 0; line-height:1.4; }
-  .tray .body { flex:1; overflow:auto; padding:8px 10px; }
-  .traygrid { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
-  .thumb { position:relative; border:1px solid var(--border); border-radius:8px; background:var(--panel);
-           aspect-ratio:1; display:grid; place-items:center; cursor:pointer; overflow:hidden; }
-  .thumb.sel { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent) inset; }
-  .thumb img { max-width:88%; max-height:88%; image-rendering:auto; pointer-events:none;
-               background-image:linear-gradient(45deg,#e3e9ec 25%,transparent 25%,transparent 75%,#e3e9ec 75%),
-                                linear-gradient(45deg,#e3e9ec 25%,transparent 25%,transparent 75%,#e3e9ec 75%);
-               background-size:10px 10px; background-position:0 0,5px 5px; }
-  .thumb .nm { position:absolute; left:0; right:0; bottom:0; font-size:9px; padding:2px 3px;
-               background:rgba(255,255,255,.82); color:var(--muted); white-space:nowrap;
-               overflow:hidden; text-overflow:ellipsis; }
-  .thumb .x { position:absolute; top:2px; right:2px; width:15px; height:15px; border-radius:50%;
-              border:none; background:rgba(0,0,0,.4); color:#fff; font-size:11px; line-height:15px;
-              padding:0; cursor:pointer; opacity:0; }
-  .thumb:hover .x { opacity:1; }
-  .tray .foot { padding:10px; border-top:1px solid var(--border); }
-  .empty { color:var(--muted); font-size:12px; padding:14px 6px; text-align:center; line-height:1.5; }
-
-  /* ---- center: stage ---- */
-  .stage { flex:1; position:relative; min-width:0; }
-  #cv { position:absolute; inset:0; width:100%; height:100%; display:block; touch-action:none; }
-  .stage.pan #cv { cursor:grabbing; }
-  .badge { position:absolute; top:10px; left:10px; font-family:ui-monospace,monospace; font-size:11px;
-           background:rgba(255,255,255,.9); border:1px solid var(--border); border-radius:8px;
-           padding:5px 9px; line-height:1.6; pointer-events:none; color:var(--muted); }
-  .badge b { color:var(--ink); font-weight:700; }
-  .zoom { position:absolute; top:10px; right:10px; display:flex; gap:4px; align-items:center;
-          background:var(--card); border:1px solid var(--border); border-radius:999px; padding:3px; }
-  .zoom button { width:28px; height:28px; border-radius:50%; padding:0; font-size:15px; border:none;
-                 background:transparent; cursor:pointer; color:var(--ink); }
-  .zoom .lv { width:auto; min-width:48px; font-size:11px; font-variant-numeric:tabular-nums; }
-
-  /* ---- right rail: docked palettes ---- */
-  .rail { width:270px; flex:none; background:var(--card); border-left:1px solid var(--border);
-          overflow:auto; height:100vh; }
-  .pal { border-bottom:1px solid var(--border); }
-  .pal > summary { list-style:none; cursor:pointer; padding:11px 14px; font-size:11px; font-weight:700;
-                   letter-spacing:.07em; text-transform:uppercase; color:var(--muted);
-                   display:flex; justify-content:space-between; align-items:center; }
-  .pal > summary::-webkit-details-marker { display:none; }
-  .pal > summary::after { content:'▸'; font-size:10px; transition:transform .12s; }
-  .pal[open] > summary::after { transform:rotate(90deg); }
-  .pal .in { padding:2px 14px 14px; }
-  .row { display:flex; flex-direction:column; gap:4px; font-size:12px; margin-bottom:11px; }
-  .row .top { display:flex; justify-content:space-between; align-items:baseline; }
-  .row .v { font-variant-numeric:tabular-nums; color:var(--accent); font-weight:700; }
-  .grid2 { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
-  .grid2 .row { margin-bottom:0; }
-  label.fld { font-size:11px; color:var(--muted); display:flex; flex-direction:column; gap:3px; }
-  input, select, button { font-family:inherit; }
-  input[type=range] { width:100%; accent-color:var(--accent); }
-  input[type=number], input[type=text], select {
-    border:1px solid var(--border); border-radius:6px; padding:6px 8px; font-size:12px; width:100%;
-    background:var(--card); color:var(--ink); }
-  /* coordinate + numeric readouts use tabular figures so digits don't jitter */
-  input[type=number] { font-variant-numeric:tabular-nums; }
-  input[type=color] { width:100%; height:30px; padding:0; border:1px solid var(--border);
-                      border-radius:6px; background:none; cursor:pointer; }
-  input[type=checkbox] { accent-color:var(--accent); }
-  .seg { display:flex; gap:5px; flex-wrap:wrap; }
-  .btn { border:1px solid var(--border); background:var(--card); color:inherit; border-radius:7px;
-         padding:6px 10px; font-size:12px; cursor:pointer; }
-  .btn:hover { border-color:var(--accent); }
-  .btn.on { background:var(--accent); border-color:var(--accent); color:var(--accent-ink); font-weight:600; }
-  .btn.wide { width:100%; text-align:center; }
-  .btn.danger { border-color:var(--danger); color:var(--danger); }
-  .btn:disabled { opacity:.4; cursor:not-allowed; }
-  .btn:disabled:hover { border-color:var(--border); }
-  .filebtn { display:inline-block; width:100%; text-align:center; }
-  input[type=file] { display:none; }
-  .hint { font-size:10px; color:var(--muted); margin-top:5px; line-height:1.5; }
-  .noseln { color:var(--muted); font-size:12px; padding:4px 0 6px; }
-
-  /* ---- hotkey legend modal ---- */
-  .modal { position:fixed; inset:0; background:rgba(10,20,30,.45); display:none; place-items:center; z-index:50; }
-  .modal.on { display:grid; }
-  .sheet { background:var(--card); border:1px solid var(--border); border-radius:12px;
-           padding:22px 26px; max-width:440px; width:90%; box-shadow:0 12px 40px rgba(20,30,40,.18); }
-  .sheet h2 { margin:0 0 14px; font-size:15px; }
-  .keys { display:grid; grid-template-columns:auto 1fr; gap:7px 16px; font-size:12.5px; }
-  .keys kbd { font-family:ui-monospace,monospace; background:var(--panel); border:1px solid var(--border);
-              border-bottom-width:2px; border-radius:5px; padding:1px 6px; font-size:11px; white-space:nowrap; }
-  .keys .d { color:var(--muted); }
-  .sheet .close { margin-top:16px; }
-</style>
-</head>
-<body>
-<div class="app" id="app">
-
-  <!-- ================= LEFT RAIL: ASSET TRAY ================= -->
-  <aside class="tray">
-    <div class="head">
-      <h1>iso-studio <span class="ver" id="verlabel"></span></h1>
-      <p class="sub">Compose isometric scenes. Drop, place, sort, export.</p>
-    </div>
-    <div class="body" id="trayBody">
-      <div class="empty" id="trayEmpty">
-        No assets yet.<br>Drop a PNG/SVG/WebP anywhere, paste from clipboard, or use the picker below.
-      </div>
-      <div class="traygrid" id="trayGrid"></div>
-    </div>
-    <div class="foot">
-      <label class="filebtn btn wide">Import image…<input type="file" id="fileImport" accept="image/*" multiple></label>
-      <div class="hint">Press <b>?</b> for the hotkey legend.</div>
-    </div>
-  </aside>
-
-  <!-- ================= CENTER: STAGE ================= -->
-  <main class="stage" id="stage">
-    <canvas id="cv"></canvas>
-    <div class="badge" id="badge"></div>
-    <div class="zoom" id="zoom">
-      <button id="zOut" title="Zoom out">−</button>
-      <button class="lv" id="zLv" title="Reset zoom">100%</button>
-      <button id="zIn" title="Zoom in">+</button>
-    </div>
-  </main>
-
-  <!-- ================= RIGHT RAIL: DOCKED PALETTES ================= -->
-  <aside class="rail" id="rail">
-
-    <!-- Grid palette -->
-    <details class="pal" open>
-      <summary>Grid</summary>
-      <div class="in">
-        <label class="fld">Projection
-          <select id="projType">
-            <option value="dimetric21">2:1 dimetric (game iso)</option>
-            <option value="true">true isometric</option>
-            <option value="custom">custom angle</option>
-          </select>
-        </label>
-        <div class="grid2" style="margin-top:10px">
-          <label class="fld">Tile W (px)<input type="number" id="tileW" min="4" step="2" value="64"></label>
-          <label class="fld">Tile H (px)<input type="number" id="tileH" min="2" step="1" value="32"></label>
-        </div>
-        <label class="fld" id="angleWrap" style="margin-top:10px; display:none">Ground angle (°)
-          <input type="number" id="angleDeg" min="1" max="89" step="0.001" value="26.565"></label>
-        <div class="grid2" style="margin-top:10px">
-          <label class="fld">Extent X<input type="number" id="extentX" min="1" step="1" value="16"></label>
-          <label class="fld">Extent Y<input type="number" id="extentY" min="1" step="1" value="16"></label>
-        </div>
-        <div class="row" style="margin-top:12px">
-          <div class="lbl" style="font-size:11px;color:var(--muted);font-weight:700;margin-bottom:4px">Snap</div>
-          <div class="seg" id="snapSeg"></div>
-        </div>
-        <label style="display:flex;gap:7px;align-items:center;font-size:12px;margin-top:8px">
-          <input type="checkbox" id="gridVis" checked> Show grid + axes</label>
-        <div class="hint" id="projHint"></div>
-      </div>
-    </details>
-
-    <!-- Blockout palette (v2 feature 8) -->
-    <details class="pal" open>
-      <summary>Blockout</summary>
-      <div class="in">
-        <div class="seg" id="primSeg"></div>
-        <div class="grid2" style="margin-top:10px">
-          <label class="fld">Footprint W<input type="number" id="pW" min="1" step="1" value="1"></label>
-          <label class="fld">Footprint H<input type="number" id="pH" min="1" step="1" value="1"></label>
-        </div>
-        <label class="fld" style="margin-top:8px">Height (z-units)<input type="number" id="pHt" min="0.1" step="0.25" value="1"></label>
-        <div class="hint">Pick a primitive, then click the grid to place (Esc disarms). Blockout volumes feed the Depth / Lineart exports for ControlNet conditioning.</div>
-      </div>
-    </details>
-
-    <!-- Inspector palette -->
-    <details class="pal" open>
-      <summary>Inspector</summary>
-      <div class="in" id="inspector">
-        <div class="noseln" id="inspEmpty">Nothing selected. Click an instance on the stage.</div>
-        <div id="inspBody" style="display:none">
-          <div style="font-size:12px;font-weight:600;margin-bottom:8px" id="inspTitle"></div>
-          <div class="grid2">
-            <label class="fld">Tile X<input type="number" id="iTileX" step="1"></label>
-            <label class="fld">Tile Y<input type="number" id="iTileY" step="1"></label>
-          </div>
-          <div class="grid2" style="margin-top:8px">
-            <label class="fld">Elevation<input type="number" id="iElev" step="1"></label>
-            <label class="fld">zBias<input type="number" id="iZBias" step="1"></label>
-          </div>
-          <div class="grid2" style="margin-top:8px">
-            <label class="fld">Scale<input type="number" id="iScale" step="0.05" min="0.05"></label>
-            <label class="fld">Layer
-              <select id="iLayer"><option value="ground">ground</option><option value="props">props</option><option value="overlay">overlay</option></select>
-            </label>
-          </div>
-          <div id="inspAsset">
-            <div class="hint" style="margin-top:10px;color:var(--ink);font-weight:600">Asset defaults (all instances)</div>
-            <div class="grid2" style="margin-top:6px">
-              <label class="fld">Footprint W<input type="number" id="iFpW" step="1" min="1"></label>
-              <label class="fld">Footprint H<input type="number" id="iFpH" step="1" min="1"></label>
-            </div>
-            <div class="grid2" style="margin-top:8px">
-              <label class="fld">Anchor X (0–1)<input type="number" id="iAncX" step="0.05" min="0" max="1"></label>
-              <label class="fld">Anchor Y (0–1)<input type="number" id="iAncY" step="0.05" min="0" max="1"></label>
-            </div>
-          </div>
-          <div id="inspPrim" style="display:none">
-            <div class="hint" style="margin-top:10px;color:var(--ink);font-weight:600">Primitive</div>
-            <div class="grid2" style="margin-top:6px">
-              <label class="fld">Footprint W<input type="number" id="iPrimW" step="1" min="1"></label>
-              <label class="fld">Footprint H<input type="number" id="iPrimH" step="1" min="1"></label>
-            </div>
-            <label class="fld" style="margin-top:8px">Height (z-units)<input type="number" id="iPrimHt" step="0.25" min="0.1"></label>
-          </div>
-          <div class="grid2" style="margin-top:10px">
-            <label class="fld">Tint<input type="color" id="iTint" value="#3d7f99"></label>
-            <label class="fld">&nbsp;<button class="btn" id="iTintClear" type="button">Clear tint</button></label>
-          </div>
-          <label style="display:flex;gap:7px;align-items:center;font-size:12px;margin-top:10px">
-            <input type="checkbox" id="iFlipX"> Flip horizontally (F)</label>
-          <div class="seg" style="margin-top:12px">
-            <button class="btn" id="btnDup" title="Ctrl+D">Duplicate</button>
-            <button class="btn danger" id="btnDel" title="Del">Delete</button>
-          </div>
-        </div>
-      </div>
-    </details>
-
-    <!-- Scene palette -->
-    <details class="pal" open>
-      <summary>Scene</summary>
-      <div class="in">
-        <div class="row">
-          <div class="lbl" style="font-size:11px;color:var(--muted);font-weight:700;margin-bottom:4px">Background</div>
-          <div class="grid2">
-            <label class="fld">Colour<input type="color" id="bgColor" value="#eef4f7"></label>
-            <label class="fld" style="justify-content:flex-end">
-              <span style="display:flex;gap:6px;align-items:center;font-size:12px">
-                <input type="checkbox" id="bgTransparent"> transparent</span></label>
-          </div>
-        </div>
-        <label style="display:flex;gap:7px;align-items:center;font-size:12px;margin-top:6px">
-          <input type="checkbox" id="checker"> Transparency checkerboard</label>
-        <div class="grid2" style="margin-top:12px">
-          <label class="fld">Canvas W<input type="number" id="canvasW" placeholder="auto" min="1" step="1"></label>
-          <label class="fld">Canvas H<input type="number" id="canvasH" placeholder="auto" min="1" step="1"></label>
-        </div>
-        <div class="hint">Blank canvas size = crop-to-content on export.</div>
-        <div class="row" style="margin-top:12px">
-          <div class="lbl" style="font-size:11px;color:var(--muted);font-weight:700;margin-bottom:4px">Scene tint (three-tone)</div>
-          <select id="tintPreset"><option value="">none (native colours)</option></select>
-          <div class="hint">Tri-tone recolour of every instance: desaturate, then remap luminance through the preset's ink → left → top ramp. Per-instance Tint (Inspector) overrides it. Presets load from assets/palettes/three-tone-presets.json.</div>
-        </div>
-        <div class="row" style="margin-top:12px">
-          <div class="lbl" style="font-size:11px;color:var(--muted);font-weight:700;margin-bottom:4px">Instances</div>
-          <div class="noseln" id="sceneStats"></div>
-        </div>
-      </div>
-    </details>
-
-    <!-- Export palette -->
-    <details class="pal" open>
-      <summary>Export</summary>
-      <div class="in">
-        <div class="lbl" style="font-size:11px;color:var(--muted);font-weight:700;margin-bottom:6px">PNG (transparent, crop-to-content)</div>
-        <div class="seg">
-          <button class="btn" data-png="1">1×</button>
-          <button class="btn" data-png="2">2×</button>
-          <button class="btn" data-png="4">4×</button>
-        </div>
-        <div class="lbl" style="font-size:11px;color:var(--muted);font-weight:700;margin:14px 0 6px">Depth map (ControlNet, near = white)</div>
-        <div class="seg">
-          <button class="btn" data-depth="1">1×</button>
-          <button class="btn" data-depth="2">2×</button>
-          <button class="btn" data-depth="4">4×</button>
-        </div>
-        <div class="lbl" style="font-size:11px;color:var(--muted);font-weight:700;margin:14px 0 6px">Lineart (ControlNet, black on white)</div>
-        <div class="seg">
-          <button class="btn" data-line="1">1×</button>
-          <button class="btn" data-line="2">2×</button>
-          <button class="btn" data-line="4">4×</button>
-        </div>
-        <div class="lbl" style="font-size:11px;color:var(--muted);font-weight:700;margin:14px 0 6px">SVG (vector scenes only)</div>
-        <button class="btn wide" id="btnSvg">Export SVG</button>
-        <div class="lbl" style="font-size:11px;color:var(--muted);font-weight:700;margin:14px 0 6px">Scene JSON (schema v1.0)</div>
-        <div class="seg">
-          <button class="btn wide" id="btnSaveScene">Save scene…</button>
-        </div>
-        <label class="filebtn btn wide" style="margin-top:8px">Load scene…<input type="file" id="fileScene" accept=".json,application/json"></label>
-        <div class="hint">Scene round-trips against assets/scene-schema.json.</div>
-      </div>
-    </details>
-
-  </aside>
-</div>
-
-<!-- ================= HOTKEY LEGEND ================= -->
-<div class="modal" id="modal">
-  <div class="sheet">
-    <h2>Keyboard & mouse</h2>
-    <div class="keys">
-      <kbd>Space</kbd>+drag <span class="d">Pan the canvas</span>
-      <kbd>Middle</kbd>-drag <span class="d">Pan the canvas</span>
-      <kbd>Wheel</kbd> <span class="d">Zoom toward cursor</span>
-      <kbd>Click</kbd> <span class="d">Select instance / place from tray</span>
-      <kbd>Drag</kbd> <span class="d">Move selection (snaps) / marquee-select</span>
-      <kbd>Shift</kbd>+click <span class="d">Add / remove from selection</span>
-      <kbd>Arrows</kbd> <span class="d">Nudge 1 tile</span>
-      <kbd>Shift</kbd>+arrows <span class="d">Nudge 1 pixel</span>
-      <kbd>Ctrl</kbd>+<kbd>D</kbd> <span class="d">Duplicate selection</span>
-      <kbd>Ctrl</kbd>+<kbd>Z</kbd> <span class="d">Undo</span>
-      <kbd>Ctrl</kbd>+<kbd>Y</kbd> <span class="d">Redo (also Ctrl+Shift+Z)</span>
-      <kbd>F</kbd> <span class="d">Flip selection horizontally</span>
-      <kbd>Del</kbd> / <kbd>Backspace</kbd> <span class="d">Delete selection</span>
-      <kbd>[</kbd> / <kbd>]</kbd> <span class="d">zBias down / up</span>
-      <kbd>Esc</kbd> <span class="d">Deselect / cancel placement</span>
-      <kbd>?</kbd> <span class="d">This legend</span>
-    </div>
-    <button class="btn wide close" id="modalClose">Close</button>
-  </div>
-</div>
-
-<script>
-"use strict";
-/* ===========================================================================
-   iso-studio v0.2 — single-file vanilla-JS isometric scene composer.
-   Sections:  STATE · MATH · BLOCKOUT · TINT · RENDER · COMMANDS(+history) ·
-              INPUT · SELECTION · IMPORT · PALETTES · IO · BOOT
-   v2 (features 8–11) is built in: blockout primitives + depth/lineart
-   ControlNet exports, tri-tone tint, undo/redo (command inverses recorded in
-   dispatch; drag-moves coalesced at pointer-up), SVG export. All scene
-   mutations still funnel through dispatch(type, payload) — extend COMMANDS +
-   makeInverse together when adding new mutation types.
-=========================================================================== */
-
-/* ===========================================================================
-   STATE
-=========================================================================== */
-const SCHEMA_VERSION = "1.0";
-const GENERATOR = "iso-studio 0.2.0";
-const LAYER_ORDER = { ground: 0, props: 1, overlay: 2 };
-const SNAP_STEPS = { full: 1, half: 0.5, quarter: 0.25, free: 0 };
-const PRIM_KINDS = ["box", "slab", "ramp", "cylinder"];
-// Default blockout greys (kenney-prototype-grey family): top light / left mid / right dark.
-const PRIM_TONES = { top: "#d8d6d0", left: "#a8a6a0", right: "#706e6a", ink: "rgba(43,43,49,.45)", flat: "#a8a6a0" };
-const $ = id => document.getElementById(id);
-
-// The scene model. `assets` and `instances` mirror scene-schema.json exactly.
-// View state (camera, selection, tool) is NOT part of the scene and is not saved.
-const S = {
-  projection: { type: "dimetric21", tileW: 64, tileH: 32, angleDeg: 26.565, unitElevation: 16 },
-  grid: { extentX: 16, extentY: 16, snap: "full", visible: true },
-  assets: [],        // {id, name, src, anchor:{x,y}, footprint:{w,h}, sourceW, sourceH, _img}
-  instances: [],     // {id, assetId, tile:{x,y}, elevation, layer, zBias, flipX, scale, tint?}
-  canvas: { bg: "#eef4f7", checkerboard: false, width: null, height: null },
-  palette: null,     // scene tint: preset name (string), inline token map, or null
-  meta: { name: "untitled", generator: GENERATOR },
-};
-
-// View / interaction state (ephemeral, not serialized).
-const V = {
-  cam: { x: 0, y: 0, zoom: 1 },   // pan offset (screen px) + zoom
-  sel: new Set(),                 // selected instance ids
-  trayPick: null,                 // assetId armed for click-to-place, or null
-  blockPick: null,                // primitive kind armed for click-to-place, or null
-  dpr: 1,
-  cssW: 0, cssH: 0,
-  hoverTile: null,                // {x,y} under cursor (integer)
-  drag: null,                     // active drag descriptor
-  needsRender: true,
-};
-
-let _idSeq = 1;
-const uid = pfx => `${pfx}${(_idSeq++).toString(36)}${Date.now().toString(36).slice(-3)}`;
-
-/* ===========================================================================
-   MATH  — must agree with references/coordinates-depth.md and iso-math.py.
-   Forward:  screenX=(x−y)·tileW/2 ; screenY=(x+y)·tileH/2 − z·unitZ
-   Inverse:  x=(sx/halfW+sy/halfH)/2 ; y=(sy/halfH−sx/halfW)/2
-   Draw order: (x+y) asc, elevation asc, layer asc, zBias asc.
-=========================================================================== */
-function halfW() { return S.projection.tileW / 2; }
-function halfH() { return S.projection.tileH / 2; }
-function unitZ() { return S.projection.unitElevation || S.projection.tileH; }
-
-// Tile (x,y,z) -> world screen pixels (before camera pan/zoom). Anchor subtraction is the caller's job.
-function tileToScreen(x, y, z) {
-  z = z || 0;
-  return { x: (x - y) * halfW(), y: (x + y) * halfH() - z * unitZ() };
-}
-// World screen pixels -> fractional tile on the ground plane (z=0).
-function screenToTile(sx, sy) {
-  const hw = halfW(), hh = halfH();
-  return { x: (sx / hw + sy / hh) / 2, y: (sy / hh - sx / hw) / 2 };
-}
-// Integer tile under a world screen point (within-diamond, §6 Method A).
-function pickTile(sx, sy) {
-  const t = screenToTile(sx, sy);
-  return { x: Math.floor(t.x), y: Math.floor(t.y) };
-}
-// Snap a fractional tile coordinate to the active snap grid.
-function snapTile(v) {
-  const step = SNAP_STEPS[S.grid.snap];
-  if (step === 0) return v;               // free
-  return Math.round(v / step) * step;
-}
-// Canonical depth-sort key tuple for an instance (ascending).
-function depthKey(inst) {
-  const fp = inst.primitive
-    ? { w: inst.primitive.w || 1, h: inst.primitive.h || 1 }
-    : ((getAsset(inst.assetId) || {}).footprint || { w: 1, h: 1 });
-  // Multi-tile sprites sort by their FRONTMOST ground cell (max x+y) — Strategy 1,
-  // references/coordinates-depth.md §9. For a 1×1 footprint this is just (x+y).
-  const frontSum = (inst.tile.x + fp.w - 1) + (inst.tile.y + fp.h - 1);
-  return [frontSum, inst.elevation || 0, LAYER_ORDER[inst.layer] || 0, inst.zBias || 0];
-}
-// Compare two depth-key tuples (stable sort caller adds index tiebreak).
-function cmpDepth(a, b) {
-  const ka = depthKey(a), kb = depthKey(b);
-  for (let i = 0; i < ka.length; i++) if (ka[i] !== kb[i]) return ka[i] - kb[i];
-  return 0;
-}
-
-// Convert screen (world) coords <-> viewport (canvas CSS px) coords via camera.
-function worldToView(wx, wy) { return { x: wx * V.cam.zoom + V.cam.x, y: wy * V.cam.zoom + V.cam.y }; }
-function viewToWorld(vx, vy) { return { x: (vx - V.cam.x) / V.cam.zoom, y: (vy - V.cam.y) / V.cam.zoom }; }
-
-// Keep projection invariants: dimetric enforces tileH = tileW/2 on the DERIVED axis.
-function applyProjectionConstraints() {
-  const p = S.projection;
-  if (p.type === "dimetric21") {
-    p.tileH = p.tileW / 2;
-    p.angleDeg = 26.565;
-  } else if (p.type === "true") {
-    // true iso: tileH/tileW follows tan30° = 0.57735
-    p.tileH = Math.round(p.tileW * 0.57735 * 100) / 100;
-    p.angleDeg = 30;
-  }
-  if (!p.unitElevation) p.unitElevation = p.tileH;
-}
-
-/* ===========================================================================
-   ASSET / INSTANCE LOOKUP
-=========================================================================== */
-function getAsset(id) { return S.assets.find(a => a.id === id); }
-function getInst(id) { return S.instances.find(i => i.id === id); }
-
-// Bounding box of a placed instance in WORLD pixels (for picking + marquee + crop).
-function instBounds(inst) {
-  if (inst.primitive) return primBounds(inst);
-  const a = getAsset(inst.assetId);
-  if (!a || !a._img || !a._img.complete || !a._img.naturalWidth) return null;
-  const w = a._img.naturalWidth, h = a._img.naturalHeight;
-  const sc = (inst.scale || 1);
-  const drawW = w * sc, drawH = h * sc;
-  const anc = a.anchor || { x: 0.5, y: 1 };
-  const p = tileToScreen(inst.tile.x, inst.tile.y, inst.elevation || 0);
-  // Anchor's image-space point lands on the tile origin p. flipX mirrors anchor.x.
-  const ancX = (inst.flipX ? (1 - anc.x) : anc.x) * drawW;
-  const ancY = anc.y * drawH;
-  const wx = p.x - ancX, wy = p.y - ancY;    // top-left in world space
-  return { wx, wy, drawW, drawH, w, h, sc, anc, img: a._img };
-}
-
-/* ===========================================================================
-   BLOCKOUT PRIMITIVES (v2 feature 8) — procedural flat-shaded volumes.
-   Geometry is computed in WORLD coordinates from the same tileToScreen math
-   as sprites, so primitives snap, sort, and export identically.
-=========================================================================== */
-// Face/path geometry for one primitive instance (world coords).
-function primShape(inst) {
-  const p = inst.primitive, e = inst.elevation || 0;
-  const tx = inst.tile.x, ty = inst.tile.y;
-  const w = p.w || 1, h = p.h || 1, ht = p.height != null ? p.height : 1;
-  const P = tileToScreen;
-  if (p.kind === "cylinder") {
-    // Elliptical-cap volume inscribed in the footprint. A tile-space circle of
-    // radius r maps to a screen ellipse with semi-axes r·halfW·√2 / r·halfH·√2.
-    const cx = tx + w / 2, cy = ty + h / 2, r = Math.min(w, h) / 2;
-    return { kind: "cylinder", pT: P(cx, cy, e + ht), pB: P(cx, cy, e),
-             rx: r * halfW() * Math.SQRT2, ry: r * halfH() * Math.SQRT2 };
-  }
-  if (p.kind === "ramp") {
-    // Wedge rising along +x: z = e at the x=tx edge, z = e+ht at the x=tx+w edge.
-    // flipX negates the slope axis (rises along −x: tall edge at x=tx). Because
-    // every render path (canvas beauty/flat/line, SVG, bounds) consumes this
-    // geometry, honoring flipX here covers them all. Box/slab/cylinder are
-    // symmetric under a horizontal mirror, so flipX stays a no-op for them.
-    const a = P(tx, ty, e), b = P(tx + w, ty, e), c = P(tx + w, ty + h, e), d = P(tx, ty + h, e);
-    if (inst.flipX) {
-      const a2 = P(tx, ty, e + ht), d2 = P(tx, ty + h, e + ht);
-      // The tall end face (x=tx) faces −x, away from the camera — not visible.
-      return { kind: "faces", faces: [
-        { pts: [d2, c, d], tone: "left" },      // +y side triangle (mid)
-        { pts: [a2, b, c, d2], tone: "top" },   // sloped top (light)
-      ] };
-    }
-    const b2 = P(tx + w, ty, e + ht), c2 = P(tx + w, ty + h, e + ht);
-    return { kind: "faces", faces: [
-      { pts: [b2, c2, c, b], tone: "right" },   // tall end face (dark)
-      { pts: [d, c2, c], tone: "left" },        // sloped side triangle (mid)
-      { pts: [a, b2, c2, d], tone: "top" },     // sloped top (light)
-    ] };
-  }
-  // box / slab (a slab is just a short box; the palette defaults height 0.25)
-  const b = P(tx + w, ty, e), c = P(tx + w, ty + h, e), d = P(tx, ty + h, e);
-  const a2 = P(tx, ty, e + ht), b2 = P(tx + w, ty, e + ht), c2 = P(tx + w, ty + h, e + ht), d2 = P(tx, ty + h, e + ht);
-  return { kind: "faces", faces: [
-    { pts: [b2, c2, c, b], tone: "right" },     // +x side face (dark)
-    { pts: [c2, d2, d, c], tone: "left" },      // +y side face (mid)
-    { pts: [a2, b2, c2, d2], tone: "top" },     // top face (light)
-  ] };
-}
-// World-space AABB of a primitive (same contract as image instBounds).
-function primBounds(inst) {
-  const s = primShape(inst);
-  if (s.kind === "cylinder")
-    return { wx: s.pT.x - s.rx, wy: s.pT.y - s.ry, drawW: 2 * s.rx,
-             drawH: (s.pB.y + s.ry) - (s.pT.y - s.ry), prim: true };
-  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
-  for (const f of s.faces) for (const q of f.pts) {
-    if (q.x < minX) minX = q.x; if (q.y < minY) minY = q.y;
-    if (q.x > maxX) maxX = q.x; if (q.y > maxY) maxY = q.y;
-  }
-  return { wx: minX, wy: minY, drawW: maxX - minX, drawH: maxY - minY, prim: true };
-}
-// Draw a primitive in WORLD coordinates (caller sets the canvas transform).
-// mode: "beauty" = three tones + ink stroke; "flat" = single tones.flat fill,
-// no stroke (depth pass); "line" = white fill + black stroke (lineart pass).
-function primDrawCanvas(c, inst, tones, lw, mode) {
-  const s = primShape(inst);
-  const fillOf = t => mode === "flat" ? tones.flat : mode === "line" ? "#ffffff" : tones[t];
-  const stroke = mode === "flat" ? null : mode === "line" ? "#000000" : tones.ink;
-  c.lineWidth = lw || 1; c.lineJoin = "round";
-  if (stroke) c.strokeStyle = stroke;
-  if (s.kind === "cylinder") {
-    const { pT, pB, rx, ry } = s;
-    if (mode === "beauty") {
-      // two shaded body halves, split on the vertical axis (left mid, right dark)
-      c.beginPath();
-      c.moveTo(pT.x - rx, pT.y); c.lineTo(pB.x - rx, pB.y);
-      c.ellipse(pB.x, pB.y, rx, ry, 0, Math.PI, Math.PI / 2, true);
-      c.lineTo(pT.x, pT.y + ry);
-      c.ellipse(pT.x, pT.y, rx, ry, 0, Math.PI / 2, Math.PI, false);
-      c.closePath(); c.fillStyle = tones.left; c.fill(); if (stroke) c.stroke();
-      c.beginPath();
-      c.moveTo(pT.x + rx, pT.y); c.lineTo(pB.x + rx, pB.y);
-      c.ellipse(pB.x, pB.y, rx, ry, 0, 0, Math.PI / 2, false);
-      c.lineTo(pT.x, pT.y + ry);
-      c.ellipse(pT.x, pT.y, rx, ry, 0, Math.PI / 2, 0, true);
-      c.closePath(); c.fillStyle = tones.right; c.fill(); if (stroke) c.stroke();
-    } else {
-      // full-body silhouette (no phantom center seam in lineart / depth)
-      c.beginPath();
-      c.moveTo(pT.x - rx, pT.y); c.lineTo(pB.x - rx, pB.y);
-      c.ellipse(pB.x, pB.y, rx, ry, 0, Math.PI, 0, true);
-      c.lineTo(pT.x + rx, pT.y);
-      c.ellipse(pT.x, pT.y, rx, ry, 0, 0, Math.PI, false);
-      c.closePath(); c.fillStyle = fillOf("left"); c.fill(); if (stroke) c.stroke();
-    }
-    c.beginPath(); c.ellipse(pT.x, pT.y, rx, ry, 0, 0, 2 * Math.PI);
-    c.fillStyle = fillOf("top"); c.fill(); if (stroke) c.stroke();
-    return;
-  }
-  for (const f of s.faces) {
-    c.beginPath(); c.moveTo(f.pts[0].x, f.pts[0].y);
-    for (let i = 1; i < f.pts.length; i++) c.lineTo(f.pts[i].x, f.pts[i].y);
-    c.closePath(); c.fillStyle = fillOf(f.tone); c.fill();
-    if (stroke) c.stroke();
-  }
-}
-
-/* ===========================================================================
-   TINT (v2 feature 9) — tri-tone recolour: desaturate to luminance, then remap
-   through a 3-stop ramp [dark, mid, light]. Canvas port of the svg-brand-tuner
-   feComponentTransfer technique. Recoloured bitmaps are cached per asset+ramp
-   so the render hot path stays allocation-free.
-=========================================================================== */
-const FALLBACK_PRESETS = [
-  // Built-in copies of three presets from assets/palettes/three-tone-presets.json,
-  // used when the runtime fetch fails (file://, moved file, old server).
-  { name: "kenney-prototype-grey", ink: "#2b2b31", top: "#d8d6d0", left: "#a8a6a0", right: "#706e6a", shadow: "#4a4844", accent: "#5b9bd5", bg: "#eceae5" },
-  { name: "blueprint", ink: "#e8f2ff", top: "#5f8fd9", left: "#3f6bb0", right: "#284a85", shadow: "#152c58", accent: "#ffffff", bg: "#0f2b5c" },
-  { name: "earthy-game", ink: "#3a2a1e", top: "#e0c07a", left: "#a8823f", right: "#7a5a2e", shadow: "#4a3620", accent: "#6f9c4a", bg: "#f2e3bd" },
-];
-let PRESETS = FALLBACK_PRESETS;
-let _sceneRamp = null;              // cached [dark,mid,light] for the active scene palette
-const _instRamps = new Map();       // tint hex -> cached ramp array
-const _tintCache = new Map();       // assetId|ramp -> recoloured offscreen canvas
-
-function hexToRgb(h) { const n = parseInt(h.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
-function rgbToHex(r, g, b) { return "#" + ((1 << 24) | (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(b)).toString(16).slice(1); }
-function mixHex(a, b, t) {
-  const A = hexToRgb(a), B = hexToRgb(b);
-  return rgbToHex(A[0] + (B[0] - A[0]) * t, A[1] + (B[1] - A[1]) * t, A[2] + (B[2] - A[2]) * t);
-}
-function presetByName(name) { return PRESETS.find(p => p.name === name) || null; }
-// Normalize S.palette (preset name string OR inline token map) to a preset-like object.
-function paletteObj() {
-  if (!S.palette) return null;
-  if (typeof S.palette === "string") return presetByName(S.palette);
-  return (S.palette.top && S.palette.left) ? S.palette : null;
-}
-function sceneRamp() {
-  if (_sceneRamp) return _sceneRamp;
-  const p = paletteObj(); if (!p) return null;
-  _sceneRamp = [p.ink || "#20242a", p.left || "#9aa2a8", p.top || "#e2e5e8"];
-  return _sceneRamp;
-}
-function instRampFor(tint) {
-  let r = _instRamps.get(tint);
-  if (!r) { r = [mixHex(tint, "#101418", 0.62), tint, mixHex(tint, "#ffffff", 0.72)]; _instRamps.set(tint, r); }
-  return r;
-}
-// Effective ramp for an instance: per-instance tint overrides the scene palette.
-function rampFor(inst) { return (inst.tint && /^#/.test(inst.tint)) ? instRampFor(inst.tint) : sceneRamp(); }
-// Effective three-tone fills for a primitive (native greys when untinted).
-function effTones(inst) {
-  if (inst.tint && /^#/.test(inst.tint)) {
-    const r = instRampFor(inst.tint);
-    return { top: r[2], left: r[1], right: r[0], ink: "rgba(16,20,24,.35)", flat: r[1] };
-  }
-  const p = paletteObj();
-  if (p) return { top: p.top, left: p.left, right: p.right, ink: p.shadow || p.ink, flat: p.left };
-  return PRIM_TONES;
-}
-// Luminance -> piecewise 3-stop ramp remap, alpha preserved.
-function recolor(img, ramp) {
-  const c = document.createElement("canvas");
-  c.width = img.naturalWidth; c.height = img.naturalHeight;
-  const x = c.getContext("2d", { willReadFrequently: true });
-  x.drawImage(img, 0, 0);
-  let id;
-  try { id = x.getImageData(0, 0, c.width, c.height); }
-  catch { return img; }               // tainted canvas (remote src) — draw untinted
-  const d = id.data, D = hexToRgb(ramp[0]), M = hexToRgb(ramp[1]), L = hexToRgb(ramp[2]);
-  for (let i = 0; i < d.length; i += 4) {
-    if (!d[i + 3]) continue;
-    const lum = (0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]) / 255;
-    let A, B, t;
-    if (lum < 0.5) { A = D; B = M; t = lum * 2; } else { A = M; B = L; t = (lum - 0.5) * 2; }
-    d[i] = A[0] + (B[0] - A[0]) * t;
-    d[i + 1] = A[1] + (B[1] - A[1]) * t;
-    d[i + 2] = A[2] + (B[2] - A[2]) * t;
-  }
-  x.putImageData(id, 0, 0);
-  return c;
-}
-// Cached tinted bitmap for an asset+instance (or the raw image when untinted).
-function tintedImage(asset, inst) {
-  if (!asset || !asset._img || !asset._img.complete || !asset._img.naturalWidth) return null;
-  const ramp = rampFor(inst);
-  if (!ramp) return asset._img;
-  const key = asset.id + "|" + ramp[0] + ramp[1] + ramp[2];
-  let c = _tintCache.get(key);
-  if (!c) {
-    if (_tintCache.size > 64) _tintCache.clear();   // simple bound; keys re-warm on demand
-    c = recolor(asset._img, ramp);
-    _tintCache.set(key, c);
-  }
-  return c;
-}
-
-/* ===========================================================================
-   RENDER  — requestAnimationFrame loop, DPR-aware, no per-frame allocations
-   in the hot path beyond the depth-sorted instance list.
-=========================================================================== */
-const cv = $("cv");
-const ctx = cv.getContext("2d");
-let _sortBuf = [];   // reused array to avoid per-frame allocation churn
-
-function resizeCanvas() {
-  const st = $("stage");
-  V.cssW = st.clientWidth; V.cssH = st.clientHeight;
-  V.dpr = window.devicePixelRatio || 1;
-  cv.width = Math.round(V.cssW * V.dpr);
-  cv.height = Math.round(V.cssH * V.dpr);
-  V.needsRender = true;
-}
-
-function requestRender() { V.needsRender = true; }
-
-function drawCheckerboard() {
-  const size = 12;
-  ctx.save();
-  ctx.fillStyle = "#e8eef1"; ctx.fillRect(0, 0, V.cssW, V.cssH);
-  ctx.fillStyle = "#d3dde2";
-  for (let y = 0; y < V.cssH; y += size)
-    for (let x = ((y / size) % 2) * size; x < V.cssW; x += size * 2)
-      ctx.fillRect(x, y, size, size);
-  ctx.restore();
-}
-
-function drawGrid() {
-  if (!S.grid.visible) return;
-  const ex = S.grid.extentX, ey = S.grid.extentY;
-  ctx.save();
-  ctx.lineWidth = 1;
-  ctx.strokeStyle = "rgba(60,90,110,.22)";
-  ctx.beginPath();
-  // lines of constant tile-x and constant tile-y across the extent
-  for (let x = 0; x <= ex; x++) {
-    const a = worldToView(...toXY(tileToScreen(x, 0, 0)));
-    const b = worldToView(...toXY(tileToScreen(x, ey, 0)));
-    ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y);
-  }
-  for (let y = 0; y <= ey; y++) {
-    const a = worldToView(...toXY(tileToScreen(0, y, 0)));
-    const b = worldToView(...toXY(tileToScreen(ex, y, 0)));
-    ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y);
-  }
-  ctx.stroke();
-  // axes: +x (red-ish), +y (green-ish) from origin
-  const o = worldToView(...toXY(tileToScreen(0, 0, 0)));
-  const ax = worldToView(...toXY(tileToScreen(Math.min(ex, 4), 0, 0)));
-  const ay = worldToView(...toXY(tileToScreen(0, Math.min(ey, 4), 0)));
-  ctx.lineWidth = 2;
-  ctx.strokeStyle = "rgba(200,70,60,.7)"; ctx.beginPath(); ctx.moveTo(o.x, o.y); ctx.lineTo(ax.x, ax.y); ctx.stroke();
-  ctx.strokeStyle = "rgba(60,150,90,.7)"; ctx.beginPath(); ctx.moveTo(o.x, o.y); ctx.lineTo(ay.x, ay.y); ctx.stroke();
-  ctx.restore();
-}
-// tiny helper so tileToScreen results spread into worldToView cleanly
-function toXY(p) { return [p.x, p.y]; }
-
-// Highlight the hovered tile diamond (placement affordance).
-function drawHoverTile() {
-  if (!V.hoverTile) return;
-  const t = V.hoverTile;
-  const c = [
-    tileToScreen(t.x, t.y, 0),
-    tileToScreen(t.x + 1, t.y, 0),
-    tileToScreen(t.x + 1, t.y + 1, 0),
-    tileToScreen(t.x, t.y + 1, 0),
-  ].map(p => worldToView(p.x, p.y));
-  ctx.save();
-  ctx.fillStyle = (V.trayPick || V.blockPick) ? "rgba(74,163,199,.25)" : "rgba(74,163,199,.14)";
-  ctx.strokeStyle = "rgba(74,163,199,.85)"; ctx.lineWidth = 1.5;
-  ctx.beginPath(); ctx.moveTo(c[0].x, c[0].y);
-  for (let i = 1; i < 4; i++) ctx.lineTo(c[i].x, c[i].y);
-  ctx.closePath(); ctx.fill(); ctx.stroke();
-  ctx.restore();
-}
-
-// Draw one instance in WORLD coordinates (caller has applied the camera/export transform).
-function drawInstance(c2d, inst, worldLW) {
-  const hasAlpha = inst.opacity != null && inst.opacity !== 1;
-  if (hasAlpha) { c2d.save(); c2d.globalAlpha = inst.opacity; }
-  if (inst.primitive) {
-    primDrawCanvas(c2d, inst, effTones(inst), worldLW, "beauty");
-  } else {
-    const b = instBounds(inst);
-    if (b) drawInstImage(c2d, inst, b, 0, 0, tintedImage(getAsset(inst.assetId), inst) || b.img);
-  }
-  if (hasAlpha) c2d.restore();
-}
-// Blit an image instance in world coords at an optional (dx,dy) offset, honoring flipX.
-function drawInstImage(c2d, inst, b, dx, dy, img) {
-  if (inst.flipX) {
-    c2d.save();
-    c2d.translate(b.wx + dx + b.drawW, b.wy + dy);
-    c2d.scale(-1, 1);
-    c2d.drawImage(img, 0, 0, b.drawW, b.drawH);
-    c2d.restore();
-  } else {
-    c2d.drawImage(img, b.wx + dx, b.wy + dy, b.drawW, b.drawH);
-  }
-}
-
-// Alignment guides (v2 feature 10): highlight the dragged instance's row and
-// column diamond bands across the grid extent while a drag-move is live.
-function drawAlignGuides() {
-  if (!V.drag || V.drag.kind !== "move" || !V.drag.moved) return;
-  const inst = getInst(V.drag.origins[0].id); if (!inst) return;
-  const cx = Math.floor(inst.tile.x), cy = Math.floor(inst.tile.y);
-  const ex = S.grid.extentX, ey = S.grid.extentY;
-  const band = pts => {
-    ctx.beginPath();
-    const v0 = worldToView(pts[0].x, pts[0].y);
-    ctx.moveTo(v0.x, v0.y);
-    for (let i = 1; i < pts.length; i++) { const v = worldToView(pts[i].x, pts[i].y); ctx.lineTo(v.x, v.y); }
-    ctx.closePath(); ctx.fill();
-  };
-  ctx.save();
-  ctx.fillStyle = "rgba(61,127,153,.10)";
-  band([tileToScreen(cx, 0, 0), tileToScreen(cx + 1, 0, 0), tileToScreen(cx + 1, ey, 0), tileToScreen(cx, ey, 0)]);
-  band([tileToScreen(0, cy, 0), tileToScreen(ex, cy, 0), tileToScreen(ex, cy + 1, 0), tileToScreen(0, cy + 1, 0)]);
-  ctx.restore();
-}
-
-function drawSelection() {
-  if (!V.sel.size) return;
-  ctx.save();
-  ctx.strokeStyle = getCss("--sel"); ctx.lineWidth = 1.5; ctx.setLineDash([5, 3]);
-  for (const id of V.sel) {
-    const inst = getInst(id); if (!inst) continue;
-    const b = instBounds(inst); if (!b) continue;
-    const v = worldToView(b.wx, b.wy);
-    ctx.strokeRect(v.x + 0.5, v.y + 0.5, b.drawW * V.cam.zoom, b.drawH * V.cam.zoom);
-  }
-  ctx.restore();
-}
-
-function drawMarquee() {
-  if (!V.drag || V.drag.kind !== "marquee") return;
-  const d = V.drag;
-  const x = Math.min(d.x0, d.x1), y = Math.min(d.y0, d.y1);
-  const w = Math.abs(d.x1 - d.x0), h = Math.abs(d.y1 - d.y0);
-  ctx.save();
-  ctx.fillStyle = getCss("--sel-soft"); ctx.strokeStyle = getCss("--sel");
-  ctx.lineWidth = 1; ctx.setLineDash([4, 3]);
-  ctx.fillRect(x, y, w, h); ctx.strokeRect(x + 0.5, y + 0.5, w, h);
-  ctx.restore();
-}
-
-function frame() {
-  if (V.needsRender) {
-    V.needsRender = false;
-    ctx.setTransform(V.dpr, 0, 0, V.dpr, 0, 0);
-    ctx.clearRect(0, 0, V.cssW, V.cssH);
-    // background
-    if (S.canvas.checkerboard) drawCheckerboard();
-    else if (!isTransparent(S.canvas.bg)) { ctx.fillStyle = S.canvas.bg; ctx.fillRect(0, 0, V.cssW, V.cssH); }
-    drawGrid();
-    drawAlignGuides();
-    drawHoverTile();
-    // depth-sorted instance draw (painter's algorithm), under the camera transform
-    _sortBuf.length = 0;
-    for (let i = 0; i < S.instances.length; i++) _sortBuf.push(i);
-    _sortBuf.sort((ia, ib) => {
-      const c = cmpDepth(S.instances[ia], S.instances[ib]);
-      return c !== 0 ? c : ia - ib;   // stable tiebreak
-    });
-    ctx.save();
-    ctx.translate(V.cam.x, V.cam.y);
-    ctx.scale(V.cam.zoom, V.cam.zoom);
-    ctx.imageSmoothingEnabled = false;   // crisp pixel/iso art
-    for (let k = 0; k < _sortBuf.length; k++) drawInstance(ctx, S.instances[_sortBuf[k]], 1 / V.cam.zoom);
-    ctx.restore();
-    drawSelection();
-    drawMarquee();
-    updateBadge();
-  }
-  requestAnimationFrame(frame);
-}
-
-function updateBadge() {
-  const p = S.projection;
-  const proj = p.type === "dimetric21" ? "2:1 dimetric" : p.type === "true" ? "true iso" : `custom ${p.angleDeg}°`;
-  const h = V.hoverTile ? `tile <b>${V.hoverTile.x},${V.hoverTile.y}</b>` : "";
-  $("badge").innerHTML =
-    `<b>${proj}</b> · ${p.tileW}×${p.tileH}px · snap <b>${S.grid.snap}</b> · zoom <b>${Math.round(V.cam.zoom * 100)}%</b><br>${h}`;
-  $("zLv").textContent = Math.round(V.cam.zoom * 100) + "%";
-  $("sceneStats").textContent = `${S.instances.length} instance${S.instances.length === 1 ? "" : "s"}, ${S.assets.length} asset${S.assets.length === 1 ? "" : "s"}`;
-}
-
-const getCss = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
-const isTransparent = c => !c || c === "transparent" || c === "none";
-
-/* ===========================================================================
-   COMMANDS  — single mutation funnel. Every scene mutation goes through
-   dispatch(type, payload); dispatch records an inverse entry onto the undo
-   stack (v2 feature 10). History entries call COMMANDS directly (never
-   dispatch) so replaying them is not re-recorded. Drag-moves are coalesced
-   into one entry at pointer-up; consecutive keyboard nudges merge.
-=========================================================================== */
-const COMMANDS = {
-  addInstance(p) { S.instances.push(p.inst); return p.inst.id; },
-  removeInstances(p) { const set = new Set(p.ids); S.instances = S.instances.filter(i => !set.has(i.id)); },
-  updateInstance(p) { const i = getInst(p.id); if (i) Object.assign(i, p.patch); },
-  moveInstances(p) { for (const m of p.moves) { const i = getInst(m.id); if (i) i.tile = { x: m.x, y: m.y }; } },
-  updateAsset(p) { const a = getAsset(p.id); if (a) Object.assign(a, p.patch); },
-  addAsset(p) { S.assets.push(p.asset); },
-  removeAsset(p) { S.assets = S.assets.filter(a => a.id !== p.id); },
-  setProjection(p) { Object.assign(S.projection, p.patch); applyProjectionConstraints(); },
-  setGrid(p) { Object.assign(S.grid, p.patch); },
-  setCanvas(p) { Object.assign(S.canvas, p.patch); },
-  setPalette(p) { S.palette = p.palette || null; _sceneRamp = null; },
-  replaceScene(p) { Object.assign(S, p.scene); },
-};
-
-// --- history ---
-const H = { undo: [], redo: [], limit: 100, batch: null };   // ≥50 steps required; keep 100
-function pushHist(e) {
-  if (H.batch) { H.batch.push(e); return; }
-  H.undo.push(e);
-  if (H.undo.length > H.limit) H.undo.shift();
-  H.redo.length = 0;
-}
-// Group every entry recorded during fn() into ONE composite history step.
-function withBatch(fn) {
-  const prev = H.batch; H.batch = [];
-  try { fn(); } finally {
-    const b = H.batch; H.batch = prev;
-    if (b.length === 1) pushHist(b[0]);
-    else if (b.length) pushHist({
-      undo() { for (let i = b.length - 1; i >= 0; i--) b[i].undo(); },
-      redo() { for (const e of b) e.redo(); },
-    });
-  }
-}
-function doUndo() { const e = H.undo.pop(); if (!e) return; e.undo(); H.redo.push(e); afterHist(); }
-function doRedo() { const e = H.redo.pop(); if (!e) return; e.redo(); H.undo.push(e); afterHist(); }
-function afterHist() {
-  for (const id of [...V.sel]) if (!getInst(id)) V.sel.delete(id);   // prune dead selection
-  applyProjectionConstraints();
-  syncTray(); syncPalettes(); requestRender();
-}
-// Snapshot the listed keys of an object (deep via JSON — scene data is plain JSON).
-const pickKeys = (obj, keys) => {
-  const o = {};
-  for (const k of keys) o[k] = (typeof obj[k] === "object" && obj[k] !== null) ? JSON.parse(JSON.stringify(obj[k])) : obj[k];
-  return o;
-};
-// Build the inverse entry for a command BEFORE it is applied. Entries hold live
-// object references for add/remove (an Image can't be JSON-cloned; later edits
-// are their own entries, so replay order keeps state consistent).
-function makeInverse(type, p) {
-  switch (type) {
-    case "addInstance": {
-      const inst = p.inst;
-      return { undo() { COMMANDS.removeInstances({ ids: [inst.id] }); }, redo() { COMMANDS.addInstance({ inst }); } };
-    }
-    case "removeInstances": {
-      const set = new Set(p.ids);
-      const saved = S.instances.map((inst, idx) => ({ inst, idx })).filter(e => set.has(e.inst.id));
-      return {
-        undo() { for (const e of saved) S.instances.splice(Math.min(e.idx, S.instances.length), 0, e.inst); },
-        redo() { COMMANDS.removeInstances({ ids: p.ids }); },
-      };
-    }
-    case "updateInstance": {
-      const i = getInst(p.id); if (!i) return null;
-      const prior = pickKeys(i, Object.keys(p.patch));
-      return { undo() { const t = getInst(p.id); if (t) Object.assign(t, prior); }, redo() { COMMANDS.updateInstance(p); } };
-    }
-    case "moveInstances": {
-      const before = p.moves.map(m => { const i = getInst(m.id); return i ? { id: m.id, x: i.tile.x, y: i.tile.y } : null; }).filter(Boolean);
-      return { undo() { COMMANDS.moveInstances({ moves: before }); }, redo() { COMMANDS.moveInstances(p); } };
-    }
-    case "updateAsset": {
-      const a = getAsset(p.id); if (!a) return null;
-      const prior = pickKeys(a, Object.keys(p.patch));
-      return { undo() { const t = getAsset(p.id); if (t) Object.assign(t, prior); }, redo() { COMMANDS.updateAsset(p); } };
-    }
-    case "addAsset": {
-      const asset = p.asset;
-      return { undo() { COMMANDS.removeAsset({ id: asset.id }); }, redo() { COMMANDS.addAsset({ asset }); } };
-    }
-    case "removeAsset": {
-      const idx = S.assets.findIndex(a => a.id === p.id); if (idx < 0) return null;
-      const asset = S.assets[idx];
-      return { undo() { S.assets.splice(Math.min(idx, S.assets.length), 0, asset); }, redo() { COMMANDS.removeAsset(p); } };
-    }
-    case "setProjection": {
-      const prior = pickKeys(S.projection, Object.keys(p.patch));
-      return { undo() { Object.assign(S.projection, prior); applyProjectionConstraints(); }, redo() { COMMANDS.setProjection(p); } };
-    }
-    case "setGrid": { const prior = pickKeys(S.grid, Object.keys(p.patch)); return { undo() { Object.assign(S.grid, prior); }, redo() { COMMANDS.setGrid(p); } }; }
-    case "setCanvas": { const prior = pickKeys(S.canvas, Object.keys(p.patch)); return { undo() { Object.assign(S.canvas, prior); }, redo() { COMMANDS.setCanvas(p); } }; }
-    case "setPalette": { const prior = S.palette; return { undo() { COMMANDS.setPalette({ palette: prior }); }, redo() { COMMANDS.setPalette(p); } }; }
-    default: return null;   // replaceScene: loadScene resets history itself
-  }
-}
-function dispatch(type, payload) {
-  const fn = COMMANDS[type];
-  if (!fn) { console.warn("unknown command", type); return; }
-  const inv = makeInverse(type, payload || {});
-  const r = fn(payload || {});
-  if (inv) pushHist(inv);
-  requestRender();
-  syncPalettes();
-  return r;
-}
-
-/* ===========================================================================
-   INPUT  — pointer (pan/zoom/select/marquee/drag-move), keyboard, DnD, paste.
-=========================================================================== */
-const stageEl = $("stage");
-let _space = false;
-
-// ---- wheel zoom toward cursor ----
-cv.addEventListener("wheel", e => {
-  e.preventDefault();
-  const rect = cv.getBoundingClientRect();
-  const mx = e.clientX - rect.left, my = e.clientY - rect.top;
-  const before = viewToWorld(mx, my);
-  const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
-  V.cam.zoom = Math.min(8, Math.max(0.15, V.cam.zoom * factor));
-  const after = viewToWorld(mx, my);
-  V.cam.x += (after.x - before.x) * V.cam.zoom;
-  V.cam.y += (after.y - before.y) * V.cam.zoom;
-  requestRender();
-}, { passive: false });
-
-// ---- pointer down ----
-cv.addEventListener("pointerdown", e => {
-  cv.setPointerCapture(e.pointerId);
-  const rect = cv.getBoundingClientRect();
-  const vx = e.clientX - rect.left, vy = e.clientY - rect.top;
-
-  // Pan: space+drag or middle button
-  if (_space || e.button === 1) {
-    V.drag = { kind: "pan", vx, vy, camx: V.cam.x, camy: V.cam.y };
-    stageEl.classList.add("pan");
-    return;
-  }
-  if (e.button !== 0) return;
-
-  const world = viewToWorld(vx, vy);
-
-  // Armed blockout-place: drop a primitive on the hovered tile.
-  if (V.blockPick) {
-    const t = pickTile(world.x, world.y);
-    placePrimitive(t.x, t.y);
-    return;
-  }
-  // Armed tray-place: drop a new instance on the hovered tile.
-  if (V.trayPick) {
-    const t = pickTile(world.x, world.y);
-    placeFromTray(V.trayPick, t.x, t.y);
-    return;
-  }
-
-  const hitId = hitTest(world.x, world.y);
-  if (hitId) {
-    if (e.shiftKey) { V.sel.has(hitId) ? V.sel.delete(hitId) : V.sel.add(hitId); }
-    else if (!V.sel.has(hitId)) { V.sel.clear(); V.sel.add(hitId); }
-    // begin drag-move of the whole selection
-    const origins = [...V.sel].map(id => { const i = getInst(id); return { id, x: i.tile.x, y: i.tile.y }; });
-    V.drag = { kind: "move", startWorld: world, origins, moved: false };
-    syncPalettes(); requestRender();
-  } else {
-    // start marquee (clears selection unless shift-adding)
-    if (!e.shiftKey) V.sel.clear();
-    V.drag = { kind: "marquee", x0: vx, y0: vy, x1: vx, y1: vy, add: e.shiftKey, base: new Set(V.sel) };
-    syncPalettes(); requestRender();
-  }
-});
-
-// ---- pointer move ----
-cv.addEventListener("pointermove", e => {
-  const rect = cv.getBoundingClientRect();
-  const vx = e.clientX - rect.left, vy = e.clientY - rect.top;
-  const world = viewToWorld(vx, vy);
-  const t = pickTile(world.x, world.y);
-  if (!V.hoverTile || V.hoverTile.x !== t.x || V.hoverTile.y !== t.y) { V.hoverTile = t; requestRender(); }
-
-  if (!V.drag) return;
-  if (V.drag.kind === "pan") {
-    V.cam.x = V.drag.camx + (vx - V.drag.vx);
-    V.cam.y = V.drag.camy + (vy - V.drag.vy);
-    requestRender();
-  } else if (V.drag.kind === "move") {
-    const dt = { x: world.x - V.drag.startWorld.x, y: world.y - V.drag.startWorld.y };
-    // translate world-pixel delta into tile-space delta directly (inverse transform, linear):
-    const dTileX = (dt.x / halfW() + dt.y / halfH()) / 2;
-    const dTileY = (dt.y / halfH() - dt.x / halfW()) / 2;
-    const moves = V.drag.origins.map(o => ({
-      id: o.id,
-      x: snapTile(o.x + dTileX),
-      y: snapTile(o.y + dTileY),
-    }));
-    if (dTileX || dTileY) V.drag.moved = true;
-    COMMANDS.moveInstances({ moves }); requestRender();
-  } else if (V.drag.kind === "marquee") {
-    V.drag.x1 = vx; V.drag.y1 = vy;
-    updateMarqueeSelection();
-    requestRender();
-  }
-});
-
-// ---- pointer up ----
-cv.addEventListener("pointerup", e => {
-  if (V.drag && V.drag.kind === "move" && V.drag.moved) {
-    // Coalesce the entire drag into ONE undoable step (origins -> final tiles).
-    const before = V.drag.origins.map(o => ({ id: o.id, x: o.x, y: o.y }));
-    const after = V.drag.origins
-      .map(o => { const i = getInst(o.id); return i ? { id: o.id, x: i.tile.x, y: i.tile.y } : null; })
-      .filter(Boolean);
-    pushHist({ undo() { COMMANDS.moveInstances({ moves: before }); }, redo() { COMMANDS.moveInstances({ moves: after }); } });
-    syncPalettes();
-  }
-  if (V.drag && V.drag.kind === "pan") stageEl.classList.remove("pan");
-  V.drag = null;
-  requestRender();   // clear alignment guides
-});
-
-function updateMarqueeSelection() {
-  const d = V.drag;
-  const x = Math.min(d.x0, d.x1), y = Math.min(d.y0, d.y1);
-  const w = Math.abs(d.x1 - d.x0), h = Math.abs(d.y1 - d.y0);
-  const sel = new Set(d.add ? d.base : []);
-  for (const inst of S.instances) {
-    const b = instBounds(inst); if (!b) continue;
-    const v = worldToView(b.wx, b.wy);
-    const bw = b.drawW * V.cam.zoom, bh = b.drawH * V.cam.zoom;
-    if (v.x < x + w && v.x + bw > x && v.y < y + h && v.y + bh > y) sel.add(inst.id);
-  }
-  V.sel = sel; syncPalettes();
-}
-
-// Topmost instance whose bounds (and opaque pixel) contain a world point.
-function hitTest(wx, wy) {
-  _sortBuf.length = 0;
-  for (let i = 0; i < S.instances.length; i++) _sortBuf.push(i);
-  _sortBuf.sort((ia, ib) => { const c = cmpDepth(S.instances[ia], S.instances[ib]); return c !== 0 ? c : ia - ib; });
-  // iterate front-to-back (reverse of draw order)
-  for (let k = _sortBuf.length - 1; k >= 0; k--) {
-    const inst = S.instances[_sortBuf[k]];
-    const b = instBounds(inst); if (!b) continue;
-    if (wx >= b.wx && wx <= b.wx + b.drawW && wy >= b.wy && wy <= b.wy + b.drawH) return inst.id;
-  }
-  return null;
-}
-
-// ---- keyboard ----
-window.addEventListener("keydown", e => {
-  const typing = /^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement && document.activeElement.tagName);
-  if (e.key === " " && !typing) { _space = true; return; }
-  if (typing) return;
-
-  if (e.key === "?" || (e.shiftKey && e.key === "/")) { toggleModal(true); e.preventDefault(); return; }
-  if (e.key === "Escape") { if ($("modal").classList.contains("on")) toggleModal(false); else { V.sel.clear(); V.trayPick = null; V.blockPick = null; syncTray(); syncBlockSeg(); syncPalettes(); requestRender(); } return; }
-
-  if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === "z" || e.key === "Z")) { e.preventDefault(); doUndo(); return; }
-  if ((e.ctrlKey || e.metaKey) && (e.key === "y" || e.key === "Y" || (e.shiftKey && (e.key === "z" || e.key === "Z")))) { e.preventDefault(); doRedo(); return; }
-  if ((e.ctrlKey || e.metaKey) && (e.key === "d" || e.key === "D")) { e.preventDefault(); duplicateSelection(); return; }
-  if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); deleteSelection(); return; }
-  if (e.key === "f" || e.key === "F") { e.preventDefault(); flipSelection(); return; }
-  if (e.key === "[") { e.preventDefault(); nudgeZBias(-1); return; }
-  if (e.key === "]") { e.preventDefault(); nudgeZBias(1); return; }
-
-  // arrow nudge
-  const arrows = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
-  if (arrows[e.key] && V.sel.size) {
-    e.preventDefault();
-    const [dx, dy] = arrows[e.key];
-    if (e.shiftKey) nudgePixels(dx, dy);   // 1px screen nudge -> fractional tile
-    else nudgeTiles(dx, dy);               // 1 whole tile
-  }
-});
-window.addEventListener("keyup", e => { if (e.key === " ") _space = false; });
-
-// ---- drag-drop image import (anywhere on the app) ----
-["dragover", "drop"].forEach(ev => $("app").addEventListener(ev, e => e.preventDefault()));
-$("app").addEventListener("drop", e => {
-  const files = [...(e.dataTransfer.files || [])].filter(f => /^image\//.test(f.type));
-  files.forEach(readImageFile);
-  const t = e.dataTransfer.getData && e.dataTransfer.getData("text/uri-list");
-  if (!files.length && t) importImageSrc(t, "dropped");
-});
-
-// ---- clipboard paste ----
-window.addEventListener("paste", e => {
-  const items = [...(e.clipboardData && e.clipboardData.items || [])];
-  for (const it of items) {
-    if (it.type && it.type.startsWith("image/")) { const f = it.getAsFile(); if (f) readImageFile(f); }
-  }
-});
-
-// ---- file picker import ----
-$("fileImport").addEventListener("change", e => { [...e.target.files].forEach(readImageFile); e.target.value = ""; });
-
-/* ===========================================================================
-   SELECTION ACTIONS  (all funnel through dispatch/COMMANDS)
-=========================================================================== */
-function selectionInsts() { return [...V.sel].map(getInst).filter(Boolean); }
-
-function duplicateSelection() {
-  if (!V.sel.size) return;
-  const newIds = new Set();
-  withBatch(() => {   // one undo step for the whole duplicate
-    for (const inst of selectionInsts()) {
-      const copy = JSON.parse(JSON.stringify(inst));
-      copy.id = uid("i"); copy.tile = { x: inst.tile.x + 1, y: inst.tile.y + 1 };
-      dispatch("addInstance", { inst: copy }); newIds.add(copy.id);
-    }
-  });
-  V.sel = newIds; syncPalettes(); requestRender();
-}
-function deleteSelection() {
-  if (!V.sel.size) return;
-  dispatch("removeInstances", { ids: [...V.sel] });
-  V.sel.clear(); syncPalettes(); requestRender();
-}
-function flipSelection() {
-  for (const inst of selectionInsts()) dispatch("updateInstance", { id: inst.id, patch: { flipX: !inst.flipX } });
-}
-// Apply a tile-space nudge to the selection, coalescing rapid consecutive
-// nudges of the same selection into one history entry (feature 10).
-function applyNudge(dTileX, dTileY) {
-  const insts = selectionInsts(); if (!insts.length) return;
-  const before = insts.map(i => ({ id: i.id, x: i.tile.x, y: i.tile.y }));
-  const after = insts.map(i => ({ id: i.id, x: i.tile.x + dTileX, y: i.tile.y + dTileY }));
-  COMMANDS.moveInstances({ moves: after });
-  const ids = insts.map(i => i.id).join(",");
-  const top = H.undo[H.undo.length - 1];
-  if (!H.batch && !H.redo.length && top && top.nudgeIds === ids && Date.now() - top.t < 1200) {
-    top.after = after; top.t = Date.now();               // merge into the live nudge run
-  } else {
-    pushHist({
-      nudgeIds: ids, t: Date.now(), before, after,
-      undo() { COMMANDS.moveInstances({ moves: this.before }); },
-      redo() { COMMANDS.moveInstances({ moves: this.after }); },
-    });
-  }
-  requestRender(); syncPalettes();
-}
-function nudgeTiles(dx, dy) { applyNudge(dx, dy); }
-function nudgePixels(dx, dy) {
-  // 1px screen-space nudge translated to tile-space (fractional, inverse transform)
-  const dTileX = (dx / halfW() + dy / halfH()) / 2;
-  const dTileY = (dy / halfH() - dx / halfW()) / 2;
-  applyNudge(dTileX, dTileY);
-}
-function nudgeZBias(d) {
-  for (const inst of selectionInsts()) dispatch("updateInstance", { id: inst.id, patch: { zBias: (inst.zBias || 0) + d } });
-}
-
-/* ===========================================================================
-   IMPORT  — load an image into the asset library, add a tray thumbnail.
-=========================================================================== */
-function readImageFile(file) {
-  const r = new FileReader();
-  r.onload = () => importImageSrc(String(r.result), file.name.replace(/\.[^.]+$/, ""));
-  r.readAsDataURL(file);   // data URI -> self-contained scenes
-}
-function importImageSrc(src, name) {
-  const img = new Image();
-  img.onload = () => {
-    const asset = {
-      id: uid("a"),
-      name: name || "asset",
-      src,
-      anchor: { x: 0.5, y: 1 },     // anchor-at-feet default (schema default)
-      footprint: { w: 1, h: 1 },
-      sourceW: img.naturalWidth, sourceH: img.naturalHeight,
-      _img: img,
-    };
-    dispatch("addAsset", { asset });
-    syncTray();
-  };
-  img.onerror = () => alert("Could not load image: " + name);
-  img.src = src;
-}
-
-// Place a new instance of an asset at a tile.
-function placeFromTray(assetId, tx, ty) {
-  const inst = {
-    id: uid("i"), assetId,
-    tile: { x: snapTile(tx), y: snapTile(ty) },
-    elevation: 0, layer: "props", zBias: 0, flipX: false, scale: 1,
-  };
-  dispatch("addInstance", { inst });
-  V.sel.clear(); V.sel.add(inst.id);
-  syncPalettes(); requestRender();
-  // stay armed for rapid placement; Esc disarms.
-}
-
-// Place a blockout primitive (v2 feature 8) at a tile, using the palette params.
-function placePrimitive(tx, ty) {
-  const w = Math.max(1, +$("pW").value | 0), h = Math.max(1, +$("pH").value | 0);
-  const ht = Math.max(0.1, +$("pHt").value || 1);
-  const inst = {
-    id: uid("i"),
-    primitive: { kind: V.blockPick, w, h, height: ht },
-    tile: { x: snapTile(tx), y: snapTile(ty) },
-    elevation: 0, layer: "props", zBias: 0, flipX: false, scale: 1,
-  };
-  dispatch("addInstance", { inst });
-  V.sel.clear(); V.sel.add(inst.id);
-  syncPalettes(); requestRender();
-  // stay armed for rapid blockout massing; Esc disarms.
-}
-
-/* ===========================================================================
-   PALETTES  — build the tray thumbnails + wire the docked right-rail controls,
-   and keep them in sync with the scene model (syncPalettes / syncTray).
-=========================================================================== */
-function syncTray() {
-  const grid = $("trayGrid"), empty = $("trayEmpty");
-  grid.innerHTML = "";
-  empty.style.display = S.assets.length ? "none" : "block";
-  for (const a of S.assets) {
-    const el = document.createElement("div");
-    el.className = "thumb" + (V.trayPick === a.id ? " sel" : "");
-    el.innerHTML = `<img src="${a.src}" alt=""><span class="nm">${escapeHtml(a.name)}</span><button class="x" title="Remove asset">×</button>`;
-    el.onclick = ev => {
-      if (ev.target.classList.contains("x")) { removeAssetAndInstances(a.id); return; }
-      V.trayPick = (V.trayPick === a.id ? null : a.id);
-      V.blockPick = null; syncBlockSeg();   // arming is exclusive
-      syncTray(); requestRender();
-    };
-    grid.appendChild(el);
-  }
-}
-function removeAssetAndInstances(id) {
-  // remove the asset AND any instances referencing it — one undo step
-  const insts = S.instances.filter(i => i.assetId === id).map(i => i.id);
-  withBatch(() => {
-    if (insts.length) dispatch("removeInstances", { ids: insts });
-    dispatch("removeAsset", { id });
-  });
-  if (V.trayPick === id) V.trayPick = null;
-  V.sel = new Set([...V.sel].filter(sid => getInst(sid)));
-  syncTray(); syncPalettes(); requestRender();
-}
-
-// --- Blockout palette wiring (v2 feature 8) ---
-function buildBlockSeg() {
-  const seg = $("primSeg"); seg.innerHTML = "";
-  for (const k of PRIM_KINDS) {
-    const b = document.createElement("button");
-    b.className = "btn"; b.textContent = k; b.dataset.k = k;
-    b.onclick = () => {
-      V.blockPick = (V.blockPick === k ? null : k);
-      if (V.blockPick) { V.trayPick = null; syncTray(); }
-      // convenience defaults: slab reads as a thin floor plate
-      if (k === "slab" && +$("pHt").value === 1) $("pHt").value = 0.25;
-      if (k === "box" && +$("pHt").value === 0.25) $("pHt").value = 1;
-      syncBlockSeg(); requestRender();
-    };
-    seg.appendChild(b);
-  }
-  syncBlockSeg();
-}
-function syncBlockSeg() {
-  const seg = $("primSeg");
-  [...seg.children].forEach(b => b.classList.toggle("on", b.dataset.k === V.blockPick));
-}
-const escapeHtml = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
-
-// --- Grid palette wiring ---
-function buildSnapSeg() {
-  const seg = $("snapSeg"); seg.innerHTML = "";
-  ["full", "half", "quarter", "free"].forEach(m => {
-    const b = document.createElement("button");
-    b.className = "btn" + (S.grid.snap === m ? " on" : "");
-    b.textContent = m; b.dataset.m = m;
-    b.onclick = () => { dispatch("setGrid", { patch: { snap: m } }); buildSnapSeg(); };
-    seg.appendChild(b);
-  });
-}
-$("projType").onchange = e => {
-  const t = e.target.value;
-  $("angleWrap").style.display = t === "custom" ? "" : "none";
-  dispatch("setProjection", { patch: { type: t } });
-  syncProjInputs();
-};
-$("tileW").onchange = e => { dispatch("setProjection", { patch: { tileW: Math.max(4, +e.target.value || 64) } }); syncProjInputs(); };
-$("tileH").onchange = e => { dispatch("setProjection", { patch: { tileH: Math.max(2, +e.target.value || 32) } }); syncProjInputs(); };
-$("angleDeg").onchange = e => { dispatch("setProjection", { patch: { angleDeg: Math.min(89, Math.max(1, +e.target.value || 26.565)) } }); };
-$("extentX").onchange = e => dispatch("setGrid", { patch: { extentX: Math.max(1, +e.target.value | 0) } });
-$("extentY").onchange = e => dispatch("setGrid", { patch: { extentY: Math.max(1, +e.target.value | 0) } });
-$("gridVis").onchange = e => dispatch("setGrid", { patch: { visible: e.target.checked } });
-
-function syncProjInputs() {
-  const p = S.projection;
-  $("projType").value = p.type;
-  $("tileW").value = p.tileW;
-  $("tileH").value = p.tileH;
-  $("angleDeg").value = p.angleDeg;
-  $("angleWrap").style.display = p.type === "custom" ? "" : "none";
-  // derived-height fields are read-only-ish for constrained projections
-  $("tileH").disabled = (p.type !== "custom");
-  const hints = {
-    dimetric21: "2:1 dimetric (commonly called isometric in games). Tile H is locked to W/2 for clean 2px:1px stepping.",
-    true: "True isometric (30° ground axis, all faces equal). Tile H follows tan30° ≈ 0.577·W.",
-    custom: "Custom ground-axis angle. Set tile W and H freely; angle is advisory metadata.",
-  };
-  $("projHint").textContent = hints[p.type] || "";
-}
-
-// --- Scene palette wiring ---
-$("bgColor").oninput = e => dispatch("setCanvas", { patch: { bg: e.target.value } });
-$("bgTransparent").onchange = e => dispatch("setCanvas", { patch: { bg: e.target.checked ? "transparent" : $("bgColor").value } });
-$("checker").onchange = e => dispatch("setCanvas", { patch: { checkerboard: e.target.checked } });
-$("canvasW").onchange = e => dispatch("setCanvas", { patch: { width: e.target.value ? +e.target.value : null } });
-$("canvasH").onchange = e => dispatch("setCanvas", { patch: { height: e.target.value ? +e.target.value : null } });
-
-// --- Inspector palette wiring ---
-function syncPalettes() {
-  syncProjInputs();
-  // scene tint select + SVG export eligibility (v2 features 9 + 11)
-  $("tintPreset").value = (typeof S.palette === "string" && presetByName(S.palette)) ? S.palette : "";
-  const svgBtn = $("btnSvg"), svgOk = svgEligible();
-  svgBtn.disabled = !svgOk;
-  svgBtn.title = svgOk
-    ? "Compose all instances into one SVG (vector primitives + tinted <image> refs)"
-    : (S.instances.length
-        ? "Disabled: the scene contains raster (non-SVG) assets — SVG export needs every placed asset to be SVG-sourced"
-        : "Disabled: nothing placed yet");
-  // Gate on RESOLVED instances, not V.sel.size — the selection can hold stale
-  // ids mid-dispatch (e.g. during removeInstances, before the caller prunes).
-  const insts = selectionInsts();
-  const has = insts.length > 0;
-  $("inspEmpty").style.display = has ? "none" : "block";
-  $("inspBody").style.display = has ? "block" : "none";
-  if (!has) return;
-  const inst = insts[0];
-  const a = getAsset(inst.assetId);
-  const isPrim = !!inst.primitive;
-  const label = a ? a.name : (isPrim ? "blockout " + inst.primitive.kind : inst.assetId);
-  $("inspTitle").textContent = V.sel.size > 1 ? `${V.sel.size} selected (editing "${label}")` : label;
-  $("iTileX").value = inst.tile.x;
-  $("iTileY").value = inst.tile.y;
-  $("iElev").value = inst.elevation || 0;
-  $("iZBias").value = inst.zBias || 0;
-  $("iScale").value = inst.scale || 1;
-  $("iLayer").value = inst.layer || "props";
-  $("iFlipX").checked = !!inst.flipX;
-  $("inspAsset").style.display = isPrim ? "none" : "block";
-  $("inspPrim").style.display = isPrim ? "block" : "none";
-  if (isPrim) {
-    $("iPrimW").value = inst.primitive.w || 1;
-    $("iPrimH").value = inst.primitive.h || 1;
-    $("iPrimHt").value = inst.primitive.height != null ? inst.primitive.height : 1;
-  } else if (a) {
-    $("iFpW").value = (a.footprint && a.footprint.w) || 1;
-    $("iFpH").value = (a.footprint && a.footprint.h) || 1;
-    $("iAncX").value = (a.anchor && a.anchor.x) != null ? a.anchor.x : 0.5;
-    $("iAncY").value = (a.anchor && a.anchor.y) != null ? a.anchor.y : 1;
-  }
-  if (/^#[0-9a-fA-F]{6}$/.test(inst.tint || "")) $("iTint").value = inst.tint;
-}
-// Inspector inputs -> apply to ALL selected instances (position applies to primary; the rest keep relative? keep simple: apply per-field)
-function inspApply(field, val) {
-  for (const inst of selectionInsts()) {
-    if (field === "tileX") dispatch("updateInstance", { id: inst.id, patch: { tile: { x: val, y: inst.tile.y } } });
-    else if (field === "tileY") dispatch("updateInstance", { id: inst.id, patch: { tile: { x: inst.tile.x, y: val } } });
-    else if (field === "elevation") dispatch("updateInstance", { id: inst.id, patch: { elevation: val } });
-    else if (field === "zBias") dispatch("updateInstance", { id: inst.id, patch: { zBias: val } });
-    else if (field === "scale") dispatch("updateInstance", { id: inst.id, patch: { scale: Math.max(0.05, val) } });
-    else if (field === "layer") dispatch("updateInstance", { id: inst.id, patch: { layer: val } });
-    else if (field === "flipX") dispatch("updateInstance", { id: inst.id, patch: { flipX: val } });
-  }
-}
-$("iTileX").onchange = e => inspApply("tileX", +e.target.value | 0);
-$("iTileY").onchange = e => inspApply("tileY", +e.target.value | 0);
-$("iElev").onchange = e => inspApply("elevation", +e.target.value || 0);
-$("iZBias").onchange = e => inspApply("zBias", +e.target.value || 0);
-$("iScale").onchange = e => inspApply("scale", +e.target.value || 1);
-$("iLayer").onchange = e => inspApply("layer", e.target.value);
-$("iFlipX").onchange = e => inspApply("flipX", e.target.checked);
-// footprint + anchor edit the ASSET (affects every instance)
-$("iFpW").onchange = e => { const inst = selectionInsts()[0]; if (inst) { const a = getAsset(inst.assetId); dispatch("updateAsset", { id: a.id, patch: { footprint: { w: Math.max(1, +e.target.value | 0), h: (a.footprint && a.footprint.h) || 1 } } }); } };
-$("iFpH").onchange = e => { const inst = selectionInsts()[0]; if (inst) { const a = getAsset(inst.assetId); dispatch("updateAsset", { id: a.id, patch: { footprint: { w: (a.footprint && a.footprint.w) || 1, h: Math.max(1, +e.target.value | 0) } } }); } };
-$("iAncX").onchange = e => { const inst = selectionInsts()[0]; if (inst) { const a = getAsset(inst.assetId); dispatch("updateAsset", { id: a.id, patch: { anchor: { x: clamp01(+e.target.value), y: (a.anchor && a.anchor.y) != null ? a.anchor.y : 1 } } }); } };
-$("iAncY").onchange = e => { const inst = selectionInsts()[0]; if (inst) { const a = getAsset(inst.assetId); dispatch("updateAsset", { id: a.id, patch: { anchor: { x: (a.anchor && a.anchor.x) != null ? a.anchor.x : 0.5, y: clamp01(+e.target.value) } } }); } };
-const clamp01 = v => Math.min(1, Math.max(0, v || 0));
-// primitive params edit the INSTANCE's primitive object (per-instance, unlike asset defaults)
-function primPatch(k, v) {
-  for (const inst of selectionInsts())
-    if (inst.primitive) dispatch("updateInstance", { id: inst.id, patch: { primitive: Object.assign({}, inst.primitive, { [k]: v }) } });
-}
-$("iPrimW").onchange = e => primPatch("w", Math.max(1, +e.target.value | 0));
-$("iPrimH").onchange = e => primPatch("h", Math.max(1, +e.target.value | 0));
-$("iPrimHt").onchange = e => primPatch("height", Math.max(0.1, +e.target.value || 1));
-// per-instance tri-tone tint (v2 feature 9); Clear reverts to scene palette / native colours
-$("iTint").onchange = e => { for (const inst of selectionInsts()) dispatch("updateInstance", { id: inst.id, patch: { tint: e.target.value } }); };
-$("iTintClear").onclick = () => { for (const inst of selectionInsts()) dispatch("updateInstance", { id: inst.id, patch: { tint: null } }); };
-// scene-wide tint preset (v2 feature 9)
-$("tintPreset").onchange = e => dispatch("setPalette", { palette: e.target.value || null });
-function buildTintSelect() {
-  const sel = $("tintPreset");
-  const cur = (typeof S.palette === "string") ? S.palette : "";
-  sel.innerHTML = '<option value="">none (native colours)</option>' +
-    PRESETS.map(p => `<option value="${escapeHtml(p.name)}">${escapeHtml(p.name)}</option>`).join("");
-  sel.value = presetByName(cur) ? cur : "";
-}
-$("btnDup").onclick = duplicateSelection;
-$("btnDel").onclick = deleteSelection;
-
-// --- zoom buttons ---
-$("zIn").onclick = () => { V.cam.zoom = Math.min(8, V.cam.zoom * 1.25); requestRender(); };
-$("zOut").onclick = () => { V.cam.zoom = Math.max(0.15, V.cam.zoom / 1.25); requestRender(); };
-$("zLv").onclick = () => { V.cam.zoom = 1; requestRender(); };
-
-// --- hotkey modal ---
-function toggleModal(on) { $("modal").classList.toggle("on", on); }
-$("modalClose").onclick = () => toggleModal(false);
-$("modal").onclick = e => { if (e.target === $("modal")) toggleModal(false); };
-
-/* ===========================================================================
-   IO  — PNG export (crop-to-content, transparent, 1×/2×/4×) and scene JSON
-   save/load conforming to scene-schema.json v1.0 (round-trips).
-=========================================================================== */
-
-// Compute the tight world-space bounding box of all instances (for crop-to-content).
-function contentBounds() {
-  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity, any = false;
-  for (const inst of S.instances) {
-    const b = instBounds(inst); if (!b) continue;
-    any = true;
-    minX = Math.min(minX, b.wx); minY = Math.min(minY, b.wy);
-    maxX = Math.max(maxX, b.wx + b.drawW); maxY = Math.max(maxY, b.wy + b.drawH);
-  }
-  if (!any) return null;
-  return { minX, minY, w: maxX - minX, h: maxY - minY };
-}
-
-// Shared export framing: fixed canvas size (Scene palette) wins; else tight
-// crop-to-content. Returns { W, H, ox, oy } (world offset) or null when empty.
-function exportFrame() {
-  const bounds = contentBounds();
-  if (!bounds && !(S.canvas.width && S.canvas.height)) return null;
-  let ox, oy, W, H;
-  if (S.canvas.width && S.canvas.height) {
-    W = S.canvas.width; H = S.canvas.height; ox = 0; oy = 0;
-    if (bounds) { ox = (W - bounds.w) / 2 - bounds.minX; oy = (H - bounds.h) / 2 - bounds.minY; }
-  } else {
-    W = Math.ceil(bounds.w); H = Math.ceil(bounds.h); ox = -bounds.minX; oy = -bounds.minY;
-  }
-  return { W, H, ox, oy };
-}
-// Offscreen canvas pre-scaled and pre-translated so all drawing happens in
-// pure WORLD coordinates (same code path as the live renderer).
-function makeExportCtx(f, scale) {
-  const out = document.createElement("canvas");
-  out.width = Math.max(1, Math.round(f.W * scale));
-  out.height = Math.max(1, Math.round(f.H * scale));
-  const octx = out.getContext("2d");
-  octx.imageSmoothingEnabled = false;
-  octx.scale(scale, scale);
-  octx.translate(f.ox, f.oy);
-  return { out, octx };
-}
-// Instances in canonical draw order (stable y-sort).
-function sortedInstances() {
-  return S.instances.map((_, i) => i)
-    .sort((ia, ib) => { const c = cmpDepth(S.instances[ia], S.instances[ib]); return c !== 0 ? c : ia - ib; })
-    .map(i => S.instances[i]);
-}
-
-function exportPNG(scale) {
-  const f = exportFrame();
-  if (!f) { alert("Nothing to export — place an asset first."); return; }
-  const { out, octx } = makeExportCtx(f, scale);
-  // transparent by default; honor an opaque scene bg (checkerboard is editor-only)
-  if (!S.canvas.checkerboard && !isTransparent(S.canvas.bg)) { octx.fillStyle = S.canvas.bg; octx.fillRect(-f.ox, -f.oy, f.W, f.H); }
-  for (const inst of sortedInstances()) drawInstance(octx, inst, 1.1 / scale);
-  out.toBlob(blob => downloadBlob(blob, `${(S.meta.name || "scene")}_${scale}x.png`), "image/png");
-}
-[...document.querySelectorAll("[data-png]")].forEach(b => b.onclick = () => exportPNG(+b.dataset.png));
-
-/* ---- ControlNet conditioning exports (v2 feature 8) ---- */
-// Reused scratch canvas: recolour an image to a flat silhouette of one colour.
-const _silh = document.createElement("canvas");
-const _silhCtx = _silh.getContext("2d");
-function silhouetteOf(img, color) {
-  _silh.width = img.naturalWidth || img.width;
-  _silh.height = img.naturalHeight || img.height;
-  _silhCtx.drawImage(img, 0, 0);
-  _silhCtx.globalCompositeOperation = "source-in";
-  _silhCtx.fillStyle = color;
-  _silhCtx.fillRect(0, 0, _silh.width, _silh.height);
-  _silhCtx.globalCompositeOperation = "source-over";
-  return _silh;
-}
-// Depth map: black background (far); each instance a flat grey from its
-// normalized depth scalar — near = white. Feeds ControlNet depth conditioning.
-// The EXPORT scalar folds elevation in: raising a block moves it toward the
-// ortho camera, so higher = nearer = whiter. One z-step counts unitZ/(2·halfH)
-// tile-steps of depth (0.5 at the default 64×32 / unitZ 16). This is export
-// normalization ONLY — the paint-sort key (depthKey) is deliberately untouched.
-function depthScalar(inst) {
-  return depthKey(inst)[0] + (inst.elevation || 0) * unitZ() / (2 * halfH());
-}
-function exportDepth(scale) {
-  const f = exportFrame();
-  if (!f) { alert("Nothing to export — place something first."); return; }
-  const { out, octx } = makeExportCtx(f, scale);
-  octx.fillStyle = "#000000"; octx.fillRect(-f.ox, -f.oy, f.W, f.H);
-  const insts = sortedInstances();
-  let min = Infinity, max = -Infinity;
-  for (const inst of insts) { const k = depthScalar(inst); if (k < min) min = k; if (k > max) max = k; }
-  const span = (max - min) || 1;
-  for (const inst of insts) {
-    const norm = (depthScalar(inst) - min) / span;     // 0 = far … 1 = near
-    const g = Math.round(48 + 207 * norm);             // keep geometry off pure black
-    const col = `rgb(${g},${g},${g})`;
-    if (inst.primitive) primDrawCanvas(octx, inst, { flat: col }, 1, "flat");
-    else { const b = instBounds(inst); if (b) drawInstImage(octx, inst, b, 0, 0, silhouetteOf(b.img, col)); }
-  }
-  out.toBlob(blob => downloadBlob(blob, `${(S.meta.name || "scene")}_depth_${scale}x.png`), "image/png");
-}
-// Lineart: visible edges only, black on white. Primitives render white-filled
-// + black-stroked in painter order (hidden-line removal for free); images get
-// an alpha-dilated outline. Feeds ControlNet lineart / MLSD conditioning.
-function exportLineart(scale) {
-  const f = exportFrame();
-  if (!f) { alert("Nothing to export — place something first."); return; }
-  const { out, octx } = makeExportCtx(f, scale);
-  octx.fillStyle = "#ffffff"; octx.fillRect(-f.ox, -f.oy, f.W, f.H);
-  const o = 1.4 / scale;   // outline thickness in world px (~1.4 output px)
-  for (const inst of sortedInstances()) {
-    if (inst.primitive) { primDrawCanvas(octx, inst, {}, 1.6 / scale, "line"); continue; }
-    const b = instBounds(inst); if (!b) continue;
-    const black = silhouetteOf(b.img, "#000000");
-    for (const d of [[-o, 0], [o, 0], [0, -o], [0, o], [-o, -o], [o, -o], [-o, o], [o, o]])
-      drawInstImage(octx, inst, b, d[0], d[1], black);
-    drawInstImage(octx, inst, b, 0, 0, silhouetteOf(b.img, "#ffffff"));
-  }
-  out.toBlob(blob => downloadBlob(blob, `${(S.meta.name || "scene")}_lineart_${scale}x.png`), "image/png");
-}
-[...document.querySelectorAll("[data-depth]")].forEach(b => b.onclick = () => exportDepth(+b.dataset.depth));
-[...document.querySelectorAll("[data-line]")].forEach(b => b.onclick = () => exportLineart(+b.dataset.line));
-
-/* ---- SVG export (v2 feature 11) — only when every placed instance is vector ---- */
-function svgEligible() {
-  return S.instances.length > 0 && S.instances.every(i =>
-    i.primitive || (((getAsset(i.assetId) || {}).src || "").startsWith("data:image/svg")));
-}
-// The svg-brand-tuner tri-tone filter, emitted natively into the SVG defs.
-function triToneFilterSvg(id, ramp) {
-  const D = hexToRgb(ramp[0]), M = hexToRgb(ramp[1]), L = hexToRgb(ramp[2]);
-  const tv = i => `${(D[i] / 255).toFixed(4)} ${(M[i] / 255).toFixed(4)} ${(L[i] / 255).toFixed(4)}`;
-  return `<filter id="${id}" color-interpolation-filters="sRGB"><feColorMatrix type="saturate" values="0"/>` +
-    `<feComponentTransfer><feFuncR type="table" tableValues="${tv(0)}"/><feFuncG type="table" tableValues="${tv(1)}"/>` +
-    `<feFuncB type="table" tableValues="${tv(2)}"/></feComponentTransfer></filter>`;
-}
-// Emit a primitive as SVG shapes (same primShape geometry as the canvas path).
-function primSvg(inst, tones) {
-  const s = primShape(inst), r2 = n => +n.toFixed(2);
-  const sw = ` stroke="${tones.ink}" stroke-width="1" stroke-linejoin="round"`;
-  if (s.kind === "cylinder") {
-    const { pT, pB, rx, ry } = s, RX = r2(rx), RY = r2(ry);
-    // left body half: down the left edge, base arc to bottom (ccw, sweep 0),
-    // up to the top ellipse's bottom, arc back to the left point (cw, sweep 1)
-    const left = `M ${r2(pT.x - rx)} ${r2(pT.y)} L ${r2(pB.x - rx)} ${r2(pB.y)} A ${RX} ${RY} 0 0 0 ${r2(pB.x)} ${r2(pB.y + ry)} L ${r2(pT.x)} ${r2(pT.y + ry)} A ${RX} ${RY} 0 0 1 ${r2(pT.x - rx)} ${r2(pT.y)} Z`;
-    const right = `M ${r2(pT.x + rx)} ${r2(pT.y)} L ${r2(pB.x + rx)} ${r2(pB.y)} A ${RX} ${RY} 0 0 1 ${r2(pB.x)} ${r2(pB.y + ry)} L ${r2(pT.x)} ${r2(pT.y + ry)} A ${RX} ${RY} 0 0 0 ${r2(pT.x + rx)} ${r2(pT.y)} Z`;
-    return `<path d="${left}" fill="${tones.left}"${sw}/><path d="${right}" fill="${tones.right}"${sw}/>` +
-      `<ellipse cx="${r2(pT.x)}" cy="${r2(pT.y)}" rx="${RX}" ry="${RY}" fill="${tones.top}"${sw}/>`;
-  }
-  return s.faces.map(fc =>
-    `<polygon points="${fc.pts.map(q => r2(q.x) + "," + r2(q.y)).join(" ")}" fill="${tones[fc.tone]}"${sw}/>`).join("");
-}
-function exportSVG() {
-  if (!svgEligible()) return;
-  const f = exportFrame(); if (!f) { alert("Nothing to export."); return; }
-  let defs = "", body = "";
-  const filterIds = new Map();   // ramp -> filter id (deduped)
-  for (const inst of sortedInstances()) {
-    const op = (inst.opacity != null && inst.opacity !== 1) ? ` opacity="${inst.opacity}"` : "";
-    if (inst.primitive) { body += `<g${op}>${primSvg(inst, effTones(inst))}</g>`; continue; }
-    const a = getAsset(inst.assetId), b = instBounds(inst); if (!a || !b) continue;
-    const ramp = rampFor(inst);
-    let filt = "";
-    if (ramp) {
-      const key = ramp.join("|");
-      if (!filterIds.has(key)) { const id = "tint" + filterIds.size; filterIds.set(key, id); defs += triToneFilterSvg(id, ramp); }
-      filt = ` filter="url(#${filterIds.get(key)})"`;
-    }
-    const x = +b.wx.toFixed(2), y = +b.wy.toFixed(2);
-    const w = +b.drawW.toFixed(2), h = +b.drawH.toFixed(2);
-    if (inst.flipX)
-      body += `<image href="${a.src}" x="${+(-(x + w)).toFixed(2)}" y="${y}" width="${w}" height="${h}" transform="scale(-1 1)"${filt}${op}/>`;
-    else
-      body += `<image href="${a.src}" x="${x}" y="${y}" width="${w}" height="${h}"${filt}${op}/>`;
-  }
-  const bg = (!S.canvas.checkerboard && !isTransparent(S.canvas.bg))
-    ? `<rect x="${+(-f.ox).toFixed(2)}" y="${+(-f.oy).toFixed(2)}" width="${f.W}" height="${f.H}" fill="${S.canvas.bg}"/>` : "";
-  const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${f.W}" height="${f.H}" viewBox="0 0 ${f.W} ${f.H}">` +
-    (defs ? `<defs>${defs}</defs>` : "") +
-    `<g transform="translate(${+f.ox.toFixed(2)} ${+f.oy.toFixed(2)})">${bg}${body}</g></svg>`;
-  downloadBlob(new Blob([svg], { type: "image/svg+xml" }), `${(S.meta.name || "scene")}.svg`);
-}
-$("btnSvg").onclick = exportSVG;
-
-// --- Scene JSON serialization (schema-conformant, round-trips) ---
-function sceneToJSON() {
-  const now = new Date().toISOString();
-  const scene = {
-    version: SCHEMA_VERSION,
-    meta: { name: S.meta.name || "untitled", generator: GENERATOR, modified: now },
-    projection: {
-      type: S.projection.type,
-      tileW: S.projection.tileW,
-      tileH: S.projection.tileH,
-      unitElevation: S.projection.unitElevation,
-    },
-    grid: { extentX: S.grid.extentX, extentY: S.grid.extentY, snap: S.grid.snap, visible: S.grid.visible },
-    assets: S.assets.map(a => ({
-      id: a.id, name: a.name, src: a.src,
-      anchor: { x: a.anchor.x, y: a.anchor.y },
-      footprint: { w: a.footprint.w, h: a.footprint.h },
-      ...(a.sourceW ? { sourceW: a.sourceW } : {}),
-      ...(a.sourceH ? { sourceH: a.sourceH } : {}),
-    })),
-    instances: S.instances.map(i => ({
-      id: i.id,
-      ...(i.assetId ? { assetId: i.assetId } : {}),
-      ...(i.primitive ? { primitive: {
-        kind: i.primitive.kind,
-        w: i.primitive.w || 1,
-        h: i.primitive.h || 1,
-        height: i.primitive.height != null ? i.primitive.height : 1,
-      } } : {}),
-      tile: { x: i.tile.x, y: i.tile.y },
-      elevation: i.elevation || 0,
-      layer: i.layer || "props",
-      zBias: i.zBias || 0,
-      flipX: !!i.flipX,
-      scale: i.scale || 1,
-      ...(i.tint ? { tint: i.tint } : {}),
-      ...(i.opacity != null && i.opacity !== 1 ? { opacity: i.opacity } : {}),
-    })),
-    ...(S.palette ? { palette: S.palette } : {}),
-    canvas: {
-      bg: S.canvas.bg,
-      checkerboard: !!S.canvas.checkerboard,
-      ...(S.canvas.width ? { width: S.canvas.width } : {}),
-      ...(S.canvas.height ? { height: S.canvas.height } : {}),
-    },
-  };
-  if (S.projection.type === "custom") scene.projection.angleDeg = S.projection.angleDeg;
-  return scene;
-}
-$("btnSaveScene").onclick = () => {
-  const json = JSON.stringify(sceneToJSON(), null, 2);
-  downloadBlob(new Blob([json], { type: "application/json" }), `${(S.meta.name || "scene")}.json`);
-};
-$("fileScene").addEventListener("change", e => {
-  const f = e.target.files[0]; if (!f) return;
-  const r = new FileReader();
-  r.onload = () => { try { loadScene(JSON.parse(String(r.result))); } catch (err) { alert("Invalid scene JSON: " + err.message); } };
-  r.readAsText(f); e.target.value = "";
-});
-
-function loadScene(scene) {
-  if (!scene || typeof scene !== "object") throw new Error("not an object");
-  if (!scene.version || !String(scene.version).startsWith("1.")) throw new Error("unsupported version " + scene.version);
-  if (!scene.projection) throw new Error("missing projection");
-  // rebuild the model, hydrating _img for each asset
-  S.projection = {
-    type: scene.projection.type || "dimetric21",
-    tileW: scene.projection.tileW || 64,
-    tileH: scene.projection.tileH || 32,
-    angleDeg: scene.projection.angleDeg || 26.565,
-    unitElevation: scene.projection.unitElevation || scene.projection.tileH || 16,
-  };
-  applyProjectionConstraints();
-  S.grid = Object.assign({ extentX: 16, extentY: 16, snap: "full", visible: true }, scene.grid || {});
-  S.canvas = Object.assign({ bg: "#eef4f7", checkerboard: false, width: null, height: null }, scene.canvas || {});
-  S.meta = Object.assign({ name: "untitled", generator: GENERATOR }, scene.meta || {});
-  S.assets = (scene.assets || []).map(a => {
-    const asset = {
-      id: a.id, name: a.name || a.id, src: a.src,
-      anchor: a.anchor || { x: 0.5, y: 1 },
-      footprint: a.footprint || { w: 1, h: 1 },
-      sourceW: a.sourceW, sourceH: a.sourceH, _img: new Image(),
-    };
-    asset._img.onload = requestRender;
-    asset._img.src = a.src;
-    return asset;
-  });
-  S.instances = (scene.instances || [])
-    .filter(i => i && i.tile && (i.assetId || i.primitive))   // schema: tile + anyOf(assetId|primitive)
-    .map(i => ({
-      id: i.id || uid("i"),
-      ...(i.assetId ? { assetId: i.assetId } : {}),
-      ...(i.primitive ? { primitive: {
-        kind: PRIM_KINDS.includes(i.primitive.kind) ? i.primitive.kind : "box",
-        w: i.primitive.w || 1,
-        h: i.primitive.h || 1,
-        height: i.primitive.height != null ? i.primitive.height : 1,
-      } } : {}),
-      tile: { x: i.tile.x, y: i.tile.y },
-      elevation: i.elevation || 0, layer: i.layer || "props", zBias: i.zBias || 0,
-      flipX: !!i.flipX, scale: i.scale || 1,
-      ...(i.tint ? { tint: i.tint } : {}),
-      ...(i.opacity != null ? { opacity: i.opacity } : {}),
-    }));
-  S.palette = scene.palette || null;
-  _sceneRamp = null; _tintCache.clear();     // invalidate tint caches for the new scene
-  H.undo.length = 0; H.redo.length = 0;      // a loaded scene starts a fresh history
-  V.sel.clear(); V.trayPick = null; V.blockPick = null;
-  buildTintSelect(); syncBlockSeg();
-  syncTray(); syncPalettes(); centerView(); requestRender();
-}
-
-function downloadBlob(blob, name) {
-  const url = URL.createObjectURL(blob);
-  const a = document.createElement("a"); a.href = url; a.download = name; a.click();
-  setTimeout(() => URL.revokeObjectURL(url), 1000);
-}
-
-/* ===========================================================================
-   BOOT
-=========================================================================== */
-function centerView() {
-  // center the origin / content in the viewport at current zoom
-  const b = contentBounds();
-  if (b) { V.cam.x = V.cssW / 2 - (b.minX + b.w / 2) * V.cam.zoom; V.cam.y = V.cssH / 2 - (b.minY + b.h / 2) * V.cam.zoom; }
-  else { V.cam.x = V.cssW / 2; V.cam.y = V.cssH / 3; }
-}
-
-function boot() {
-  $("verlabel").textContent = "v0.2";
-  applyProjectionConstraints();
-  resizeCanvas();
-  buildSnapSeg();
-  buildBlockSeg();
-  buildTintSelect();     // fallback presets first; live file may replace below
-  syncProjInputs();
-  syncTray();
-  syncPalettes();
-  $("bgColor").value = /^#/.test(S.canvas.bg) ? S.canvas.bg : "#eef4f7";
-  centerView();
-  requestRender();
-  requestAnimationFrame(frame);
-  // Load the full preset library from assets/palettes/ (served by server.mjs).
-  // Graceful fallback: on file:// or a missing file the 3 built-ins remain.
-  fetch("../palettes/three-tone-presets.json")
-    .then(r => (r.ok ? r.json() : Promise.reject()))
-    .then(j => {
-      if (j && Array.isArray(j.presets) && j.presets.length) {
-        PRESETS = j.presets;
-        _sceneRamp = null;
-        buildTintSelect();
-        requestRender();
-      }
-    })
-    .catch(() => {});
-}
-window.addEventListener("resize", () => { resizeCanvas(); requestRender(); });
-new ResizeObserver(() => { resizeCanvas(); requestRender(); }).observe($("stage"));
-boot();
-</script>
-</body>
-</html>

+ 0 - 54
skills/isometric-ops/assets/iso-studio/server.mjs

@@ -1,54 +0,0 @@
-// Tiny zero-dependency static server for iso-studio (the isometric-ops scene composer).
-// Serves its OWN directory so the app (index.html) loads same-origin. Run:
-//   node server.mjs            (then open http://localhost:4323)
-//   PORT=8080 node server.mjs  (PORT env overrides the port)
-// No build step, no dependencies — just Node stdlib. Pattern precedent: tools/svg-brand-tuner/server.mjs.
-
-import { createServer } from 'node:http';
-import { readFile } from 'node:fs/promises';
-import { fileURLToPath } from 'node:url';
-import { dirname, join, extname, normalize, sep } from 'node:path';
-
-const root = dirname(fileURLToPath(import.meta.url));
-const port = Number(process.env.PORT) || 4323;
-const TYPES = {
-  '.html': 'text/html; charset=utf-8',
-  '.svg': 'image/svg+xml',
-  '.js': 'text/javascript; charset=utf-8',
-  '.mjs': 'text/javascript; charset=utf-8',
-  '.css': 'text/css; charset=utf-8',
-  '.json': 'application/json; charset=utf-8',
-  '.png': 'image/png',
-  '.webp': 'image/webp',
-  '.jpg': 'image/jpeg',
-  '.jpeg': 'image/jpeg',
-  '.gif': 'image/gif',
-};
-
-createServer(async (req, res) => {
-  try {
-    let p = decodeURIComponent((req.url || '/').split('?')[0]);
-    if (p === '/' || p === '') p = '/index.html';
-    // /palettes/* is served from the sibling assets/palettes/ dir ONLY (the
-    // three-tone presets live at skills/isometric-ops/assets/palettes/). The
-    // prefix is stripped and requests resolve against that dir specifically, so
-    // /palettes/../anything cannot reach other files in assets/.
-    const isPal = p.startsWith('/palettes/');
-    const base = isPal ? normalize(join(root, '..', 'palettes')) : root;
-    const file = normalize(join(base, isPal ? p.slice('/palettes'.length) : p));
-    // Path-traversal guard: resolved path must stay inside its base dir.
-    if (file !== base && !file.startsWith(base + sep)) {
-      res.writeHead(403);
-      return res.end('forbidden');
-    }
-    const data = await readFile(file);
-    res.writeHead(200, {
-      'content-type': TYPES[extname(file)] || 'application/octet-stream',
-      'cache-control': 'no-store',
-    });
-    res.end(data);
-  } catch {
-    res.writeHead(404);
-    res.end('not found');
-  }
-}).listen(port, () => console.log(`iso-studio → http://localhost:${port}`));

+ 0 - 302
skills/isometric-ops/assets/scene-schema.json

@@ -1,302 +0,0 @@
-{
-  "$schema": "http://json-schema.org/draft-07/schema#",
-  "$id": "https://claude-mods/isometric-ops/scene-schema/v1.0",
-  "title": "iso-studio scene",
-  "description": "Scene file for the iso-studio isometric composer (isometric-ops). A scene declares its projection once, an asset library (referenced by id), and a list of placed instances. The renderer resolves each instance's screen position from its tile coordinate + elevation using the scene's projection, then draws back-to-front by the depth-sort doctrine: (tile.x + tile.y) ascending, then elevation, then layer, then zBias. This schema is the contract the iso-studio app (next build phase) is built against; treat it as stable API. Version 1.0.",
-  "$comment": "Forward-compatibility policy: `additionalProperties` is left OPEN (true, the JSON Schema default) at the scene root and inside every extensible object (projection, grid, asset, instance, canvas). This is deliberate — a v1.0 validator must not reject a scene that carries fields introduced by a later minor version (1.x). Consumers MUST ignore unknown keys rather than error. Enumerations that drive rendering behaviour (projection.type, grid.snap, instance.layer) are the exception: they are closed to keep the renderer's switch statements exhaustive, and a genuinely new value is a MAJOR (2.0) change. See references/tile-spec.md for the asset-spec discipline these fields encode. Projection constants: true isometric = 30 deg ground axis / 35.264 deg cube tilt / 81.65% foreshortening; 2:1 dimetric (commonly called isometric in games) = 26.565 deg ground axis, W = 2 x H tiles. Extension log: 2026-07-03 — instance gained an optional `primitive` (blockout volume) alternative to `assetId`; instance.required relaxed to [tile] + anyOf(assetId|primitive). Backward-compatible minor extension; version stays 1.0.",
-  "type": "object",
-  "required": ["version", "projection"],
-  "additionalProperties": true,
-  "properties": {
-    "version": {
-      "description": "Scene-schema version this file targets. Fixed string for v1.0; a MAJOR bump signals a breaking change. Match with a caret range when validating (accept 1.x).",
-      "type": "string",
-      "const": "1.0"
-    },
-    "meta": {
-      "description": "Optional non-rendering metadata. Free-form; never affects layout.",
-      "type": "object",
-      "additionalProperties": true,
-      "properties": {
-        "name": { "type": "string", "description": "Human-readable scene title." },
-        "author": { "type": "string" },
-        "created": { "type": "string", "format": "date-time", "description": "ISO-8601 timestamp." },
-        "modified": { "type": "string", "format": "date-time" },
-        "description": { "type": "string" },
-        "generator": { "type": "string", "description": "Tool/version that wrote the file, e.g. \"iso-studio 0.1.0\"." }
-      }
-    },
-    "projection": {
-      "description": "The single most important field: which projection the whole scene uses. Decided FIRST, before any placement. Determines the tile->screen transform. Mixing projections in one scene is undefined behaviour.",
-      "type": "object",
-      "required": ["type", "tileW", "tileH"],
-      "additionalProperties": true,
-      "properties": {
-        "type": {
-          "description": "true = true isometric projection (30 deg ground axis, all faces equal, for vector/web illustration). dimetric21 = 2:1 dimetric, commonly called isometric in games (26.565 deg ground axis, tile W = 2 x H, integer 2px:1px stepping for clean tiling). custom = author-specified angle via angleDeg.",
-          "type": "string",
-          "enum": ["true", "dimetric21", "custom"]
-        },
-        "tileW": {
-          "description": "Tile footprint width in pixels for a 1x1 tile diamond. For dimetric21 this MUST be 2 x tileH (e.g. 64x32, 128x64). For true isometric tileW/tileH follows the source projection.",
-          "type": "number",
-          "exclusiveMinimum": 0
-        },
-        "tileH": {
-          "description": "Tile footprint height in pixels for a 1x1 tile diamond.",
-          "type": "number",
-          "exclusiveMinimum": 0
-        },
-        "angleDeg": {
-          "description": "Ground-axis angle from horizontal, in degrees. REQUIRED when type is \"custom\"; ignored otherwise. Reference values: true isometric = 30, 2:1 dimetric = 26.565. Range excludes the degenerate 0 (flat) and 90 (vertical).",
-          "type": "number",
-          "exclusiveMinimum": 0,
-          "exclusiveMaximum": 90
-        },
-        "unitElevation": {
-          "description": "Pixels of vertical screen offset per one z-step of elevation. Defaults to tileH if omitted. Keep constant across a scene so stacked objects align.",
-          "type": "number",
-          "exclusiveMinimum": 0
-        }
-      },
-      "allOf": [
-        {
-          "$comment": "custom projection requires an explicit angle.",
-          "if": { "properties": { "type": { "const": "custom" } } },
-          "then": { "required": ["angleDeg"] }
-        }
-      ]
-    },
-    "grid": {
-      "description": "Editing grid + placement snapping. Optional; a scene without a grid is a free-canvas composition. Extent bounds are advisory (the editor viewport), not a hard clamp on instance coordinates.",
-      "type": "object",
-      "additionalProperties": true,
-      "properties": {
-        "extentX": {
-          "description": "Grid width in tiles (advisory editor bound).",
-          "type": "integer",
-          "minimum": 0
-        },
-        "extentY": {
-          "description": "Grid depth in tiles (advisory editor bound).",
-          "type": "integer",
-          "minimum": 0
-        },
-        "snap": {
-          "description": "Placement snapping granularity. full = whole tile; half = 0.5-tile steps; quarter = 0.25-tile steps; free = no snap (continuous tile coordinates).",
-          "type": "string",
-          "enum": ["full", "half", "quarter", "free"],
-          "default": "full"
-        },
-        "visible": {
-          "description": "Whether the editor draws the grid overlay. Rendering-only; ignored at export.",
-          "type": "boolean",
-          "default": true
-        }
-      }
-    },
-    "assets": {
-      "description": "The scene's asset library. Each entry is referenced by instances via `assetId`. Defining an asset once and instancing it many times keeps scenes small and edits global.",
-      "type": "array",
-      "items": { "$ref": "#/definitions/asset" }
-    },
-    "instances": {
-      "description": "Placed occurrences of assets. Draw order is DERIVED, not stored: sort by (tile.x + tile.y) ascending, then elevation ascending, then layer order (ground < props < overlay), then zBias ascending. The array order is NOT the draw order.",
-      "type": "array",
-      "items": { "$ref": "#/definitions/instance" }
-    },
-    "palette": {
-      "description": "Optional scene palette. Either an inline map of token -> hex, or the name of a three-tone preset (see assets/palettes/three-tone-presets.json). Cross-reference references/style-guide.md for the three-tone plane doctrine.",
-      "oneOf": [
-        {
-          "type": "object",
-          "description": "Inline token map, e.g. { \"ink\": \"#1a1a2e\", \"top\": \"#e8d8c0\" }.",
-          "additionalProperties": {
-            "type": "string",
-            "pattern": "^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$"
-          }
-        },
-        {
-          "type": "string",
-          "description": "Named preset id from three-tone-presets.json, e.g. \"kenney-prototype-grey\"."
-        }
-      ]
-    },
-    "canvas": {
-      "description": "Canvas / background presentation. Rendering-only; does not affect placement math.",
-      "type": "object",
-      "additionalProperties": true,
-      "properties": {
-        "bg": {
-          "description": "Background fill. A hex color (#RGB/#RGBA/#RRGGBB/#RRGGBBAA), the string \"transparent\", or a palette token name resolved against `palette`.",
-          "type": "string"
-        },
-        "checkerboard": {
-          "description": "Show a transparency checkerboard behind the scene (editor aid). true = default checker, or an object to configure it.",
-          "oneOf": [
-            { "type": "boolean" },
-            {
-              "type": "object",
-              "additionalProperties": true,
-              "properties": {
-                "size": { "type": "number", "exclusiveMinimum": 0, "description": "Checker cell size in px." },
-                "colorA": { "type": "string" },
-                "colorB": { "type": "string" }
-              }
-            }
-          ],
-          "default": false
-        },
-        "width": { "type": "number", "exclusiveMinimum": 0, "description": "Optional fixed export width in px (else auto-fit to content)." },
-        "height": { "type": "number", "exclusiveMinimum": 0, "description": "Optional fixed export height in px." }
-      }
-    }
-  },
-  "definitions": {
-    "asset": {
-      "description": "A reusable source graphic. `src` is resolved once; instances reference by `id`.",
-      "type": "object",
-      "required": ["id", "src"],
-      "additionalProperties": true,
-      "properties": {
-        "id": {
-          "description": "Stable unique identifier referenced by instances via `assetId`. Slug-like; must be unique within the scene (uniqueness is enforced by the app, not JSON Schema).",
-          "type": "string",
-          "minLength": 1,
-          "pattern": "^[A-Za-z0-9._:-]+$"
-        },
-        "name": {
-          "description": "Human-readable label for the editor's asset panel. Falls back to `id` if absent.",
-          "type": "string"
-        },
-        "src": {
-          "description": "Image source: a relative/absolute file path, an https URL, or an inline data URI (data:image/png;base64,...). Prefer relative paths for portable scenes; data URIs make a scene self-contained at the cost of size.",
-          "type": "string",
-          "minLength": 1
-        },
-        "anchor": {
-          "description": "The image-space point that lands on the instance's tile origin, normalized 0..1 (0,0 = top-left of the source image, 1,1 = bottom-right). Isometric anchors sit at the VISUAL FEET of the sprite (bottom of ground contact), typically near { x: 0.5, y: ~0.9 } for a floor tile or object standing on it — NEVER the image center, which breaks depth sorting. See references/coordinates-depth.md.",
-          "$ref": "#/definitions/point01",
-          "default": { "x": 0.5, "y": 1 }
-        },
-        "footprint": {
-          "description": "The tile footprint the asset occupies in tile units (grammar: 1x1, 2x1, 2x2, ...). Used for multi-tile AABB depth sorting and grid collision. Defaults to a single tile.",
-          "type": "object",
-          "additionalProperties": true,
-          "properties": {
-            "w": { "type": "number", "exclusiveMinimum": 0, "default": 1, "description": "Footprint width in tiles (along +x)." },
-            "h": { "type": "number", "exclusiveMinimum": 0, "default": 1, "description": "Footprint depth in tiles (along +y)." }
-          },
-          "default": { "w": 1, "h": 1 }
-        },
-        "sourceW": {
-          "description": "Pixel width of the source image (optional cache; the app can read it from `src`).",
-          "type": "number",
-          "exclusiveMinimum": 0
-        },
-        "sourceH": {
-          "description": "Pixel height of the source image (optional cache).",
-          "type": "number",
-          "exclusiveMinimum": 0
-        }
-      }
-    },
-    "instance": {
-      "description": "One placement onto the grid — either of a library asset (`assetId`) or of a procedural blockout primitive (`primitive`). Exactly one of the two should be present; if both are, `primitive` wins. The `primitive` alternative is a backward-compatible v1.0 extension added 2026-07-03 for iso-studio blockout mode: every scene valid before the extension remains valid unchanged (assetId-only instances still satisfy the anyOf).",
-      "type": "object",
-      "required": ["tile"],
-      "anyOf": [
-        { "required": ["assetId"] },
-        { "required": ["primitive"] }
-      ],
-      "additionalProperties": true,
-      "properties": {
-        "id": {
-          "description": "Optional per-instance identifier (for selection/undo/references). Unique within the scene when present.",
-          "type": "string",
-          "minLength": 1
-        },
-        "assetId": {
-          "description": "The `id` of an entry in `assets`. Referential integrity is enforced by the app. May be omitted when `primitive` is present.",
-          "type": "string",
-          "minLength": 1
-        },
-        "primitive": {
-          "description": "Procedural flat-shaded blockout volume rendered by the app instead of an image asset (iso-studio blockout mode; feeds the depth/lineart ControlNet exports — see references/ai-generation.md). kind: box = full rectangular volume; slab = thin box (height conventionally 0.25); ramp = wedge rising along +x from z=elevation to z=elevation+height; cylinder = elliptical-cap volume inscribed in the footprint. w/h are the footprint in tiles (along +x/+y) and double as the depth-sort footprint; height is in z-units (see projection.unitElevation). Three-tone shading (top light / left mid / right dark) per references/style-guide.md.",
-          "type": "object",
-          "required": ["kind"],
-          "additionalProperties": true,
-          "properties": {
-            "kind": { "type": "string", "enum": ["box", "slab", "ramp", "cylinder"] },
-            "w": { "type": "number", "exclusiveMinimum": 0, "default": 1, "description": "Footprint width in tiles (along +x)." },
-            "h": { "type": "number", "exclusiveMinimum": 0, "default": 1, "description": "Footprint depth in tiles (along +y)." },
-            "height": { "type": "number", "exclusiveMinimum": 0, "default": 1, "description": "Volume height in z-units." }
-          }
-        },
-        "tile": {
-          "description": "Tile coordinate of the instance's anchor. Continuous when grid.snap is not \"full\". +x and +y are the two ground axes; screen position is derived per the scene projection.",
-          "type": "object",
-          "required": ["x", "y"],
-          "additionalProperties": true,
-          "properties": {
-            "x": { "type": "number", "description": "Tile column (ground axis +x)." },
-            "y": { "type": "number", "description": "Tile row (ground axis +y)." }
-          }
-        },
-        "elevation": {
-          "description": "Height above the ground plane, in z-steps (see projection.unitElevation). Raises the sprite by elevation * unitElevation screen px and is a secondary depth-sort key. Default 0 = on the ground.",
-          "type": "number",
-          "default": 0
-        },
-        "layer": {
-          "description": "Coarse draw band, applied AFTER the (x+y) and elevation sort keys. ground = floor/terrain, props = objects on the floor, overlay = always-on-top (UI markers, labels, cutaway highlights). Order: ground < props < overlay.",
-          "type": "string",
-          "enum": ["ground", "props", "overlay"],
-          "default": "props"
-        },
-        "zBias": {
-          "description": "Manual tiebreaker added to the derived sort order, for the rare case two instances share (x+y), elevation, and layer. Positive = drawn later (on top). Default 0.",
-          "type": "number",
-          "default": 0
-        },
-        "flipX": {
-          "description": "Mirror the sprite horizontally (e.g. reuse one directional sprite for its mirror). Anchor is mirrored with it.",
-          "type": "boolean",
-          "default": false
-        },
-        "scale": {
-          "description": "Per-instance uniform scale multiplier applied to the sprite (1 = native). Keep at 1 in production scenes to preserve consistent object scale (references/style-guide.md, scale grammar).",
-          "type": "number",
-          "exclusiveMinimum": 0,
-          "default": 1
-        },
-        "rotationDeg": {
-          "description": "Optional in-plane sprite rotation in degrees (rarely used for iso; most direction changes swap the sprite instead). Default 0.",
-          "type": "number",
-          "default": 0
-        },
-        "opacity": {
-          "description": "Instance opacity 0..1. Default 1. Useful for cutaway ghosting.",
-          "type": "number",
-          "minimum": 0,
-          "maximum": 1,
-          "default": 1
-        },
-        "tint": {
-          "description": "Optional multiply/overlay tint color (hex #RGB/#RGBA/#RRGGBB/#RRGGBBAA) or a palette token name. The app's blend mode is implementation-defined.",
-          "type": "string"
-        }
-      }
-    },
-    "point01": {
-      "description": "A point in normalized image space, both components in [0,1].",
-      "type": "object",
-      "required": ["x", "y"],
-      "additionalProperties": false,
-      "properties": {
-        "x": { "type": "number", "minimum": 0, "maximum": 1 },
-        "y": { "type": "number", "minimum": 0, "maximum": 1 }
-      }
-    }
-  }
-}

+ 6 - 5
skills/isometric-ops/references/ai-generation.md

@@ -214,11 +214,12 @@ asset from multiple lighting angles — consistent, interactive, and grid-aligne
 pixelation post-process here if the target is pixel art.
 
 **Lightweight alternative — iso-studio blockout export.** The Blender route is powerful
-but heavy. The bundled **iso-studio** app (`assets/iso-studio/`, launch with
-`node server.mjs`) exports a blockout **depth map** (per-instance flat grey, normalized
-near-white → far-black — massing-grade conditioning) and a **lineart render** (visible
-edges, black on white) directly from a scene composed against
-`assets/scene-schema.json` — the same dual-control idea without a full Blender rig.
+but heavy. The companion **iso-studio** app (standalone repo, local checkout
+`X:\Forge\iso-studio`, launch with `node server.mjs`) exports a blockout **depth map**
+(per-instance flat grey, elevation-aware, normalized near-white → far-black —
+massing-grade conditioning) and a **lineart render** (visible edges, black on white)
+directly from a scene composed against its `scene-schema.json` — the same dual-control
+idea without a full Blender rig.
 Stage grey primitives on the grid, export both maps at 1×/2×/4×, and feed them to the
 depth + lineart/MLSD ControlNet units exactly as in the Blender workflow above. Full
 walkthrough: [`iso-studio.md`](iso-studio.md) §"Blockout → ControlNet".

+ 44 - 446
skills/isometric-ops/references/iso-studio.md

@@ -1,451 +1,49 @@
-# iso-studio — the Scene Composer App Manual
+# iso-studio — the Companion Scene Composer (standalone app)
 
-**iso-studio** is a zero-dependency, no-build isometric scene composer bundled with this
-skill at `assets/iso-studio/` — exactly two runtime files, `index.html` (the entire app:
-canvas, state, math, render, input, palettes, IO, all in one `<script>`) and `server.mjs`
-(a Node-stdlib static file server, no npm install). It stages assets on a snap-to-grid
-isometric canvas, sorts them automatically by depth, and exports PNGs, SVGs, and scene
-JSON — plus a **blockout mode** that renders procedural grey volumes and exports
-depth/lineart conditioning maps for the AI pipeline. This file documents what the app
-**is**, verified against its source at `assets/iso-studio/index.html`: every hotkey,
-palette, snap mode, and export option below is read from the running code, not the
-original design brief.
+**iso-studio** is the zero-dependency browser scene composer that grew out of this
+skill. It is now a **standalone app in its own repository** — it outgrew the skill
+(an app with a roadmap and an asset library is a product, not a reference) and was
+extracted so the plugin stays lean while the app evolves on its own release cadence.
 
-For the coordinate math the app implements (tile↔screen, depth-sort doctrine,
-anchor-at-feet), see [`coordinates-depth.md`](coordinates-depth.md) — iso-studio's `MATH`
-section is a direct port of those formulas and is required to agree with them. For the
-projection decision itself, see [`projection-math.md`](projection-math.md). For the
-ControlNet workflow iso-studio's blockout export feeds, see
-[`ai-generation.md`](ai-generation.md) §4.
-
----
-
-## Contents
-
-1. [Launch](#1-launch)
-2. [Workspace tour](#2-workspace-tour)
-3. [Projection and snap configuration](#3-projection-and-snap-configuration)
-4. [Importing assets](#4-importing-assets)
-5. [Placing and editing instances](#5-placing-and-editing-instances)
-6. [Tint](#6-tint)
-7. [Hotkey reference](#7-hotkey-reference)
-8. [Scene JSON — save, load, schema](#8-scene-json--save-load-schema)
-9. [Exports](#9-exports)
-10. [Signature workflow: blockout → depth/lineart → ControlNet](#10-signature-workflow-blockout--depthlineart--controlnet)
-11. [Known limits](#11-known-limits)
-
----
-
-## 1. Launch
-
-Node stdlib only — no `npm install`, no build step:
+| | |
+|---|---|
+| Local checkout | `X:\Forge\iso-studio` |
+| Repository | `https://github.com/0xDarkMatter/iso-studio` |
+| Launch | `node server.mjs` → http://localhost:4323 (`PORT` env overrides) |
+| Manual | `docs/MANUAL.md` in the app repo — workspace tour, hotkeys, scene schema, known limits |
+| Scene format | `scene-schema.json` in the app repo (draft-07, version `"1.0"`) |
+
+## What it does
+
+- **Stage** — snap-to-grid placement (full/half/quarter/free) on true-isometric,
+  2:1 dimetric, or custom-angle grids; drag-drop/paste/pick PNG/SVG/WebP; per-asset
+  anchor (feet by default) and tile footprint
+- **Compose** — automatic y-sort implementing this skill's
+  [depth doctrine](coordinates-depth.md) (`(x+y)`, elevation, layer, zBias), three
+  layers, marquee/nudge/flip, undo/redo, tri-tone tint (per scene or per instance,
+  presets shared with [`assets/palettes/`](../assets/palettes/three-tone-presets.json))
+- **Blockout → ControlNet** — the signature workflow: place parametric grey primitives
+  (box/slab/ramp/cylinder), export an elevation-aware **depth map** and a **lineart
+  render**, and feed both into the ControlNet conditioning workflow in
+  [`ai-generation.md`](ai-generation.md) §4 — the lightweight, web-native alternative
+  to the [Blender depth/normal pipeline](blender-prerender.md) §3
+- **Export** — PNG 1×/2×/4× (transparent, crop-to-content), SVG when the composition
+  is all-vector, and versioned scene JSON that round-trips with assets embedded
+
+## How this skill and the app relate
+
+The app implements this skill's math and doctrine — its `MATH` section is a direct
+port of [`coordinates-depth.md`](coordinates-depth.md) and is required to agree with
+[`projection-math.md`](projection-math.md)'s canonical constants. The skill owns the
+knowledge (references, tile-spec, AI pipeline, CLI scripts); the app repo owns the
+software, the scene schema, and the starter asset library. When composing scenes:
+write the [tile spec](tile-spec.md) first, validate AI-generated tiles with
+[`tile-validate.py`](../scripts/tile-validate.py), compose in iso-studio, then pack
+shipping sheets with [`sheet-pack.py`](../scripts/sheet-pack.py).
+
+If the local checkout is missing, clone it:
 
 ```
-node assets/iso-studio/server.mjs        # then open http://localhost:4323
-PORT=8080 node assets/iso-studio/server.mjs
+git clone https://github.com/0xDarkMatter/iso-studio X:\Forge\iso-studio
+node X:\Forge\iso-studio\server.mjs
 ```
-
-`server.mjs` serves its own directory as the app root, with one exception: requests to
-`/palettes/*` resolve one directory up, to `assets/palettes/`, so the app can `fetch()`
-the shared [`three-tone-presets.json`](../assets/palettes/three-tone-presets.json)
-without a copy living inside `iso-studio/`. A path-traversal guard confirms every
-resolved path stays inside its serving root before reading it. Every response is sent
-with `cache-control: no-store`, so editing `index.html` and reloading the browser always
-picks up the change — there is no build step to forget to re-run.
-
-Opening `index.html` directly via `file://` also works for basic editing, but the
-preset fetch will fail silently (CORS blocks `fetch()` on `file://`) and the app falls
-back to three built-in presets (`kenney-prototype-grey`, `blueprint`, `earthy-game`) —
-run it through `server.mjs` to get the full 8-preset library.
-
-Pattern precedent: `tools/svg-brand-tuner/` — the same zero-dependency, single-`<script>`
-philosophy, scaled up to a full canvas editor.
-
----
-
-## 2. Workspace tour
-
-Three-column layout, no chrome beyond a 1px border and the accent colour:
-
-| Region | Contents |
-|---|---|
-| **Left rail — asset tray** | Header, a thumbnail grid of imported assets (checkerboard-backed so transparency is visible), an "Import image…" file button, and a hint pointing at `?` for the hotkey legend. Click a thumbnail to arm it for click-to-place; the `×` badge (visible on hover) removes the asset and every instance that references it. |
-| **Center — stage** | The `<canvas>` itself, a monospace status badge (top-left: active projection, tile size, snap mode, zoom %, hovered tile coordinate), and a floating zoom control (top-right: `−` / current % / `+`, click the % to reset to 100%). |
-| **Right rail — docked palettes** | Five `<details>` accordions, all open by default: **Grid**, **Blockout**, **Inspector**, **Scene**, **Export**. Each is independently collapsible; the aesthetic (quiet neutral palette, one accent colour `#3d7f99`, no gradients, 1px borders) is shared with `tools/svg-brand-tuner`. |
-
-The whole app is built on `Manrope, "Manrope", Inter, system-ui, -apple-system,
-sans-serif` with **no Google Fonts `<link>` and no embedded font data-URI** — it degrades
-to Inter or the OS UI font when Manrope isn't installed locally, and stays fully
-offline-capable either way. Numeric readouts (tile coordinates, zoom %, the status badge)
-use `font-variant-numeric: tabular-nums` so digits don't jitter as they change.
-
----
-
-## 3. Projection and snap configuration
-
-The **Grid** palette is the projection decision made concrete, per
-[`projection-math.md`](projection-math.md) §1 — decide before you place anything:
-
-| Field | Behaviour |
-|---|---|
-| **Projection** | `2:1 dimetric (game iso)` (default), `true isometric`, or `custom angle`. Switching type re-derives Tile H immediately via `applyProjectionConstraints()`. |
-| **Tile W (px)** | Free for all three types; default 64. |
-| **Tile H (px)** | **Locked** (disabled input) for `dimetric21` (forced to `tileW / 2`) and for `true` (forced to `round(tileW × 0.57735) / 100`, i.e. `tan 30°`). Only editable when Projection = `custom`. |
-| **Ground angle (°)** | Hidden unless Projection = `custom`; free-typed 1–89°, default 26.565. For the two built-in types the angle is fixed metadata (26.565 or 30) and not user-editable — it isn't load-bearing for custom, either: the app doesn't derive tileW/tileH from `angleDeg` for `custom`, they're independent free fields. |
-| **Extent X / Extent Y** | Grid width/depth in tiles, advisory (editor viewport bound, not a hard clamp on instance coordinates) — same contract as `scene-schema.json`'s `grid.extentX/extentY`. |
-| **Snap** | A segmented control: `full` / `half` / `quarter` / `free`. Internally `SNAP_STEPS = { full: 1, half: 0.5, quarter: 0.25, free: 0 }`; `free` (step 0) disables snapping entirely and instances keep continuous fractional tile coordinates. |
-| **Show grid + axes** | Toggles the diamond grid lines and the two coloured axis rays (red-ish `+x`, green-ish `+y`) from the origin. Rendering-only — has no effect on placement or export. |
-
-A hint line under the controls restates the active projection's contract in one sentence
-(e.g. "Tile H is locked to W/2 for clean 2px:1px stepping").
-
----
-
-## 4. Importing assets
-
-Three import paths, all landing in the asset tray as a new library entry:
-
-1. **Drag-drop** — drop an image file anywhere on the app (`dragover`/`drop` are
-   intercepted app-wide, not just over the tray). Files are filtered to `image/*`; a
-   dropped URL (`text/uri-list`, e.g. an image dragged from a browser tab) is accepted
-   too when no files are present.
-2. **Clipboard paste** — `Ctrl+V` anywhere reads `image/*` items off `clipboardData`.
-3. **File picker** — the "Import image…" button in the tray footer, `accept="image/*"`,
-   `multiple` (import several at once).
-
-Every path reads the file to a **data URI** (`FileReader.readAsDataURL`), not a file
-path reference — this is deliberate: it makes a saved scene JSON self-contained (the
-`asset.src` field is the image itself, not a link that can go stale), at the cost of
-larger scene files. PNG, SVG, and WebP all work identically; the app does not
-special-case SVG for placement (SVG assets get the same raster-style `<image>` treatment
-on the canvas — SVG-specific handling only reappears at **export**, see §9).
-
-**Anchor + footprint editing.** Every newly-imported asset gets the schema default
-anchor `{x: 0.5, y: 1}` (bottom-centre) and footprint `{w: 1, h: 1}`. These are **asset
-properties**, not instance properties — editing them in the Inspector's "Asset defaults"
-block (§5) changes every placed instance of that asset at once. This is why anchor lives
-on the asset, not the instance: a directional sprite sheet's eight frames should all
-share one anchor rule.
-
-**Why anchor-at-feet matters.** The anchor is the image-space point (normalized 0–1)
-that lands on the instance's tile origin. iso-studio's depth sort and its screen
-placement both key off this point — an anchor at the image center instead of the visual
-feet will place the sprite correctly on screen but sort it *wrong* relative to
-neighbours, because the sort key is derived from the tile the object's feet stand on,
-not its visual bulk. See [`coordinates-depth.md`](coordinates-depth.md) §8, "The
-anchor-at-feet rule (and why center anchors break sorting)," for the full lamppost/crate
-worked example — iso-studio is a direct implementation of that doctrine, not just an
-illustration of it.
-
----
-
-## 5. Placing and editing instances
-
-**Placement.** Click an armed tray thumbnail or Blockout primitive button, then click a
-tile on the stage (`placeFromTray` / `placePrimitive`). The app **stays armed** after
-placing — click again to place another copy — until you press `Esc` or arm something
-else (arming is mutually exclusive: picking a tray asset disarms any armed primitive and
-vice versa).
-
-**Selection.**
-- Click an instance to select it (replacing the current selection); `Shift`+click adds
-  or removes it from a multi-selection.
-- Click empty canvas and drag to **marquee-select** — any instance whose world-space
-  bounding box intersects the marquee rectangle is added (or unioned with the existing
-  selection if `Shift` is held at drag-start).
-- Hit-testing walks instances **front-to-back** (reverse draw order) so the topmost
-  instance under the cursor wins, using each instance's world AABB (`instBounds` /
-  `primBounds`) — not per-pixel alpha.
-
-**Moving.** Dragging a selected instance translates the pointer's world-pixel delta into
-a tile-space delta via the inverse transform, then snaps every selected instance's tile
-coordinate independently through the active snap step. The entire drag — from
-pointer-down to pointer-up — is coalesced into **one** undo entry, not one per
-intermediate frame.
-
-**Nudging.** Arrow keys move the selection **1 whole tile**; `Shift`+arrow moves it by
-one **screen pixel**, translated into a fractional tile delta via the same inverse
-transform used for dragging. Rapid consecutive nudges of the *same* selection within
-1.2 seconds merge into a single undo entry (`applyNudge`'s coalescing check), so tapping
-an arrow key ten times to line something up produces one undo step, not ten.
-
-**Flip.** `F` mirrors the selection horizontally (`flipX`). Flipping mirrors the anchor
-point too (`ancX = (1 - anchor.x) × drawW` when `flipX` is set) so a flipped sprite still
-anchors correctly at its feet. **Flipping a blockout ramp reverses its slope axis**
-(rises along `−x` instead of `+x`); box, slab, and cylinder are procedurally symmetric,
-so flipping them has no visual effect (see [Known limits](#11-known-limits)).
-
-**Layers.** Every instance carries a `layer` of `ground` / `props` / `overlay`
-(`LAYER_ORDER = {ground:0, props:1, overlay:2}`), set per-instance in the Inspector.
-Newly placed instances (from tray or blockout) default to `props`.
-
-**zBias.** `[` / `]` decrement/increment the selection's `zBias` by 1 — a manual
-tiebreaker for the rare case two instances land on the same `(x+y, elevation, layer)`
-key. Also editable numerically in the Inspector.
-
-**Duplicate / delete.** `Ctrl+D` duplicates the selection, offsetting each copy by
-`+1, +1` tile and wrapping the whole batch (however many instances are selected) in a
-single undo step (`withBatch`). `Delete`/`Backspace` removes the selection.
-
-**Undo/redo.** `Ctrl+Z` undoes, `Ctrl+Y` (or `Ctrl+Shift+Z`) redoes. History is a
-command-pattern stack (`H.undo`/`H.redo`) capped at **100 entries** (the v2 spec called
-for "≥50 steps" — the shipped limit is double that). Every mutation funnels through a
-single `dispatch(type, payload)` entry point that computes an inverse *before* applying
-the command, so every `COMMANDS` entry (add/remove/update instance or asset, set
-projection/grid/canvas/palette) is automatically undoable — there is no separate
-"remember to make this undoable" step for new mutation types, only the requirement to
-extend `makeInverse` alongside `COMMANDS` when adding a new mutation kind.
-
-**Selection details apply to multiple instances at once** for most Inspector fields
-(tile X/Y, elevation, zBias, scale, layer, flipX all iterate `selectionInsts()`), but
-footprint and anchor are **asset-level** fields — editing them in the Inspector always
-edits the asset of the *first* selected instance, affecting every instance of that
-asset scene-wide, not just the current selection.
-
----
-
-## 6. Tint
-
-Two independent tint layers, both implemented as a luminance→3-stop-ramp remap (the
-`tools/svg-brand-tuner` `feComponentTransfer` technique, ported to canvas pixel
-manipulation for raster assets and to a native SVG `<filter>` for vector export):
-
-- **Scene tint** (Scene palette, "Scene tint (three-tone)" dropdown) — recolours every
-  untinted instance in the scene through a preset's `ink → left → top` ramp. Presets
-  load at boot from
-  [`assets/palettes/three-tone-presets.json`](../assets/palettes/three-tone-presets.json)
-  via `fetch("../palettes/three-tone-presets.json")`; if that fetch fails (no server,
-  `file://`, moved file) the app falls back to three built-in copies
-  (`kenney-prototype-grey`, `blueprint`, `earthy-game`) baked into `index.html` itself.
-- **Per-instance tint** (Inspector, "Tint" colour swatch + "Clear tint" button) —
-  overrides the scene tint for that instance only, building a synthetic 3-stop ramp by
-  mixing the chosen hex toward black (62%) and white (72%). "Clear tint" reverts the
-  instance to the scene tint (or native colours if no scene tint is set).
-
-For raster assets, tinted bitmaps are cached per `assetId + ramp` in a `Map` bounded at
-64 entries (cleared wholesale, not LRU-evicted, once exceeded) so the render hot path
-never re-runs the luminance remap per frame. Blockout primitives don't need this cache —
-their three-tone fills are computed directly from the active ramp on every draw call via
-`effTones()`, since they're flat colour fills, not bitmaps.
-
----
-
-## 7. Hotkey reference
-
-Extracted verbatim from the in-app legend (press **`?`** to open it; `Esc` or the
-backdrop click closes it):
-
-| Input | Action |
-|---|---|
-| `Space`+drag | Pan the canvas |
-| Middle-drag | Pan the canvas |
-| Wheel | Zoom toward cursor (steps ×1.1 per notch, clamped 15%–800%) |
-| Click | Select instance / place from tray or blockout |
-| Drag | Move selection (snaps) / marquee-select |
-| `Shift`+click | Add / remove from selection |
-| Arrows | Nudge 1 tile |
-| `Shift`+arrows | Nudge 1 pixel |
-| `Ctrl`+`D` | Duplicate selection |
-| `Ctrl`+`Z` | Undo |
-| `Ctrl`+`Y` | Redo (also `Ctrl`+`Shift`+`Z`) |
-| `F` | Flip selection horizontally |
-| `Del` / `Backspace` | Delete selection |
-| `[` / `]` | zBias down / up |
-| `Esc` | Deselect / cancel placement |
-| `?` | This legend |
-
-All keyboard handling is suppressed while focus is inside an `<input>`, `<select>`, or
-`<textarea>` (typing a tile coordinate into the Inspector won't accidentally trigger
-`F` or `Del`) — except `Space`, which is only checked for pan-arming when not typing.
-
----
-
-## 8. Scene JSON — save, load, schema
-
-Scene files conform to [`assets/scene-schema.json`](../assets/scene-schema.json)
-version `"1.0"` (draft-07 JSON Schema). iso-studio round-trips its **entire** in-memory
-model through this format:
-
-**Root fields** — `version` (fixed `"1.0"`), `meta` (`name`, `generator` — the app
-stamps `"iso-studio 0.2.0"`, `modified` timestamp), `projection`
-(`type`/`tileW`/`tileH`/`unitElevation`, plus `angleDeg` **only** when `type: "custom"`),
-`grid` (`extentX`/`extentY`/`snap`/`visible`), `assets[]`, `instances[]`, `palette?`
-(the active scene-tint preset name, omitted when none is set), `canvas`
-(`bg`/`checkerboard`/optional `width`/`height`).
-
-**`assets[]`** — each entry: `id`, `name`, `src` (a data URI for every asset iso-studio
-itself imports — see §4), `anchor {x,y}`, `footprint {w,h}`, and `sourceW`/`sourceH`
-when known. This mirrors the schema's `definitions.asset` exactly.
-
-**`instances[]`** — each entry carries `id`, **either** `assetId` **or** `primitive`
-(never both — the app writes whichever the instance has), `tile {x,y}`, `elevation`,
-`layer`, `zBias`, `flipX`, `scale`, and conditionally `tint` / `opacity` (only written
-when they differ from the default, keeping saved files lean). The `primitive` object
-(`kind`, `w`, `h`, `height`) is the schema's `2026-07-03` extension — a scene with only
-`assetId` instances is still valid under the same `"1.0"` version, and iso-studio treats
-that extension as backward-compatible in both directions: `loadScene()` accepts files
-with or without any `primitive` instances.
-
-**Loading** (`loadScene()`) validates minimally but deliberately: it requires an object
-with a `version` string starting `"1."` and a `projection` object, rejecting anything
-else with a thrown, user-visible alert (`"Invalid scene JSON: …"`). It does **not**
-run full JSON-Schema validation against `scene-schema.json` — it reconstructs the model
-field-by-field with sane defaults for anything missing (e.g. a missing `grid` becomes
-the default 16×16/full/visible grid), so a hand-edited or partially-authored scene file
-loads best-effort rather than hard-failing on an incomplete document. Loading always:
-re-hydrates every asset's `_img` (a fresh `Image()` per asset, `src` re-assigned so the
-browser re-decodes it), clears both undo stacks (a loaded scene starts fresh history),
-clears the current selection and any armed tray/blockout pick, and re-centers the
-camera on the new content.
-
-**Saving** (`btnSaveScene`) serializes the current model to pretty-printed JSON
-(`JSON.stringify(..., null, 2)`) and downloads it as `<scene-name-or-"scene">.json` via
-an in-memory Blob URL (no server round-trip — the whole save/load cycle is client-side).
-
----
-
-## 9. Exports
-
-All exports live under the **Export** palette and share a common **framing** step
-(`exportFrame()`): if the Scene palette's Canvas W/H are both set, that fixed size wins
-and content is centered within it; otherwise the export tightly crops to the world-space
-bounding box of every placed instance (`contentBounds()`) — "Blank canvas size =
-crop-to-content on export," per the Scene palette's own hint text.
-
-| Export | Sizes | Notes |
-|---|---|---|
-| **PNG** | 1×, 2×, 4× | Transparent by default; honours an opaque scene background colour (the editor checkerboard itself is never baked in). Draws the full depth-sorted instance list through the same `drawInstance` path the live canvas uses. |
-| **Depth map** | 1×, 2×, 4× | See §10 — near = white, far = black, ControlNet-conditioning grade. |
-| **Lineart** | 1×, 2×, 4× | See §10 — black edges on white, ControlNet-conditioning grade. |
-| **SVG** | single button, no scale options | **Gated** — see the eligibility rule below. |
-| **Scene JSON** | — | Save/Load, §8. |
-
-**SVG eligibility rule.** The "Export SVG" button is disabled unless **every** placed
-instance qualifies: `svgEligible()` requires `S.instances.length > 0` and each instance
-to be either a blockout `primitive` (always vector — emitted as native `<path>`/
-`<polygon>`/`<ellipse>`) **or** a raster asset whose `src` starts with
-`data:image/svg` — i.e., every non-primitive instance's *source asset* must literally be
-an SVG. One PNG anywhere in the scene disables the button scene-wide; the disabled
-button's `title` attribute explains why ("the scene contains raster (non-SVG) assets…")
-so the constraint is discoverable without reading this doc. When eligible, export
-composes vector primitives and tinted `<image>` references (tint becomes a native SVG
-`<filter>` via `triToneFilterSvg`, deduplicated per unique ramp) into one self-contained
-`.svg` file.
-
----
-
-## 10. Signature workflow: blockout → depth + lineart export → ControlNet conditioning
-
-This is the app's headline feature and the reason blockout mode exists at all: **stage a
-scene's massing in iso-studio, export two conditioning maps, and hand them to a
-ControlNet-based image generator** — the lightweight, web-native alternative to building
-the same conditioning pair in Blender (full workflow:
-[`blender-prerender.md`](blender-prerender.md) §3; the AI side:
-[`ai-generation.md`](ai-generation.md) §4).
-
-**Step 1 — Block out the scene.** Open the **Blockout** palette. Pick a primitive kind
-— `box` (full rectangular volume), `slab` (thin box, default height 0.25 — a floor
-plate), `ramp` (wedge rising along `+x` from `elevation` to `elevation + height`), or
-`cylinder` (elliptical-cap volume inscribed in the footprint). Set Footprint W/H (tiles)
-and Height (z-units), then click the grid to place; the tool stays armed for rapid
-massing (`Esc` disarms). Every primitive renders with the same three-tone shading
-convention as the rest of the skill — top lightest, one side mid, one side dark
-(`PRIM_TONES`: top `#d8d6d0`, left `#a8a6a0`, right `#706e6a`) — and participates in the
-normal depth sort and Inspector editing (footprint/height are per-instance for
-primitives, unlike an asset's footprint which is shared across all its instances).
-
-**Step 2 — Export the depth map.** Export palette → "Depth map (ControlNet, near =
-white)" → pick 1×/2×/4×. The renderer fills the frame black, then for each instance
-computes a **normalized depth value** from its primary sort key
-(`(depthKey[0] − min) / span` across the scene, where `depthKey[0]` is the `(x+y)`
-frontmost-cell sum) and maps it to a flat grey `rgb(g,g,g)` with `g = 48 + 207 × norm` —
-nearer instances (larger `x+y`) render lighter, farther instances darker, and the floor
-is kept off pure black (`g ≥ 48`) so geometry stays distinguishable from the background.
-Image-sourced instances are flattened to a same-shape silhouette in that grey
-(`silhouetteOf`); primitives are filled flat with no per-face shading and no stroke
-(`mode: "flat"` — see [Known limits](#11-known-limits) on why this is massing-grade, not
-per-face depth).
-
-**Step 3 — Export the lineart map.** Export palette → "Lineart (ControlNet, black on
-white)" → pick 1×/2×/4×. The frame starts white; primitives render white-filled with a
-black stroke in normal painter's order, which gives correct hidden-line removal for free
-(a nearer opaque primitive's white fill simply paints over a farther one's stroke).
-Image-sourced instances get an outline by drawing eight 1px-offset black silhouettes
-around each instance (approximating a dilate) and then re-drawing a white silhouette on
-top, leaving just the outline ring.
-
-**Step 4 — Feed both maps into ControlNet.** Load the depth PNG into a **Depth**
-ControlNet unit and the lineart PNG into a **Lineart** (or **Canny/MLSD**, depending on
-how hard-edged your primitives are) unit, per the parameters and prompt doctrine in
-[`ai-generation.md`](ai-generation.md) §4 (Euler, ~15 steps, 768², CFG 7). Because both
-maps come from the exact same scene and export frame, they stay in registration with
-each other automatically — there's no separate camera-alignment step to get wrong, which
-is the whole reason this route is lighter than the Blender depth+normal pipeline (no
-normal pass, no camera rig to keep motionless between two renders — just re-click Export
-twice).
-
-**Step 5 — Generate, refine, vectorize.** Continue the AI pipeline as documented in
-[`ai-generation.md`](ai-generation.md) §§4–5 and
-[`ai-refinement.md`](ai-refinement.md) — upscale in the creative camp at
-resemblance-high/creativity-low, vectorize if the deliverable needs to scale, and
-re-import the finished art back into iso-studio as a tray asset to replace the blockout
-stand-in at its exact tile position.
-
----
-
-## 11. Known limits
-
-Read this before treating any export as more precise than it is.
-
-- **Depth export is per-instance flat grey, not per-face.** Every instance — image or
-  primitive — gets exactly **one** grey value derived from its scene-level sort key. A
-  primitive's three visible faces (top/left/right) are *not* individually shaded by
-  distance in the depth pass the way `blender-prerender.md`'s true Z-pass shades every
-  polygon by its own camera-space depth. This is a **massing-grade** depth cue (which
-  object is in front of which) suitable for ControlNet's depth conditioning at
-  scene-composition scale, not a per-pixel depth map with correct intra-object gradient.
-  Don't expect a box's near corner to render lighter than its far corner.
-- **Elevation IS folded into the depth export (as of v0.2), but only there.** The depth
-  export normalizes on `depthScalar(inst)` = `depthKey(inst)[0] + elevation ×
-  unitZ/(2·halfH)` — a raised block sits nearer the ortho camera, so higher reads whiter
-  (one z-step counts half a tile-step of depth at the default 64×32 / `unitZ` 16). The
-  *paint sort* is deliberately unchanged: `elevation` remains the secondary tiebreaker
-  after `(x+y)` per [`coordinates-depth.md`](coordinates-depth.md) §7's doctrine (`z`
-  breaks ties, never blended into the sort key). The scalar is still per-instance flat —
-  don't read the depth export as a per-pixel elevation map.
-- **`flipX` mirrors a ramp's slope; it is a no-op on the symmetric primitives.** A
-  flipped **ramp** reverses its slope axis (rises along `−x`, tall edge at the `x = tx`
-  side) consistently across canvas, depth, lineart, and SVG export — all four paths
-  consume the same `primShape` geometry. **Box, slab, and cylinder** are left/right
-  symmetric by construction, so flipping them changes nothing on screen or in export
-  (the stored flag still round-trips through scene JSON). For image-sourced instances,
-  flip mirrors both the sprite and its anchor.
-- **SVG export is all-or-nothing per scene**, not per-instance — one PNG-sourced asset
-  anywhere in the placed instances disables the SVG button for the whole scene (§9). To
-  get an SVG export, every *placed* asset (not just every imported one — an unused PNG
-  sitting in the tray doesn't block it) must be SVG-sourced.
-- **Loading a scene does not validate against `scene-schema.json`.** `loadScene()` checks
-  only `version` (must start `"1."`) and the presence of `projection`, then reconstructs
-  the model field-by-field with defaults for anything absent. A structurally-invalid
-  scene (wrong types, out-of-range values) can load without a schema-validation error and
-  fail more confusingly later (e.g. at render or export) — validate scenes generated by
-  external tooling against the schema *before* handing them to iso-studio if you need a
-  hard guarantee.
-- **The tint bitmap cache is a blunt 64-entry cap**, cleared entirely (not LRU-evicted)
-  once exceeded — a scene that cycles through many asset+ramp combinations will
-  periodically re-run the luminance remap for everything currently on screen. Not a
-  correctness issue, just a performance one on very large, heavily-retinted scenes.
-
----
-
-## Related
-
-[`coordinates-depth.md`](coordinates-depth.md) (the math iso-studio implements) ·
-[`projection-math.md`](projection-math.md) (the projection decision) ·
-[`ai-generation.md`](ai-generation.md) §4 (the ControlNet workflow this app's blockout
-mode feeds) · [`blender-prerender.md`](blender-prerender.md) §3 (the heavier,
-Blender-based alternative to the same depth/normal conditioning idea) ·
-[`style-guide.md`](style-guide.md) (the three-tone shading doctrine blockout primitives
-follow) · [`../assets/scene-schema.json`](../assets/scene-schema.json) (the schema scene
-files conform to) · [`../assets/palettes/three-tone-presets.json`](../assets/palettes/three-tone-presets.json)
-(the tint preset library).

+ 2 - 2
skills/isometric-ops/references/threejs-orthographic.md

@@ -378,8 +378,8 @@ Why rotate the subject and not the camera:
 - The camera frustum, zoom, and pixel-per-unit calibration (§4) never change, so every
   frame lands on the same pixel grid — no per-direction re-alignment.
 - For 4-direction sets use `DIRECTIONS = 4` (90° steps); mirror-symmetric subjects can bake
-  5 and flip in-engine (`flipX`) to save frames — the `flipX` field exists in
-  `assets/scene-schema.json` for exactly this.
+  5 and flip in-engine (`flipX`) to save frames — the `flipX` field exists in the
+  iso-studio app's `scene-schema.json` for exactly this.
 
 Anchor/pivot: keep the model's **visual feet at the pivot origin** so every baked frame
 shares the feet-anchor the depth sort expects (`references/coordinates-depth.md`), and the

+ 8 - 78
skills/isometric-ops/tests/run.sh

@@ -1,26 +1,26 @@
 #!/usr/bin/env bash
-# Self-test for isometric-ops scripts + iso-studio app.
+# Self-test for isometric-ops scripts.
 #
 # Offline-deterministic (no network, no live registry calls — check-iso-facts
 # is exercised only in --offline mode). Builds throwaway tile fixtures via
 # `uv run` + Pillow, asserts documented exit codes and key output of each
-# script, greps iso-studio's index.html/server.mjs for the load-bearing
-# contract points, then cleans up. Resolves paths relative to itself so it
-# works both in the repo and once installed to ~/.claude/skills/isometric-ops/.
+# script, then cleans up. Resolves paths relative to itself so it works both
+# in the repo and once installed to ~/.claude/skills/isometric-ops/.
+#
+# The companion iso-studio app was extracted to its own repository
+# (github.com/0xDarkMatter/iso-studio) and carries its own tests/run.sh.
 #
 # Usage:   bash tests/run.sh
 # Exit:    0 all pass (including graceful skips), 1 one or more failures
 #
-# Skips gracefully (with a message, not a failure) when uv or node are
-# unavailable on this machine — see the tile-validate/sheet-pack and
-# iso-studio sections below.
+# Skips gracefully (with a message, not a failure) when uv is unavailable —
+# see the tile-validate/sheet-pack sections below.
 
 set -uo pipefail
 
 HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 SKILL="$(dirname "$HERE")"
 SCRIPTS="$SKILL/scripts"
-STUDIO="$SKILL/assets/iso-studio"
 
 # Pick a python that actually executes — skips the Windows Store `python3`
 # stub (an app-execution alias that exits non-zero non-interactively).
@@ -260,76 +260,6 @@ expect_exit "--offline -> 0" 0 $?
 jout="$("$PYTHON" "$SCRIPTS/check-iso-facts.py" --offline --json 2>/dev/null)"
 expect_has "--offline --json parses" '"data"' "$jout"
 
-# ── iso-studio: node --check + index.html contract greps ──────────────────
-echo "-- iso-studio --"
-if command -v node >/dev/null 2>&1; then
-  if [[ -f "$STUDIO/server.mjs" ]]; then
-    node --check "$STUDIO/server.mjs" >/dev/null 2>&1
-    expect_exit "node --check server.mjs" 0 $?
-  else
-    echo "  SKIP  $STUDIO/server.mjs not found"
-  fi
-else
-  echo "  SKIP  node not found — server.mjs syntax check skipped"
-fi
-
-if [[ -f "$STUDIO/index.html" ]]; then
-  HTML="$STUDIO/index.html"
-  grep -q 'SCHEMA_VERSION *= *"1\.0"' "$HTML" && ok "index.html declares SCHEMA_VERSION \"1.0\"" \
-    || no "index.html missing SCHEMA_VERSION \"1.0\""
-  grep -q 'scene\.version' "$HTML" && ok "index.html checks scene.version on load" \
-    || no "index.html does not reference scene.version"
-  for mode in full half quarter free; do
-    grep -q "$mode" "$HTML" && ok "index.html mentions snap mode '$mode'" \
-      || no "index.html missing snap mode '$mode'"
-  done
-  for kind in box slab ramp cylinder; do
-    grep -q "\"$kind\"" "$HTML" && ok "index.html mentions blockout primitive '$kind'" \
-      || no "index.html missing blockout primitive '$kind'"
-  done
-  grep -q 'Manrope' "$HTML" && ok "index.html declares Manrope font stack" \
-    || no "index.html missing Manrope font stack"
-  grep -qE 'font-family *: *Manrope' "$HTML" && ok "index.html font-family starts with Manrope" \
-    || no "index.html font-family does not lead with Manrope"
-
-  # No external network requests. Pragmatic check: strip HTML/JS comments, then
-  # look for a scheme-qualified URL that isn't the SVG/XML namespace declaration
-  # or the localhost dev-server hint.
-  "$PYTHON" - "$HTML" <<'PYEOF' >"$SB/ext_url_check.out" 2>&1
-import re, sys
-with open(sys.argv[1], encoding="utf-8") as f:
-    src = f.read()
-# strip /* */ and // and <!-- --> comments (best-effort, line-based for //)
-src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
-src = re.sub(r"<!--.*?-->", "", src, flags=re.S)
-lines = []
-for line in src.splitlines():
-    stripped = line.strip()
-    if stripped.startswith("//"):
-        continue
-    lines.append(line)
-src = "\n".join(lines)
-urls = re.findall(r'https?://[^\s"\'<>]+', src)
-# allow the XML/SVG namespace URIs only
-allowed_prefixes = (
-    "http://www.w3.org/2000/svg",
-    "http://www.w3.org/1999/xlink",
-)
-bad = [u for u in urls if not u.startswith(allowed_prefixes)]
-if bad:
-    print("UNEXPECTED_URLS:" + ",".join(sorted(set(bad))))
-    sys.exit(1)
-print("clean")
-PYEOF
-  if [[ "$(cat "$SB/ext_url_check.out")" == "clean" ]]; then
-    ok "index.html has no external network URLs outside XML namespaces"
-  else
-    no "index.html external URL check: $(cat "$SB/ext_url_check.out")"
-  fi
-else
-  echo "  SKIP  $STUDIO/index.html not found"
-fi
-
 # ── summary ────────────────────────────────────────────────────────────────
 echo "=== $PASS passed, $FAIL failed ==="
 if [[ "$PASS" -eq 0 && "$FAIL" -eq 0 ]]; then