Browse Source

feat(skills): Add isometric-ops skill with iso-studio scene composer

Comprehensive isometric asset creation skill (skill #98): 14 references
anchored by machine-checked projection math (true iso 30deg vs 2:1
dimetric 26.565deg vs pixel-neat 1:2), coordinate/y-sort doctrine,
tile-spec discipline, SVG/CSS/three.js generation, Aseprite pixel-art
workflow, dual Blender ortho rigs (60deg dimetric vs 54.736deg true iso),
engine tilemaps (Godot 4/Unity/Phaser 3), and the AI pipeline with
ControlNet depth/MLSD structure control plus licence discipline.

Scripts (Resource Protocol): iso-math.py (constants/transforms/grid-svg/
per-tool recipes), tile-validate.py (AI-tile QA gate), sheet-pack.py
(spritesheet + atlas), check-iso-facts.py (offline constants gate in
check-resources.sh; live npm resolution in freshness.yml).

iso-studio: zero-dependency browser scene composer (index.html +
server.mjs) - snap-to-grid staging, footprint-aware y-sort, docked
palettes, tri-tone tint, undo/redo, PNG/SVG/scene-JSON export, and
blockout mode exporting elevation-aware depth + lineart maps for
ControlNet conditioning. 76-assertion offline test suite; adversarially
reviewed (verdict: ship) with live-browser verification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0xDarkMatter 1 week ago
parent
commit
f2b0ed4e21
39 changed files with 11083 additions and 6 deletions
  1. 1 1
      .claude-plugin/plugin.json
  2. 9 0
      .github/workflows/freshness.yml
  3. 1 1
      AGENTS.md
  4. 25 0
      CHANGELOG.md
  5. 6 2
      README.md
  6. 1 1
      docs/PLAN.md
  7. 317 0
      skills/isometric-ops/SKILL.md
  8. 663 0
      skills/isometric-ops/assets/blender-iso-rig.py
  9. 31 0
      skills/isometric-ops/assets/grids/dimetric-2to1-128.svg
  10. 31 0
      skills/isometric-ops/assets/grids/dimetric-2to1-32.svg
  11. 31 0
      skills/isometric-ops/assets/grids/dimetric-2to1-64.svg
  12. 31 0
      skills/isometric-ops/assets/grids/true-iso-128.svg
  13. 31 0
      skills/isometric-ops/assets/grids/true-iso-32.svg
  14. 31 0
      skills/isometric-ops/assets/grids/true-iso-64.svg
  15. 1861 0
      skills/isometric-ops/assets/iso-studio/index.html
  16. 54 0
      skills/isometric-ops/assets/iso-studio/server.mjs
  17. 111 0
      skills/isometric-ops/assets/palettes/three-tone-presets.json
  18. 362 0
      skills/isometric-ops/assets/prompt-library.md
  19. 302 0
      skills/isometric-ops/assets/scene-schema.json
  20. 422 0
      skills/isometric-ops/references/ai-generation.md
  21. 273 0
      skills/isometric-ops/references/ai-refinement.md
  22. 138 0
      skills/isometric-ops/references/asset-sourcing.md
  23. 329 0
      skills/isometric-ops/references/blender-prerender.md
  24. 648 0
      skills/isometric-ops/references/coordinates-depth.md
  25. 365 0
      skills/isometric-ops/references/css-isometric.md
  26. 285 0
      skills/isometric-ops/references/engine-integration.md
  27. 451 0
      skills/isometric-ops/references/iso-studio.md
  28. 250 0
      skills/isometric-ops/references/pixel-art-workflow.md
  29. 405 0
      skills/isometric-ops/references/projection-math.md
  30. 333 0
      skills/isometric-ops/references/style-guide.md
  31. 388 0
      skills/isometric-ops/references/svg-vector-generation.md
  32. 441 0
      skills/isometric-ops/references/threejs-orthographic.md
  33. 336 0
      skills/isometric-ops/references/tile-spec.md
  34. 455 0
      skills/isometric-ops/scripts/check-iso-facts.py
  35. 514 0
      skills/isometric-ops/scripts/iso-math.py
  36. 423 0
      skills/isometric-ops/scripts/sheet-pack.py
  37. 383 0
      skills/isometric-ops/scripts/tile-validate.py
  38. 339 0
      skills/isometric-ops/tests/run.sh
  39. 6 1
      tests/check-resources.sh

+ 1 - 1
.claude-plugin/plugin.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
   "name": "claude-mods",
-  "version": "3.4.0",
+  "version": "3.5.0",
   "description": "Custom commands, skills, agents, rules, hooks, and output styles for Claude Code - session continuity and modern CLI tooling for real-world development workflows",
   "author": {
     "name": "0xDarkMatter"

+ 9 - 0
.github/workflows/freshness.yml

@@ -97,6 +97,15 @@ jobs:
           if [ "$rc" -eq 7 ]; then echo "::warning::r-ops live check unreachable (CRAN/crandb) — skipped"; fi
           exit 0
 
+      - name: isometric-ops cited packages vs npm registry
+        run: |
+          set +e
+          python skills/isometric-ops/scripts/check-iso-facts.py --live
+          rc=$?
+          if [ "$rc" -eq 10 ]; then echo "::error::isometric-ops drift — a cited npm package is gone or renamed"; exit 1; fi
+          if [ "$rc" -eq 7 ]; then echo "::warning::isometric-ops live check unreachable (npm registry) — skipped"; fi
+          exit 0
+
       - name: GitHub Action refs still resolve
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 1 - 1
AGENTS.md

@@ -5,7 +5,7 @@
 This is **claude-mods** - a collection of custom extensions for Claude Code:
 - **3 expert agents** for pure context-isolation/worker roles (git-agent, firecrawl-expert, project-organizer) - every domain-knowledge agent became an `-ops` skill (v3.0, skills-first)
 - **2 commands** for session management (/sync, /save)
-- **97 skills** for CLI tools, patterns, workflows, and development tasks (incl. `r-ops` for tidyverse-first modern R / data analysis, `loop-ops` for outer-loop design discipline, `ffmpeg-ops` for probe-first media processing and EDL-driven editing, `supply-chain-defense` for behavioural-first dependency security, `prompt-injection-defense` for instruction-integrity scanning, `pypi-ops` for OIDC Trusted Publishing to PyPI, `net-ops` for network troubleshooting, `windows-ops` / `mac-ops` for workstation diagnostics, `fleet-worker` for cheap parallel worker delegation)
+- **98 skills** for CLI tools, patterns, workflows, and development tasks (incl. `r-ops` for tidyverse-first modern R / data analysis, `loop-ops` for outer-loop design discipline, `ffmpeg-ops` for probe-first media processing and EDL-driven editing, `supply-chain-defense` for behavioural-first dependency security, `prompt-injection-defense` for instruction-integrity scanning, `pypi-ops` for OIDC Trusted Publishing to PyPI, `net-ops` for network troubleshooting, `windows-ops` / `mac-ops` for workstation diagnostics, `fleet-worker` for cheap parallel worker delegation)
 - **13 output styles** for response personality (Vesper, Spartan, Mentor, Executive, Pair, Atlas, Coach, Harbour, Meridian, Noir, Roast, Sage, Scout)
 - **13 hooks** for pre-commit linting, post-edit formatting, dangerous command warnings, uv enforcement, dependency-install + manifest-edit supply-chain advisories, hidden-Unicode scanning (session-start + pre-commit), live config-change + worktree guards, mid-session peer-writer guard + touched-files ledger, and pmail notifications - security set auto-wired via plugin hooks.json
 - **Pigeon** inter-session messaging (`pigeon send/read/reply`) - SQLite-backed pmail at `~/.claude/pmail.db`

+ 25 - 0
CHANGELOG.md

@@ -5,6 +5,31 @@ All notable changes to claude-mods are documented here. Format follows
 [Semantic Versioning](https://semver.org/). Fuller narrative entries for
 feature releases live in the README "Recent Updates" section.
 
+## [3.5.0] - 2026-07-03
+
+### Added
+- **`isometric-ops` skill** - creation, refinement, composition, and export of
+  isometric illustrative assets for websites and games. 14 reference files carry
+  the exact projection math (true isometric 30° vs 2:1 dimetric 26.565° vs
+  pixel-neat 1:2 — with every constant derived and machine-checked), coordinate
+  transforms + y-sort depth doctrine, the tile-spec discipline that prevents
+  misaligned tilesets, SVG/CSS/three.js generation routes, pixel-art (Aseprite)
+  workflow, dual Blender ortho rigs (60° dimetric vs 54.736° true iso — a
+  distinction most tutorials gloss), engine tilemaps (Godot 4 / Unity / Phaser 3),
+  the AI pipeline (Recraft/Midjourney/Flux+LoRA with ControlNet depth/MLSD
+  structure control, upscale + vectorization ladders), asset sourcing with
+  AI-training-clause licence discipline, and a curated prompt library. Four
+  Resource-Protocol scripts: `iso-math.py` (constants, transforms, SVG grid
+  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.
+
 ## [3.4.0] - 2026-06-23
 
 ### Added

+ 6 - 2
README.md

@@ -12,16 +12,19 @@
 
 > *A comprehensive extension toolkit that transforms Claude Code into a specialized development powerhouse.*
 
-**claude-mods** is a production-ready plugin that extends Claude Code with 97 specialized skills, 3 expert agents, 13 output styles, 13 hooks, and modern CLI tools designed for real-world development workflows. Whether you're debugging React hooks, optimizing PostgreSQL queries, or building production CLI applications, this toolkit equips Claude with the domain expertise and procedural knowledge to work at expert level across multiple technology stacks.
+**claude-mods** is a production-ready plugin that extends Claude Code with 98 specialized skills, 3 expert agents, 13 output styles, 13 hooks, and modern CLI tools designed for real-world development workflows. Whether you're debugging React hooks, optimizing PostgreSQL queries, or building production CLI applications, this toolkit equips Claude with the domain expertise and procedural knowledge to work at expert level across multiple technology stacks.
 
 Built on the [Agent Skills specification](https://agentskills.io/specification) (an open standard backed by Anthropic, Vercel, Google, Microsoft, and 40+ agent platforms), claude-mods fills critical gaps in Claude Code's capabilities: persistent session state that survives across machines, on-demand expert knowledge for specialized domains, token-efficient modern CLI tools (10-100x faster than traditional alternatives), and proven workflow patterns for TDD, code review, and feature development. The toolkit implements Anthropic's [recommended patterns for long-running agents](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents), ensuring your development context never vanishes when sessions end.
 
 From Python async patterns to Rust ownership models, from AWS Fargate deployments to Craft CMS development - claude-mods provides the specialized knowledge and tools that transform Claude from a general-purpose assistant into a domain expert who understands your stack, remembers your workflow, and ships production code.
 
-**3 agents. 97 skills. 13 styles. 13 hooks. 8 rules. One install.**
+**3 agents. 98 skills. 13 styles. 13 hooks. 8 rules. One install.**
 
 ## 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**.
+
 **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`.
 
@@ -215,6 +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) |
 | [unfold-admin](skills/unfold-admin/) | Django Unfold admin theme - ModelAdmin, dashboards, filters, widgets, theming |
 
 #### Python Skills

+ 1 - 1
docs/PLAN.md

@@ -16,7 +16,7 @@
 | Component | Count | Notes |
 |-----------|-------|-------|
 | Agents | 3 | Pure context-isolation/worker roles only: git-agent (background commits/PRs), firecrawl-expert (noisy scrapes), project-organizer (bulk restructure) |
-| Skills | 97 | Operational skills, CLI tools, workflows, diagnostics, security |
+| Skills | 98 | Operational skills, CLI tools, workflows, diagnostics, security |
 | Commands | 2 | Session management (sync, save) |
 | Rules | 8 | cli-tools, commit-style, naming-conventions, prompt-injection, skill-agent-updates, supply-chain, worktree-boundaries, loop-engineering |
 | Output Styles | 13 | Vesper, Spartan, Mentor, Executive, Pair, Atlas, Coach, Harbour, Meridian, Noir, Roast, Sage, Scout |

+ 317 - 0
skills/isometric-ops/SKILL.md

@@ -0,0 +1,317 @@
+---
+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."
+license: MIT
+allowed-tools: "Read Write Bash"
+metadata:
+  author: claude-mods
+  related-skills: "genart-ops, threejs-ops, color-ops, frontend-design, playwright-ops"
+---
+
+# Isometric Operations
+
+Create, refine, compose, and export isometric illustrative assets for websites and
+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
+derived and machine-checked; every workflow is runnable; every claim is sourced in its
+reference file.
+
+> **Boundary — what this skill does NOT own.** General three.js / creative-coding
+> scaffolding lives in [`genart-ops`](../genart-ops/SKILL.md); app- and game-scale
+> three.js (GLTF pipeline, react-three-fiber, `InstancedMesh`) lives in the `threejs-ops`
+> skill. Colour science and perceptual (OKLCH) ramp construction live in
+> [`color-ops`](../color-ops/SKILL.md). `isometric-ops` owns **only the isometric delta**
+> — it cross-links those siblings, it never restates them. Detail lives in the reference
+> files below: this router points, it does not duplicate.
+
+---
+
+## 1. The projection decision — always step zero
+
+Choose the projection **before** you draw, generate, or model anything. This is not a
+deferrable stylistic preference: it fixes the grid math, the tile aspect ratio, the
+camera rig, and the anti-aliasing strategy for the entire pipeline downstream. Changing
+it later means re-cutting every asset.
+
+| Job | Projection | Ground-axis angle | Tile / face rule | Where |
+|---|---|---|---|---|
+| Web / vector illustration, diagrams, icons, hero art | **True isometric** | **30°** (all three axes 120° apart) | all axes foreshorten equally; smooth vector edges ignore pixel stepping | [`projection-math.md`](references/projection-math.md) §1, [`svg-vector-generation.md`](references/svg-vector-generation.md), [`css-isometric.md`](references/css-isometric.md) |
+| Game tiles, tilemaps, sprite worlds, most "isometric" games | **2:1 dimetric** (commonly called isometric in games) | **26.565°** = arctan(1/2) | integer 2px:1px steps tessellate; `tileW = 2·tileH` | [`projection-math.md`](references/projection-math.md) §2, [`coordinates-depth.md`](references/coordinates-depth.md), [`engine-integration.md`](references/engine-integration.md) |
+| Hand-placed / library pixel-art primitives (cubes, bricks, slopes) | **Pixel-neat 1:2** | **22.6°** (obelisk.js pixel-dot pattern) | 1:2 pixel dot stepping avoids staircasing at primitive-drawing level | [`projection-math.md`](references/projection-math.md) §7, [`pixel-art-workflow.md`](references/pixel-art-workflow.md) |
+
+**The failure mode this prevents.** Nearly every "isometric" game is actually
+**dimetric** (only two of the three axis separations are equal: ≈116.565°, 116.565°,
+126.870°). The trap: an artist draws a "30° isometric" tile, the engine places it on a
+2:1 (26.565°) diamond grid, and **the tile edges do not meet their neighbours**. Small at
+one tile, catastrophic across a 50×50 map. The fix is not a nudge tool — it is deciding,
+up front, that game tiles are **2:1 dimetric at 26.565°** and writing that number into
+the [tile spec](references/tile-spec.md) so every asset is cut to the same grid.
+
+**Terminology discipline (applies everywhere).** On first use per document write
+**"2:1 dimetric (commonly called isometric in games)"**, then "2:1 dimetric" thereafter.
+Never call the 2:1 game projection "isometric" unqualified. Distinguish **"isometric
+drawing"** (100% scale) from **"isometric projection"** (81.65% = √(2/3) scale) whenever
+the distinction affects a measurement.
+
+### Canonical constants (the authority table)
+
+Machine-emitted by [`iso-math.py constants`](scripts/iso-math.py); full derivations and
+ground-truth checks in [`projection-math.md`](references/projection-math.md).
+
+| Symbol | Exact form | Value | Appears as |
+|---|---|---|---|
+| Cube tilt ("magic angle") | `arctan(1/√2) = arcsin(1/√3)` | **35.264°** | 3D iso rotation |
+| Foreshortening (projection scale) | `cos(35.264°) = √(2/3)` | **81.65%** (0.81650) | true-projection edge scale |
+| Isometric **drawing** scale | full-scale convention | **100%** | vector iso (edges read at 100%) |
+| True ground angle | definition | **30°** | vector iso axes |
+| SSR / top-plane squash | `cos(30°)` | **86.602%** (0.86603) | Illustrator scale, 2D plane matrices |
+| Figma height / circle→ellipse | `tan(30°)` | **57.735%** (0.57735) | Figma hack, ellipse minor axis |
+| CSS back-tip | `arctan(√2) = 90° − 35.264°` | **54.7356°** | CSS `rotateX` |
+| CSS un-foreshorten | `√(3/2) = 1/cos(35.264°)` | **1.22474** | CSS `scale3d` |
+| Dimetric ground angle | `arctan(1/2)` | **26.565°** | game tiles |
+| Dimetric screen slope | `tileH/tileW` (2:1) | **0.5** | tile-space `+x` = `(+tileW/2, +tileH/2)` |
+
+> Two Blender ortho-camera rigs, both must appear wherever rigs are discussed: **2:1
+> dimetric = RotX 60°, RotY 0°, RotZ 45°** (cube top 2× wide as tall, `sin 30° = 0.5`);
+> **true isometric = RotX 54.736°, RotY 0°, RotZ 45°** (all three faces equal). Most
+> tutorials use 60/0/45 and mislabel it "isometric" — it is dimetric. See
+> [`blender-prerender.md`](references/blender-prerender.md) §1.
+
+---
+
+## 2. Task router — six routes
+
+Each route is a numbered mini-workflow. Follow the links for the exact numbers, code, and
+gotchas — the steps here are the spine, the references are the flesh.
+
+### Route A — Illustrate for the web (vector / CSS / SVG)
+
+For diagrams, icons, hero art, marketing scenes → **true isometric (30°)**.
+
+1. **Decide** true iso (§1). Pin the light direction and palette up front
+   ([`style-guide.md`](references/style-guide.md)).
+2. **Pick the medium.** Live DOM elements you want selectable / accessible / SEO-visible →
+   CSS. Static shapes and icon sets → SVG. See the decision tables in
+   [`css-isometric.md`](references/css-isometric.md) and
+   [`svg-vector-generation.md`](references/svg-vector-generation.md).
+3. **CSS route.** 3D: `transform-style: preserve-3d` on the container, per-face children,
+   then the outer stack `rotateX(54.7356deg) rotateZ(-45deg) scale3d(1.22474,…)` — the
+   scale goes on the container **only** (or faces double-scale). 2D affine for flat cards:
+   the `rotate(-30deg) skewX(30deg) scaleY(0.866)` recipe family, derived per plane in
+   [`css-isometric.md`](references/css-isometric.md).
+4. **SVG route.** Reach for [`@elchininet/isometric`](references/svg-vector-generation.md)
+   (SVG-native, planes/paths) or hand-roll diamond/cube/prism paths using the plane
+   `matrix()` recipes (Top `matrix(0.86603, 0.5, 0.86603, -0.5, 0, 0)`, etc.). Grab a
+   ready grid from [`assets/grids/`](assets/grids/) or emit one with
+   `iso-math.py grid-svg`.
+5. **Optimise & export.** Simplify paths in-tool → export → SVGO/SVGOMG → raster
+   derivatives, **in that order** ([`svg-vector-generation.md`](references/svg-vector-generation.md) §7).
+6. **Verify visually.** For map-heavy web work, headless-screenshot checks belong to
+   [`playwright-ops`](../playwright-ops/SKILL.md); tie the render back to the
+   [style checklist](references/style-guide.md).
+
+### Route B — Build a game tileset (spec → generate → validate → pack → engine)
+
+For tilemaps and sprite worlds → **2:1 dimetric (26.565°)**. This is the discipline route;
+skipping the spec is how sets drift.
+
+1. **Write the spec first.** Copy the fill-in template from
+   [`tile-spec.md`](references/tile-spec.md) and pin: projection + exact angle, tile W×H
+   (`W = 2H`), unit elevation (px per z-step), anchor **at the feet**, footprint grammar
+   (1×1, 2×1, 2×2…), transparent margin/bleed, palette tokens, one light direction, output
+   format, `name_direction_variant.png` naming, and scale grammar (one human = N tiles).
+2. **Generate** the tiles — draw them ([`pixel-art-workflow.md`](references/pixel-art-workflow.md)),
+   pre-render from 3D (Route D), or AI-generate (Route C). Every asset obeys the spec's
+   numbers.
+3. **Validate** each tile against the spec:
+   `uv run scripts/tile-validate.py --tile-w 64 --tile-h 32 tiles/*.png` — flags dimension
+   drift, alpha halos, edge-bleed, off-centre anchor, palette overflow (exit 10 on any
+   violation). Every spec line maps to a check
+   ([`tile-spec.md`](references/tile-spec.md) "How the spec feeds tile-validate.py").
+4. **Pack** into an atlas:
+   `uv run scripts/sheet-pack.py tiles/ --trim --padding 2 --pot` → one sheet PNG + a JSON
+   atlas. An atlas turns N texture binds into 1 — the single biggest win for tile-heavy
+   scenes ([`coordinates-depth.md`](references/coordinates-depth.md) §11).
+5. **Integrate** into the engine — Godot 4 `TileMapLayer` (Shape=Isometric, Layout=Diamond
+   Down, Y-Sort on, origin at feet), Unity orthographic checklist, or Phaser/PixiJS manual
+   cart↔iso. Atlas anchor/pivot mapping is spelled out in
+   [`engine-integration.md`](references/engine-integration.md).
+6. **The runtime math** — tile↔screen, picking, and the `(x+y, z, layer, zBias)` depth
+   sort with anchor-at-feet — lives in [`coordinates-depth.md`](references/coordinates-depth.md)
+   and is mirrored by `iso-math.py to-screen` / `to-tile` (round-trip verified).
+
+### Route C — AI pipeline (generate → control → refine → vectorize)
+
+Fast, but perspective drifts without structure control. Pick the model by what the output
+must *be*, then hold the geometry with ControlNet.
+
+1. **Climb the decision ladder** ([`ai-generation.md`](references/ai-generation.md) §1):
+   editable vectors → Recraft (vector-native); hero raster → Midjourney `--sref`/`--sw`
+   (+ Firefly for brand-safe vector with Content Credentials); local control / tilesets →
+   Flux/SDXL + iso LoRA + ControlNet; consistent large sets → a custom-trained model
+   (Scenario/Layer) on 10–20 on-style refs.
+2. **Prompt** from the ready scaffolds in [`assets/prompt-library.md`](assets/prompt-library.md)
+   — subject + projection + material language + simplification rule + lighting rule +
+   output intent, plus the universal negative-prompt block (vanishing points, perspective
+   distortion, dramatic shadows, text, watermarks). Doctrine in
+   [`ai-generation.md`](references/ai-generation.md) §6.
+3. **Control the structure.** For anything that must tessellate or hold true perspective,
+   condition with ControlNet: depth (massing), MLSD (architecture lines), lineart/canny
+   (exact outlines). The gold-standard workflow is Blender blockout → depth + normal pass →
+   dual-ControlNet generation ([`ai-generation.md`](references/ai-generation.md) §4;
+   blockout export via Route D or iso-studio, Route E).
+4. **Refine.** Upscale with the *creative* camp at resemblance-high / creativity-low to
+   sharpen edges without inventing perspective-breaking geometry; need >4× → regenerate at
+   a higher base instead. Clean AI edge-halos (semi-transparent fringe) mechanically —
+   `tile-validate.py` detects them ([`ai-refinement.md`](references/ai-refinement.md)).
+5. **Vectorize** if you need scalable output: Recraft (cleanest) → Vectorizer.AI →
+   SVGcode/potrace → Illustrator Image Trace + Expand; re-impose the three-tone plane
+   system after tracing ([`ai-refinement.md`](references/ai-refinement.md) §4,
+   [`style-guide.md`](references/style-guide.md)).
+6. **Check licences before delivery** — LoRA and model licences bite (see Route F and the
+   gotcha index).
+
+### Route D — Pre-render from 3D (Blender / three.js)
+
+Model once, bake sprites for eight directions. The web-native alternative to Blender is a
+three.js scene.
+
+1. **Rig the ortho camera** at the correct rotation for your projection — **both** rigs are
+   in [`blender-prerender.md`](references/blender-prerender.md) §1 (60/0/45 dimetric vs
+   54.736/0/45 true iso) with the cube-top verification test.
+2. **Blender route.** Drive it headless:
+   `blender -b -P assets/blender-iso-rig.py -- --projection dimetric21 --directions 8 --out ./sheet`.
+   A parented empty spins the model for N-direction batching; transparent film; one render
+   per direction. Add `--passes` for the depth + camera-space normal maps that feed
+   ControlNet (Route C).
+3. **three.js route.** Owns only the iso delta ([`threejs-orthographic.md`](references/threejs-orthographic.md)):
+   exact-rotation idiom (`camera.rotation.order='YXZ'; y=-π/4; x=atan(-1/√2)`),
+   frustum sizing with the resize-recompute gotcha, pixel-perfect world→CSS-px mapping,
+   render-to-target sprite export at 1×/2×/4×, constrained `OrbitControls`, and 8-direction
+   sprite baking in the browser. General scene scaffolding → [`genart-ops`](../genart-ops/SKILL.md).
+4. **Feed the tileset pipeline.** Baked sprites re-enter Route B at step 3 (validate) → 4
+   (pack) → 5 (engine).
+
+### 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.
+
+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.
+3. **Place & snap** with full / half / quarter / free snap modes; set each asset's anchor
+   and footprint so snapping and sorting stay correct.
+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").
+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).
+
+### Route F — Source existing assets (licences)
+
+Do not draw what you can legally reuse — but check the licence *before* delivery.
+
+1. **CC0 first** — Kenney iso packs, itch.io CC0 sets (Screaming Brain's 1,008 floors,
+   etc.), OpenGameArt ([`asset-sourcing.md`](references/asset-sourcing.md)).
+2. **Marketplaces** — IconScout, Flaticon (attribution on free), Icons8, Streamline,
+   Iconify, DrawKit, Blush, Storyset, Icograms.
+3. **The procurement rule** — before client delivery verify current plan + current licence +
+   **AI-training clause**. "Commercial use permitted" ≠ "dataset use permitted" (DrawKit
+   explicitly forbids AI training). Track attribution; prefer SVG source over PNG.
+
+---
+
+## 3. Scripts
+
+All scripts follow the [Skill Resource Protocol](../../docs/SKILL-RESOURCE-PROTOCOL.md):
+stdout = data only, semantic exit codes, `--help` with EXAMPLES, `--json` envelopes.
+Pure-stdlib scripts run with `python`; Pillow scripts use PEP 723 inline metadata via
+`uv run` (on this Windows machine avoid the Store `python3` stub — it exits 49).
+
+| Script | What it does | Launch |
+|---|---|---|
+| [`iso-math.py`](scripts/iso-math.py) | Canonical constants, tile↔screen transforms, SVG grids, CSS/SVG/Illustrator/Figma transform recipes | `python scripts/iso-math.py constants --projection true --json` · `… to-screen 3 2 --tile-w 64 --tile-h 32` · `… grid-svg --projection dimetric21 --tile-w 64 --extent 8 > grid.svg` · `… transforms --target css-3d` |
+| [`tile-validate.py`](scripts/tile-validate.py) | QA gate for (especially AI) tiles: dimension, alpha-halo, edge-bleed, anchor, palette checks; exit 10 on violation | `uv run scripts/tile-validate.py --tile-w 64 --tile-h 32 tiles/*.png` |
+| [`sheet-pack.py`](scripts/sheet-pack.py) | Pack a tiles directory into a spritesheet PNG + JSON atlas; `--trim --padding N --pot`, deterministic order | `uv run scripts/sheet-pack.py tiles/ --trim --padding 2 --pot` |
+| [`check-iso-facts.py`](scripts/check-iso-facts.py) | §7 staleness verifier: `--offline` asserts constants + reference citations; `--live` npm-checks named packages (exit 7 advisory / 10 drift) | `python scripts/check-iso-facts.py --offline` |
+
+## 4. Assets
+
+| Asset | What it is | Use |
+|---|---|---|
+| [`prompt-library.md`](assets/prompt-library.md) | Ready-to-paste prompt scaffolds by target tool (city block, room cutaway, floating island, warehouse, control room, dashboard, sprite tileset, icon) + universal negative block + Midjourney/Firefly/Recraft/Flux cheatsheets | Route C, step 2 |
+| [`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 |
+
+## 5. iso-studio — the scene composer
+
+**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:
+
+```
+node assets/iso-studio/server.mjs        # then open http://localhost:4323
+PORT=8080 node assets/iso-studio/server.mjs
+```
+
+- **Canvas + Grid** — projection selector (2:1 dimetric / true isometric / custom angle),
+  tile W×H (H is derived-and-locked for the two named projections), grid extent, and a
+  full / half / quarter / free snap segmented control.
+- **Asset tray** — drag-drop, clipboard-paste, or file-picker import (PNG/SVG/WebP, stored
+  as data URIs so scenes are self-contained); click-to-place, stays armed for rapid
+  placement.
+- **Depth sorting** — automatic `(x+y) → elevation → layer → zBias` sort across
+  ground / props / overlay, matching the doctrine in
+  [`coordinates-depth.md`](references/coordinates-depth.md) exactly.
+- **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").
+- **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
+  ([`ai-generation.md`](references/ai-generation.md) §4) without touching Blender.
+- **Undo/redo** (`Ctrl+Z` / `Ctrl+Y`, ≥50 steps, drag-moves and rapid nudges coalesced
+  into single entries) and the full hotkey legend via `?` in-app.
+
+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).
+
+---
+
+## 6. Gotcha index — the top 10 footguns
+
+| # | Footgun | Fix | Reference |
+|---|---|---|---|
+| 1 | **Mislabelled dimetric** — calling 2:1 game tiles "isometric" and cutting them to a 30° grid; tiles don't tessellate | Game tiles are **2:1 dimetric at 26.565°**; write the exact angle into the tile spec | [`projection-math.md`](references/projection-math.md) §2/§4, [`tile-spec.md`](references/tile-spec.md) |
+| 2 | **Skew without scale** — shearing a flat asset onto the iso axes but skipping the `cos(30°)=0.86603` vertical scale; right axes, wrong height, won't stack | Run the full **Scale → Shear → Rotate**; build on the iso plane, never skew-and-call-it-done | [`projection-math.md`](references/projection-math.md) §3.2/§3.3 |
+| 3 | **Centre anchors break y-sort** — a tall sprite's centre is high on screen, so a centre-based sort draws it behind nearer objects | Anchor every sprite at its **visual feet** (ground contact); the sort key uses the tile it stands on | [`coordinates-depth.md`](references/coordinates-depth.md) §8 |
+| 4 | **Elevation folded into the depth key** — using `x+y+z` makes a tall near object sort behind a short far one | `z` is a **tie-breaker after** `(x+y)`, never added into it: `(x+y, z, layer, zBias)` | [`coordinates-depth.md`](references/coordinates-depth.md) §5/§7 |
+| 5 | **Unity near-clip clipping** — default near plane hides geometry behind the ortho camera on the XZ map | Push near clip to a large negative (−1000+); Stable Fit → Close Fit shadows; disable cascades | [`engine-integration.md`](references/engine-integration.md) §2 |
+| 6 | **Ortho frustum not recomputed on resize** — the iso view stretches when the viewport changes | Recompute aspect-scaled left/right/top/bottom in the resize handler; update the projection matrix | [`threejs-orthographic.md`](references/threejs-orthographic.md) §3 |
+| 7 | **AI halo edges** — semi-transparent fringe around AI-generated tiles that shows as a seam | Alpha-threshold / defringe; `tile-validate.py` flags halo % mechanically | [`ai-refinement.md`](references/ai-refinement.md) §3 |
+| 8 | **Licence AI-training clause** — "commercial use OK" is not "dataset use OK"; DrawKit forbids AI training | Verify plan + licence + **AI-training clause** before client delivery | [`asset-sourcing.md`](references/asset-sourcing.md) |
+| 9 | **Flux-dev LoRA licences** — Flux.1-dev derivatives are **non-commercial**; shipping them in a client project is a violation | Check each LoRA's licence flag; prefer permissively-licensed adapters for commercial work | [`ai-generation.md`](references/ai-generation.md) §2/§7 |
+| 10 | **Staircasing & the >4× upscale trap** — 30° lines staircase in pixel art; pushing an upscaler past 4× invents perspective-breaking detail | Commit to 2:1 pixel-neat stepping; above 4× **regenerate at a higher base**, don't upscale | [`pixel-art-workflow.md`](references/pixel-art-workflow.md) §1, [`ai-refinement.md`](references/ai-refinement.md) §1 |
+
+---
+
+## Related skills
+
+[`genart-ops`](../genart-ops/SKILL.md) (general three.js / creative coding) ·
+`threejs-ops` (app/game-scale three.js: GLTF, r3f, `InstancedMesh`) ·
+[`color-ops`](../color-ops/SKILL.md) (colour science, OKLCH ramps) ·
+[`frontend-design`](../frontend-design/SKILL.md) (production UI craft) ·
+[`playwright-ops`](../playwright-ops/SKILL.md) (headless render verification).

+ 663 - 0
skills/isometric-ops/assets/blender-iso-rig.py

@@ -0,0 +1,663 @@
+#!/usr/bin/env python3
+# Build a Blender orthographic isometric render rig and (headless) bake N-direction sprites.
+#
+# Usage:   blender -b -P blender-iso-rig.py -- [OPTIONS]
+#          blender    -P blender-iso-rig.py         # GUI: build the rig only, no render
+#          python3 blender-iso-rig.py --help        # print this help outside Blender
+# Input:   argv after the `--` separator (Blender convention). No stdin.
+# Output:  stdout = data only. Rendered PNG paths (one per line), or a `--json` envelope.
+# Stderr:  headers, progress, warnings, errors, Blender's own render log.
+# Exit:    0 ok, 2 usage, 5 not-inside-Blender (bpy missing) / bad env, 10 nothing rendered.
+#
+# Canonical rig table (isometric-ops brief — verify against references/blender-prerender.md):
+#   Target                     | Camera rotation (X, Y, Z, degrees) | Verification
+#   ---------------------------|------------------------------------|--------------------------
+#   2:1 dimetric (game tiles)  | 60.000, 0, 45                      | cube top 2x wide as tall
+#   true isometric             | 54.736, 0, 45                      | all three cube faces equal
+#   The dimetric elevation is 30deg (sin 30 = 0.5 -> 2:1). The true-iso tilt is
+#   arctan(sqrt(2)) = 54.7356deg = 90 - 35.264deg. Most tutorials use 60/0/45 and call it
+#   "isometric"; it is 2:1 dimetric. This script keeps the two projections distinct.
+#
+# Examples:
+#   blender -b -P blender-iso-rig.py -- --projection dimetric21 --directions 8 --out ./sheet
+#   blender -b -P blender-iso-rig.py -- --projection true --directions 4 --resolution 512 --out ./iso
+#   blender -b -P blender-iso-rig.py -- --projection dimetric21 --passes --out ./ctrlnet  # +depth +normal for ControlNet
+#   blender    -P blender-iso-rig.py -- --projection true       # GUI: build rig, skip render
+#
+# References:
+#   Clint Bellanger, "Isometric Tiles in Blender" (canonical 60/0/45 rig, parented
+#     RenderPlatform empty, 8-direction rotation): http://clintbellanger.net/articles/isometric_tiles/
+#   Blender Python API (bpy) reference: https://docs.blender.org/api/current/
+#   ControlNet depth+normal workflow (RotX 54.736, RotZ 45; Z-pass; camera-space normal):
+#     see references/blender-prerender.md and references/ai-generation.md in this skill.
+
+"""Build a Blender orthographic isometric render rig and bake N-direction sprites.
+
+Run this INSIDE Blender:  ``blender -b -P blender-iso-rig.py -- <options>``.
+Outside Blender it degrades gracefully: ``python3 blender-iso-rig.py --help`` prints
+usage, and any other invocation prints an install/usage hint to stderr and exits 5
+(PRECONDITION) rather than crashing with an ``ImportError`` traceback.
+"""
+
+import sys
+import os
+import json
+import math
+import argparse
+
+# ---------------------------------------------------------------------------
+# Constants (must agree with the isometric-ops canonical constants table).
+# ---------------------------------------------------------------------------
+
+SCHEMA = "claude-mods.isometric-ops.blender-iso-rig/v1"
+
+# Camera X-rotation per projection, in DEGREES. Z is always 45, Y always 0.
+# dimetric21: elevation 30deg above ground -> tilt 60deg from vertical (sin 30 = 0.5 -> 2:1).
+# true:       arctan(sqrt(2)) = 54.7356deg -> all three cube faces render equal.
+CAMERA_ROT_X_DEG = {
+    "dimetric21": 60.0,
+    "true": math.degrees(math.atan(math.sqrt(2.0))),  # 54.735610...
+}
+CAMERA_ROT_Y_DEG = 0.0
+CAMERA_ROT_Z_DEG = 45.0
+
+# Semantic exit codes (Skill Resource Protocol section 5).
+EXIT_OK = 0
+EXIT_USAGE = 2
+EXIT_PRECONDITION = 5
+EXIT_NOTHING = 10
+
+RENDER_PLATFORM = "RenderPlatform"  # Bellanger's parented pivot-empty name.
+CAM_NAME = "IsoCamera"
+KEY_LIGHT_NAME = "IsoKeyLight"
+
+
+# ---------------------------------------------------------------------------
+# Argument parsing (Blender passes script args AFTER a lone `--` in argv).
+# ---------------------------------------------------------------------------
+
+def _script_argv(argv):
+    """Return the args intended for this script.
+
+    Inside Blender, ``blender -b -P script.py -- --projection true`` puts the
+    script's own args after a lone ``--``. Outside Blender (plain ``python3``)
+    there is usually no ``--``; fall back to everything after argv[0].
+    """
+    if "--" in argv:
+        return argv[argv.index("--") + 1:]
+    # Plain `python3 blender-iso-rig.py --help` -> everything past the program name.
+    return argv[1:]
+
+
+def build_parser():
+    p = argparse.ArgumentParser(
+        prog="blender-iso-rig.py",
+        description=(
+            "Build a Blender orthographic isometric render rig and (headless) "
+            "bake N-direction sprites. Run inside Blender: "
+            "blender -b -P blender-iso-rig.py -- <options>"
+        ),
+        epilog=(
+            "EXAMPLES:\n"
+            "  blender -b -P blender-iso-rig.py -- --projection dimetric21 --directions 8 --out ./sheet\n"
+            "  blender -b -P blender-iso-rig.py -- --projection true --directions 4 --resolution 512 --out ./iso\n"
+            "  blender -b -P blender-iso-rig.py -- --projection dimetric21 --passes --out ./ctrlnet\n"
+            "  blender    -P blender-iso-rig.py -- --projection true    # GUI: build rig only\n"
+        ),
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+    p.add_argument(
+        "--projection",
+        choices=("dimetric21", "true"),
+        default="dimetric21",
+        help=(
+            "dimetric21 = 2:1 dimetric game tiles (camera RotX 60), commonly (mis)called "
+            "isometric; true = mathematically true isometric (camera RotX 54.736). "
+            "Default: dimetric21."
+        ),
+    )
+    p.add_argument(
+        "--directions",
+        type=int,
+        default=8,
+        metavar="N",
+        help="Number of yaw directions to bake (1-64). Default: 8 (N, NE, E, ...).",
+    )
+    p.add_argument(
+        "--out",
+        default=None,
+        metavar="DIR",
+        help="Output directory for rendered PNGs. Required when rendering (-b). Created if absent.",
+    )
+    p.add_argument(
+        "--resolution",
+        type=int,
+        default=256,
+        metavar="N",
+        help="Square render resolution in pixels (16-8192). Default: 256.",
+    )
+    p.add_argument(
+        "--ortho-scale",
+        type=float,
+        default=None,
+        metavar="F",
+        help=(
+            "Orthographic scale (world units spanned by the frame). Default: auto-fit "
+            "the scene bounding sphere with a 10%% margin, or 8.0 for an empty scene."
+        ),
+    )
+    p.add_argument(
+        "--distance",
+        type=float,
+        default=100.0,
+        metavar="F",
+        help="Camera distance from the pivot along the view axis. Default: 100 (ortho: only sign/clip matter).",
+    )
+    p.add_argument(
+        "--passes",
+        action="store_true",
+        help=(
+            "Also export a normalized depth (Z) pass and a camera-space normal pass per "
+            "direction, for ControlNet (depth + normal) conditioning. Requires -b."
+        ),
+    )
+    p.add_argument(
+        "--name-prefix",
+        default="sprite",
+        metavar="STR",
+        help="Filename prefix; frames are <prefix>_dir<NN>.png. Default: sprite.",
+    )
+    p.add_argument(
+        "--json",
+        action="store_true",
+        help="Emit a JSON envelope on stdout instead of plain paths.",
+    )
+    return p
+
+
+def _fail_usage(msg, want_json):
+    """Print a USAGE error and exit 2. Structured to stdout under --json, human to stderr."""
+    if want_json:
+        print(json.dumps({"error": {"code": "USAGE", "message": msg, "details": {}}}))
+    print("error: %s" % msg, file=sys.stderr)
+    sys.exit(EXIT_USAGE)
+
+
+# ---------------------------------------------------------------------------
+# Rig construction (all bpy access is confined below this line).
+# ---------------------------------------------------------------------------
+
+def build_rig(bpy, mathutils, args):
+    """Create the ortho camera, parented pivot empty, and a consistent key light.
+
+    Returns (camera_object, pivot_empty_object). Idempotent: reuses objects by
+    name so re-running in the same .blend does not stack duplicate rigs.
+    """
+    scene = bpy.context.scene
+
+    # --- Pivot empty at the world origin (Bellanger's RenderPlatform). -------
+    pivot = bpy.data.objects.get(RENDER_PLATFORM)
+    if pivot is None:
+        pivot = bpy.data.objects.new(RENDER_PLATFORM, None)  # None data => Empty.
+        pivot.empty_display_type = "PLAIN_AXES"
+        scene.collection.objects.link(pivot)
+    pivot.location = (0.0, 0.0, 0.0)
+    pivot.rotation_euler = (0.0, 0.0, 0.0)
+
+    # --- Orthographic camera. ------------------------------------------------
+    cam_data = bpy.data.cameras.get(CAM_NAME) or bpy.data.cameras.new(CAM_NAME)
+    cam_data.type = "ORTHO"
+    cam_data.clip_start = 0.001
+    cam_data.clip_end = args.distance * 4.0 + 10.0
+
+    cam = bpy.data.objects.get(CAM_NAME)
+    if cam is None:
+        cam = bpy.data.objects.new(CAM_NAME, cam_data)
+        scene.collection.objects.link(cam)
+    else:
+        cam.data = cam_data
+
+    rot_x = math.radians(CAMERA_ROT_X_DEG[args.projection])
+    rot_y = math.radians(CAMERA_ROT_Y_DEG)
+    rot_z = math.radians(CAMERA_ROT_Z_DEG)
+    cam.rotation_mode = "XYZ"
+    cam.rotation_euler = (rot_x, rot_y, rot_z)
+
+    # Place the camera back along its own local -Z (view direction) so the pivot
+    # is centred in frame. Ortho render is distance-invariant for scale, but the
+    # camera must sit inside [clip_start, clip_end] and in front of the geometry.
+    view_dir = mathutils.Vector((0.0, 0.0, -1.0))
+    view_dir.rotate(cam.rotation_euler)
+    cam.location = -view_dir * args.distance
+
+    cam_data.ortho_scale = _resolve_ortho_scale(bpy, args)
+
+    scene.camera = cam
+
+    # --- One fixed key light (three-tone doctrine: single consistent source). -
+    light_data = bpy.data.lights.get(KEY_LIGHT_NAME)
+    if light_data is None:
+        light_data = bpy.data.lights.new(KEY_LIGHT_NAME, type="SUN")
+    light_data.energy = 3.0
+    light_data.angle = math.radians(2.0)  # crisp, slightly-soft shadows
+    light = bpy.data.objects.get(KEY_LIGHT_NAME)
+    if light is None:
+        light = bpy.data.objects.new(KEY_LIGHT_NAME, light_data)
+        scene.collection.objects.link(light)
+    else:
+        light.data = light_data
+    # Light from upper-front-left, consistent across the whole set. This is a
+    # rig default; override in-scene for a bespoke look.
+    light.rotation_mode = "XYZ"
+    light.rotation_euler = (math.radians(50.0), math.radians(0.0), math.radians(-125.0))
+
+    return cam, pivot
+
+
+def _resolve_ortho_scale(bpy, args):
+    """Pick an orthographic scale: explicit flag, else auto-fit the scene, else 8.0."""
+    if args.ortho_scale is not None:
+        return max(0.001, args.ortho_scale)
+
+    # Auto-fit: bounding sphere of all renderable meshes, +10% margin.
+    radius = 0.0
+    center = [0.0, 0.0, 0.0]
+    coords = []
+    for obj in bpy.context.scene.objects:
+        if obj.type != "MESH" or obj.hide_render:
+            continue
+        mw = obj.matrix_world
+        for corner in obj.bound_box:
+            wc = mw @ _as_vec(bpy, corner)
+            coords.append((wc[0], wc[1], wc[2]))
+    if not coords:
+        return 8.0  # sensible default for an empty scene (rig-only / cube test).
+    for i in range(3):
+        lo = min(c[i] for c in coords)
+        hi = max(c[i] for c in coords)
+        center[i] = (lo + hi) / 2.0
+    for c in coords:
+        d = math.sqrt(sum((c[i] - center[i]) ** 2 for i in range(3)))
+        radius = max(radius, d)
+    return max(0.5, radius * 2.0 * 1.1)
+
+
+def _as_vec(bpy, corner):
+    """bound_box corners are already Vector-like; wrap defensively for old builds."""
+    from mathutils import Vector
+    return Vector((corner[0], corner[1], corner[2]))
+
+
+def configure_render(bpy, args):
+    """Square render, transparent film, PNG RGBA output."""
+    scene = bpy.context.scene
+    r = scene.render
+    r.resolution_x = args.resolution
+    r.resolution_y = args.resolution
+    r.resolution_percentage = 100
+    r.film_transparent = True
+    r.image_settings.file_format = "PNG"
+    r.image_settings.color_mode = "RGBA"
+    r.image_settings.color_depth = "8"
+
+
+# ---------------------------------------------------------------------------
+# Optional ControlNet passes: normalized depth + camera-space normal.
+# ---------------------------------------------------------------------------
+
+# Node names for the pass-export graph, so re-runs find and reuse them instead
+# of stacking duplicate nodes into the compositor tree.
+DEPTH_OUTPUT_NODE = "IsoDepthOutput"
+NORMAL_OUTPUT_NODE = "IsoNormalOutput"
+DEPTH_SUBDIR = "depth"
+NORMAL_SUBDIR = "normal"
+
+
+def enable_controlnet_passes(bpy, out_dir):
+    """Wire a compositor graph that writes a normalized-depth pass and a
+    camera-space normal pass to their OWN files, via dedicated File Output
+    nodes, WITHOUT touching the beauty render.
+
+    The scene's ``Composite`` node (the beauty pass that
+    ``render.render(write_still=True)`` saves to ``scene.render.filepath``) is
+    left entirely alone — the depth/normal maps go to separate
+    ``CompositorNodeOutputFile`` nodes writing under ``<out_dir>/depth`` and
+    ``<out_dir>/normal``. This is additive: any existing user graph is
+    preserved; we only add (or reuse-by-name) the Render-Layers source and the
+    two File Output nodes.
+
+    Depth: Render-Layers ``Depth`` -> Normalize -> depth File Output (grayscale
+    Z, [0,1]).
+    Normal: Render-Layers ``Normal`` (world space) -> Vector Transform
+    (World -> Camera) -> Multiply-Add mapping [-1,1] -> [0,1] -> normal File
+    Output (camera-space normal, ControlNet-ready).
+
+    Returns (depth_node, normal_node) on success — the File Output nodes whose
+    per-direction ``base_path``/slot filename is set in the render loop — or
+    ``(None, None)`` on best-effort failure (warned to stderr).
+    """
+    scene = bpy.context.scene
+    try:
+        vl = scene.view_layers[0]
+        vl.use_pass_z = True
+        vl.use_pass_normal = True
+    except Exception as exc:  # pragma: no cover - depends on Blender build
+        print("warning: could not enable Z/Normal passes: %s" % exc, file=sys.stderr)
+        return (None, None)
+
+    try:
+        scene.use_nodes = True
+        tree = scene.node_tree
+
+        # Reuse a Render-Layers node if the tree already has one (default scenes
+        # ship with Render-Layers + Composite); otherwise add one. Never gate on
+        # an empty tree — a headless scene's tree is NOT empty.
+        rl = None
+        for node in tree.nodes:
+            if node.bl_idname == "CompositorNodeRLayers":
+                rl = node
+                break
+        if rl is None:
+            rl = tree.nodes.new("CompositorNodeRLayers")
+
+        # --- Depth branch: RL.Depth -> Normalize -> File Output (grayscale). --
+        depth_out = tree.nodes.get(DEPTH_OUTPUT_NODE)
+        if depth_out is None:
+            depth_out = tree.nodes.new("CompositorNodeOutputFile")
+            depth_out.name = DEPTH_OUTPUT_NODE
+            depth_out.label = "Iso Depth (Z)"
+        depth_out.format.file_format = "PNG"
+        depth_out.format.color_mode = "BW"
+        depth_out.format.color_depth = "16"
+
+        if "Depth" in rl.outputs:
+            norm = tree.nodes.new("CompositorNodeNormalize")  # depth -> [0,1]
+            tree.links.new(rl.outputs["Depth"], norm.inputs[0])
+            tree.links.new(norm.outputs[0], depth_out.inputs[0])
+        else:
+            print("warning: Render-Layers has no 'Depth' output; depth pass skipped",
+                  file=sys.stderr)
+            depth_out = None
+
+        # --- Normal branch: RL.Normal (world) -> World->Camera -> *0.5+0.5. ---
+        normal_out = tree.nodes.get(NORMAL_OUTPUT_NODE)
+        if normal_out is None:
+            normal_out = tree.nodes.new("CompositorNodeOutputFile")
+            normal_out.name = NORMAL_OUTPUT_NODE
+            normal_out.label = "Iso Normal (camera-space)"
+        normal_out.format.file_format = "PNG"
+        normal_out.format.color_mode = "RGB"
+        normal_out.format.color_depth = "16"
+
+        if "Normal" in rl.outputs:
+            # World-space normals -> camera space so the map is view-consistent.
+            vt = tree.nodes.new("CompositorNodeVecTransform")
+            vt.vector_type = "NORMAL"
+            vt.convert_from = "WORLD"
+            vt.convert_to = "CAMERA"
+            tree.links.new(rl.outputs["Normal"], vt.inputs[0])
+
+            # Remap components from [-1,1] to [0,1] (n * 0.5 + 0.5) so the PNG is
+            # a standard ControlNet normal map. MixRGB MULTIPLY then ADD on the
+            # Vector; use a Mix node pair operating on the vector as color.
+            mul = tree.nodes.new("CompositorNodeMixRGB")
+            mul.blend_type = "MULTIPLY"
+            mul.inputs[0].default_value = 1.0
+            mul.inputs[2].default_value = (0.5, 0.5, 0.5, 1.0)
+            tree.links.new(vt.outputs[0], mul.inputs[1])
+
+            add = tree.nodes.new("CompositorNodeMixRGB")
+            add.blend_type = "ADD"
+            add.inputs[0].default_value = 1.0
+            add.inputs[2].default_value = (0.5, 0.5, 0.5, 1.0)
+            tree.links.new(mul.outputs[0], add.inputs[1])
+            tree.links.new(add.outputs[0], normal_out.inputs[0])
+        else:
+            print("warning: Render-Layers has no 'Normal' output; normal pass skipped",
+                  file=sys.stderr)
+            normal_out = None
+
+    except Exception as exc:  # pragma: no cover
+        print("warning: compositor pass graph not created: %s" % exc, file=sys.stderr)
+        return (None, None)
+
+    return (depth_out, normal_out)
+
+
+# ---------------------------------------------------------------------------
+# Rendering.
+# ---------------------------------------------------------------------------
+
+def _outfile_slot_path(out_node):
+    """Return the single slot object of a File Output node across Blender API
+    variants (``file_slots`` on modern builds; ``layer_slots`` for multilayer)."""
+    slots = getattr(out_node, "file_slots", None)
+    if slots is not None and len(slots) > 0:
+        return slots[0]
+    return None
+
+
+def render_directions(bpy, args, out_dir, depth_out=None, normal_out=None):
+    """Render one PNG per yaw direction by rotating the pivot empty.
+
+    Rotating the RenderPlatform (which parents the geometry, in practice) yaws
+    the subject under a fixed camera — the Bellanger technique. Here the pivot
+    is at origin; parent scene geometry to it in your .blend for it to spin.
+
+    When ``--passes`` wired the depth/normal File Output nodes, this points each
+    at a per-direction file under ``<out_dir>/depth`` and ``<out_dir>/normal``
+    before every render, so the ControlNet maps land alongside the beauty
+    sprite WITHOUT overwriting it (the beauty PNG still comes from
+    ``scene.render.filepath`` via the untouched Composite node).
+
+    Returns ``(beauty_paths, depth_paths, normal_paths)`` — the depth/normal
+    lists are empty unless the corresponding pass node was wired.
+    """
+    import mathutils  # noqa: F401  (kept for symmetry / future transforms)
+
+    scene = bpy.context.scene
+    pivot = bpy.data.objects[RENDER_PLATFORM]
+
+    depth_dir = os.path.join(out_dir, DEPTH_SUBDIR)
+    normal_dir = os.path.join(out_dir, NORMAL_SUBDIR)
+    if depth_out is not None:
+        os.makedirs(depth_dir, exist_ok=True)
+        depth_out.base_path = depth_dir
+    if normal_out is not None:
+        os.makedirs(normal_dir, exist_ok=True)
+        normal_out.base_path = normal_dir
+
+    # File Output nodes always append the current frame number to the slot path.
+    # Pin the frame so that suffix is a stable, predictable ``0001`` we can
+    # resolve back to a concrete on-disk path for the JSON meta.
+    frame_no = scene.frame_current
+    frame_suffix = "%04d" % frame_no
+
+    n = args.directions
+    written = []
+    depth_written = []
+    normal_written = []
+    for i in range(n):
+        yaw = (2.0 * math.pi) * (i / float(n))
+        pivot.rotation_euler = (0.0, 0.0, yaw)
+
+        frame_path = os.path.join(out_dir, "%s_dir%02d.png" % (args.name_prefix, i))
+        scene.render.filepath = frame_path
+
+        # Point each pass File Output at this direction's file. Blender appends
+        # the frame number, so slot path "sprite_dir00_" yields
+        # "sprite_dir00_0001.png"; record that resolved path.
+        depth_path = None
+        normal_path = None
+        if depth_out is not None:
+            slot = _outfile_slot_path(depth_out)
+            stem = "%s_dir%02d_" % (args.name_prefix, i)
+            if slot is not None:
+                slot.path = stem
+            depth_path = os.path.abspath(
+                os.path.join(depth_dir, stem + frame_suffix + ".png"))
+        if normal_out is not None:
+            slot = _outfile_slot_path(normal_out)
+            stem = "%s_dir%02d_" % (args.name_prefix, i)
+            if slot is not None:
+                slot.path = stem
+            normal_path = os.path.abspath(
+                os.path.join(normal_dir, stem + frame_suffix + ".png"))
+
+        # write_still renders and saves in one call; no GUI required under -b.
+        # The File Output nodes write their pass PNGs as a side effect of the
+        # same render; the beauty PNG comes from scene.render.filepath.
+        bpy.ops.render.render(write_still=True)
+
+        written.append(os.path.abspath(frame_path))
+        print("rendered %s" % frame_path, file=sys.stderr)
+        if depth_path is not None:
+            depth_written.append(depth_path)
+            print("  depth  %s" % depth_path, file=sys.stderr)
+        if normal_path is not None:
+            normal_written.append(normal_path)
+            print("  normal %s" % normal_path, file=sys.stderr)
+
+    # Reset pivot so re-runs / GUI inspection start clean.
+    pivot.rotation_euler = (0.0, 0.0, 0.0)
+    return written, depth_written, normal_written
+
+
+# ---------------------------------------------------------------------------
+# Output.
+# ---------------------------------------------------------------------------
+
+def emit(args, cam_rot_x_deg, ortho_scale, rendered,
+         depth_paths=None, normal_paths=None):
+    """Write the data product to stdout per the stream-separation contract.
+
+    ``data`` is the beauty-sprite path list. When ``--passes`` produced depth
+    and/or camera-space normal maps, their concrete file paths are reported
+    under ``meta.passes`` (a mapping) so a downstream ControlNet step can find
+    them — not a bare ``true``.
+    """
+    depth_paths = depth_paths or []
+    normal_paths = normal_paths or []
+    if args.json:
+        if args.passes:
+            passes_meta = {
+                "enabled": True,
+                "depth": depth_paths,
+                "normal": normal_paths,
+            }
+        else:
+            passes_meta = {"enabled": False, "depth": [], "normal": []}
+        envelope = {
+            "data": rendered,
+            "meta": {
+                "count": len(rendered),
+                "schema": SCHEMA,
+                "projection": args.projection,
+                "camera_rotation_deg": {
+                    "x": round(cam_rot_x_deg, 4),
+                    "y": CAMERA_ROT_Y_DEG,
+                    "z": CAMERA_ROT_Z_DEG,
+                },
+                "directions": args.directions,
+                "resolution": args.resolution,
+                "ortho_scale": round(ortho_scale, 4),
+                "passes": passes_meta,
+            },
+        }
+        print(json.dumps(envelope, indent=2))
+    else:
+        for path in rendered:
+            print(path)
+        for path in depth_paths:
+            print(path)
+        for path in normal_paths:
+            print(path)
+
+
+# ---------------------------------------------------------------------------
+# Entry point.
+# ---------------------------------------------------------------------------
+
+def main():
+    parser = build_parser()
+    script_args = _script_argv(sys.argv)
+
+    # Parse. argparse exits 2 on bad args and prints to stderr; that matches the
+    # protocol's USAGE code, so let it handle --help / errors for the CLI itself.
+    args = parser.parse_args(script_args)
+
+    if args.directions < 1 or args.directions > 64:
+        _fail_usage("--directions must be between 1 and 64", args.json)
+    if args.resolution < 16 or args.resolution > 8192:
+        _fail_usage("--resolution must be between 16 and 8192", args.json)
+
+    # --- Are we inside Blender? ---------------------------------------------
+    try:
+        import bpy
+        import mathutils
+    except ImportError:
+        msg = (
+            "this script must run inside Blender: "
+            "blender -b -P blender-iso-rig.py -- --projection dimetric21 --out ./sheet\n"
+            "install Blender from https://www.blender.org/download/ "
+            "(the bundled Python provides the 'bpy' module)."
+        )
+        if args.json:
+            print(json.dumps({"error": {"code": "PRECONDITION", "message":
+                "not running inside Blender (bpy unavailable)", "details": {}}}))
+        print("error: " + msg, file=sys.stderr)
+        sys.exit(EXIT_PRECONDITION)
+
+    # --- Build the rig (always). --------------------------------------------
+    cam, pivot = build_rig(bpy, mathutils, args)
+    cam_rot_x_deg = CAMERA_ROT_X_DEG[args.projection]
+    ortho_scale = cam.data.ortho_scale
+    print(
+        "rig built: projection=%s  camera RotX=%.4f RotY=%.1f RotZ=%.1f  ortho_scale=%.4f"
+        % (args.projection, cam_rot_x_deg, CAMERA_ROT_Y_DEG, CAMERA_ROT_Z_DEG, ortho_scale),
+        file=sys.stderr,
+    )
+
+    # --- Headless mode renders; GUI mode (no -b) just builds the rig. --------
+    if not bpy.app.background:
+        print(
+            "GUI mode (no -b): rig built, skipping render. "
+            "Parent your geometry to '%s' and re-run headless to bake sprites." % RENDER_PLATFORM,
+            file=sys.stderr,
+        )
+        emit(args, cam_rot_x_deg, ortho_scale, [])
+        sys.exit(EXIT_OK)
+
+    # Headless render path requires an output directory.
+    if not args.out:
+        _fail_usage("--out DIR is required when rendering (-b headless mode)", args.json)
+
+    out_dir = os.path.abspath(args.out)
+    try:
+        os.makedirs(out_dir, exist_ok=True)
+    except OSError as exc:
+        if args.json:
+            print(json.dumps({"error": {"code": "PRECONDITION",
+                "message": "cannot create --out directory", "details": {"path": out_dir}}}))
+        print("error: cannot create output directory %s: %s" % (out_dir, exc), file=sys.stderr)
+        sys.exit(EXIT_PRECONDITION)
+
+    configure_render(bpy, args)
+    depth_out = normal_out = None
+    if args.passes:
+        depth_out, normal_out = enable_controlnet_passes(bpy, out_dir)
+
+    rendered, depth_paths, normal_paths = render_directions(
+        bpy, args, out_dir, depth_out=depth_out, normal_out=normal_out)
+
+    emit(args, cam_rot_x_deg, ortho_scale, rendered,
+         depth_paths=depth_paths, normal_paths=normal_paths)
+
+    if not rendered:
+        print("warning: no frames were rendered", file=sys.stderr)
+        sys.exit(EXIT_NOTHING)
+    sys.exit(EXIT_OK)
+
+
+if __name__ == "__main__":
+    main()

+ 31 - 0
skills/isometric-ops/assets/grids/dimetric-2to1-128.svg

@@ -0,0 +1,31 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="1540.0" height="772.0" viewBox="0 0 1540.0 772.0">
+  <!-- isometric-ops grid: projection=dimetric21 tileW=128 extent=12 axis-slope=0.5 -->
+  <g fill="none" stroke="#334155" stroke-width="1" stroke-linecap="round">
+  <line x1="770.0" y1="2.0" x2="2.0" y2="386.0"/>
+  <line x1="834.0" y1="34.0" x2="66.0" y2="418.0"/>
+  <line x1="898.0" y1="66.0" x2="130.0" y2="450.0"/>
+  <line x1="962.0" y1="98.0" x2="194.0" y2="482.0"/>
+  <line x1="1026.0" y1="130.0" x2="258.0" y2="514.0"/>
+  <line x1="1090.0" y1="162.0" x2="322.0" y2="546.0"/>
+  <line x1="1154.0" y1="194.0" x2="386.0" y2="578.0"/>
+  <line x1="1218.0" y1="226.0" x2="450.0" y2="610.0"/>
+  <line x1="1282.0" y1="258.0" x2="514.0" y2="642.0"/>
+  <line x1="1346.0" y1="290.0" x2="578.0" y2="674.0"/>
+  <line x1="1410.0" y1="322.0" x2="642.0" y2="706.0"/>
+  <line x1="1474.0" y1="354.0" x2="706.0" y2="738.0"/>
+  <line x1="1538.0" y1="386.0" x2="770.0" y2="770.0"/>
+  <line x1="770.0" y1="2.0" x2="1538.0" y2="386.0"/>
+  <line x1="706.0" y1="34.0" x2="1474.0" y2="418.0"/>
+  <line x1="642.0" y1="66.0" x2="1410.0" y2="450.0"/>
+  <line x1="578.0" y1="98.0" x2="1346.0" y2="482.0"/>
+  <line x1="514.0" y1="130.0" x2="1282.0" y2="514.0"/>
+  <line x1="450.0" y1="162.0" x2="1218.0" y2="546.0"/>
+  <line x1="386.0" y1="194.0" x2="1154.0" y2="578.0"/>
+  <line x1="322.0" y1="226.0" x2="1090.0" y2="610.0"/>
+  <line x1="258.0" y1="258.0" x2="1026.0" y2="642.0"/>
+  <line x1="194.0" y1="290.0" x2="962.0" y2="674.0"/>
+  <line x1="130.0" y1="322.0" x2="898.0" y2="706.0"/>
+  <line x1="66.0" y1="354.0" x2="834.0" y2="738.0"/>
+  <line x1="2.0" y1="386.0" x2="770.0" y2="770.0"/>
+  </g>
+</svg>

+ 31 - 0
skills/isometric-ops/assets/grids/dimetric-2to1-32.svg

@@ -0,0 +1,31 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="388.0" height="196.0" viewBox="0 0 388.0 196.0">
+  <!-- isometric-ops grid: projection=dimetric21 tileW=32 extent=12 axis-slope=0.5 -->
+  <g fill="none" stroke="#334155" stroke-width="1" stroke-linecap="round">
+  <line x1="194.0" y1="2.0" x2="2.0" y2="98.0"/>
+  <line x1="210.0" y1="10.0" x2="18.0" y2="106.0"/>
+  <line x1="226.0" y1="18.0" x2="34.0" y2="114.0"/>
+  <line x1="242.0" y1="26.0" x2="50.0" y2="122.0"/>
+  <line x1="258.0" y1="34.0" x2="66.0" y2="130.0"/>
+  <line x1="274.0" y1="42.0" x2="82.0" y2="138.0"/>
+  <line x1="290.0" y1="50.0" x2="98.0" y2="146.0"/>
+  <line x1="306.0" y1="58.0" x2="114.0" y2="154.0"/>
+  <line x1="322.0" y1="66.0" x2="130.0" y2="162.0"/>
+  <line x1="338.0" y1="74.0" x2="146.0" y2="170.0"/>
+  <line x1="354.0" y1="82.0" x2="162.0" y2="178.0"/>
+  <line x1="370.0" y1="90.0" x2="178.0" y2="186.0"/>
+  <line x1="386.0" y1="98.0" x2="194.0" y2="194.0"/>
+  <line x1="194.0" y1="2.0" x2="386.0" y2="98.0"/>
+  <line x1="178.0" y1="10.0" x2="370.0" y2="106.0"/>
+  <line x1="162.0" y1="18.0" x2="354.0" y2="114.0"/>
+  <line x1="146.0" y1="26.0" x2="338.0" y2="122.0"/>
+  <line x1="130.0" y1="34.0" x2="322.0" y2="130.0"/>
+  <line x1="114.0" y1="42.0" x2="306.0" y2="138.0"/>
+  <line x1="98.0" y1="50.0" x2="290.0" y2="146.0"/>
+  <line x1="82.0" y1="58.0" x2="274.0" y2="154.0"/>
+  <line x1="66.0" y1="66.0" x2="258.0" y2="162.0"/>
+  <line x1="50.0" y1="74.0" x2="242.0" y2="170.0"/>
+  <line x1="34.0" y1="82.0" x2="226.0" y2="178.0"/>
+  <line x1="18.0" y1="90.0" x2="210.0" y2="186.0"/>
+  <line x1="2.0" y1="98.0" x2="194.0" y2="194.0"/>
+  </g>
+</svg>

+ 31 - 0
skills/isometric-ops/assets/grids/dimetric-2to1-64.svg

@@ -0,0 +1,31 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="772.0" height="388.0" viewBox="0 0 772.0 388.0">
+  <!-- isometric-ops grid: projection=dimetric21 tileW=64 extent=12 axis-slope=0.5 -->
+  <g fill="none" stroke="#334155" stroke-width="1" stroke-linecap="round">
+  <line x1="386.0" y1="2.0" x2="2.0" y2="194.0"/>
+  <line x1="418.0" y1="18.0" x2="34.0" y2="210.0"/>
+  <line x1="450.0" y1="34.0" x2="66.0" y2="226.0"/>
+  <line x1="482.0" y1="50.0" x2="98.0" y2="242.0"/>
+  <line x1="514.0" y1="66.0" x2="130.0" y2="258.0"/>
+  <line x1="546.0" y1="82.0" x2="162.0" y2="274.0"/>
+  <line x1="578.0" y1="98.0" x2="194.0" y2="290.0"/>
+  <line x1="610.0" y1="114.0" x2="226.0" y2="306.0"/>
+  <line x1="642.0" y1="130.0" x2="258.0" y2="322.0"/>
+  <line x1="674.0" y1="146.0" x2="290.0" y2="338.0"/>
+  <line x1="706.0" y1="162.0" x2="322.0" y2="354.0"/>
+  <line x1="738.0" y1="178.0" x2="354.0" y2="370.0"/>
+  <line x1="770.0" y1="194.0" x2="386.0" y2="386.0"/>
+  <line x1="386.0" y1="2.0" x2="770.0" y2="194.0"/>
+  <line x1="354.0" y1="18.0" x2="738.0" y2="210.0"/>
+  <line x1="322.0" y1="34.0" x2="706.0" y2="226.0"/>
+  <line x1="290.0" y1="50.0" x2="674.0" y2="242.0"/>
+  <line x1="258.0" y1="66.0" x2="642.0" y2="258.0"/>
+  <line x1="226.0" y1="82.0" x2="610.0" y2="274.0"/>
+  <line x1="194.0" y1="98.0" x2="578.0" y2="290.0"/>
+  <line x1="162.0" y1="114.0" x2="546.0" y2="306.0"/>
+  <line x1="130.0" y1="130.0" x2="514.0" y2="322.0"/>
+  <line x1="98.0" y1="146.0" x2="482.0" y2="338.0"/>
+  <line x1="66.0" y1="162.0" x2="450.0" y2="354.0"/>
+  <line x1="34.0" y1="178.0" x2="418.0" y2="370.0"/>
+  <line x1="2.0" y1="194.0" x2="386.0" y2="386.0"/>
+  </g>
+</svg>

+ 31 - 0
skills/isometric-ops/assets/grids/true-iso-128.svg

@@ -0,0 +1,31 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="1540.0" height="890.81" viewBox="0 0 1540.0 890.81">
+  <!-- isometric-ops grid: projection=true tileW=128 extent=12 axis-slope=0.57735 -->
+  <g fill="none" stroke="#334155" stroke-width="1" stroke-linecap="round">
+  <line x1="770.0" y1="2.0" x2="2.0" y2="445.405"/>
+  <line x1="834.0" y1="38.95" x2="66.0" y2="482.355"/>
+  <line x1="898.0" y1="75.901" x2="130.0" y2="519.306"/>
+  <line x1="962.0" y1="112.851" x2="194.0" y2="556.256"/>
+  <line x1="1026.0" y1="149.802" x2="258.0" y2="593.207"/>
+  <line x1="1090.0" y1="186.752" x2="322.0" y2="630.157"/>
+  <line x1="1154.0" y1="223.703" x2="386.0" y2="667.108"/>
+  <line x1="1218.0" y1="260.653" x2="450.0" y2="704.058"/>
+  <line x1="1282.0" y1="297.603" x2="514.0" y2="741.008"/>
+  <line x1="1346.0" y1="334.554" x2="578.0" y2="777.959"/>
+  <line x1="1410.0" y1="371.504" x2="642.0" y2="814.909"/>
+  <line x1="1474.0" y1="408.455" x2="706.0" y2="851.86"/>
+  <line x1="1538.0" y1="445.405" x2="770.0" y2="888.81"/>
+  <line x1="770.0" y1="2.0" x2="1538.0" y2="445.405"/>
+  <line x1="706.0" y1="38.95" x2="1474.0" y2="482.355"/>
+  <line x1="642.0" y1="75.901" x2="1410.0" y2="519.306"/>
+  <line x1="578.0" y1="112.851" x2="1346.0" y2="556.256"/>
+  <line x1="514.0" y1="149.802" x2="1282.0" y2="593.207"/>
+  <line x1="450.0" y1="186.752" x2="1218.0" y2="630.157"/>
+  <line x1="386.0" y1="223.703" x2="1154.0" y2="667.108"/>
+  <line x1="322.0" y1="260.653" x2="1090.0" y2="704.058"/>
+  <line x1="258.0" y1="297.603" x2="1026.0" y2="741.008"/>
+  <line x1="194.0" y1="334.554" x2="962.0" y2="777.959"/>
+  <line x1="130.0" y1="371.504" x2="898.0" y2="814.909"/>
+  <line x1="66.0" y1="408.455" x2="834.0" y2="851.86"/>
+  <line x1="2.0" y1="445.405" x2="770.0" y2="888.81"/>
+  </g>
+</svg>

+ 31 - 0
skills/isometric-ops/assets/grids/true-iso-32.svg

@@ -0,0 +1,31 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="388.0" height="225.703" viewBox="0 0 388.0 225.703">
+  <!-- isometric-ops grid: projection=true tileW=32 extent=12 axis-slope=0.57735 -->
+  <g fill="none" stroke="#334155" stroke-width="1" stroke-linecap="round">
+  <line x1="194.0" y1="2.0" x2="2.0" y2="112.851"/>
+  <line x1="210.0" y1="11.238" x2="18.0" y2="122.089"/>
+  <line x1="226.0" y1="20.475" x2="34.0" y2="131.326"/>
+  <line x1="242.0" y1="29.713" x2="50.0" y2="140.564"/>
+  <line x1="258.0" y1="38.95" x2="66.0" y2="149.802"/>
+  <line x1="274.0" y1="48.188" x2="82.0" y2="159.039"/>
+  <line x1="290.0" y1="57.426" x2="98.0" y2="168.277"/>
+  <line x1="306.0" y1="66.663" x2="114.0" y2="177.514"/>
+  <line x1="322.0" y1="75.901" x2="130.0" y2="186.752"/>
+  <line x1="338.0" y1="85.138" x2="146.0" y2="195.99"/>
+  <line x1="354.0" y1="94.376" x2="162.0" y2="205.227"/>
+  <line x1="370.0" y1="103.614" x2="178.0" y2="214.465"/>
+  <line x1="386.0" y1="112.851" x2="194.0" y2="223.703"/>
+  <line x1="194.0" y1="2.0" x2="386.0" y2="112.851"/>
+  <line x1="178.0" y1="11.238" x2="370.0" y2="122.089"/>
+  <line x1="162.0" y1="20.475" x2="354.0" y2="131.326"/>
+  <line x1="146.0" y1="29.713" x2="338.0" y2="140.564"/>
+  <line x1="130.0" y1="38.95" x2="322.0" y2="149.802"/>
+  <line x1="114.0" y1="48.188" x2="306.0" y2="159.039"/>
+  <line x1="98.0" y1="57.426" x2="290.0" y2="168.277"/>
+  <line x1="82.0" y1="66.663" x2="274.0" y2="177.514"/>
+  <line x1="66.0" y1="75.901" x2="258.0" y2="186.752"/>
+  <line x1="50.0" y1="85.138" x2="242.0" y2="195.99"/>
+  <line x1="34.0" y1="94.376" x2="226.0" y2="205.227"/>
+  <line x1="18.0" y1="103.614" x2="210.0" y2="214.465"/>
+  <line x1="2.0" y1="112.851" x2="194.0" y2="223.703"/>
+  </g>
+</svg>

+ 31 - 0
skills/isometric-ops/assets/grids/true-iso-64.svg

@@ -0,0 +1,31 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="772.0" height="447.405" viewBox="0 0 772.0 447.405">
+  <!-- isometric-ops grid: projection=true tileW=64 extent=12 axis-slope=0.57735 -->
+  <g fill="none" stroke="#334155" stroke-width="1" stroke-linecap="round">
+  <line x1="386.0" y1="2.0" x2="2.0" y2="223.703"/>
+  <line x1="418.0" y1="20.475" x2="34.0" y2="242.178"/>
+  <line x1="450.0" y1="38.95" x2="66.0" y2="260.653"/>
+  <line x1="482.0" y1="57.426" x2="98.0" y2="279.128"/>
+  <line x1="514.0" y1="75.901" x2="130.0" y2="297.603"/>
+  <line x1="546.0" y1="94.376" x2="162.0" y2="316.079"/>
+  <line x1="578.0" y1="112.851" x2="194.0" y2="334.554"/>
+  <line x1="610.0" y1="131.326" x2="226.0" y2="353.029"/>
+  <line x1="642.0" y1="149.802" x2="258.0" y2="371.504"/>
+  <line x1="674.0" y1="168.277" x2="290.0" y2="389.979"/>
+  <line x1="706.0" y1="186.752" x2="322.0" y2="408.455"/>
+  <line x1="738.0" y1="205.227" x2="354.0" y2="426.93"/>
+  <line x1="770.0" y1="223.703" x2="386.0" y2="445.405"/>
+  <line x1="386.0" y1="2.0" x2="770.0" y2="223.703"/>
+  <line x1="354.0" y1="20.475" x2="738.0" y2="242.178"/>
+  <line x1="322.0" y1="38.95" x2="706.0" y2="260.653"/>
+  <line x1="290.0" y1="57.426" x2="674.0" y2="279.128"/>
+  <line x1="258.0" y1="75.901" x2="642.0" y2="297.603"/>
+  <line x1="226.0" y1="94.376" x2="610.0" y2="316.079"/>
+  <line x1="194.0" y1="112.851" x2="578.0" y2="334.554"/>
+  <line x1="162.0" y1="131.326" x2="546.0" y2="353.029"/>
+  <line x1="130.0" y1="149.802" x2="514.0" y2="371.504"/>
+  <line x1="98.0" y1="168.277" x2="482.0" y2="389.979"/>
+  <line x1="66.0" y1="186.752" x2="450.0" y2="408.455"/>
+  <line x1="34.0" y1="205.227" x2="418.0" y2="426.93"/>
+  <line x1="2.0" y1="223.703" x2="386.0" y2="445.405"/>
+  </g>
+</svg>

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

@@ -0,0 +1,1861 @@
+<!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>

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

@@ -0,0 +1,54 @@
+// 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}`));

+ 111 - 0
skills/isometric-ops/assets/palettes/three-tone-presets.json

@@ -0,0 +1,111 @@
+{
+  "_schema": {
+    "id": "claude-mods.isometric-ops.three-tone-presets/v1",
+    "updated": "2026-07-03",
+    "doctrine": "references/style-guide.md — three-tone plane shading: the TOP plane (ground-facing-up) is lightest, one visible side plane is MID value, the other visible side plane is DARK. Apply the SAME tint/tonal family across every asset in a set and hold ONE fixed light direction for the whole scene or tileset. Do not mix top/left/right assignments within one set. Fields:",
+    "fields": {
+      "name": "kebab-case identifier, stable — safe to reference from prompts, configs, or scripts",
+      "description": "one-line: mood, genre fit, and where the palette comes from or is inspired by",
+      "ink": "hex — outline/linework stroke color (or 'none' convention marker if the style is strokeless; always a real hex here, pick the darkest neutral in the family when strokeless is intended)",
+      "top": "hex — top (roof/ground-plane) fill, lightest of the three plane tones",
+      "left": "hex — one side plane, mid tone (by convention: the side toward the light)",
+      "right": "hex — other side plane, dark tone (by convention: the side away from the light)",
+      "shadow": "hex — cast/ambient-occlusion shadow color; almost never pure black — usually a desaturated dark tint of the hue family, per style-guide.md's 'soft long shadows cast along plane angles'",
+      "accent": "hex — sparing highlight/CTA/emissive color, used at low area coverage for focal points",
+      "bg": "hex — recommended canvas/backdrop behind the asset or scene"
+    },
+    "usage": "top/left/right map directly to the plane-shading recipes in references/projection-math.md (SSR, Figma-hack, CSS/SVG plane transforms) and to references/ai-generation.md prompt color-language. left is conventionally the plane nearer the fixed light direction from style-guide.md; right is the plane away from it — swap left/right consistently across a whole set if your light comes from the other side, never per-asset.",
+    "verification": "top must be the highest-luminance fill of the three per preset (checked via WCAG relative-luminance, not raw hex-string comparison) — mirrored left/right (light-from-the-other-side) is a valid variant, top-is-not-lightest is not.",
+    "related": "references/style-guide.md (doctrine + inspiration canon), color-ops skill (OKLCH ramp construction, perceptual palette theory — this file supplies fixed instances, not the color-science derivation)"
+  },
+  "presets": [
+    {
+      "name": "kenney-prototype-grey",
+      "description": "Neutral warm-grey greybox set matching the Kenney.nl CC0 isometric prototype-tile look — for blockouts, engine wiring, and pre-art-pass level tests where color must not read as final.",
+      "ink": "#2b2b31",
+      "top": "#d8d6d0",
+      "left": "#a8a6a0",
+      "right": "#706e6a",
+      "shadow": "#4a4844",
+      "accent": "#5b9bd5",
+      "bg": "#eceae5"
+    },
+    {
+      "name": "pastel-dollhouse",
+      "description": "Soft, low-saturation warm pastels for cozy room-cutaway and dollhouse-aesthetic scenes (bedroom interiors, cafés, miniature domestic scenes).",
+      "ink": "#5a4a4e",
+      "top": "#fbe8d9",
+      "left": "#f0b8b4",
+      "right": "#c98a92",
+      "shadow": "#8e6570",
+      "accent": "#f7d154",
+      "bg": "#fdf3ea"
+    },
+    {
+      "name": "industrial-muted",
+      "description": "Desaturated steel and rust tones for warehouse, logistics, and factory-floor scenes — reads as functional infrastructure, not toyetic.",
+      "ink": "#23262b",
+      "top": "#9aa3ab",
+      "left": "#6c747c",
+      "right": "#454b52",
+      "shadow": "#2c3036",
+      "accent": "#d8862b",
+      "bg": "#c7cdd2"
+    },
+    {
+      "name": "cyberpunk-teal-violet",
+      "description": "High-contrast neon teal/violet duotone for control-room, server-room, and night-city scenes — pairs with the Grid Dynamics 'cyberpunk style, made in blender 3D' prompt scaffold.",
+      "ink": "#0c0a1a",
+      "top": "#7be6dc",
+      "left": "#3fb8d6",
+      "right": "#5a3f9e",
+      "shadow": "#241448",
+      "accent": "#ff4fd8",
+      "bg": "#120b24"
+    },
+    {
+      "name": "blueprint",
+      "description": "Monochrome cyanotype blueprint scheme — white/light-cyan linework on blueprint-blue fills, for engineering-diagram, floor-plan, and architectural-schematic renders rather than painterly illustration.",
+      "ink": "#e8f2ff",
+      "top": "#5f8fd9",
+      "left": "#3f6bb0",
+      "right": "#284a85",
+      "shadow": "#152c58",
+      "accent": "#ffffff",
+      "bg": "#0f2b5c"
+    },
+    {
+      "name": "earthy-game",
+      "description": "Warm terracotta, moss, and sand tones for stylized fantasy/adventure tile sets — floating islands, rustic villages, overworld terrain.",
+      "ink": "#3a2a1e",
+      "top": "#e0c07a",
+      "left": "#a8823f",
+      "right": "#7a5a2e",
+      "shadow": "#4a3620",
+      "accent": "#6f9c4a",
+      "bg": "#f2e3bd"
+    },
+    {
+      "name": "mono-ink",
+      "description": "Strict single-hue greyscale-with-a-whisper-of-blue for icon sets, technical diagrams, and print-safe line illustration where color must not compete with UI or text.",
+      "ink": "#111214",
+      "top": "#e4e6ea",
+      "left": "#b0b4bb",
+      "right": "#74787f",
+      "shadow": "#3a3d42",
+      "accent": "#3d7fd6",
+      "bg": "#f6f7f9"
+    },
+    {
+      "name": "brand-neutral",
+      "description": "Low-personality, high-adaptability neutral scheme for product/marketing isometric illustrations that must sit next to an arbitrary existing brand palette — swap only 'accent' to match the client brand color and the set still reads correctly.",
+      "ink": "#22242a",
+      "top": "#f2f3f5",
+      "left": "#c7cbd1",
+      "right": "#8d929b",
+      "shadow": "#565a61",
+      "accent": "#2e7dd6",
+      "bg": "#ffffff"
+    }
+  ]
+}

+ 362 - 0
skills/isometric-ops/assets/prompt-library.md

@@ -0,0 +1,362 @@
+# Isometric Prompt Library
+
+Ready-to-paste prompt scaffolds for generating isometric illustration with text-to-image
+models. Curated from field-tested scaffolds (Grid Dynamics' production tutorial), vendor
+prompt guidance (Midjourney, Adobe Firefly), and cross-checked prompt sets for common
+subject categories. Pair this file with `references/ai-generation.md` (the decision
+ladder for *which* tool/model to reach for) and `references/style-guide.md` (the
+three-tone shading doctrine these prompts encode in words).
+
+**The projection decision still comes first.** Every scaffold below assumes you already
+decided: true isometric (30°, vector/hero illustration) or 2:1 dimetric (26.565°, "game
+isometric" tiles). Say so explicitly in the prompt — models default to arbitrary
+axonometric angles otherwise, and this is the #1 cause of AI perspective drift. See
+`references/projection-math.md` for the projection decision table.
+
+Volatile facts (parameter syntax, weight ranges, pricing) are date-stamped **as of July
+2026** — these move fast. Verify against the vendor's live docs before building a
+pipeline around a specific flag; `scripts/check-iso-facts.py --live` tracks the packages
+named elsewhere in this skill but does not track AI vendor parameters, which are out of
+scope for a stdlib grep-check.
+
+---
+
+## 1. The universal prompt formula
+
+Every scaffold in this file follows the same anatomy — this is the doctrine, the
+scaffolds below are just filled-in instances of it:
+
+```
+[SUBJECT] + [PROJECTION] + [MATERIAL LANGUAGE] + [SIMPLIFICATION RULE] + [LIGHTING RULE] + [OUTPUT INTENT]
+```
+
+| Slot | Purpose | Example fragment |
+|---|---|---|
+| Subject | What it is, concretely | "modern logistics warehouse" |
+| Projection | Locks the axonometric angle in words | "2:1 dimetric projection", "30 degree isometric perspective view" |
+| Material language | Concrete surface/material nouns, not mood words | "corrugated metal siding, concrete loading dock" |
+| Simplification rule | Forces flat/clean geometry over photoreal clutter | "simple geometric forms, clean vector art style" |
+| Lighting rule | One fixed light direction, soft shadows, no drama | "soft long shadows, consistent lighting, no dramatic shadows" |
+| Output intent | Tells the model what medium/use it's for | "scalable SVG icon style", "game asset quality design" |
+
+Comma-separated keyword style beats narrative prose for structural control — models
+respond more reliably to a flat list of concrete nouns/adjectives than a sentence. Use
+round brackets `(term)` (Midjourney/SD family) to up-weight a single critical keyword
+when the model keeps dropping it, e.g. `(isometric:1.3)` or `(orthographic:1.2)`.
+
+---
+
+## 2. Universal negative-prompt block
+
+Append this to every generation regardless of subject or tool (drop the syntax that
+doesn't apply — Firefly and Recraft don't take a separate negative-prompt field the way
+Stable-Diffusion-family UIs do; fold the concepts into "avoid" language in the main
+prompt instead):
+
+```
+vanishing points, perspective distortion, dramatic shadows, realistic photography,
+lens flare, depth of field, motion blur, fisheye, wide-angle lens, camera lens realism,
+mixed scale objects, uneven wall thickness, harsh spot lighting, text, signatures,
+watermarks, logos, borders, frame, cropped, out of frame, low quality, blurry,
+duplicate, deformed, disfigured
+```
+
+Rationale for each cluster (so you can trim intelligently rather than pasting blindly):
+
+- **`vanishing points, perspective distortion, ... lens flare, depth of field, ... fisheye,
+  wide-angle lens, camera lens realism`** — the single biggest failure mode. Diffusion
+  models are trained overwhelmingly on photographs, which have perspective and lens
+  effects; isometric/axonometric projection has neither. Naming these explicitly is more
+  effective than hoping "isometric" alone overrides the photographic prior.
+- **`mixed scale objects, uneven wall thickness`** — enforces the style-guide.md scale
+  grammar and cutaway conventions (see `references/style-guide.md`) directly in the
+  negative space.
+- **`harsh spot lighting, dramatic shadows`** — enforces the three-tone doctrine's single
+  soft fixed light direction; models left alone default to moody single-source drama
+  lighting borrowed from photography/cinema training data.
+- **`text, signatures, watermarks, logos, borders, frame, cropped, out of frame`** —
+  standard hygiene negatives; isometric icon/scene generations are especially prone to
+  picking up stock-art watermark ghosts because so much iso stock art in training sets
+  carries them.
+- **`low quality, blurry, duplicate, deformed, disfigured`** — generic SD-family quality
+  negatives, harmless to include, cheap insurance.
+
+---
+
+## 3. Subject scaffolds
+
+Each scaffold is a complete, paste-ready prompt. Swap the bracketed subject noun and
+adjust the material language for your specific asset; keep the projection, lighting,
+and simplification clauses intact — they are what stops the drift.
+
+### 3.1 City block / urban environment
+
+```
+Isometric city block illustration, 2:1 dimetric projection, 30 degree angle
+perspective view, detailed buildings and streets, mixed low- and mid-rise structures,
+clean architectural massing, miniature urban environment, muted urban color palette
+with one accent hue, soft long shadows, consistent single light direction,
+flat colors with soft ambient occlusion, clean vector art style, no perspective
+distortion, game asset quality design, scalable SVG icon style
+```
+
+**Do:** keep every building's vertical edges parallel to the three iso axes; keep
+window/door proportions consistent across buildings (same-scale grammar, one human ≈ N
+tiles tall — see `references/style-guide.md` §Scale grammar). **Avoid:** vanishing
+points on street recession, mixed building scales, photographic window reflections.
+
+### 3.2 Room / interior cutaway
+
+```
+Isometric room interior cutaway, 30 degree isometric view angle, [cozy bedroom /
+minimalist studio apartment / cluttered workshop] with detailed furniture, walls
+removed cleanly showing interior, uniform wall thickness at the cut edge, warm soft
+ambient lighting, single fixed light direction, three-tone plane shading (lightest
+top, mid-tone one side wall, darkest other side wall), miniature dollhouse aesthetic,
+clean detailed illustration, limited pastel color palette, architectural
+visualization style, no perspective distortion
+```
+
+**Do:** cut walls with one uniform thickness all around (the cutaway-conventions rule
+in `references/style-guide.md`); keep furniture on the same scale grammar as the room.
+**Avoid:** uneven wall thickness at the cut plane, harsh spot lighting, furniture at
+inconsistent scale relative to the room shell.
+
+### 3.3 Floating island / nature environment
+
+```
+Isometric floating island scene, fantasy nature environment, waterfall trees and
+mossy rocks, 30 degree isometric perspective view, low-poly stylized aesthetic,
+clean sharp geometric edges throughout, vibrant greens and blues, high-contrast flat
+color blocking, one fixed soft light direction with long cast shadows, game
+environment concept art, magical whimsical atmosphere, no perspective distortion,
+no airbrushed gradients
+```
+
+**Do:** use clean, sharp geometric edges (avoid organic photographic foliage
+rendering); apply high-contrast flat color, not gradient-heavy painterly shading.
+**Avoid:** inconsistent ground-plane angles between the island's top surface and its
+underside/root mass, organic airbrushed gradients that break the flat-shading language.
+
+### 3.4 Warehouse / logistics
+
+```
+Clean isometric vector illustration of a modern logistics warehouse, 2:1 dimetric
+projection, corrugated metal siding, loading dock with roller doors, stacked
+pallet racking visible through a cutaway wall, simple geometric forms, clear
+top/left/right plane separation, soft long shadows, consistent single light
+direction, muted industrial palette (steel grey, safety yellow accent), no
+perspective distortion, no text, scalable SVG icon style
+```
+
+**Do:** keep the three visible planes (top/left/right) each a single flat tone per the
+three-tone doctrine — this is the scaffold that maps most directly onto the SSR/CSS
+plane-composition recipes in `references/projection-math.md`. **Avoid:** photographic
+metal specular highlights, mixed-scale pallets/racking.
+
+### 3.5 Control room (cyberpunk / sci-fi)
+
+```
+Isometric cyberpunk control room, orthographic feel, 30 degree isometric
+perspective, modular wall panels, bank of monitor screens, teal-violet accent
+lighting against a dark neutral base palette, precise clean geometry, layered
+depth via overlapping panel modules, high readability, one dominant fixed light
+source, no dramatic shadows, suitable for conversion into a hero illustration,
+no perspective distortion, no lens flare
+```
+
+**Do:** let the accent color (teal-violet) carry all the "mood," while geometry and
+shading stay flat and legible — accent color is not a substitute for the three-tone
+plane structure. **Avoid:** volumetric fog/light-shaft photographic effects, screen
+glare/bloom that implies a camera lens.
+
+### 3.6 Dashboard / product-marketing scene
+
+```
+Minimal isometric finance dashboard scene, 30 degree isometric projection, white
+background, geometric UI cards floating above a flat isometric desk/base plane,
+shallow depth, consistent 30-degree axes throughout every card, gentle ambient
+occlusion, soft single-direction shadow beneath each card, flat clean color
+palette with one brand accent, product-marketing illustration style, no
+perspective distortion, no text, no watermark
+```
+
+**Do:** keep every floating card at the *same* projection angle as the base plane —
+this is the most common failure in dashboard/SaaS-marketing iso art (cards rendered at
+a different axonometric angle than the ground they float above). **Avoid:** drop
+shadows that imply a different light source per card, cards at inconsistent scale.
+
+### 3.7 Grid Dynamics production scaffold (game sprite tileset)
+
+The field-tested scaffold from Grid Dynamics' "Game Asset Creation With Generative AI
+Tools" tutorial — a systematic Midjourney + Scenario workflow for consistent iso
+building sprites. Good starting template when you need dozens of matching tileset
+pieces rather than one hero illustration:
+
+```
+isometric [subject], cyberpunk style, realistic, video game, style of behance, made
+in blender 3D, white background
+```
+
+This is the scaffold as documented — `cyberpunk style` is the actual worked example in
+the source tutorial, not a placeholder. Fill `[subject]` with a concrete noun (`house`,
+`watchtower`, `market stall`) for your own tileset. Swapping `cyberpunk style` for a
+different art-direction phrase (`medieval fantasy style`, `sci-fi industrial style`) is
+a reasonable generalization of the pattern, but it is **not** itself sourced — treat it
+as an inferred extension and verify the result still reads clean before committing a
+whole tileset to it. The `made in blender 3D` clause is doing real work here — it steers
+the model toward clean-render aesthetics (flat AO, no photographic grain) rather than
+painterly illustration, which matters when every tile in a set must read as the same
+"material" as its neighbors. `white background` isolates the subject for downstream
+background removal / sprite packing (`scripts/sheet-pack.py`).
+
+**Do:** generate the whole tileset in one session/seed lineage where the tool
+supports it (Midjourney's seed-locking options — unverified against this skill's
+sources, check `docs.midjourney.com` for current syntax — or a single Scenario custom
+model) so lighting direction and material rendering stay consistent across tiles.
+**Avoid:** regenerating
+each tile from a fresh unrelated seed — this is the single most common cause of a
+tileset that "almost matches."
+
+### 3.8 Isometric icon (single subject, UI/brand icon set)
+
+```
+isometric icon of a [subject], 30 degree isometric projection, top-down 45 degree
+axonometric, single object centered, simple geometric forms, minimal detail, flat
+color fills, one soft ambient shadow beneath the object, transparent background,
+clean vector icon style, no perspective distortion, no text, no background scene
+```
+
+Recraft explicitly supports "isometric illustration" and "top-down 45° axonometric"
+phrasing as first-class prompt vocabulary for its vector-native icon/vector-art models
+— use both phrasings together (as above) when targeting Recraft, since its models were
+trained to recognize them as a matched pair.
+
+---
+
+## 4. Model-specific parameter cheatsheets
+
+Organized by target tool. All parameter values below are **as of July 2026** — Midjourney
+in particular has shifted `--sref`/`--cref` syntax between major versions before; verify
+against `docs.midjourney.com` before locking a production workflow to a specific flag.
+
+### 4.1 Midjourney
+
+Parameters marked **[sourced]** are confirmed against this skill's named source
+documents (SRC-A/B/C). Parameters marked **[unverified]** are real Midjourney flags
+from general model knowledge, not confirmed against those sources — spot-check them at
+`docs.midjourney.com` before depending on the exact syntax/defaults shown.
+
+| Parameter | Purpose | Value / default |
+|---|---|---|
+| `--sref <code or image URL>` **[sourced]** | Style reference — locks a consistent visual style (palette, rendering, "isometric-ness") across a whole generation set | one or more codes/URLs; combine multiple for a blended style |
+| `--sw <0–1000>` **[sourced]** | Style weight — how strongly `--sref` pulls the result toward the reference style | default **100**; push toward 1000 for strict style-lock, lower toward 0 to let the text prompt dominate |
+| `--cref <image URL>` **[sourced]** | Character/subject reference (omni-reference) — locks a consistent *subject*, independent of style | pairs with `--cw` (character weight, **[unverified]**) |
+| `--iw <0–2>` **[unverified]** | Image weight — how strongly an attached image prompt influences the result vs. the text prompt | default varies by model version; raise when the image prompt should dominate composition |
+| `--style raw` **[unverified]** | Reduces Midjourney's default aesthetic embellishment, useful for cleaner iso geometry | boolean flag |
+
+**Recommended pattern for a consistent iso set:** generate one strong hero result, run
+it through Style Explorer or extract its `--sref` code, then reuse that `--sref` at
+`--sw 150–300` across every subsequent prompt in the set. Named community iso style
+codes exist (Midlibrary's "Isometric Mellow Scribe", sref-midjourney.com's isometric
+library) as starting points — treat these as inspiration, not guaranteed-stable IDs;
+community `--sref` catalogs churn as Midjourney model versions change.
+
+**Prompt formula for Midjourney specifically:** `[Subject] + [Environment] + [Lighting]
++ [Medium] + [--params]` — put the isometric/projection constraint inside `[Medium]`
+(e.g. "clean vector art, isometric game asset") since Midjourney treats medium-language
+as a strong style anchor.
+
+### 4.2 Adobe Firefly
+
+| Concept | Notes |
+|---|---|
+| Asset-type framing | Firefly's vector workflow responds better when you name the artefact type explicitly — `icon`, `scene`, or `subject` — rather than leaving it implicit. Add this as an explicit word in the prompt. |
+| Text to Vector | Generates editable, grouped vector paths directly; try this first when you need vector output immediately — it can skip the raster→trace step entirely. |
+| Content Credentials | Firefly applies Content Credentials (provenance metadata) to generated outputs — the strongest governance/attribution story of the tools in this table, relevant when client delivery requires provenance disclosure. |
+| Commercial governance | Firefly requires users to confirm rights/permissions for any image uploaded to train a custom model; review plan terms per client and company size regardless. |
+
+No `--sref`-equivalent flag syntax is documented for Firefly's public prompt field as
+of July 2026 — style consistency is driven by reference-image upload and the
+brand/style controls in the Firefly UI, not inline text parameters.
+
+### 4.3 Recraft
+
+- No negative-prompt field or reference-code parameter syntax; style consistency comes
+  from brand-style image uploads (consistency across an icon set) and from its
+  dedicated "Icon" vs "Vector Art" model selection.
+- Prompt vocabulary Recraft explicitly recognizes: `"isometric illustration"`,
+  `"top-down 45 degree axonometric"` — use this exact phrasing (§3.8 above) rather than
+  paraphrasing.
+- Free tier makes generations public; paid tiers keep generations private and grant
+  full commercial ownership — relevant before running client work through the free tier.
+
+### 4.4 Flux / SDXL (ComfyUI / Automatic1111 family)
+
+Full negative-prompt syntax applies (§2 above, verbatim). Typical generation
+parameters for the Blender-blockout → ControlNet iso workflow (see
+`references/blender-prerender.md` and `references/ai-generation.md` for the full
+pipeline): Euler sampler, ~15 sampling steps, 768×768 resolution, CFG scale 7 (favors
+structural fidelity to the ControlNet conditioning over creative variance — raise CFG
+if the model ignores the depth/normal maps, lower if output looks over-baked/artifacted).
+
+Weight emphasis syntax `(term:1.3)` works in this family exactly as in Midjourney —
+use it to up-weight `isometric`, `orthographic`, or `flat lighting` when a LoRA or base
+checkpoint keeps drifting toward photographic perspective.
+
+---
+
+## 5. Drift-recovery tactics
+
+When output keeps introducing vanishing points, camera-lens effects, or otherwise
+breaking the flat axonometric read, apply these in order — cheapest fix first:
+
+1. **Add an image prompt or style reference.** Feed a shape/mood reference (Midjourney
+   image prompt or `--sref`, Firefly reference image, SDXL IP-Adapter) alongside the
+   text prompt. Text alone under-constrains the geometry; an image anchors it.
+2. **Name the intended artefact type explicitly.** "icon", "scene", "sprite", "game
+   asset" — vague subject nouns let the model fall back on its dominant (photographic)
+   training prior.
+3. **Explicitly forbid camera-lens realism.** Add `no camera lens, no depth of field,
+   no lens flare, orthographic, not a photograph` to the negative space — this is
+   stronger than relying on "isometric" alone to suppress photographic priors.
+4. **Escalate to structural control.** If prompt-only recovery fails after 2–3 retries,
+   stop prompting and switch to ControlNet depth/MLSD conditioning from a Blender
+   blockout (see `references/ai-generation.md` §ControlNet workflow) — this is a hard
+   geometric constraint the model cannot drift away from, unlike a text/image prompt
+   which is only ever a soft bias.
+5. **For a whole inconsistent set**, don't keep re-rolling individual tiles — train a
+   small custom model (Scenario/Layer.ai DreamBooth-style, 10–20 on-style references,
+   or a house LoRA on 30–100 curated examples with one projection/material/shadow
+   logic) per `references/ai-generation.md`'s LoRA dataset discipline. Re-rolling
+   individual outputs against a drifting base model has a hard ceiling; a trained model
+   does not.
+
+---
+
+## 6. Sources
+
+- Grid Dynamics, "Game Asset Creation With Generative AI Tools" — production Midjourney
+  + Scenario tileset scaffold (§3.7).
+- Midjourney official parameter documentation, `docs.midjourney.com` — `--sref`, `--sw`
+  (0–1000, default 100), `--cref` (as of July 2026; verify before production use, syntax
+  has shifted between model versions).
+- Adobe Firefly product documentation and Content Credentials overview,
+  `helpx.adobe.com/firefly` — Text to Vector, asset-type framing, governance model.
+- Recraft product documentation — `"isometric illustration"` / `"top-down 45 degree
+  axonometric"` prompt vocabulary, Icon/Vector Art model distinction.
+- SRC-B (Engineering and Aesthetic Standards for Isometric Design) — prompt template
+  table (city/room/nature scaffolds §3.1–3.3), negative-constraint doctrine, Blender→
+  ControlNet step-4 generation parameters (§4.4).
+- SRC-C (isometric tool/market landscape PDF) — warehouse/control-room/dashboard prompt
+  examples (§3.4–3.6), drift-recovery tactics (§5), AI ethics/licensing caveats
+  (cross-referenced in `references/asset-sourcing.md` and `references/ai-generation.md`).
+- SRC-A (compass artifact resource library) — Civitai LoRA catalog, ControlNet
+  preprocessor tooling, upscaler two-camp decision (all detailed in
+  `references/ai-generation.md` and `references/ai-refinement.md`, not restated here).
+
+Cross-references: `references/ai-generation.md` (model/tool decision ladder, LoRA
+catalog, ControlNet workflow in full), `references/ai-refinement.md` (upscaling and
+vectorization after generation), `references/style-guide.md` (the three-tone/scale/
+composition doctrine these prompts encode), `references/asset-sourcing.md` (licensing
+and AI-training-clause discipline for any reference imagery you feed into a prompt).

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

@@ -0,0 +1,302 @@
+{
+  "$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 }
+      }
+    }
+  }
+}

+ 422 - 0
skills/isometric-ops/references/ai-generation.md

@@ -0,0 +1,422 @@
+# AI Generation of Isometric Assets
+
+The deep chapter. This file covers **generating** isometric assets with AI — model
+selection, LoRA/style control, ControlNet-conditioned structure, the Blender-blockout
+pipeline, and prompt doctrine. It stops at the point where a raster generation exists.
+
+- **Refining** what you generated — upscaling, vectorization, edge-halo cleanup —
+  lives in [`ai-refinement.md`](ai-refinement.md). Generate here, refine there.
+- **Ready-to-paste prompt scaffolds** live in
+  [`assets/prompt-library.md`](../assets/prompt-library.md). This file teaches the
+  *doctrine*; that file is the copy-paste bank.
+- **Plane/shadow/palette rules** the prompts and post-processing enforce live in
+  [`style-guide.md`](style-guide.md) (three-tone shading, one light direction,
+  scale grammar).
+- **The projection decision** — true iso vs 2:1 dimetric vs pixel-neat — is upstream of
+  everything and lives in [`projection-math.md`](projection-math.md). Decide it *before*
+  you write a single prompt; the prompt names the projection.
+
+> **Terminology.** Games call their 2:1 grid "isometric" but it is **2:1 dimetric
+> (commonly called isometric in games)**, at 26.565° (arctan 1/2), not the 30° / 120°
+> of true isometric. AI models trained on game art will happily give you dimetric when
+> you ask for "isometric." If you need *true* iso (equal foreshortening, 30° axes) you
+> must condition structure with ControlNet or a 3D blockout — text prompts alone do not
+> reliably hit true iso. See `projection-math.md` for why.
+
+> **Volatility.** AI-tool facts (model versions, LoRA URLs, Midjourney params, pricing,
+> upscaler tiers) move fast. Every volatile figure below is date-stamped **"as of
+> July 2026"** and kept in grep-able tables. Re-verify on the vendor's live docs before
+> committing to a pipeline — `scripts/check-iso-facts.py --live` exists to catch drift
+> in the named packages.
+
+---
+
+## 1. The decision ladder — pick the model by what the output must *be*
+
+The single most consequential choice is made before any prompt: **what does the final
+asset need to be, and how many of them need to match?** That determines the model, not
+the subject matter.
+
+```
+                          ┌─ Editable VECTORS (icons, SVG UI illustration)?
+                          │     → Recraft (vector-native — generates real SVG, not traced)
+                          │       + Adobe Firefly when brand-safe vector + Content
+                          │         Credentials matter (see §7 ethics)
+                          │
+  What must the output ───┼─ ONE hero RASTER (marketing, landing-page centerpiece)?
+      be, and how         │     → Midjourney + --sref / --sw for a locked style
+      many must match?    │       (+ Firefly Text-to-Vector if it must become editable)
+                          │
+                          ├─ LOCAL control / a game TILESET you'll iterate on?
+                          │     → Flux or SDXL + a named isometric LoRA + ControlNet
+                          │       (depth for massing, MLSD/lineart for edges)
+                          │
+                          └─ A CONSISTENT LARGE SET (dozens of matching assets)?
+                                → Scenario / Layer.ai: train a custom model on 10–20
+                                  on-style references. The only reliable way to get
+                                  dozens of assets that actually match.
+```
+
+**Model landscape (as of July 2026):**
+
+| Model / platform | Best for | Output | Commercial / licence note |
+|---|---|---|---|
+| **Recraft** (V4 / V4.1 engine) | Vector-native iso icons & illustration; low-node SVG | Editable **SVG** + PNG | Free tier makes creations *public*; paid keeps them private + grants full commercial ownership |
+| **Adobe Firefly** (Text-to-Vector / Illustrator Text to Vector Graphic) | Brand-safe editable vector, Content Credentials provenance | Editable **AI/SVG/PDF/EPS** inside Illustrator | Creative Cloud subscription; consumes generative credits; requires rights confirmation for custom-model training |
+| **Midjourney** | Hero raster, style exploration, strongest general aesthetic | Raster | Paid plans carry commercial-use rights for most users — company-size rules apply; verify per client |
+| **FLUX.1 / FLUX.1 Kontext** | Open-weight generation + image-conditioned editing; local control | Raster | **FLUX.1 [dev] outputs OK for commercial use, but the model + derivatives (LoRA weights) are non-commercial** — see §3 |
+| **Stable Diffusion XL (SDXL)** | Open ecosystem, ControlNet, LoRA/DreamBooth fine-tuning | Raster | Model-specific Stability licence + acceptable-use policy; verify per org before commercialising |
+| **Scenario** (scenario.com) | Style-consistent **tilesets**; DreamBooth-style custom models; game studios | Raster (edit-with-prompts) | Vendor case studies (Ubisoft, InnoGames) are vendor-reported; SOC 2; Unity plugin, API/MCP |
+| **Layer.ai** | "AI OS for creative teams"; 40+ style models + custom training; vectorize/upscale/3D | Raster + vector | Pooled-credit pricing; Unity Verified Solution |
+| **Recraft V4 "Icon" / "Vector Art" models** | Minimal node count, clean curves | SVG | as above |
+
+**Rules of thumb:**
+
+1. **Match the model to the output need first, subject second.** Needing *editable
+   vectors* points at Recraft or Firefly no matter what you're drawing; needing *dozens
+   of matching tiles* points at Scenario/Layer no matter how good Midjourney's single
+   image is.
+2. **Do not blend generations.** Pick *one* strong composition and drive it forward.
+   Blending five half-good generations is the most common way to lose plane and scale
+   consistency across a set.
+3. **When text prompts stop being enough, stop prompting.** Repeatable perspective/
+   structure is a ControlNet or custom-model problem (§4, §5), not a prompt-wording
+   problem.
+
+---
+
+## 2. LoRA catalog — named isometric adapters (Civitai)
+
+LoRAs (Low-Rank Adaptation adapters) bolt an isometric *style* onto a Flux or SDXL base
+checkpoint. The Civitai `isometric` tag lists ~136 iso-tagged models/LoRAs/checkpoints/
+embeddings as of July 2026. The high-signal, named ones:
+
+| LoRA | Base | Civitai model ID | Recommended weight / trigger | Licence flag |
+|---|---|---|---|---|
+| **Isometric Style** v1.0 | SDXL | 158081 | **weight 0.7** (per model page) | verify per-model |
+| **Isometric** (kiyomoritaira) | SDXL | 1463579 | start ~0.7 | verify per-model |
+| **IsometricPixelFlux** | Flux.1 [dev] | 671566 | start ~0.7 | ⚠ **Flux.1-dev derivative — non-commercial** |
+| **Isometric — interior/outdoor building** (oosayam) | SDXL | 461566 | separate interior vs outdoor versions | verify per-model |
+| **Isometric & Pixelized View** | SDXL | 153601 | buildings, cutaways, "streamer rooms" | verify per-model |
+| **Zavy's Cute Isometric Tiles** | SDXL | — | trigger **`zavy-ctsmtrc, isometric`**; pairs with a transparent-SDXL LoRA | verify per-model |
+| **Isometric Dreams** (ktiseos_nyx) | SDXL | 98851 | start ~0.7 | verify per-model |
+| **Isometric world 01** | SD1.5 | 91248 | use with ReV Animated + Hires.fix | verify per-model |
+
+**Licence discipline (this is the trap):** LoRAs trained on **FLUX.1 [dev]** inherit its
+**non-commercial** derivative restriction. FLUX.1 [dev]'s *generated outputs* may be used
+commercially, but the **model weights and their derivatives (any dev-based LoRA) are
+licensed non-commercial**. So a Flux-dev iso LoRA is fine for personal/experimental work
+and its images are usable, but shipping or redistributing the *adapter* in a commercial
+product crosses the licence. Prefer SDXL-based LoRAs (or a properly licensed base) for
+commercial pipelines. **Check each model's licence before commercial use** — the flag
+column above is a prompt to verify, not a warranty.
+
+> Verify LoRA IDs/availability on Civitai before building a pipeline — the catalog churns.
+> `scripts/check-iso-facts.py` tracks the *package* names in prose (§7 verifier); the
+> Civitai IDs above are date-stamped July 2026 and must be re-checked live.
+
+Sources: Civitai `isometric` tag and the individual model pages (civitai.com/models/{ID});
+FLUX.1 [dev] license, https://huggingface.co/black-forest-labs/FLUX.1-dev.
+
+---
+
+## 3. ControlNet — conditioning structure so perspective holds
+
+Text prompts drift. **ControlNet** conditions the generation on a structural map so the
+iso geometry is *enforced*, not hoped for. Three preprocessors matter for iso work:
+
+| ControlNet | Preprocessor / model | Controls | Use for |
+|---|---|---|---|
+| **Depth** | `control_v11f1p_sd15_depth`, midas / depth_anything | 3D volume, spatial massing | Preserving the *shape and layout* of an iso scene from a depth map |
+| **MLSD** | mlsd | Straight lines only | Architecture / building edges — the crisp iso line grammar |
+| **Lineart / Canny** | lineart, canny | Exact outlines | Locking precise iso outlines when you already have clean line art |
+
+**Tooling.** In ComfyUI, install **ComfyUI ControlNet Auxiliary Preprocessors**
+(`Fannovel16/comfyui_controlnet_aux`) — it supplies the midas / depth_anything / mlsd
+preprocessors. ComfyUI-Wiki and OpenArt host drag-and-drop Depth/MLSD/multi-ControlNet
+workflows. **LooseControl** (arXiv 2312.03079) extends depth conditioning to 3D-box /
+scene-boundary control — useful for laying out an iso scene from primitive boxes.
+
+**When to reach for it:** the moment you need *the same perspective repeatedly* rather
+than one-off images — a tileset, a matched prop library, a series. That is the boundary
+between "prompt harder" and "condition structure."
+
+Sources: ComfyUI ControlNet Auxiliary Preprocessors
+(github.com/Fannovel16/comfyui_controlnet_aux); LooseControl, arXiv 2312.03079.
+
+---
+
+## 4. The Blender-blockout → dual-ControlNet workflow (the gold standard)
+
+For **absolute perspective alignment across a large asset library**, combine a 3D
+blockout with ControlNet-conditioned generation. This is the most reliable route to a
+matched set at true isometric angles, and it feeds two control maps (depth + normal)
+into a dual-ControlNet generation. The Blender-side rig detail (camera rotations, the
+60° vs 54.736° distinction, Z-pass and normal-pass compositor node graphs) lives in
+[`blender-prerender.md`](blender-prerender.md); here is the AI half, end to end.
+
+**Step 1 — Model & camera in Blender.** Build a simple blockout (walls, beams, crates,
+barrels — basic geometry). Add an **orthographic** camera. For **true isometric**, set
+**RotX = 54.736°, RotZ = 45°** (this is `90° − 35.264°`; all three cube faces render
+equal). For **2:1 dimetric game tiles**, use **RotX = 60°, RotZ = 45°** instead — the
+rendered cube top is exactly 2× as wide as it is tall. (Both rigs and the cube-top
+verification test are in `blender-prerender.md`; SRC-B's ControlNet workflow uses the
+54.736° true-iso rig — correct *for true iso*.)
+
+**Step 2 — Depth map (Z-pass).** `View Layer Properties → Passes → Data → enable Z`. In
+the Compositor, route the camera's Z output through a Normalize node to a grayscale
+gradient (near = white, far = black). Export as the **depth guide**.
+
+**Step 3 — Camera-space normal map.** Build a shader that maps surface normals to RGB:
+connect a **Geometry** node's `Normal` output → a **Vector Transform** node set
+**World → Camera** space → a **Multiply-Add** math node that remaps `[−1.0, 1.0]` →
+`[0.0, 1.0]` (multiply 0.5, add 0.5). Assign to all meshes, render from the iso camera,
+save as the **camera-space normal map**.
+
+**Step 4 — Dual-ControlNet generation.** In your SD interface (e.g. AUTOMATIC1111), load
+a stylized checkpoint (SRC-B uses *AyoniMix v6*) with ControlNet 1.1 weights, then set:
+
+| Parameter | Value |
+|---|---|
+| Sampler | **Euler** |
+| Sampling steps | **~15** |
+| Resolution | **768 × 768** |
+| CFG scale | **7** (enforce structure over creative variance) |
+| ControlNet Unit 0 | **Depth** — load the Blender grayscale depth map, `depth` preprocessor + model |
+| ControlNet Unit 1 | **Normal** — load the baked camera-space normal map, `normal` preprocessor + model |
+
+Example prompt blocks (SRC-B, medieval-tavern case; note the `(centered:1.5)` and
+`(soft shading:0.7)` weighting and the `isometric, orthographic` anchors):
+
+- **Building texture:** `medieval tavern, support beams, stone floor, wood walls,
+  building interior, interior, diagram overlook, thunderstorm, isometric cutaway,
+  3d render, stylized, intricate, 4k uhd, gradients, (centered:1.5), ambient occlusion,
+  (soft shading:0.7), view from above, angular, isometric, orthographic, FXAA`
+- **Props (barrels):** `wood barrels, wooden barrels, oak barrel, vertical wood,
+  diagram overlook, thunderstorm, isometric cutaway, 3d render, stylized, intricate,
+  4k uhd, gradients, (centered:1.5), ambient occlusion, (soft shading:0.7), view from
+  above, angular, isometric, orthographic, FXAA`
+- **Props (crates):** `old, wooden crates, metal handles, storage crate, dark oak crate,
+  storage boxes, ((metal frames)), rusty metal, diagram overlook, thunderstorm,
+  isometric cutaway, 3d render, stylized, intricate, 4k uhd, gradients, (centered:1.5),
+  ambient occlusion, (soft shading:0.7), view from above, angular, isometric,
+  orthographic, FXAA`
+- **Negative:** `shadows, torch, fire, lamp, light, cartoon, zombie, disfigured,
+  deformed, b&w, black and white, duplicate, morbid, cropped, out of frame, clone,
+  photoshop, tiling, cut off, patterns, borders, (frame:1.4), symmetry, signature,
+  text, watermark, fisheye, harsh lighting`
+
+**Step 5 — Texture projection mapping back onto geometry.** Import the AI images into
+Blender and **project them from the camera's exact coordinates** onto the 3D models
+(camera-projection UVs from the orthographic view). Bake into flat UV maps, apply PBR
+material properties, set up scene lighting, and you can now re-render the *textured*
+asset from multiple lighting angles — consistent, interactive, and grid-aligned. Add a
+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.
+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".
+
+Sources: SRC-B "3D-to-2D ControlNet Rendering Workflow in Blender" (steps 1–5); SRC-A
+Ch. 6 ControlNet section.
+
+---
+
+## 5. Consistent large sets — custom-trained models
+
+When you need **dozens of assets that genuinely match** (a full tileset, a themed prop
+library, a character-variation set), neither prompting nor a generic LoRA is enough.
+Train a **custom model on 10–20 of your own on-style references** (DreamBooth-style):
+
+- **Scenario** (scenario.com) — creative AI infrastructure for game studios; 500+ base
+  models, Unity plugin, API/MCP. Dedicated iso workflows include an **Isometric Tile
+  Maker** pipeline and "Dual Reference" (Image-to-Image + ControlNet Structure) to
+  *reskin* materials while preserving edges. Vendor-reported: Ubisoft produced 10,000+
+  character variations for the isometric *Captain Laserhawk: The G.A.M.E.*; InnoGames
+  reports ~50% asset-time reduction. (Vendor case studies — treat the figures as
+  vendor-reported, not independently verified.)
+- **Layer.ai** — 40+ pre-built style models + custom training; built-in vectorization,
+  upscaling, 3D, node-based workflows; Unity Verified Solution.
+- **Diffusers + LoRA / DreamBooth (self-hosted)** — the open route. Hugging Face's
+  text-to-image + LoRA training docs, the LoRA paper (why low-rank adaptation is
+  efficient), the DreamBooth paper (subject-driven tuning), and the `sd-scripts` /
+  `kohya_ss` GUI/script layer are the standard toolkit.
+
+**LoRA dataset discipline (the make-or-break):** a good house-style isometric LoRA
+dataset is **30–100 tightly curated examples**, all sharing **one projection logic, one
+material logic, one shadow logic, and a narrow colour grammar**. If the data mixes
+different axonometric rules, the model learns "generic illustration vibe" instead of a
+reliable isometric language. Curate ruthlessly: one projection angle, one light
+direction, one plane-shading system across the entire dataset. (This mirrors the
+[`style-guide.md`](style-guide.md) consistency rules — the dataset must already obey
+them.)
+
+Sources: SRC-A Ch. 6 (Scenario, Layer.ai, Diffusers/LoRA/DreamBooth, Grid Dynamics
+tutorial); Hugging Face Diffusers docs (huggingface.co/docs/diffusers/en/training/text2image).
+
+---
+
+## 6. Prompt doctrine
+
+The prompt pattern that works is **not** "isometric illustration." It is a six-part
+structure:
+
+```
+subject + projection + material language + simplification rule + lighting rule + output intent
+```
+
+| Slot | What goes here | Examples |
+|---|---|---|
+| **Subject** | The thing, concretely | `modern logistics warehouse`, `cozy bedroom cutaway`, `cyberpunk control room` |
+| **Projection** | Name the angle explicitly | `30 degree isometric perspective view`, `2:1 isometric`, `consistent 30-degree axes`, `no perspective distortion` |
+| **Material language** | Surface + finish | `clean vector art style`, `flat colors with soft ambient occlusion`, `low poly stylized`, `muted industrial palette` |
+| **Simplification rule** | Forbid clutter/realism | `simple geometric forms`, `clean sharp edges throughout`, `high readability` |
+| **Lighting rule** | One direction, soft | `soft long shadows consistent lighting`, `warm soft lighting ambiance`, `gentle ambient occlusion` |
+| **Output intent** | What it's for | `scalable SVG icon style`, `game asset quality`, `suitable for conversion into a hero illustration`, `product-marketing illustration` |
+
+**Keyword structuring:** favour comma-separated keywords over narrative sentences. Use
+round brackets `( )` to emphasise critical elements (and weights like `(soft shading:0.7)`
+in SD). Name the intended artefact type — *icon* vs *scene* vs *subject* — Firefly's
+vector workflow in particular is stronger when you say which.
+
+**The universal negative-prompt block** (forbid the perspective-breakers):
+
+```
+vanishing points, perspective distortion, dramatic shadows, realistic photography,
+harsh lighting, text, signatures, watermarks, fisheye, camera lens realism
+```
+
+**Drift recovery** — when a model keeps breaking perspective, in order of effectiveness:
+
+1. **Stop wording, add structure.** Feed an **image prompt / style reference**
+   (Midjourney `--sref`) or switch to **ControlNet depth/MLSD** (§3). Perspective is a
+   structure problem; solve it structurally.
+2. **Name the artefact type** — "icon" / "scene" / "tile" / "subject" — so the model
+   picks the right internal prior.
+3. **Explicitly forbid lens realism** — add `no perspective distortion`, `no vanishing
+   points`, `orthographic`, `camera lens realism` (negative). Repetition in the negative
+   block helps.
+4. **Escalate to a custom-trained model** (§5) if drift persists across a set — one-off
+   drift is a prompt issue; *set-wide* drift is a training issue.
+
+Three worked prompt targets (SRC-B/SRC-C — the full bank is in
+[`assets/prompt-library.md`](../assets/prompt-library.md)):
+
+- **City block:** `isometric city block illustration, 30 degree angle perspective view,
+  detailed buildings and streets, miniature urban environment, soft shadows consistent
+  lighting, vibrant color palette, clean vector art style, no perspective distortion,
+  game asset quality design`
+- **Room cutaway:** `isometric room interior cutaway, cozy bedroom detailed furniture,
+  30 degree isometric view angle, walls removed showing interior, warm soft lighting
+  ambiance, miniature dollhouse aesthetic, clean detailed illustration, pastel color
+  scheme, architectural visualization style`
+- **Dashboard scene:** `minimal isometric finance dashboard scene, white background,
+  geometric UI cards, shallow depth, consistent 30-degree axes, gentle ambient
+  occlusion, product-marketing illustration`
+
+**Midjourney parameter cheatsheet (as of July 2026):**
+
+| Param | Meaning | Range / default |
+|---|---|---|
+| `--sref <code>` | Style reference — lock a consistent iso style across a set | a style code or image URL |
+| `--sw <n>` | Style weight — how strongly `--sref` applies | **0–1000, default 100** (per Midjourney docs) |
+| `--cref <url>` | Character reference — consistent *subject* across images | image URL (omni-reference in newer versions) |
+
+> ⚠ Midjourney's `--sref`/`--cref` syntax **shifts between versions** — several 2026
+> sources warn on this explicitly. Verify against Midjourney's live docs before building
+> a `--sref`-dependent workflow.
+
+Sources: SRC-B "Prompt Engineering and Aesthetic Structuring" + curated prompt
+templates; SRC-A Ch. 6 (Midjourney `--sref`/`--sw`, per docs.midjourney.com); SRC-C
+prompt examples.
+
+---
+
+## 7. Ethics, IP & commercial-use — the two legal layers most teams miss
+
+AI-assisted delivery has **two** licence layers, and teams routinely check only the
+first.
+
+**Layer 1 — the model licence (what the generator lets you commercialise):**
+
+- **Adobe Firefly** — the strongest governance story here: applies **Content
+  Credentials** (C2PA provenance) to outputs, and requires users to **confirm they hold
+  rights** for anything uploaded to train custom models. Best choice when brand safety
+  and disclosure matter.
+- **Midjourney** — paid plans grant commercial-use rights for most users, but **terms
+  and company-size rules apply** — verify per client and org size.
+- **FLUX.1 [dev]** — **outputs usable commercially, but the model + derivatives (LoRA
+  weights) are non-commercial.** A dev-based LoRA is not commercially redistributable
+  even though its images are usable. (§2.)
+- **Stability / SDXL** — model-specific licensing + acceptable-use policy; self-hosting
+  or open-weight access does **not** remove the need to verify what your org may
+  commercialise.
+
+**Layer 2 — the *asset-library* licence (the one teams miss):** an asset library's
+licence can **forbid AI training even when it permits normal commercial use.** DrawKit,
+for example, **explicitly forbids using its icons and illustrations to train, fine-tune,
+or improve AI/ML models** — while allowing ordinary commercial use of the art itself. So:
+
+> **"Commercial use permitted" ≠ "dataset use permitted."** Before you feed *any*
+> sourced asset into a LoRA/DreamBooth training set, check its licence's **AI-training
+> clause** specifically — not just its commercial-use clause.
+
+This directly gates §5: your custom-model training set must be assets you are licensed
+to *train on*, not merely licensed to *use*. See [`asset-sourcing.md`](asset-sourcing.md)
+for the per-library procurement rule and the AI-training-clause audit.
+
+**Delivery hygiene.** When AI was involved, attach provenance/notes for client delivery —
+especially if your org uses Content Credentials or has internal disclosure rules.
+
+Sources: SRC-C "Ethics, IP and commercial-use considerations" (Firefly Content
+Credentials, DrawKit AI-training prohibition); SRC-B; FLUX.1 [dev] license,
+https://huggingface.co/black-forest-labs/FLUX.1-dev; DrawKit license,
+https://www.drawkit.com/license.
+
+---
+
+## 8. End-to-end AI pipeline (brand-safe)
+
+The through-line — AI as **composition and ideation, not final master**:
+
+1. **Decide the projection** (`projection-math.md`) and build a small **reference board**
+   — one palette, one material direction, three-to-six exemplar shapes.
+2. **Generate composition candidates** — Midjourney / Firefly / Flux / SDXL, using image
+   prompts / style refs where available. Match model to output need (§1).
+3. **Pick ONE composition.** Do not blend five half-good generations.
+4. **Enforce structure if it drifts** — ControlNet depth/MLSD (§3) or the Blender
+   dual-control route (§4); a custom-trained model for a whole set (§5).
+5. **Get to vector if you need it** — Firefly Text-to-Vector first; else Image Trace /
+   Vectorizer.AI / SVGcode. → hand off to [`ai-refinement.md`](ai-refinement.md).
+6. **Rebuild key edges manually** in Illustrator / Affinity / Inkscape — this is where
+   production quality actually appears. (`ai-refinement.md`.)
+7. **Normalise the planes** — re-impose the three-tone top/left/right highlight-midtone-
+   shadow system ([`style-guide.md`](style-guide.md)). Clean edge halos on tiles
+   (detected mechanically by `scripts/tile-validate.py`; see `ai-refinement.md`).
+8. **Optimise & attach provenance** — SVGO/SVGOMG + a raster optimiser
+   ([`svg-vector-generation.md`](svg-vector-generation.md)), then attach Content
+   Credentials / disclosure notes for delivery (§7).
+
+Steps 1–4 are *this* file. Steps 5–8 hand off to `ai-refinement.md`,
+`svg-vector-generation.md`, and `style-guide.md`.
+
+Sources: SRC-C "AI-assisted isometric workflow" + "Step-by-step AI tutorial"; SRC-A
+Ch. 6 recommendations.
+
+---
+
+## Related
+
+- [`ai-refinement.md`](ai-refinement.md) — upscaling, vectorization, edge-halo cleanup (the *refine* half).
+- [`assets/prompt-library.md`](../assets/prompt-library.md) — ready-to-paste prompt scaffolds by tool.
+- [`blender-prerender.md`](blender-prerender.md) — the Blender rigs, Z-pass and normal-pass detail feeding §4.
+- [`projection-math.md`](projection-math.md) — the projection decision that precedes every prompt.
+- [`style-guide.md`](style-guide.md) — three-tone shading, one light direction, scale grammar the prompts enforce.
+- [`asset-sourcing.md`](asset-sourcing.md) — per-library licences + the AI-training-clause audit gating §5/§7.
+- Colour ramps / perceptual palettes: cross-link the **color-ops** skill (don't restate colour science here).

+ 273 - 0
skills/isometric-ops/references/ai-refinement.md

@@ -0,0 +1,273 @@
+# AI Refinement: Upscaling, Vectorization, and Halo Cleanup
+
+Once an isometric tile or scene has been generated (see
+[`ai-generation.md`](ai-generation.md) for the generate → control step), it is
+rarely production-ready as-is. AI rasters need **upscaling** without breaking
+projection geometry, **vectorization** when the deliverable must be editable
+SVG, **halo cleanup** when alpha edges are dirty (the single most common
+AI-tile defect), and a final **normalization** pass that re-imposes this
+skill's three-tone plane discipline. This file is the refinement stage of the
+pipeline; it assumes you already have a raster or rough-vector candidate in
+hand and covers everything between "generated" and "shippable."
+
+Related: [`ai-generation.md`](ai-generation.md) (generation + ControlNet),
+[`svg-vector-generation.md`](svg-vector-generation.md) (hand-rolled and
+library-based SVG authoring — the non-AI vectorization path),
+[`style-guide.md`](style-guide.md) (the three-tone plane system this file's
+normalization step re-imposes), `scripts/tile-validate.py` (mechanical
+detection of the defects described below).
+
+---
+
+## 1. Upscaling: the two-camp decision
+
+Every upscaler on the market optimizes for one of two incompatible goals.
+Picking the wrong camp for isometric work is the most common way a clean
+generation gets ruined at the refinement stage.
+
+| Camp | Behavior | Tools | Use for |
+|---|---|---|---|
+| **Creative / "hallucinate"** | Adds plausible new detail; can invent texture, geometry, and micro-perspective that was not in the source | **Magnific** (Freepik; Creativity + Resemblance sliders, up to 10K output, Precision V2 models), **SUPIR** (open-source, SDXL-prior, text-promptable, runs in ComfyUI via `kijai/ComfyUI-SUPIR`, needs 12GB+ VRAM), Krea Enhance, Clarity | AI-generated iso art that needs sharper edges and more surface detail than the base render had |
+| **Faithful / "preserve"** | Enlarges pixels without inventing content; will also enlarge existing noise/blur/error | **Topaz Gigapixel** (local, Bloom diffusion model, face recovery, batch), **Real-ESRGAN** (free, fast, excellent for flat-color/line-art), **Upscayl** (free local GUI wrapper around Real-ESRGAN-class models) | Pixel-art tiles, flat-color vector-style renders, or any asset where the pixels ARE the deliverable and must not drift |
+
+**As of July 2026**, Magnific is packaged under Freepik's account/credit system;
+SUPIR remains the open self-hosted route for teams that need the creative camp
+without a subscription. Verify current tiers on the vendor sites before
+committing a pipeline — pricing and packaging on AI upscalers changes
+quarterly.
+
+### The isometric rule
+
+For AI-generated isometric art, **default to the creative camp, but bias the
+sliders toward resemblance-high / creativity-low.** The goal is sharper
+edges and cleaner surface detail, not new content. A creative upscaler run at
+high creativity will happily "fix" a flat isometric plane into something with
+implied depth, ambient occlusion gradients that fight the three-tone system,
+or — worst case — subtly curved edges that break the projection's dead-straight
+lines. Concretely:
+
+- Magnific: bias the Resemblance slider high and Creativity low (neither
+  source document gives concrete percentages — treat this as directional
+  guidance, not a calibrated setting; see Flags). Pushed too far toward
+  creativity, Magnific starts re-drawing panel lines and adding perspective
+  cues that were never in the source.
+- SUPIR: keep the text prompt minimal/descriptive ("clean isometric game
+  tile, flat lighting") rather than evocative — evocative prompts invite the
+  model to invent atmosphere, which is exactly the failure mode to avoid.
+- If the source is pixel art or a flat vector-style render where every pixel
+  is intentional, skip the creative camp entirely and use Real-ESRGAN/Upscayl
+  (faithful camp) at an integer scale factor (2×, 4×) so pixel-art grids stay
+  aligned.
+
+### The >4× ceiling
+
+**Quality drops across every upscaler above roughly 4× linear scale**,
+creative or faithful. Above that ratio, artifacts compound: creative
+upscalers hallucinate visibly, faithful ones amplify compression noise and
+produce soft/waxy results. If you need more than 4× resolution:
+
+**Regenerate at a higher base resolution instead of chaining upscales.**
+Two successive 2× passes are not a substitute for generating at 2× the
+target resolution in the first place — each pass compounds its camp's
+failure mode. Solve resolution upstream (at generation time), not by
+stacking refinement passes; see the Sources section below for this
+skill's own SRC-A citation of the >4× ceiling.
+
+---
+
+## 2. Vectorization ladder
+
+When the deliverable must be editable vector (icon sets, brand-safe
+illustration, anything a designer will need to recolor or resize by hand),
+raster AI output has to cross into SVG. Quality and editability fall off in a
+strict order — work down the ladder only as far as you need to:
+
+| Rung | Tool | Output quality | When to use |
+|---|---|---|---|
+| 1 | **Recraft** | Cleanest — native vector generation, or upload-to-vectorize with purpose-trained tracing (not a generic autotrace) | First choice whenever available; produces the fewest, cleanest path nodes of anything on this ladder |
+| 2 | **Vectorizer.AI** | High — deep-learning raster→vector, palette control, symmetry detection; outputs SVG/EPS/DXF/PDF; REST API + CLI + SDKs (~$9.99/mo as of July 2026) | API/CLI pipelines that need programmatic, unattended vectorization |
+| 3 | **SVGcode** (free, open-source PWA, Potrace/WASM, fully offline) or plain **potrace** (CLI) | Moderate — classic autotrace algorithm, no ML | Free/local/offline requirement; simple flat-color source art; budget-constrained teams |
+| 4 | **Adobe Illustrator Image Trace** (+ Object → Expand) | Lowest starting quality, but fully editable once expanded | Hand-drawn or AI-sketch sources where you're going to manually rebuild edges anyway |
+
+Adobe Firefly's native "Text to Vector" mode can skip this ladder entirely
+when brand-safe, Content-Credentialed vector output is the goal (see
+`ai-generation.md` for the licensing rationale) — check it first if Firefly
+is already in your stack.
+
+### Path-soup avoidance
+
+Every rung below Recraft has the same failure mode: **complex shading or
+noisy raster input produces "path soup"** — hundreds of tiny, overlapping
+fill regions where a human artist would have drawn three or four clean
+shapes. Path soup is expensive twice: it bloats file size, and every
+downstream edit (recoloring a plane, adjusting an outline) becomes a
+multi-select nightmare across dozens of fragments.
+
+Mitigations, in order of effectiveness:
+
+1. **Simplify the raster before tracing.** Flatten gradients to hard color
+   bands (2–4 tones per plane, matching the three-tone system) before
+   vectorizing — the autotracer will produce roughly one path per color
+   region, so fewer input tones means fewer output paths.
+2. **Use a vectorizer's own simplification controls** (Vectorizer.AI's
+   detail slider, Illustrator's Image Trace "Colors" and "Paths" sliders,
+   SVGcode's threshold/turd-size options) rather than accepting defaults.
+3. **Rebuild key edges manually** in Illustrator, Affinity, or Inkscape
+   after autotracing. This is the step where production quality actually
+   appears — treat the autotrace output as a floor plan, not a finished
+   asset. Do not ship raw autotrace output for anything beyond a throwaway
+   prototype.
+4. **Run the export/optimisation pass** (simplify → export → SVGO/SVGOMG →
+   raster derivatives) documented in `svg-vector-generation.md` — that file
+   owns the full pipeline; this file only flags path soup as the reason the
+   pass exists.
+
+---
+
+## 3. Edge-halo cleanup
+
+The single most common mechanical defect in AI-generated tiles with
+transparent backgrounds is a **semi-transparent fringe** — a ring of
+partially-opaque pixels (alpha strictly between 0 and 255) around the
+subject's silhouette, left over from the model's soft-edged background
+removal or from JPEG-style compression artifacts baked into the alpha
+channel. On a game tile, this fringe shows up as a visible light or dark
+halo when the tile is composited over a different background color or an
+adjacent tile.
+
+### Detection and fix
+
+Neither SRC-A nor SRC-C describes a specific alpha-compositing technique for
+this defect — the mitigation below is standard raster-compositing practice
+supplied from general prior knowledge, not sourced from either document (see
+Flags). Treat the terminology and step order as a reasonable default, not a
+sourced claim.
+
+- **Alpha thresholding**: force every pixel's alpha to either fully
+  transparent or fully opaque at a chosen cutoff (commonly 50%/128), instead
+  of preserving a soft gradient. This is correct for hard-edged game tiles;
+  it is wrong for intentionally soft effects (glows, shadows painted into
+  the alpha channel) — decide per-asset-class, don't blanket-apply.
+- **Defringe** (a.k.a. "matte cleanup" / "spill removal"): for edge pixels
+  that must stay semi-transparent (anti-aliased silhouette edges), strip the
+  residual background color that bled into their RGB before compositing —
+  otherwise a white-background source produces a visible white halo even
+  after correct alpha, because the RGB itself is contaminated. Most raster
+  editors (Photoshop, Affinity, GIMP) expose this as a "Defringe" or "Matte"
+  filter; the underlying operation un-premultiplies the color against the
+  known background before discarding it.
+- **Threshold-then-defringe order matters**: threshold first to establish a
+  clean hard/soft edge boundary, then defringe only the pixels that remain
+  in the soft band. Defringing before thresholding leaves you defringing
+  pixels that are about to be discarded anyway, wasting the operation on
+  irrelevant data.
+
+### Mechanical detection: `tile-validate.py`
+
+This defect is exactly what `scripts/tile-validate.py`'s **alpha-halo
+check** exists to catch automatically: it scans a tile for the percentage of
+semi-transparent pixels (`0 < alpha < 255`) and flags a violation when that
+percentage exceeds threshold — the check a human reviewer would otherwise
+have to eyeball tile-by-tile across a large AI-generated batch. The same
+script's **edge-bleed check** catches the sibling defect (opaque pixels
+touching the outermost rows/columns of the canvas, meaning the subject was
+not given enough transparent margin and will visibly clip against
+neighboring tiles). Run `tile-validate.py` as the QA gate immediately after
+any AI-generation-and-refine pass and before the asset enters
+`sheet-pack.py` — see `tile-spec.md` for how a written asset spec's margin
+and format lines map onto these checks. Treat a violation as blocking: fix
+the source art or re-run defringe/threshold, don't ship a tile with a known
+halo because "it's probably fine at game resolution."
+
+---
+
+## 4. Post-vectorization normalization
+
+Vectorizing a raster (or generating a rough vector directly) does not, by
+itself, produce output that respects this skill's shading discipline. A
+generic autotrace or a loosely-prompted AI generation will typically produce
+inconsistent lighting logic between the top, left, and right planes — soft
+gradients where there should be flat fills, or a light direction that
+subtly shifts across the composition. **The last step before an
+AI-refined asset is production-ready is always a normalization pass** that
+re-imposes the three-tone plane system documented in full in
+[`style-guide.md`](style-guide.md):
+
+1. **Flatten each plane to its assigned tone.** Top plane gets the lightest
+   fill, one side plane gets the mid tone, the other side plane gets the
+   dark tone — per the fixed light direction chosen for the whole asset set,
+   not re-derived per tile.
+2. **Collapse any residual gradient banding** left by the vectorizer into
+   the flat three-tone fills (or, if soft shading is an intentional style
+   choice, make sure the gradient stops match the ramp defined in
+   `assets/palettes/three-tone-presets.json` rather than whatever the
+   autotrace happened to sample).
+3. **Check cross-tile/cross-asset consistency.** A single AI-refined asset
+   can look correct in isolation and still break the set if its light
+   direction or tone mapping doesn't match its siblings — verify against the
+   consistency checklist in `style-guide.md` before considering the asset
+   done, not just against itself.
+4. **Re-run `tile-validate.py`** after normalization — flattening fills can
+   shift edge pixels and occasionally reintroduces halo or edge-bleed if the
+   normalization pass touched the silhouette boundary.
+
+This four-step pass is what turns "AI output that looks isometric" into
+"an asset that matches the rest of the set" — treat it as mandatory, not
+optional polish, for anything beyond a one-off concept image.
+
+---
+
+## Sources
+
+- SRC-A ch.6 ("AI-Assisted Generation & Refinement") — upscaling two-camp
+  framing (Magnific/SUPIR vs Topaz/Real-ESRGAN/Upscayl), the isometric
+  resemblance-high/creativity-low rule, the >4× regenerate-instead-of-chain
+  threshold, the vectorization tool list (Recraft, Vectorizer.AI, SVGcode,
+  Illustrator Image Trace), and the licensing caveat that AI tool pricing
+  and availability move fast.
+- SRC-C (converted PDF, "Export and optimisation workflow" and "Step-by-step
+  AI tutorial" sections) — the vectorize → clean paths in
+  Illustrator/Affinity/Inkscape → normalize highlight-midtone-shadow → SVGO/
+  SVGOMG optimise pipeline order, and the "outputs still need cleanup;
+  complex shading can become path soup — use for conversion, not as the
+  final illustrator" characterization of AI-driven vectorizers. Neither
+  SRC-A nor SRC-C names the specific alpha-compositing technique described
+  in "Edge-halo cleanup" above (thresholding, defringe/matte-cleanup,
+  un-premultiplying) — that material is general raster-compositing prior
+  knowledge, not drawn from either source document; see Flags.
+- Magnific (Freepik): https://magnific.ai
+- SUPIR (ComfyUI integration): https://github.com/kijai/ComfyUI-SUPIR
+- Topaz Gigapixel: https://www.topazlabs.com/gigapixel-ai
+- Real-ESRGAN: https://github.com/xinntao/Real-ESRGAN
+- Upscayl: https://github.com/upscayl/upscayl
+- Recraft: https://www.recraft.ai
+- Vectorizer.AI: https://vectorizer.ai
+- SVGcode: https://github.com/tomayac/SVGcode
+- potrace: http://potrace.sourceforge.net
+
+## Flags
+
+- SRC-A's mention of "Krea Enhance" and "Clarity" as additional creative-camp
+  upscalers is a brief aside with no further detail in either source
+  document; included here for completeness but not independently verified
+  against vendor docs — treat as pointers, not endorsements, and verify
+  current capability before depending on either.
+- Neither source document gives an exact numeric threshold for the
+  alpha-halo percentage that `tile-validate.py` should flag as a violation;
+  that threshold is owned by the script itself (`--help` documents its
+  default and `--` flag to override it), not restated here.
+- The Magnific slider guidance ("bias Resemblance high / Creativity low")
+  is qualitative in both SRC-A and SRC-C — neither source gives concrete
+  percentages. An earlier draft of this file stated "Resemblance 70–90%,
+  Creativity 10–30%" as if sourced; those numbers were invented precision
+  and have been removed. Treat any specific slider percentage as a starting
+  point to tune against the vendor's current UI, not a sourced fact.
+- The "Edge-halo cleanup" section (alpha thresholding, defringe/matte
+  cleanup, threshold-then-defringe ordering, the un-premultiply mechanism)
+  has no support in SRC-A or SRC-C — a grep of both for halo/fringe/matte/
+  premultiply/alpha turns up nothing outside this file. It is standard
+  raster-compositing prior knowledge presented as workflow guidance, not
+  a claim attributed to either named source. The mechanical detection it
+  feeds (`tile-validate.py`'s alpha-halo and edge-bleed checks) is real and
+  sourced to this skill's own script, but the cleanup technique itself is
+  uncited.

+ 138 - 0
skills/isometric-ops/references/asset-sourcing.md

@@ -0,0 +1,138 @@
+# Asset Sourcing — licences, libraries, and the procurement rule
+
+Where to get isometric assets you didn't draw yourself: free/CC0 game-art packs, icon and
+illustration marketplaces, and the licence discipline that keeps a sourced asset from
+becoming a legal liability at client delivery. This file is about **acquiring** existing
+assets. For building your own from scratch, see `svg-vector-generation.md` (hand-rolled
+SVG/vector), `pixel-art-workflow.md` (sprites), `ai-generation.md` (generative pipelines),
+and `blender-prerender.md` (pre-rendered 3D). For the visual discipline sourced or
+generated assets must still conform to, see `style-guide.md`.
+
+Projection note: most "isometric" packs below are actually **2:1 dimetric (commonly
+called isometric in games)** — see `projection-math.md` for the distinction. Check a
+pack's tile geometry (2:1 aspect, e.g. 128×64) before mixing it with true-iso vector work.
+
+## The procurement rule (read this before downloading anything)
+
+For every asset you plan to ship — especially from a marketplace or subscription library —
+check three things, in order, **before client delivery**:
+
+1. **Current plan.** Marketplaces mix free, subscription, and seller-specific rights inside
+   the same site. What was free last quarter may now be a paid-tier asset, or vice versa.
+2. **Current licence.** Read the licence page itself, not a blog summary — attribution
+   requirements, redistribution/resale limits, and "unlimited use" caveats vary pack-to-pack
+   even within one vendor.
+3. **The AI-training clause.** This is the layer most teams miss. **"Commercial use
+   permitted" does not imply "dataset use permitted."** Some vendors explicitly forbid using
+   their assets to train, fine-tune, or improve AI/ML models even while allowing normal
+   commercial use in shipped products. DrawKit is the documented example: its licence
+   explicitly forbids using DrawKit icons and illustrations to train, fine-tune, or improve
+   AI/ML models, while otherwise permitting commercial use with unlimited copies and no
+   attribution ([drawkit.com/license](https://www.drawkit.com/license)). Treat any
+   AI-training-adjacent use (dataset curation, LoRA training references, style-transfer
+   corpora) as a separate rights question from "can I ship this in a product."
+
+Pricing, plan tiers, and licence text change without notice — treat every vendor's own
+licence page as the source of truth at the moment of use, not this document, not a cached
+summary, and not a review blog.
+
+## CC0-first: free game-art tile and prop packs
+
+Prefer these before reaching for a paid marketplace — CC0 means no attribution, no
+licence-check step, and no AI-training ambiguity (public domain dedication has no training
+clause to violate).
+
+| Source | What's there | Licence | Notes |
+|---|---|---|---|
+| **Kenney.nl** ([kenney.nl](https://kenney.nl/assets?q=isometric)) | Isometric Prototypes Tiles (50+), Isometric Dungeon Tiles (70+), Isometric Library Tiles (30+), Isometric Blocks (130+), Isometric Landscape (128 assets), Isometric Miniature Bases, Isometric City | **CC0** | The gold standard for CC0 game art. Packs ship with Unity + Tiled sample projects — the fastest path from download to engine. No attribution needed, but a credit is appreciated practice. |
+| **itch.io** ([itch.io](https://itch.io), tag: isometric) | Screaming Brain Studios: 1,008 Isometric Floor Tiles, 443 Town/Roof Tiles, 1,872 Wall Tiles. Also: DevilsWork.shop, dani maccari "Tiny Blocks," MarkGosbell "50+ Hand Drawn Isometric Dungeon Assets," "Mushy — neural-network-generated isometric tiles" | **CC0** (per-pack — verify each listing) | Deepest CC0 catalog by raw tile count. itch.io licences are set per-creator per-listing, not platform-wide — check each pack's page even though the ecosystem trends CC0. |
+| **OpenGameArt** ([opengameart.org](https://opengameart.org)) | Isometric City (mirrors Kenney's set), plus many community tilesets | Mixed — **CC0, CC-BY, CC-BY-SA** all present | Filter by licence on every search; OpenGameArt hosts multiple licence families side by side, unlike Kenney's blanket CC0. |
+
+## Icon and illustration marketplaces (paid + freemium)
+
+These trade CC0 simplicity for volume, consistency, and format breadth (SVG/PNG/EPS/AI/
+Lottie/3D). All require the procurement rule above before shipping.
+
+| Library | What it's for | Formats | Licence shape | Recommended use |
+|---|---|---|---|---|
+| **IconScout** ([iconscout.com/icons/isometric](https://iconscout.com/icons/isometric)) | Large free+premium isometric icon and illustration sets; brand recolouring | SVG, PNG, EPS, AI, PDF, 3D, Lottie | Free tier + paid individual/team plans; read [iconscout.com/licenses](https://iconscout.com/licenses) per-asset — IconScout mixes free, subscription, and seller-specific rights in one catalog | Wide-format needs (Lottie/3D alongside vector); Figma/XD/Sketch plugin workflows |
+| **Flaticon** ([flaticon.com/free-icons/isometric](https://flaticon.com/free-icons/isometric)) | Large isometric icon catalog (tens of thousands of icons) | SVG, PSD, PNG, EPS, icon font | Free **with attribution**; premium licence (via Flaticon/Freepik) removes the attribution requirement | Fast icon fills where attribution is acceptable, or budget covers the premium tier |
+| **Icons8** ([icons8.com](https://icons8.com)) | Systematically organized isometric icon families | SVG, PNG; Pichon desktop app for offline access | Free tier + subscription; API + Figma/Sketch/Adobe plugins | Teams wanting a consistent family across a large icon surface, with offline/API tooling |
+| **Streamline** ([streamlinehq.com](https://streamlinehq.com)) | Massive, highly consistent isometric icon/illustration systems | SVG, PNG, Figma | Premium subscription | Design systems needing hundreds of icons in one consistent hand |
+| **Iconify** ([icon-sets.iconify.design](https://icon-sets.iconify.design)) | Open-source icon aggregator/framework spanning many icon sets | SVG, framework components (React/Vue/Svelte) | Aggregates icon sets with **their own individual licences** — check the specific set, not "Iconify" as a blanket licence | The developer-friendly way to pull SVG icons programmatically (npm package per icon set, tree-shakeable) |
+| **DrawKit** ([drawkit.com](https://drawkit.com)) | Curated 2D & 3D illustration and icon packs, including isometric-themed sets (e.g. "Isotopia") | SVG, PNG, Figma | Free + Pro packs; commercial use permitted under the DrawKit Licence, no attribution required — **but AI-training explicitly forbidden**, see licence page | Polished, curated illustration where volume matters less than hand-consistency |
+| **Blush** ([blush.design](https://blush.design)) | Character-led, customisable illustration compositions (mix-and-match) | SVG, Figma/Sketch plugin | Free + Pro; commercial use allowed with unlimited copies, no attribution | Onboarding art, editorial scenes composed live inside a design tool |
+| **Storyset** ([storyset.com](https://storyset.com)) | Free customisable illustration library (Freepik/Flaticon ecosystem) | SVG, web-based colour customiser | Free with attribution; premium via Flaticon removes attribution | Quick blog/marketing fills, budget-conscious editorial illustration |
+| **Icograms** ([icograms.com](https://icograms.com)) | Purpose-built isometric diagram/map editor with a large built-in icon+template library (1,000+ icons, thousands of templates per vendor) | Browser editor; SVG/PNG export | Free to try; paid individual plans (roughly $19–34/mo at time of writing, verify current pricing) | Isometric maps, campus plans, logistics diagrams, network/infra diagrams — the map/diagram problem specifically, not general illustration |
+
+Dated note: pricing figures above (IconScout tiers, Icograms $19–34/mo) are **as of July
+2026** and vendor pricing shifts without notice — always confirm on the vendor's own
+pricing page before quoting a client.
+
+## Cloud-architecture diagram tools (adjacent, worth knowing)
+
+Not "asset libraries" in the sprite-sheet sense, but they ship their own maintained
+isometric icon sets (AWS/Azure/GCP/Kubernetes) and are the fastest path for infra diagrams
+specifically:
+
+- **Isoflow** ([isoflow.io](https://isoflow.io)) — open-source native isometric diagramming, drag-and-drop, free with a premium tier.
+- **FossFLOW** — fully open-source (Unlicense), built on the Isoflow library, adds JSON export/import.
+- **Cloudcraft** ([cloudcraft.co](https://cloudcraft.co)) — live-connected AWS/Azure architecture diagrams with cost overlays; free tier then paid.
+- **Holori** — multi-cloud (AWS/Azure/GCP/OCI/OVH/Scaleway/DigitalOcean), imports from Terraform/AWS console/GitHub.
+
+## Attribution tracking practice
+
+When a sourced pack requires attribution (Flaticon free tier, Storyset free tier, some
+itch.io listings), track it the same way you'd track a software dependency, not as an
+afterthought in a README nobody reads:
+
+- Keep a single `CREDITS.md` (or equivalent) per project, one line per pack: **source,
+  licence, attribution text required, URL to the exact licence page you checked, and the
+  date you checked it.** Licence terms drift; the date matters.
+- If an asset later gets swapped for a CC0 or in-house replacement, keep the credit line
+  until the asset is fully gone from every shipped build, not just the working file.
+- For AI-training-restricted assets (DrawKit and similar), add an explicit note in the same
+  file — "not for dataset/training use" — so a future teammate building an in-house LoRA
+  doesn't accidentally violate the source licence months later.
+
+## Prefer SVG source over PNG
+
+When a library offers both, take the SVG. Reasons specific to isometric work:
+
+- **Re-coloring for the three-tone plane system** (`style-guide.md`) requires editable fill
+  paths — a PNG forces you to redraw or paint-bucket-and-hope, an SVG lets you swap the
+  `fill` per plane directly.
+- **Re-scaling to match your tile module** (32/64/128 px tile width) is lossless from SVG,
+  lossy from PNG raster.
+- **Downstream optimisation** (SVGO/SVGOMG, see `svg-vector-generation.md`) only works on
+  vector source — you cannot "SVGO" a PNG.
+- If the only available format is PNG (common for AI-generated or hand-painted pixel-art
+  packs), that's fine — pixel-art tiles are raster-native by design (`pixel-art-workflow.md`)
+  and vectorizing them is usually the wrong move. The SVG-preference rule applies to
+  icon/illustration-style assets meant to scale, not to intentionally-raster pixel tiles.
+
+## Quick decision table
+
+| You need | Go to |
+|---|---|
+| Free game tiles/props, no licence review needed | Kenney.nl first, then itch.io (check per-listing), then OpenGameArt (filter by licence) |
+| A large, consistent icon family for a product UI | Icons8 or Streamline (subscription, consistent hand) |
+| Fast marketing/editorial illustration, budget-conscious | Storyset (free) or Blush (free/Pro) |
+| A cloud/infra isometric diagram | Icograms (dedicated iso editor) or Isoflow/Cloudcraft/Holori (cloud-provider icon sets) |
+| Curated, higher-craft illustration and don't need AI-training rights | DrawKit |
+| Programmatic SVG icon pulls into a build pipeline | Iconify (check the specific icon set's licence, not just "Iconify") |
+| Anything destined for an AI training/fine-tuning dataset | CC0 sources only (Kenney, verified-CC0 itch.io/OpenGameArt), or your own commissioned/generated work — never a licence you haven't confirmed permits training use |
+
+## Sources
+
+- SRC-A ch.4 "Icon & Asset Libraries" — Kenney, itch.io, OpenGameArt pack names and counts.
+- SRC-A ch.5 (diagram tools) — Isoflow, FossFLOW, Cloudcraft, Holori.
+- SRC-C "Core asset libraries" table and "Ethics, IP and commercial-use considerations"
+  section — IconScout, Flaticon, Icons8, Streamline, Iconify, DrawKit, Blush, Storyset,
+  Icograms descriptions, pricing, and licence shapes; the DrawKit AI-training prohibition
+  and the general "commercial use permitted ≠ dataset use permitted" doctrine.
+- [drawkit.com/license](https://www.drawkit.com/license) — primary source for the
+  AI-training exclusion clause, verified directly (not vendor-summary-only).
+- [iconscout.com/licenses](https://iconscout.com/licenses), [kenney.nl](https://kenney.nl),
+  [icograms.com](https://icograms.com), [storyset.com/faqs](https://storyset.com/faqs),
+  [blush.design](https://blush.design) — vendor licence/product pages cited in SRC-C.

+ 329 - 0
skills/isometric-ops/references/blender-prerender.md

@@ -0,0 +1,329 @@
+# Blender Pre-Rendering for Isometric Assets
+
+Pre-rendering 3D geometry into 2D isometric sprites is the production route classic and
+modern isometric games actually use — *Age of Empires*, *Factorio*, and *Hades* all bake
+high-fidelity 3D models to 2D sprite sheets rather than rendering isometric scenes in
+real time.[^grid-dynamics] Blender is the free, scriptable tool of choice for this:
+model once, render every camera direction, ship flat PNGs to any 2D engine.
+
+This file owns the Blender-specific delta: camera rigs, batch rendering, the
+depth/normal-pass export used to condition AI generation, and projecting AI output back
+onto geometry. For the web-native alternative (rendering isometric sprites straight out
+of a three.js scene, no Blender required), see
+[`threejs-orthographic.md`](threejs-orthographic.md). For the underlying angle math, see
+[`projection-math.md`](projection-math.md). For post-render QA, see
+[`../scripts/tile-validate.py`](../scripts/tile-validate.py).
+
+**The projection decision comes first**, per [`projection-math.md`](projection-math.md):
+decide *true isometric* (81.65% axonometric foreshortening, all three cube faces
+render equal) or *2:1 dimetric* (commonly called isometric in games; game tile
+rendering, cube top is exactly 2× as wide as tall) before touching the camera. The two
+rigs below are not interchangeable and mixing them across a set is the single most
+common isometric-pre-render mistake.
+
+---
+
+## 1. The two camera rigs (headline distinction)
+
+Most tutorials present only one rig and treat it as "the" isometric camera. It isn't —
+there are two correct rigs for two different projections, and conflating them produces
+tiles that don't tile or illustrations that read as subtly "off." **Both rigs below must
+be considered whenever an isometric Blender pipeline is discussed; picking the wrong one
+for the job is the signature footgun this reference exists to prevent.**
+
+| Target | Camera rotation (XYZ, degrees) | Projection type | Verification test |
+|---|---|---|---|
+| 2:1 dimetric game tiles | **RotX 60°, RotY 0°, RotZ 45°** | 2:1 dimetric (not true isometric) | Rendered cube top face is exactly **2× as wide as tall** — elevation angle is 30° above the horizon, and sin(30°) = 0.5, which is the 2:1 pixel ratio the whole "game isometric" convention is built on. |
+| True isometric | **RotX 54.736°, RotY 0°, RotZ 45°** | True isometric (all axes at 120°, cube tilt 35.264°) | **All three visible cube faces render as equal parallelograms** — no face looks "flatter" than another. 54.736° = 90° − 35.264° (arctan(√2)); this is the camera-elevation form of the same true-iso angle used everywhere else in this skill. |
+
+Both rigs use an **orthographic** camera — never a perspective camera; a perspective
+lens introduces vanishing points, which is the #1 giveaway that breaks the isometric
+illusion no matter how the rotation is set.
+
+Why both exist in the wild, unlabeled: Clint Bellanger's widely-cited Blender tutorial
+("Isometric Tiles in Blender") uses the 60/0/45 rig and explicitly verifies the "cube top
+2× as wide as tall" check[^bellanger] — that is the 2:1 dimetric rig, correct for pixel
+game tiles, and is what most "Blender isometric" tutorials teach because most people
+asking are making game tiles. The ControlNet ortho-to-AI workflow (§3 below) instead uses
+54.736/0/45 — true isometric — because it targets illustrative renders (architecture,
+cutaways, hero art) where equal-face fidelity matters more than 2:1 pixel-grid tiling.
+**Neither tutorial is wrong; they are solving different problems.** State which one you
+need before opening Blender. A Blender Artists forum thread on "Creating an Isometric
+Camera" independently confirms the historical footgun: ortho angles that merely "look
+isometric" by eye are usually **trimetric**, not true isometric or 2:1 dimetric, and a
+commenter there calls this out explicitly — always dial in the exact rotation numbers
+above rather than eyeballing the viewport.[^blenderartists]
+
+### Camera placement (both rigs)
+
+Position is far less exacting than rotation for an orthographic camera — since ortho
+projection has no perspective falloff, *where* the camera sits along its view vector
+doesn't change the render, only *how far back* it needs to be to fit the subject inside
+the ortho scale. Bellanger's tutorial places the camera at a sample offset such as
+LocX=10, LocY=−10, LocZ=10 (a diagonal position consistent with a 45° Z-rotation looking
+back at the origin) purely to keep it clear of geometry — treat the exact coordinates as
+illustrative, not load-bearing; only the **rotation** and **orthographic scale** are
+load-bearing.[^bellanger]
+
+- **Orthographic Scale** (Camera Data Properties → Lens → Orthographic Scale) sets the
+  world-space width the camera frame captures. Pick a scale so your subject (a 1×1 tile
+  cube, a building footprint) fills the frame with your intended margin; keep this value
+  **identical across every direction render in a set** so all sprites share one
+  world-to-pixel ratio. Changing it between renders is the classic cause of a spritesheet
+  where objects mysteriously change size between frames.
+- **Clip distances**: push `Clip Start` low and `Clip End` high enough that geometry
+  never gets clipped as the rig rotates around it (the same failure mode documented for
+  Unity's near-clip in [`engine-integration.md`](engine-integration.md) — an orthographic
+  camera with a tight clip plane will silently clip corners of tall geometry that a
+  perspective camera's wider frustum tolerated).
+- **Film → Transparent** (Render Properties → Film) — enable transparent background so
+  the render composites cleanly as a sprite with alpha, not a scene with a solid backdrop
+  baked in.
+
+---
+
+## 2. 8-direction rig via a parented empty, and batch rendering
+
+The production pattern for "one asset, every direction" is a **parented pivot empty**:
+the camera is parented to an Empty placed at the world origin (or the asset's pivot
+point); rotating the *empty* around its local Z axis rotates the camera around the
+subject while preserving the camera's own tilt (RotX / the 60° or 54.736° elevation).
+This gives clean, exact rotation increments instead of re-deriving camera position by
+hand for each direction.
+
+1. Add an **Empty** (Plain Axes is fine) at the asset's pivot — usually world origin, or
+   the point that should stay fixed as the asset "turns."
+2. Parent the camera to the empty (`Ctrl+P` → Object, keep transform) so the camera's
+   *local* rotation (its RotX from the table above, RotZ 45°) is preserved relative to
+   the empty.
+3. To get **N directions**, rotate the empty's world Z rotation in `360° / N` steps and
+   render at each step:
+   - **4-direction** (top-down four-quadrant view, common for simple tile sprites): step
+     90°.
+   - **8-direction** (the standard for isometric character/prop sprites — N, NE, E, SE,
+     S, SW, W, NW): step 45°. This is the QWeb "Creating an isometric rig in Blender"
+     pattern — animate the empty's rotation across 8 keyframes and batch-render one frame
+     per direction in a single click, using a downloadable pre-built rig as the
+     reference implementation.[^qweb]
+4. Keep the **camera's own rotation fixed** (RotX = 60° or 54.736°, RotZ = 45° per the
+   rig table) — only the empty's Z rotation changes between frames. Never touch the
+   camera's RotX per-direction; if a direction render looks "wrong," the bug is almost
+   always an accidentally-nudged camera, not a math error.
+5. Batch by looping empty-rotation → render → save-as-numbered-frame, either via
+   Blender's built-in animation render (one keyframe per direction, render as an image
+   sequence) or headlessly with `assets/blender-iso-rig.py --directions N` (§5) which
+   automates exactly this loop from the CLI.
+
+### Sprite-sheet output
+
+Render each direction to its own numbered PNG (`name_dir00.png` … `name_dir07.png` for
+8-direction), transparent background, identical orthographic scale and resolution across
+all frames. Do **not** try to composite the sheet inside Blender — hand the per-direction
+PNGs to [`scripts/sheet-pack.py`](../scripts/sheet-pack.py), which packs a directory of
+tiles into one spritesheet PNG plus a JSON atlas (frame `{x,y,w,h,trimmed,sourceW,
+sourceH}` per name) — Blender's compositor is the wrong tool for atlas packing; use the
+purpose-built script downstream of the render.
+
+### Modern pixelation post-processing
+
+For pixel-art-styled games, render at a comfortably high resolution (e.g. 512² or 1024²)
+with anti-aliasing on for clean edges, then **downsample and posterize/pixelate as a
+post-process** rather than trying to render native low-res — this is the workflow
+demonstrated in community tutorials on rendering and pixelating isometric assets in
+Blender, and it produces cleaner results than forcing Blender's renderer to output
+native low-res pixel art directly.[^pixelate-tutorial] Downsampling with a box/nearest
+filter to the target tile resolution (e.g. 512² → 64²) after rendering at high
+resolution avoids the jagged, uncontrolled aliasing that comes from rendering natively
+small. Feed the downsampled output through
+[`scripts/tile-validate.py`](../scripts/tile-validate.py) `--max-colors` to confirm the
+palette stayed within your target budget after any smoothing/dithering step.
+
+---
+
+## 3. Depth pass and camera-space normal pass export (for ControlNet conditioning)
+
+When the goal is AI-generated final art that is perspective-*locked* to a 3D blockout
+(see [`ai-generation.md`](ai-generation.md) for the full decision ladder and ControlNet
+theory), Blender's job shifts from "renderer of final sprites" to "renderer of
+*conditioning maps*" — a depth pass and a camera-space normal pass, both exported as
+flat images and fed into Stable Diffusion's ControlNet as structural guides. This is
+steps 1–3 (of a 5-step pipeline) of the Blender→ControlNet workflow; step 4
+(ControlNet + Stable Diffusion generation) belongs to
+[`ai-generation.md`](ai-generation.md) and is only summarized here for continuity.
+
+### Step 1 — Model and configure the camera
+
+Build a simple 3D blockout of the target scene using primitive geometry — walls, support
+beams, crates, barrels; massing and silhouette matter far more than surface detail at
+this stage, since the blockout only needs to condition depth/normal maps, not appear in
+the final render. Set the camera to **Orthographic** and rotate it to the **true
+isometric** rig: **RotX 54.736°, RotZ 45°** (the true-iso row of the rig table in §1 —
+this workflow specifically wants true isometric, not the 2:1 dimetric game rig, because
+the target output is illustrative cutaway/scene art, not tiling game sprites). Isolate
+and scale up any complex assets (barrels, crates) within the camera frame so their
+geometric detail is clearly captured in the depth/normal passes — a barrel that occupies
+4 pixels of the frame will bake to a useless depth blob.[^src-b-step1]
+
+### Step 2 — Render and export the depth map (Z-pass)
+
+1. `View Layer Properties → Passes → Data` → enable the **Z** pass.
+2. In the **Compositing** workspace, connect the render layer's Z output through a
+   Normalize node (or Map Range, clamped to the scene's near/far bounds) into a grayscale
+   output — this converts raw depth values into a high-contrast grayscale gradient where
+   **near geometry renders white and far geometry renders black** (or the inverse,
+   depending on the ControlNet depth preprocessor's expected convention — check which
+   polarity your target preprocessor/model expects before exporting).
+3. Export the composited grayscale image as the depth guide — this is the file that gets
+   loaded into ControlNet's Depth unit in step 4.[^src-b-step2]
+
+### Step 3 — Bake and export the camera-space normal map
+
+1. Create a dedicated shader material whose sole job is to encode surface normals as RGB
+   color, so that rendering the scene with this material assigned produces a normal map
+   image instead of a lit render.
+2. In the Shader Editor: `Geometry` node → `Normal` output → `Vector Transform` node, set
+   to convert **World Space → Camera Space**. This step is what makes the normal map
+   *camera-space* rather than *world/object-space* — camera-space normals are what most
+   ControlNet "normal" preprocessors/models expect, since they encode which way each
+   surface faces relative to the *view*, not relative to the world.
+3. Feed the transformed vector through a `Multiply-Add` math node to remap the
+   [−1.0, 1.0] vector-component range into the standard [0.0, 1.0] RGB image range
+   (multiply by 0.5, add 0.5 — the conventional tangent-to-RGB normal-map encoding).
+4. Assign this material to every model in the scene (a temporary material override, or a
+   dedicated render layer with the override applied), render the viewport from the same
+   isometric camera used for the depth pass, and save the result as the camera-space
+   normal map.[^src-b-step3]
+
+Both exported maps (depth + normal) must come from the **exact same camera transform**
+used for step 1 — any camera nudge between the two passes desynchronizes them and the
+dual-ControlNet conditioning in step 4 will fight itself.
+
+### Step 4 (pointer only) — ControlNet conditioning + generation
+
+Covered in full in [`ai-generation.md`](ai-generation.md): load the depth map into a
+Depth ControlNet unit and the normal map into a Normal ControlNet unit in
+AUTOMATIC1111/ComfyUI, generate with the documented parameters (Euler, ~15 steps, 768²,
+CFG 7), using the prompt/negative-prompt doctrine described there. Do not duplicate that
+material here — this file's job ends at "export two clean conditioning images from
+Blender."
+
+---
+
+## 4. Step 5 — Texture projection mapping: AI output back onto geometry
+
+Once the AI has synthesized high-resolution, perspective-locked texture art
+(step 4, elsewhere), the workflow closes the loop by projecting that art back onto the
+original 3D blockout:
+
+1. Import the synthesized AI image(s) back into the Blender project as image textures.
+2. Use **texture projection mapping** (Blender's UV Project modifier, or manual "Project
+   From View" UV unwrapping) to project the generated texture from the **camera's exact
+   coordinates** — the same orthographic camera and transform used to export the
+   depth/normal passes — directly onto the 3D models. Because the projection uses the
+   identical camera the AI image was conditioned against, this automatically produces
+   accurate UV coordinates with no manual re-alignment.
+3. With projection mapping complete, bake the projected texture into a flat, standard UV
+   map per object, apply conventional PBR material properties on top of the baked
+   texture, and light the scene normally in Blender.
+4. The payoff: because the texture is now baked into real UVs on real geometry (not just
+   a flat billboard), the scene can be **re-rendered from multiple lighting angles or
+   re-lit dynamically**, producing a family of consistent isometric sprites/frames from
+   one AI-conditioned texture pass rather than needing a fresh AI generation per lighting
+   variant.[^src-b-step5]
+
+This is the highest-effort tier of the isometric AI pipeline — reserve it for hero
+assets or asset families that need lighting variation; one-off sprites are usually
+better served by generating directly at the target angle (see the decision ladder in
+[`ai-generation.md`](ai-generation.md)) without the round-trip through Blender geometry.
+
+---
+
+## 5. Driving it with `assets/blender-iso-rig.py`
+
+The skill ships `assets/blender-iso-rig.py` to automate §1 (rig construction) and §2
+(N-direction batch rendering) so a team doesn't hand-build the empty/camera parenting
+every time. Documented here per the Resource Protocol contract; see the script's own
+`--help` and first-comment-block for the authoritative, current interface.
+
+**Invocation (headless, inside Blender's bundled Python):**
+
+```
+blender -b -P assets/blender-iso-rig.py -- --projection dimetric21|true --directions 8 --out DIR [--resolution N]
+```
+
+- Everything **before** the bare `--` is consumed by Blender itself (`-b` = background/
+  headless, `-P` = run this Python file); everything **after** `--` is the script's own
+  argv — this split is a Blender convention, not a choice this script makes, and it's
+  the reason the CLI can't be `blender -b -P script.py --projection true` (Blender would
+  swallow `--projection` as one of its own flags).
+- `--projection dimetric21` selects the **RotX 60°, RotZ 45°** rig (§1); `--projection
+  true` selects **RotX 54.736°, RotZ 45°**. The script's `argparse` default is
+  `dimetric21` (confirm with `--help`) — but treat that as a convenience for the common
+  game-tile case, not license to skip the decision: the projection choice is still the
+  first decision per this skill's doctrine (§1), and passing `--projection` explicitly
+  every time is the way to avoid silently shipping the wrong rig for an illustrative
+  (true-isometric) job.
+- `--directions N` builds the parented-empty rig (§2) with N evenly-spaced rotations
+  (`360/N` per step) — `4` and `8` are the common values; the empty's rotation step and
+  the camera's own rig rotation (RotX/RotZ) are independent and the script must not
+  conflate them.
+- `--out DIR` is the output directory for the numbered per-direction renders
+  (`name_dir00.png` … `name_dir{N-1}.png`), transparent film, one orthographic-scale
+  value held constant across all frames per §2.
+- `--resolution N` sets the square render resolution in pixels (applies to both width
+  and height — isometric sprite renders are conventionally square-canvas even when the
+  final sprite content isn't square, so downstream trimming in
+  [`scripts/sheet-pack.py`](../scripts/sheet-pack.py) `--trim` has clean margin to work
+  with).
+
+**Two run modes, by design:**
+
+- **GUI-run (no CLI args, launched by opening the `.py` in Blender's Text Editor and
+  pressing Run, or via Blender's normal windowed startup):** builds the camera + empty
+  rig only — no render, no `--out` required. This lets an artist inspect the rig in the
+  3D viewport, nudge the orthographic scale to frame their asset, and render manually
+  or resume the headless batch path once satisfied.
+- **Headless (`blender -b -P … -- --out DIR`):** builds the rig *and* renders every
+  direction to `--out`, suitable for CI/batch pipelines with no display.
+
+**Degrading gracefully outside Blender.** This file is plain Python but only runs
+correctly inside Blender's bundled interpreter, which provides the `bpy` module. Running
+it under a system Python (`python assets/blender-iso-rig.py`) must not crash with a raw
+`ModuleNotFoundError` traceback — per the Resource Protocol's agent-safety rule, the
+script catches the `bpy` import, prints a helpful stderr message naming the correct
+invocation (`blender -b -P assets/blender-iso-rig.py -- --help`), and exits with the
+protocol's `5` (PRECONDITION — environment issue, wrong interpreter) rather than an
+uncaught exception. `--help` (run under either interpreter, since argument parsing
+should not itself require `bpy`) prints the usage above plus worked EXAMPLES per the
+first-comment-block contract in
+[`SKILL-RESOURCE-PROTOCOL.md`](../../../docs/SKILL-RESOURCE-PROTOCOL.md).
+
+---
+
+## Footnotes / sources
+
+[^grid-dynamics]: The pre-render pipeline pattern (*Age of Empires*, *Factorio*, *Hades*
+    baking 3D-to-2D sprite sheets) — SRC-B, "3D-to-2D Pre-Rendering Pipeline" section.
+[^bellanger]: Clint Bellanger, "Isometric Tiles in Blender" — the canonical tutorial for
+    the 60/0/45 dimetric rig and the "cube top 2× as wide as tall" verification test;
+    cited via SRC-A's Blender-for-isometric-rendering catalog.
+[^blenderartists]: Blender Artists forum, "Creating an Isometric Camera" — documents the
+    true-iso rig and the historical orthographic-scale bug (fixed in Blender 2.49+); a
+    commenter's correction that eyeballed "nice-looking" ortho angles are typically
+    trimetric, not true isometric — cited via SRC-A.
+[^qweb]: QWeb, "Creating an isometric rig in Blender" — the parented-empty, 8-frame
+    animated-rotation batch-render pattern with a downloadable pre-built rig — cited via
+    SRC-A.
+[^pixelate-tutorial]: Community tutorial, "Rendering Isometric Assets & Pixelating
+    Renders [In Blender]" — the render-high/downsample-to-pixelate post-process pattern —
+    cited via SRC-A.
+[^src-b-step1]: SRC-B, "3D-to-2D ControlNet Rendering Workflow in Blender," Step 1
+    (Modeling and Camera Configuration).
+[^src-b-step2]: SRC-B, ibid., Step 2 (Rendering and Exporting the Depth Map / Z-Pass).
+[^src-b-step3]: SRC-B, ibid., Step 3 (Baking and Exporting the Normal Map).
+[^src-b-step5]: SRC-B, ibid., Step 5 (Texture Projection Mapping in Blender).
+
+**Source documents** (paths as supplied to the isometric-ops build):
+SRC-A = `compass_artifact_wf-75a0e032-3465-48c7-84ea-e104bae213c2_text_markdown.md`
+(Blender-for-isometric-rendering catalog); SRC-B = "Engineering and Aesthetic Standards
+for Isometric Design" (3D-to-2D ControlNet Rendering Workflow section, steps 1–3 and 5).

+ 648 - 0
skills/isometric-ops/references/coordinates-depth.md

@@ -0,0 +1,648 @@
+# Coordinates & Depth Sorting
+
+The runtime math of an isometric world: converting between the logical **tile grid**
+(cartesian, integer `x`/`y`) and the **screen** (pixels), picking the tile under the
+cursor, and drawing everything back-to-front so near objects occlude far ones. This is
+the code that runs every frame — get it right once and the whole map behaves; get the
+anchor or the sort key wrong and sprites clip through each other for the rest of the
+project.
+
+> **Scope.** This file owns the coordinate transforms and depth-sorting doctrine for
+> **tile-based** isometric worlds (the 2:1 dimetric family — commonly called
+> isometric in games — and true-iso grids). The *projection geometry* (why 2:1,
+> the exact angles, the plane matrices) lives in
+> [`projection-math.md`](projection-math.md); the *asset spec* that pins tile W×H,
+> anchor, and elevation-per-step lives in [`tile-spec.md`](tile-spec.md); engine-native
+> tilemap nodes (Godot `TileMapLayer` Y-Sort, Unity `sortingOrder`) live in
+> [`engine-integration.md`](engine-integration.md). Everything here is the raw math
+> those wrappers hide.
+
+All formulas below are parametrized by `tileW` and `tileH` (the on-screen width and
+height of a single ground diamond in pixels). For **2:1 dimetric** `tileW = 2·tileH`
+(e.g. 64×32, 128×64), which is what almost every tiled game uses. The formulas are
+general — they hold for any `tileW:tileH` ratio, including true-iso grids where the
+ratio follows `tan 30° = 0.57735` rather than exactly 0.5.
+
+**Terminology note.** Throughout, "2:1 dimetric (commonly called isometric in games)"
+is the 26.565° projection at a 2px:1px pixel step. After this first mention it is
+"2:1 dimetric". See [`projection-math.md`](projection-math.md) for why the game-standard
+projection is dimetric, not isometric.
+
+---
+
+## Contents
+
+1. [Coordinate systems and conventions](#1-coordinate-systems-and-conventions)
+2. [Tile → screen (the forward transform)](#2-tile--screen-the-forward-transform)
+3. [Screen → tile (the inverse transform)](#3-screen--tile-the-inverse-transform)
+4. [Worked numeric examples (64×32)](#4-worked-numeric-examples-6432)
+5. [Elevation (the z axis)](#5-elevation-the-z-axis)
+6. [Picking — the within-diamond test](#6-picking--the-within-diamond-test)
+7. [Depth sorting doctrine](#7-depth-sorting-doctrine)
+8. [The anchor-at-feet rule (and why center anchors break sorting)](#8-the-anchor-at-feet-rule-and-why-center-anchors-break-sorting)
+9. [Multi-tile and oversized sprites — AABB in iso space](#9-multi-tile-and-oversized-sprites--aabb-in-iso-space)
+10. [Moving platforms and multi-floor](#10-moving-platforms-and-multi-floor)
+11. [Performance thresholds](#11-performance-thresholds)
+12. [Reference snippets (pseudocode, JS, Python)](#12-reference-snippets-pseudocode-js-python)
+13. [Sources](#13-sources)
+
+---
+
+## 1. Coordinate systems and conventions
+
+Three coordinate frames are in play. Confusing them is the single most common source of
+"my tiles are off by a diamond" bugs.
+
+| Frame | Symbols | Units | Origin | Description |
+|---|---|---|---|---|
+| **Tile / grid** (cartesian) | `x`, `y` | tiles (integer or fractional) | tile `(0,0)` | The logical world. `x` increases toward the lower-right screen edge, `y` toward the lower-left. This is where game logic, pathfinding (A\*), and the map array live. |
+| **Screen** | `screenX`, `screenY` | pixels | a chosen anchor pixel | Where you actually `drawImage`. **y-down**, matching every 2D canvas / DOM / engine viewport. |
+| **Elevation** | `z` | z-steps (integer) | ground plane `z=0` | Height above the ground diamond. Lifts a sprite straight up the screen and is a **tie-breaker** in the sort key — never a horizontal offset. |
+
+**Fixed conventions used in this file (state yours explicitly in your tile-spec):**
+
+- **y-down screen space.** `screenY` grows downward. This is universal for canvas/DOM/
+  engine viewports; the formulas below assume it. (Three.js/WebGL use y-up world space —
+  that world is handled in [`threejs-orthographic.md`](threejs-orthographic.md), not here.)
+- **Diamond-down tile layout.** Tile `(0,0)`'s top vertex sits at the screen origin;
+  `+x` runs down-right, `+y` runs down-left. This matches Godot's *Diamond Down* layout
+  and the canonical `(x−y, x+y)` transform.
+- **Anchor at the visual feet.** The reference point of every sprite is the bottom of its
+  ground contact, not its center. §8 explains why this is load-bearing.
+- **Half-dimensions.** Because every term divides `tileW` and `tileH` by 2, precompute
+  `halfW = tileW/2`, `halfH = tileH/2` once. For 64×32 that's `halfW=32, halfH=16`.
+
+---
+
+## 2. Tile → screen (the forward transform)
+
+The canonical transform. A unit step in tile `x` moves half a tile right and half a
+tile down; a unit step in tile `y` moves half a tile left and half a tile down. That is
+exactly the diamond lattice.
+
+```
+screenX = (x − y) · tileW / 2
+screenY = (x + y) · tileH / 2
+```
+
+Equivalently, with `halfW = tileW/2` and `halfH = tileH/2`:
+
+```
+screenX = (x − y) · halfW
+screenY = (x + y) · halfH
+```
+
+**Ground-truth check.** Substitute the basis tiles at 64×32 (`halfW=32, halfH=16`):
+
+| Tile `(x,y)` | `screenX = (x−y)·32` | `screenY = (x+y)·16` | Meaning |
+|---|---|---|---|
+| `(0,0)` | `0` | `0` | origin (top vertex of tile 0,0) |
+| `(1,0)` | `+32` | `+16` | one step down-**right** ✓ |
+| `(0,1)` | `−32` | `+16` | one step down-**left** ✓ |
+| `(1,1)` | `0` | `+32` | straight down one full tile ✓ |
+
+The `(1,0)` and `(0,1)` rows are the two ground-axis basis vectors: `(+halfW, +halfH)`
+and `(−halfW, +halfH)`. Their slope magnitude is `halfH/halfW = tileH/tileW`. For a 2:1
+tile that is `16/32 = 0.5` — the defining 2px-across : 1px-up pixel step. For a true-iso
+grid it is `tan 30° = 0.57735`. That single ratio is the projection fingerprint; a grid
+SVG's line slope must match it (see the `assets/grids/` verification in the skill).
+
+This transform positions the tile's **origin vertex**. To draw a diamond *sprite* whose
+image is `tileW × tileH`, blit its top-left corner at `(screenX − halfW, screenY)` so the
+diamond's top vertex lands on `(screenX, screenY)` — or, more robustly, anchor by the
+sprite's declared anchor point (§8) rather than a corner.
+
+---
+
+## 3. Screen → tile (the inverse transform)
+
+Inverting the 2×2 system above (used for mouse picking, drag-drop, click-to-move):
+
+```
+x = ( screenX / (tileW/2) + screenY / (tileH/2) ) / 2
+y = ( screenY / (tileH/2) − screenX / (tileW/2) ) / 2
+```
+
+With half-dimensions:
+
+```
+x = ( screenX/halfW + screenY/halfH ) / 2
+y = ( screenY/halfH − screenX/halfW ) / 2
+```
+
+**Derivation (so you can re-derive it, not memorise it).** The forward map is a linear
+system:
+
+```
+screenX = halfW·x − halfW·y
+screenY = halfH·x + halfH·y
+```
+
+Divide the first by `halfW` and the second by `halfH`:
+
+```
+u = screenX/halfW = x − y
+v = screenY/halfH = x + y
+```
+
+Then `x = (u + v)/2` and `y = (v − u)/2`. Substituting `u` and `v` back gives the
+boxed formulas. The determinant of the forward matrix is `halfW·halfH − (−halfW·halfH)
+= 2·halfW·halfH ≠ 0`, so the inverse always exists for a non-degenerate tile.
+
+The result is **fractional** `x`, `y`. To get the integer tile you must `floor` — but a
+naive floor of a fractional tile coordinate is *not* the same as the diamond the cursor
+is inside near the diamond edges. §6 gives the correct within-diamond picking test.
+
+**Numeric sanity check (must round-trip):** feed any `(x,y)` through §2 then §3 and you
+must recover `(x,y)` exactly. Verified below.
+
+---
+
+## 4. Worked numeric examples (64×32)
+
+`tileW=64, tileH=32 → halfW=32, halfH=16`. Forward then inverse; the inverse recovers the
+original tile exactly (this is the round-trip the `iso-math.py to-screen` / `to-tile`
+subcommands assert, and what the JS/Python snippets in §12 reproduce):
+
+| Tile in | `to-screen` | `to-tile` back | Round-trip |
+|---|---|---|---|
+| `(0,0)` | `(0, 0)` | `(0, 0)` | ✓ |
+| `(1,0)` | `(32, 16)` | `(1, 0)` | ✓ |
+| `(0,1)` | `(−32, 16)` | `(0, 1)` | ✓ |
+| `(3,2)` | `(32, 80)` | `(3, 2)` | ✓ |
+| `(5,5)` | `(0, 160)` | `(5, 5)` | ✓ |
+| `(10,7)` | `(96, 272)` | `(10, 7)` | ✓ |
+
+**A non-integer pick.** Screen point `(48, 8)` at 64×32:
+
+```
+x = (48/32 + 8/16)/2 = (1.5 + 0.5)/2 = 1.0
+y = (8/16 − 48/32)/2 = (0.5 − 1.5)/2 = −0.5
+```
+
+→ tile `(1.0, −0.5)`. The *integer* `x = 1.0` is the boundary signal here: in the
+unit-square `(fx,fy)` space of §6 an integer coordinate lands exactly on a diamond
+*edge*. The `y = −0.5` is the opposite — a mid-diamond value (its remainder `0.5` is the
+diamond *center* on that axis, as far from an edge as you can get). So the cursor sits on
+the shared edge between tiles `(0,−1)` and `(1,−1)` (both diamonds meet along `x = 1.0`),
+and `floor(1.0, −0.5)` resolves it to `(1,−1)` — a correct, unambiguous pick.
+
+The subtlety §6 warns about is not *this* clean case but the **float-noise** one: when a
+computed coordinate should be an exact integer edge value but arrives as `0.9999997` or
+`1.0000004`, `floor` can flip it to the wrong side of the seam — the off-by-one-diamond
+picking bug. Snap near-integer results (or use §6's remainder test) before flooring at
+the seams; don't trust raw floating-point equality on a boundary.
+
+CLI equivalent (see `scripts/iso-math.py`):
+
+```
+iso-math.py to-screen 3 2 --tile-w 64 --tile-h 32     # → 32  80
+iso-math.py to-tile   32 80 --tile-w 64 --tile-h 32   # → 3   2
+```
+
+---
+
+## 5. Elevation (the z axis)
+
+Elevation lifts a sprite **straight up the screen** — a pure vertical (`−screenY`)
+offset. It is never a horizontal shift, because moving up in the world does not change
+which ground tile you stand on.
+
+Let `unitZ` be the pixels-per-z-step your tile-spec pins (a per-set constant — e.g. a
+64×32 dungeon commonly uses `unitZ = 16`, i.e. one z-step = the tile's half-height, so a
+wall block reads as one tile tall). Then:
+
+```
+screenX = (x − y) · halfW
+screenY = (x + y) · halfH − z · unitZ        // subtract: up is −y in y-down space
+```
+
+`unitZ` belongs in the **spec**, not the code — it defines how tall a "floor" reads and
+must be identical across every asset in a set or stacked blocks won't line up. Pin it in
+[`tile-spec.md`](tile-spec.md).
+
+**Elevation and sorting.** `z` does *not* enter the primary `(x+y)` sort key — two tiles
+at the same `(x+y)` but different height still occupy the same screen column band, and
+the higher one must draw *after* (on top of) the lower. So `z` is the **second** sort key
+(§7). A common mistake is to fold `z` into the depth key as `x + y + z`; that makes a
+tall object on a near tile incorrectly sort behind a short object on a far tile.
+
+---
+
+## 6. Picking — the within-diamond test
+
+Reverse-projecting a screen point (§3) gives fractional tile coordinates. Turning those
+into "which diamond is the cursor in" needs care at the diamond edges, where a plain
+`floor` picks the wrong tile ~half the time along the seams. Two robust methods:
+
+### Method A — floor + local-remainder test (analytic, exact)
+
+Work in the tile's local diamond space. For screen point `(sx, sy)`:
+
+```
+// fractional tile coords
+fx = ( sx/halfW + sy/halfH ) / 2
+fy = ( sy/halfH − sx/halfW ) / 2
+
+// candidate integer tile
+tx = floor(fx)
+ty = floor(fy)
+```
+
+Because the inverse transform maps the diamond onto the unit square in `(fx,fy)` space,
+`floor` on both axes lands you in the correct diamond directly — the diamond's four
+edges become the unit-square's four sides. This is the elegance of doing the test *after*
+the inverse transform rather than in raw screen space: **no per-corner edge test is
+needed.** The remainders `fx − tx` and `fy − ty` further tell you *where inside* the
+diamond the cursor sits (useful for sub-tile snapping: `< 0.5 / ≥ 0.5` quadrants).
+
+> This is why the inverse transform is written as the full linear solve and not an
+> ad-hoc "divide screenX by tileW" — only the correct `/2` averaging step makes `floor`
+> land in the right diamond.
+
+### Method B — color / ID pick map (robust, O(1), art-tolerant)
+
+For irregularly shaped or overlapping tiles where the math test is ambiguous, render an
+off-screen buffer where each tile is filled with a unique color encoding its `(x,y)`
+(or a monotonic tile ID). Read back the single pixel under the cursor and decode. This
+sidesteps all edge math and handles non-diamond footprints, at the cost of a second
+render target. Standard technique for editors and complex maps.
+
+**Elevation-aware picking.** If tiles have height, the topmost *visible* surface under
+the cursor may be an elevated tile, not the ground tile the math returns. Resolve by
+casting from the cursor and testing candidate tiles from high `z` down, or use Method B
+against the composited (post-elevation) frame.
+
+---
+
+## 7. Depth sorting doctrine
+
+Isometric rendering is **painter's algorithm**: draw far things first, near things last,
+so near occludes far. The whole game hinges on the sort key.
+
+### The canonical sort key
+
+Draw order key, in priority:
+
+1. **`(x + y)` ascending** — the primary depth. Larger `x+y` is nearer the camera
+   (lower on screen) and draws later (on top). This is the diagonal "wavefront" that
+   sweeps from the back corner to the front corner.
+2. **elevation `z` ascending** — tie-breaker for same-column stacks; the higher object
+   draws after (over) the lower.
+3. **explicit `layer` then `zBias`** — a manual override for authored exceptions
+   (a decal that must sit over its tile, a bridge deck over the river beneath it). Keep
+   `layer` coarse (`ground < props < overlay`) and `zBias` a small integer nudge.
+
+```
+sortKey = (x + y, z, layer, zBias)
+```
+
+Sort ascending on this tuple; render in that order.
+
+### The nested-loop shortcut (regular grids only)
+
+When every sprite occupies exactly one tile and nothing overlaps tile boundaries, you do
+**not** need to sort at all — a back-to-front nested loop emits tiles in `(x+y)`-ascending
+order for free:
+
+```
+for sum in 0 .. (W-1)+(H-1):          // each diagonal wavefront
+    for x in max(0, sum-(H-1)) .. min(sum, W-1):
+        y = sum - x
+        draw(tile[x][y])
+```
+
+or the simpler double loop that also produces correct order for a full rectangular map:
+
+```
+for y in 0 .. H-1:
+    for x in 0 .. W-1:
+        draw(tile[x][y])            // each tile precedes both its front neighbours
+```
+
+The double loop works by a **local** invariant, not a global one. Its `(x+y)` sequence is
+*not* monotone: a 3×3 map visits sums `[0,1,2, 1,2,3, 2,3,4]`, which drops at every row
+boundary (2→1, 3→2). What makes it a correct painter's order is that every tile is drawn
+*before* both of its front (nearer-camera) neighbours — the tile at `(x,y)` is emitted
+before `(x+1,y)` (later in the same row) and before `(x,y+1)` (the next row), and those
+two are the only tiles a static, non-overlapping single-tile grid can occlude it. Because
+each tile precedes everything that can draw over it, the order is correct even though the
+row-major traversal is *not* a global `(x+y)`-ascending sort. It is the cheapest correct
+order for a static single-tile grid, and only for that case. **The moment you add
+free-moving actors, multi-tile props, or elevation, this neighbour-adjacency guarantee
+no longer holds — fall back to an explicit `(x+y, z, layer, zBias)` sort on the tuple
+above.** (yal.cc, Packt.)
+
+### Stability
+
+Use a **stable** sort so equal-key sprites keep a deterministic order (insertion order,
+or add the entity id as a final tuple element). An unstable sort makes coplanar sprites
+flicker their z-order frame to frame.
+
+---
+
+## 8. The anchor-at-feet rule (and why center anchors break sorting)
+
+**Rule: the sprite's anchor (its `(x,y)` reference point and its screen blit origin) is
+the bottom of its ground contact — its visual feet — never its center.**
+
+This is not a stylistic choice; it is what makes the depth sort correct. The sort key
+uses the tile the object *stands on*. Depth is determined by where an object touches the
+ground, because that is what decides whether it is in front of or behind another object.
+
+**Why center anchors break it.** Consider a tall lamppost and a short crate, both drawn
+with center anchors. The lamppost's center is high on screen (its bulk is above the
+ground), so a center-based `screenY` sort places it *behind* the crate even when the
+lamppost stands on a nearer tile. Result: the crate incorrectly draws over the base of
+the lamppost, or the character walks and their head-height center makes them pop in front
+of / behind objects at the wrong moment. Anchoring at the feet ties the sort to ground
+contact, which is the only surface that determines occlusion.
+
+**Consequences to encode in the tile-spec** (see [`tile-spec.md`](tile-spec.md)):
+
+- The anchor is a named point in the sprite (e.g. `anchor = {x: halfW, y: spriteHeight −
+  footOffset}`), pinned per asset, consistent across a set.
+- Tall sprites (walls, trees) extend *upward* from the anchor into transparent margin;
+  the transparent pixels above the feet do not affect the anchor.
+- When you blit, subtract the anchor from the computed screen position:
+  `blitX = screenX − anchor.x; blitY = screenY − anchor.y`.
+- A "tile" and a "character" share the same anchor discipline so they sort against each
+  other correctly.
+
+If a sprite genuinely has no single ground-contact point (a floating platform), give it
+an explicit `layer`/`zBias` and treat its *logical* footprint tile as the anchor
+(§9–§10).
+
+---
+
+## 9. Multi-tile and oversized sprites — AABB in iso space
+
+A single `(x+y)` scalar sorts *point* objects correctly. It fails for objects that cover
+**more than one tile** (a 2×2 house, a 3×1 bridge, a long train car), because such an
+object has no single `(x+y)` — it spans a range, and it can be simultaneously in front of
+one neighbour and behind another.
+
+**Doctrine: sort large sprites by their axis-aligned bounding box (AABB) in iso/tile
+space, comparing overlaps, not by a single point.** (jwopitz "Absolute Isometric Depth
+Sorting"; mazebert forum.)
+
+Two workable strategies:
+
+### Strategy 1 — decompose into per-tile cells
+
+Treat a `W×H`-footprint object as its `W·H` component tiles for sorting purposes; draw
+the whole sprite when its **frontmost** cell (max `x+y`, i.e. the ground cell nearest the
+camera) comes up in the sort. This keeps the single-key sort but pins the object's draw
+slot to its nearest ground contact. Cheap and correct for convex, axis-aligned
+footprints — the common case (buildings, crates, floors).
+
+### Strategy 2 — topological (pairwise) sort with iso-AABB overlap
+
+When footprints interleave (an L-shaped wall partly in front of and partly behind a
+column), a total order on a scalar key does not exist. Build a directed graph: for each
+pair of sprites whose iso-space AABBs overlap in screen projection, add an edge "A must
+draw before B" using the standard behind/in-front test:
+
+```
+A is behind B  if   A.maxTileX ≤ B.minTileX
+                or  A.maxTileY ≤ B.minTileY
+                or  A.maxZ     ≤ B.minZ
+```
+
+(each condition means A is entirely on the far side of B along one axis). Then
+**topological-sort** the graph to get a valid draw order. This is the general,
+always-correct method (used by Unity/Unreal iso plugins and Tiled-based renderers) and
+degrades to the scalar sort when no footprints overlap. It is O(n²) in the pair test —
+restrict it to sprites whose screen AABBs actually intersect (broad-phase first), or
+cluster by grid region, to keep it cheap.
+
+**Choosing:** use Strategy 1 by default (nearly all game props are convex axis-aligned
+footprints); reach for Strategy 2 only where sprites genuinely interlock and Strategy 1
+shows visible z-errors.
+
+---
+
+## 10. Moving platforms and multi-floor
+
+Moving and stacked-floor elements are where naive `(x+y)` sorting visibly fails, because
+a moving actor's `z`/floor changes its correct draw slot mid-motion. (Envato Tuts+
+"Isometric Depth Sorting for Moving Platforms" is the definitive treatment.)
+
+- **Moving actors on a moving platform.** Sort the platform and its rider together as a
+  group, or re-parent the rider's logical tile to the platform's tile each frame so the
+  shared sort key moves with them. A rider sorted independently against the world will
+  pop through the platform edge as it moves.
+- **Multi-floor / stacked buildings.** Add the floor index into the elevation term:
+  `z = floorIndex · floorHeightSteps + localZ`. The `z` tie-breaker (§7) then keeps upper
+  floors over lower ones. For a cutaway building (walls removed to see inside), authored
+  `layer`/`zBias` overrides handle the "wall is in front but must be drawn transparent or
+  omitted" cases — a rendering decision, not a sort decision (see cutaway conventions in
+  [`style-guide.md`](style-guide.md)).
+- **Bridges / overpasses.** The classic ambiguity: something can pass *over* one tile and
+  *under* another. Model the bridge deck as an elevated multi-tile sprite (§9) with its
+  own `z`; actors on the deck inherit the deck's `z`, actors beneath keep `z=0`. The
+  standard sort then resolves both without special cases.
+- **Engine reality.** In Godot 4, `Y-Sort` on a `TileMapLayer`/`Node2D` does the `(x+y)`
+  ordering automatically off the node's `y` position (anchor at feet still required); in
+  Unity it's `sortingOrder`. See [`engine-integration.md`](engine-integration.md) — this
+  file is the math those features implement, for when you render manually or need to
+  understand/override the engine's choice.
+
+---
+
+## 11. Performance thresholds
+
+Small maps can brute-force everything. Past a size threshold, three techniques become
+**non-negotiable**:
+
+| Threshold | Technique | Why |
+|---|---|---|
+| **> ~50×50 tiles** | **Viewport culling** | Never transform/sort/draw tiles outside the visible frustum. Compute the visible tile-coordinate window from the camera's screen rect via the inverse transform (§3) on the four viewport corners, then iterate only that `(x,y)` range. Turns O(map) per frame into O(screen). |
+| **> ~50×50 tiles** | **Chunking** | Partition the map into fixed chunks (e.g. 16×16 tiles). Cull, sort, and (optionally) pre-render each chunk to its own canvas/texture; redraw a chunk only when its contents change. Static ground chunks then blit as a single image. |
+| **Many small sprites** | **Batching / texture atlas** | Every distinct source image is a draw-call/state-change; thousands of individual tile images tank the framerate. Pack all tiles into one **texture atlas** (a single sheet + a `{name → x,y,w,h}` map — see `scripts/sheet-pack.py`) so the whole visible set draws from one texture with minimal state changes. This is the single biggest win for tile-heavy scenes. |
+
+**Atlas rationale in one line:** the GPU is fast at drawing many quads from *one*
+texture and slow at *switching* textures — an atlas converts N texture binds into 1.
+Pack with [`scripts/sheet-pack.py`](../scripts/sheet-pack.py); it emits the atlas PNG +
+JSON the renderer indexes by frame name.
+
+**Depth-sort cost at scale.** Sorting every sprite every frame is O(n log n); at 50×50+
+that's wasteful when most tiles don't move. Sort only the *dynamic* layer (actors,
+moving props) against a pre-sorted static ground, or maintain the sorted order
+incrementally (only re-insert sprites that moved this frame). Combined with culling, you
+sort dozens of movers, not thousands of tiles.
+
+---
+
+## 12. Reference snippets (pseudocode, JS, Python)
+
+All three implementations below produce **identical** results and agree with the
+canonical transforms in §2–§3 and with `scripts/iso-math.py` (`to-screen` / `to-tile`).
+Copy the one matching your stack.
+
+### Pseudocode (language-agnostic core)
+
+```
+halfW = tileW / 2
+halfH = tileH / 2
+
+function tileToScreen(x, y, z):
+    screenX = (x - y) * halfW
+    screenY = (x + y) * halfH - z * unitZ    // z: up is -y (y-down screen)
+    return (screenX, screenY)
+
+function screenToTile(screenX, screenY):     // ground plane (z = 0)
+    x = (screenX / halfW + screenY / halfH) / 2
+    y = (screenY / halfH - screenX / halfW) / 2
+    return (x, y)                             // fractional; floor for the diamond
+
+function pickTile(screenX, screenY):
+    (fx, fy) = screenToTile(screenX, screenY)
+    return (floor(fx), floor(fy))            // floor lands in the correct diamond (§6)
+
+function depthKey(sprite):                    // ascending sort; stable
+    return (sprite.x + sprite.y, sprite.z, sprite.layer, sprite.zBias)
+```
+
+### JavaScript
+
+```javascript
+// Isometric coordinate + depth utilities.
+// Parametrized by tile pixel dimensions; 2:1 dimetric uses tileW = 2*tileH.
+// Screen space is y-down (canvas/DOM). Agrees with scripts/iso-math.py.
+
+function makeIso(tileW, tileH, unitZ = tileH / 2) {
+  const halfW = tileW / 2;
+  const halfH = tileH / 2;
+
+  return {
+    // tile (x,y,z) -> screen pixels; anchor subtraction is the caller's job (§8)
+    tileToScreen(x, y, z = 0) {
+      return {
+        x: (x - y) * halfW,
+        y: (x + y) * halfH - z * unitZ, // up is -y in y-down space
+      };
+    },
+
+    // screen pixels -> fractional tile on the ground plane (z = 0)
+    screenToTile(sx, sy) {
+      return {
+        x: (sx / halfW + sy / halfH) / 2,
+        y: (sy / halfH - sx / halfW) / 2,
+      };
+    },
+
+    // screen pixels -> integer tile under the point (within-diamond, §6 Method A)
+    pickTile(sx, sy) {
+      const t = this.screenToTile(sx, sy);
+      return { x: Math.floor(t.x), y: Math.floor(t.y) };
+    },
+  };
+}
+
+// Depth sort: (x+y) asc, then z, then layer, then zBias. Stable via id fallback.
+function sortByDepth(sprites) {
+  return sprites
+    .map((s, i) => [s, i])
+    .sort((a, b) => {
+      const [A, ia] = a, [B, ib] = b;
+      return (A.x + A.y) - (B.x + B.y)
+          || (A.z || 0)  - (B.z || 0)
+          || (A.layer || 0) - (B.layer || 0)
+          || (A.zBias || 0) - (B.zBias || 0)
+          || ia - ib;              // stable tie-break
+    })
+    .map(([s]) => s);
+}
+
+// --- round-trip check (matches §4 table) ---
+// const iso = makeIso(64, 32);
+// iso.tileToScreen(3, 2);        // { x: 32, y: 80 }
+// iso.screenToTile(32, 80);      // { x: 3, y: 2 }
+```
+
+### Python
+
+```python
+# Isometric coordinate + depth utilities (pure stdlib).
+# Parametrized by tile pixel dimensions; 2:1 dimetric uses tile_w = 2*tile_h.
+# Screen space is y-down. Agrees with scripts/iso-math.py to-screen / to-tile.
+import math
+
+
+def tile_to_screen(x, y, tile_w, tile_h, z=0, unit_z=None):
+    """Tile (x, y, z) -> (screen_x, screen_y) in pixels. Up is -y (y-down)."""
+    half_w, half_h = tile_w / 2, tile_h / 2
+    if unit_z is None:
+        unit_z = half_h
+    screen_x = (x - y) * half_w
+    screen_y = (x + y) * half_h - z * unit_z
+    return screen_x, screen_y
+
+
+def screen_to_tile(screen_x, screen_y, tile_w, tile_h):
+    """Screen pixels -> fractional tile on the ground plane (z = 0)."""
+    half_w, half_h = tile_w / 2, tile_h / 2
+    x = (screen_x / half_w + screen_y / half_h) / 2
+    y = (screen_y / half_h - screen_x / half_w) / 2
+    return x, y
+
+
+def pick_tile(screen_x, screen_y, tile_w, tile_h):
+    """Screen pixels -> integer tile under the point (within-diamond, §6)."""
+    fx, fy = screen_to_tile(screen_x, screen_y, tile_w, tile_h)
+    return math.floor(fx), math.floor(fy)
+
+
+def depth_key(sprite):
+    """Ascending sort key: (x+y, z, layer, zBias). Use with a stable sort."""
+    return (
+        sprite["x"] + sprite["y"],
+        sprite.get("z", 0),
+        sprite.get("layer", 0),
+        sprite.get("zBias", 0),
+    )
+
+
+# sprites.sort(key=depth_key)  # list.sort is stable -> coplanar order preserved
+
+# --- round-trip check (matches §4 table) ---
+# tile_to_screen(3, 2, 64, 32)   -> (32.0, 80.0)
+# screen_to_tile(32, 80, 64, 32) -> (3.0, 2.0)
+```
+
+Python's `list.sort` / `sorted` are guaranteed stable, so `depth_key` needs no explicit
+id tie-break; the JS `Array.prototype.sort` is stable in modern engines (ES2019+) but the
+snippet adds the index tie-break belt-and-braces so coplanar sprites never flicker.
+
+---
+
+## 13. Sources
+
+- **yal.cc — "Understanding isometric grids"** — the cleanest short treatment of the
+  grid math and the nested-loop draw order. <https://yal.cc/understanding-isometric-grids/>
+- **Pikuma — "Isometric Projection in Game Development"** (Gustavo Pezzi) — why 2:1 was
+  chosen (integer steps, bit-shift `/2`) and the cart↔iso transform.
+  <https://pikuma.com/blog/isometric-projection-in-games>
+- **gamedevfaqs.com — "Converting Isometric Tile Coordinates To Screen Coordinates"** —
+  `iso_to_screen(x,y,tw,th) = ((x−y)·tw/2, (x+y)·th/2)`, layered draw order, y-sort.
+- **Packt — "Going Isometric"** — the classic `x_iso = x_cart − y_cart; y_iso =
+  (x_cart + y_cart)/2` and its inverse, with an IsoHelper class.
+- **Envato Tuts+ — "Isometric Depth Sorting for Moving Platforms"** — the definitive
+  treatment of depth sorting with moving / multi-floor elements (Unity `sortingOrder`).
+- **jwopitz — "Absolute Isometric Depth Sorting"** and **mazebert forum — "Isometric
+  depth sorting"** — AABB-in-iso-space approaches for stacked / large sprites.
+- **Kari Vierimaa — "Demystifying Isometric Projection in 2D Games (with Python!)"**
+  (Medium) — Python transforms, good on the dimetric reality.
+- Cross-references within this skill: [`projection-math.md`](projection-math.md)
+  (angles, plane matrices, the projection decision), [`tile-spec.md`](tile-spec.md)
+  (anchor / `unitZ` / footprint discipline), [`engine-integration.md`](engine-integration.md)
+  (Godot Y-Sort, Unity `sortingOrder`), [`threejs-orthographic.md`](threejs-orthographic.md)
+  (y-up world space, `scripts/sheet-pack.py` atlas consumption),
+  [`style-guide.md`](style-guide.md) (cutaway conventions), and `scripts/iso-math.py`
+  (the `to-screen` / `to-tile` CLI these snippets mirror).
+
+> **Terminology reminder for citing files:** this doc's transforms are dimensionally
+> identical for true-iso and 2:1 dimetric grids — only the `tileW:tileH` ratio differs
+> (0.57735 vs 0.5). The *projection* choice is made first, in
+> [`projection-math.md`](projection-math.md); the math here is downstream of it.

+ 365 - 0
skills/isometric-ops/references/css-isometric.md

@@ -0,0 +1,365 @@
+# CSS isometric: 3D transforms and 2D affine tricks
+
+Two unrelated techniques both get called "CSS isometric" and this file covers both,
+clearly separated:
+
+1. **The 3D route** — real `transform-style: preserve-3d` with `rotateX`/`rotateY`/
+   `translateZ`, composited by the browser's own 3D transform pipeline. Gives you an
+   actual rotated cube in space; individual faces can still have their own hover/light
+   states, box-shadows, and nested DOM content.
+2. **The 2D affine route** — no 3D context at all. `rotate() skewX()/skewY() scaleY()`
+   applied to a flat, 2D element fakes the same visual result using pure matrix math.
+   Cheaper, has none of the 3D-context gotchas (no `perspective`, no z-fighting), but
+   every face needs a *different* 2D recipe and there's no real depth to hang shadows
+   or lighting on.
+
+Per the projection decision (see `references/projection-math.md`): both routes below
+implement **true isometric projection** (30°/120°, 81.65% axonometric foreshortening
+for the 3D route; the SSR-derived 86.602% family for the 2D route). If you actually
+need 2:1 dimetric ("game isometric" — commonly called isometric in games) — e.g. to
+match pixel-art tile assets 1:1 — swap in the 2:1 dimetric angles from
+`references/projection-math.md` (`rotateX(60deg)` derivatives, `2:1` skew ratios) and
+say so explicitly; do not call a 2:1 CSS grid "isometric" unqualified.
+
+## Table of contents
+- [The 3D route: preserve-3d and the true-iso stack](#the-3d-route-preserve-3d-and-the-true-iso-stack)
+- [Per-face cube composition](#per-face-cube-composition)
+- [Hover lifts and interaction](#hover-lifts-and-interaction)
+- [Codrops-style scrollable iso grids](#codrops-style-scrollable-iso-grids)
+- [The 2D affine route for cards and tiles](#the-2d-affine-route-for-cards-and-tiles)
+- [Deriving which recipe yields which plane](#deriving-which-recipe-yields-which-plane)
+- [When CSS beats canvas/WebGL](#when-css-beats-canvaswebgl)
+- [Gotchas](#gotchas)
+
+## The 3D route: preserve-3d and the true-iso stack
+
+The exact true-isometric camera transform, derived and ground-truth-checked in
+`references/projection-math.md`:
+
+```css
+.iso-true {
+  transform: rotateX(54.7356deg) rotateZ(-45deg) scale3d(1.22474, 1.22474, 1.22474);
+}
+```
+
+- `54.7356deg = atan(√2) = 90° − 35.264°` — tilts the view down onto the cube's
+  space-diagonal vertex, the same angle a physical isometric camera would use.
+- `-45deg` — spins the cube so two vertical edges disappear behind the front corner,
+  leaving exactly three faces visible (top, left, right).
+- `scale3d(1.22474, …) = √(3/2) = 1/cos(35.264°)` — **undoes the true-projection
+  foreshortening.** A raw isometric camera view flattens depth to 81.65% of its
+  original size (`cos(35.264°)`); the `1.22474` scale-up brings 1:1 CSS pixel/unit
+  edges back to their nominal size so a 100×100px element still measures 100px along
+  each visible edge. Drop the `scale3d` term if you deliberately want the
+  foreshortened "camera photograph" look instead of the "isometric drawing" look —
+  see the drawing-vs-projection distinction in `references/projection-math.md`.
+
+**`transform-style: preserve-3d` is mandatory on every ancestor of the faces you want
+composited in 3D** — not just the rotated container, but every intermediate wrapper
+between it and the face elements. The moment one ancestor omits it (or the browser
+defaults it back to `flat`, which is the default value), that ancestor flattens its
+children into a 2D plane and the whole cube collapses. This single missing property
+is the most common reason a "3D CSS isometric" demo renders as a flat rectangle —
+verified against Envato Tuts+ "How to Create an Isometric Layout With CSS 3D
+Transforms" and the CodePen family deriving this exact stack (search
+`scootman "CSS 3D transforms: true isometric"` on CodePen for a live reference).
+
+```css
+.scene   { perspective: 1200px; }          /* optional: adds a vanishing point, off by default = orthographic-like */
+.iso-true,
+.iso-true * { transform-style: preserve-3d; }
+```
+
+Note: true isometric projection is itself **orthographic** (parallel projection, no
+vanishing point). Setting a CSS `perspective` on an ancestor introduces a *camera*
+perspective that competes with the isometric look — for a faithful true-iso render,
+omit `perspective` entirely (or set it very large, effectively flattening its effect)
+so parallel edges in 3D stay parallel on screen.
+
+## Per-face cube composition
+
+Build a cube (or any box: a tile, a card, a UI panel) from six independently
+stylable faces, each a normal DOM element you can put content, gradients, or
+`box-shadow` on:
+
+```html
+<div class="cube">
+  <div class="face top"></div>
+  <div class="face front"></div>
+  <div class="face right"></div>
+</div>
+```
+
+```css
+.cube {
+  position: relative;
+  width: 200px; height: 200px;
+  transform-style: preserve-3d;
+  transform: rotateX(54.7356deg) rotateZ(-45deg) scale3d(1.22474, 1.22474, 1.22474);
+}
+.face {
+  position: absolute;
+  width: 200px; height: 200px;
+  backface-visibility: hidden;   /* skip rendering faces that end up pointing away */
+}
+/* Top face: rotate flat into the XZ-plane, then push up by half the cube's depth */
+.top   { transform: rotateX(90deg) translateZ(100px); }
+/* Front (left-visible) face: sits at its native orientation, pushed toward camera */
+.front { transform: translateZ(100px); }
+/* Right face: rotate around Y into the YZ-plane, pushed out by half the width */
+.right { transform: rotateY(90deg) translateZ(100px); }
+```
+
+Only the three faces that end up facing the (post-`rotateX(54.7356) rotateZ(-45)`)
+camera are visible — the opposite three faces are either behind the visible ones or
+back-face-culled by `backface-visibility: hidden`. This is exactly the "all three
+cube faces equal" true-isometric property from the canonical rig table in
+`references/projection-math.md` and `references/blender-prerender.md`: because the
+tilt is 54.736° (not 60°), the top/front/right faces render as three congruent
+rhombi, not a mix of shapes.
+
+For a **2:1 dimetric** cube instead (matching pixel-art game tiles), swap the parent
+rotation to `rotateX(60deg) rotateZ(-45deg)` and drop the `scale3d` foreshortening
+correction (dimetric game art is normally authored already at drawing scale) — but
+say "2:1 dimetric" in your CSS class names and comments, not "isometric", per the
+terminology rule.
+
+## Hover lifts and interaction
+
+A `translateZ` bump on hover reads as "lifting the cube toward the camera" because
+the parent's `rotateX/rotateZ` already establishes the 3D basis — no extra math
+needed, just animate along the face's own local Z:
+
+```css
+.cube { transition: transform 200ms ease-out; }
+.cube:hover { transform: rotateX(54.7356deg) rotateZ(-45deg)
+                          scale3d(1.22474, 1.22474, 1.22474)
+                          translateZ(20px); }
+```
+
+Because the `translateZ(20px)` is applied *inside* the already-rotated coordinate
+system (it's the last term in the same `transform` value, composed after the
+rotation), it moves the cube up the isometric camera's viewing axis rather than along
+the screen's vertical — the lift reads as "toward the viewer," which is the effect
+almost every isometric card/tile hover-state design wants (dashboards, game-tile
+pickers, portfolio grids).
+
+## Codrops-style scrollable iso grids
+
+The scrollable "isometric floor" effect (Codrops "Isometric and 3D Grids", built with
+Masonry-style layout underneath) is the same `.iso-true` transform applied to a large
+flat `<div>` container whose *children* are normal in-flow 2D grid items (CSS Grid or
+Masonry). Because `preserve-3d` propagates the parent's rotation to everything below
+it, positioning the grid children is still ordinary 2D layout — only the outermost
+container carries the isometric transform:
+
+```css
+.iso-floor {
+  transform-style: preserve-3d;
+  transform: rotateX(54.7356deg) rotateZ(-45deg) scale3d(1.22474, 1.22474, 1.22474);
+  display: grid;
+  grid-template-columns: repeat(auto-fill, 200px);
+  gap: 20px;
+}
+```
+
+Scrolling the page (or a `overflow: auto` ancestor) still works normally because the
+3D transform doesn't change the element's layout box for scroll purposes — only its
+rendered appearance. This is the pattern behind most "isometric portfolio" and
+"isometric dashboard" demos; see also `references/svg-vector-generation.md` for the
+SVG-based equivalent used by JointJS-style diagram editors.
+
+## The 2D affine route for cards and tiles
+
+For a single flat card, icon tile, or UI panel where you don't need a real 3D
+context (no per-face content, no `perspective`, no hover-into-depth), a pure 2D
+`rotate()`/`skew()`/`scale()` composition is cheaper and simpler. This is the
+`.iso { transform: rotate(-30deg) skewX(30deg) scaleY(0.866); }` family that shows up
+across "CSS isometric card" snippets, and it is mathematically the same **SSR
+(Scale → Shear → Rotate)** technique vector tools use — see
+`references/projection-math.md` for the exact Illustrator SSR percentages this
+mirrors.
+
+**Top-face recipe (verified, exact to true isometric):**
+
+```css
+.iso-top {
+  transform: rotate(-30deg) skewX(30deg) scaleY(0.86603);
+  transform-origin: center;
+}
+```
+
+Mirror it for the opposite-handed top diamond:
+
+```css
+.iso-top-mirror {
+  transform: rotate(30deg) skewX(-30deg) scaleY(0.86603);
+}
+```
+
+**Left/right face recipe (verified, exact — the `skewY` family):**
+
+```css
+.iso-left  { transform: skewY(30deg); }
+.iso-right { transform: skewY(-30deg); }
+```
+
+Composed together (a shared width/height rectangle) these three recipes produce a
+correctly-proportioned isometric cube face-set from three separate flat 2D elements —
+no `preserve-3d`, no 3D context, no `perspective` needed. This is the "disciplined 2D
+transformation rather than full 3D rendering" pattern that a large share of
+"isometric" browser work actually is (an observation echoed across the CSS-only iso
+tutorial literature — Envato Tuts+, CSS-Tricks, FreeFrontend).
+
+## Deriving which recipe yields which plane
+
+Don't take any of the above on faith — verify it yourself by tracking where the unit
+basis vectors land. CSS composes a `transform` list **left to right, each subsequent
+function operating in the coordinate system already established by the previous
+ones** — equivalent to matrix-multiplying in the *written* order and applying the
+product to a column vector: `M = A · B · C`, point `p' = M·p`.
+
+**Top-face check.** For `transform: rotate(-30deg) skewX(30deg) scaleY(0.86603)`,
+composing `M = R(−30°) · Skew_x(30°) · Scale(1, 0.86603)` and applying it to the unit
+basis vectors (screen coordinates, **y-down**, the CSS/SVG convention):
+
+```
+M · (1, 0)ᵀ = (0.8660, −0.5000)
+M · (0, 1)ᵀ = (0.8660,  0.5000)
+```
+
+Both basis vectors land at the same 0.8660 horizontal run with a ±0.5 vertical rise —
+exactly `cos(30°) = 0.86603` horizontal and `sin(30°) = 0.5` vertical, the two edges
+of a diamond whose long axis is horizontal: **this is the top-plane rhombus**, the
+same 2:1-looking (but exactly √3:1, true-iso) diamond you'd get slicing the top face
+off the 3D cube in the previous section. If you re-derive this and get anything other
+than `(±0.866, ∓0.5)`-family vectors, the recipe is wrong for true isometric — a
+common broken variant is scaling by the visually-similar-but-wrong `0.864` (an
+imprecise rounding that has propagated through several popular blog snippets) instead
+of the exact `0.86603 = cos(30°)`; the axes then land a fraction of a degree off,
+invisible at small sizes but compounding into visible drift on large grids or tiled
+assets.
+
+**Left/right-face check.** For `transform: skewY(30deg)`:
+
+```
+M · (1, 0)ᵀ = (1, 0.5774)
+M · (0, 1)ᵀ = (0, 1)
+```
+
+The vertical edge (`(0,1)`) stays exactly vertical — a left-face parallelogram keeps
+its true-vertical edges vertical, which is what makes it read as the "side" of a cube
+rather than another sloped diamond. The horizontal edge tilts by `atan(0.5774) = 30°`
+— matching the top face's 30° edge exactly, so the two faces share a seamless edge
+when butted together. `0.5774 = tan(30°) = 0.57735`, the same constant family as the
+Figma-hack height scale and the "top-plane circle → ellipse" ratio in
+`references/projection-math.md` — not a coincidence; it's the same 30°-shear
+identity showing up in every true-iso 2D-affine derivation. `skewY(-30deg)` mirrors
+this for the right face (`M · (1,0)ᵀ = (1, −0.5774)`).
+
+Run the numbers yourself before shipping a new recipe variant — any 2D affine
+isometric transform must satisfy: (a) the shared edge between adjacent faces has
+matching slope in both faces' bases, and (b) the diamond half-angle is exactly 30°
+(true iso) or `atan(0.5) = 26.565°` (2:1 dimetric) — never eyeballed values like the
+`51deg`/`43deg` "pragmatic dimetric" numbers seen in some minimal snippets (those
+approximate the *look* of isometric but do not satisfy either projection's exact
+angle and will not tile edge-to-edge with true-projection or 2:1-dimetric assets).
+
+## When CSS beats canvas/WebGL
+
+Prefer the CSS routes (either 3D or 2D-affine) over `<canvas>`/WebGL for:
+
+- **DOM semantics** — each face/tile is a real element; screen readers, `aria-*`
+  attributes, and semantic HTML (`<button>`, `<a>`, headings) work unmodified inside
+  an isometric-transformed container.
+- **Text selection and copy/paste** — text inside a `preserve-3d` face is still
+  selectable, searchable (`Ctrl+F`), and copyable; canvas-rendered text is not.
+- **SEO** — content is crawlable HTML, not pixels on a canvas or an opaque WebGL
+  draw call.
+- **Native interaction** — `:hover`, `:focus-visible`, `<input>` elements, form
+  controls, and CSS `:has()`/media-query responsiveness all work without
+  reimplementing hit-testing.
+- **Low element counts** — dozens to a few hundred faces/tiles. CSS 3D transforms are
+  GPU-composited per element, which is cheap at DOM scale but does not scale to
+  thousands of independently-transformed elements the way an instanced WebGL draw
+  call does.
+
+Prefer canvas/WebGL (see `genart-ops` for general three.js scaffolding,
+`references/threejs-orthographic.md` for the iso-specific delta) once you need:
+hundreds+ of independently-positioned tiles/sprites, per-pixel effects (lighting,
+shadows, particle systems), or a real camera/scene graph for a game rather than a
+static or lightly-interactive layout.
+
+## Gotchas
+
+- **`transform-origin` defaults to `50% 50%` (center)** — fine for a single rotated
+  card, wrong for compositing cube faces around a shared pivot. When building a
+  multi-face cube from the 3D route, set an explicit `transform-origin` (or rely on
+  the `translateZ` half-extent trick shown above, which sidesteps the issue by
+  keeping every face's own origin at its own center and translating outward instead
+  of rotating around a shared point).
+- **Blurry text after transforms.** Both `skew()` and non-90°-multiple `rotate()`
+  put text on a sub-pixel grid, and browsers do not always sub-pixel-hint
+  transformed text as crisply as untransformed text. Mitigations: increase source
+  font-size and scale down (renders at a higher effective resolution before the
+  blur-inducing transform), prefer `font-smooth`/`-webkit-font-smoothing:
+  antialiased`, or — for the 2D-affine route — apply the transform to a wrapper
+  `<div>` and keep a small counter-rotated/counter-skewed inner element for text
+  that must stay perfectly legible (accepting that it will look "flat" against the
+  tilted background).
+- **Z-fighting and stacking-context surprises.** `preserve-3d` creates a genuine 3D
+  rendering context; elements at the *same* Z-depth (e.g. two faces both left at
+  `translateZ(0)`) render in DOM/paint order, not a deterministic depth-sort, so
+  overlapping same-depth elements can flicker or overlap unpredictably on
+  z-fighting-prone edges. Give every face a distinct `translateZ` (even a 1px
+  nudge) to force a stable order. Separately, `preserve-3d` **breaks** the moment
+  any ancestor introduces `overflow`, `filter`, `opacity < 1`, `will-change`,
+  `mask`, `clip-path`, `contain`, or `perspective` on itself (all of these force
+  their own new stacking context and flatten `preserve-3d` beneath them per the CSS
+  Transforms spec) — a `filter: drop-shadow(...)` added for a shadow effect on a
+  cube's wrapper is a common way to silently flatten the whole cube back to 2D.
+- **Sub-pixel seams between adjacent faces/tiles.** Because `skewY(30deg)` and its
+  siblings produce transcendental (non-integer) pixel offsets, two adjacent tiles
+  laid edge-to-edge in the 2D-affine route can show a hairline gap or overlap at
+  certain zoom levels due to sub-pixel rounding differences between the two
+  elements' independently-computed transform matrices. Mitigations: render tiles
+  from a single shared parent transform rather than per-tile transforms where
+  possible (fewer independent roundings), nudge overlapping edges by ~0.5–1px via
+  `outline`/negative margin as a defeat-device, or move to the 3D `preserve-3d`
+  route (a single parent transform, no per-face seam math) once seams become
+  visible at your target zoom/DPI.
+
+## Related references
+
+- `references/projection-math.md` — the derivations behind every constant used here
+  (30°, 35.264°, 54.7356°, 86.602%, 57.735%, 1.22474), the Illustrator SSR method
+  this 2D route mirrors, and the full projection-decision table.
+- `references/svg-vector-generation.md` — the SVG `matrix()` equivalent of these same
+  plane transforms, for when you need a `<path>`-based asset instead of a
+  transformed DOM element.
+- `references/coordinates-depth.md` — tile↔screen math and draw-order/y-sort doctrine
+  for when CSS tiles become an actual game-like grid rather than a static layout.
+- `skills/genart-ops/SKILL.md` — general three.js/creative-coding scaffolding
+  (canvas/WebGL route, out of scope here).
+
+## Sources
+
+- Envato Tuts+, "How to Create an Isometric Layout With CSS 3D Transforms" —
+  `transform-style: preserve-3d` mandatory-on-children finding.
+- CSS-Tricks, `rotateZ()` almanac entry — `rotateX(60deg) rotateZ(-45deg)` dimetric
+  variant.
+- CodePen, scootman `QWvYoyY`, "CSS 3D transforms: true isometric" — derivation of
+  `rotateX(54.736deg) rotateZ(-45deg) scale3D(1.2247)`.
+- Codrops, "Isometric and 3D Grids" — scrollable isometric grid pattern.
+- FreeFrontend, "8 CSS Isometric Designs" — snippet gallery and the DOM-semantics/
+  accessibility argument for CSS over canvas/WebGL.
+- 30 Seconds of Code, "Isometric card" — the minimal `rotateX(51deg) rotateZ(43deg)`
+  pragmatic-dimetric hover-card recipe (cited here as the "eyeballed angle" anti-
+  pattern, not a recommended exact recipe).
+- SRC-C (`iso-pdf.md`) — the `.iso { transform: rotate(-30deg) skewX(30deg)
+  scaleY(0.866); }` starter snippet and its framing as "disciplined 2D
+  transformation rather than full 3D rendering," alongside `isometric-css`
+  (see `references/svg-vector-generation.md`), Codrops IsometricGrids, and the
+  JointJS SVG guide.
+- Canonical constants table (this skill's build brief) — 30°/35.264°/54.7356°/
+  86.602%/57.735%/1.22474; the CSS true-iso transform line item verbatim.

+ 285 - 0
skills/isometric-ops/references/engine-integration.md

@@ -0,0 +1,285 @@
+# Engine Integration — Godot, Unity, Phaser, PixiJS, and Tiled
+
+How to wire isometric/dimetric tile art into the engines and frameworks that actually
+ship games. This file assumes the **projection decision** is already made (see
+[projection-math.md](projection-math.md)) and the tileset already exists (see
+[tile-spec.md](tile-spec.md), [pixel-art-workflow.md](pixel-art-workflow.md), or
+[blender-prerender.md](blender-prerender.md)). It owns the *integration* delta only —
+camera/tilemap/depth-sort configuration per engine — not asset creation.
+
+> Terminology reminder: the tile grids described here are almost always **2:1 dimetric
+> (commonly called isometric in games)** — see
+> [projection-math.md](projection-math.md#mislabel-table) for the full disambiguation.
+> Everywhere below that says "isometric" in a menu label or API name (Godot's
+> `Tile Shape = Isometric`, Unity's "Isometric Tilemap") is the engine vendor's own
+> (loose) terminology, not a claim that the math is true 30° isometric.
+
+---
+
+## 1. Godot 4 — `TileMapLayer`
+
+Godot 4.3+ uses the `TileMapLayer` node (the pre-4.3 single `TileMap` node with multiple
+layers was deprecated in 4.3 and is scheduled for removal in a future 4.x release —
+verify against the version pinned in your project; if you're still on 4.0–4.2, the same
+tile-set configuration applies to the legacy `TileMap` node's per-layer settings).
+
+### TileSet configuration
+
+1. Create or select a `TileSet` resource on your `TileMapLayer`.
+2. In the TileSet inspector: **Tile Shape → Isometric**.
+3. **Tile Layout → Diamond Down.** (Godot offers `Diamond Down` and `Diamond Right`;
+   `Diamond Down` is the conventional choice — it matches the "top point, then widen
+   left/right, then bottom point" diamond most 2:1 dimetric art assumes. `Diamond Right`
+   rotates the same math 90° and is used for isometric layouts read top-to-bottom in
+   the opposite screen direction.)
+4. **Tile Size** — set to your tile module in pixels, e.g. **32×16** or **64×32** (the
+   canonical 2:1 aspect from [projection-math.md](projection-math.md)). This is the
+   *bounding box* of one diamond tile, not the sprite canvas size — oversized props
+   (a tree, a building) still get authored on a larger canvas and placed via the
+   TileMapLayer's per-cell offset/Y-sort, not by inflating the base tile size.
+5. Godot computes the diamond hit-test and cell↔pixel mapping internally once
+   `Tile Shape = Isometric` is set — you do not hand-write the `screenX/screenY`
+   transform from [coordinates-depth.md](coordinates-depth.md) for placement; that
+   transform is still what you reach for in a custom minimap, a non-Godot picking
+   overlay, or when reasoning about layout before art exists.
+
+### Y-Sort (draw order)
+
+- Enable **Y Sort Enabled** on the `TileMapLayer` (or on the parent `Node2D`/`CanvasItem`
+  that groups your tiles with dynamic sprites — characters, props that move between
+  cells). Godot then reorders draw calls every frame by each node's *effective* Y
+  position (global Y plus any `Y Sort Origin` offset you set per-node), not by tile
+  index.
+- **Anchor at the visual feet, never the sprite center.** This is the same doctrine as
+  [coordinates-depth.md](coordinates-depth.md#anchor-at-feet): if a sprite's origin is
+  its geometric center, a tall object (a tower, a tree) will Y-sort using a point that
+  sits *above* where it visually touches the ground, and it will draw in front of
+  objects that are actually nearer to camera. Set each `Sprite2D`'s offset/pivot so
+  `(0,0)` in local space lands at the bottom-center visual contact point, or set
+  `Y Sort Origin` explicitly to compensate.
+- Y-Sort inside a `TileMapLayer` sorts *between tiles on that layer and other Y-sorted
+  CanvasItems in the same Y-sort group* — it does not reach across separate, non-Y-sorted
+  layers. Ground, props, and overlay layers still need an explicit z-index/layer order
+  for the *layer* stacking (per [tile-spec.md](tile-spec.md)'s `layer` field:
+  `ground | props | overlay`); Y-Sort resolves ordering *within* the dynamic layer.
+
+### Reference
+
+- Godot 4 docs: `https://docs.godotengine.org/en/stable/classes/class_tilemaplayer.html`
+- Y-Sort: `https://docs.godotengine.org/en/stable/classes/class_canvasitem.html#class-canvasitem-property-y-sort-enabled`
+- Stephan Bester, "Isometric tiles for a pixel art game in Godot 4.3" (Medium) — worked
+  TileMapLayer + Diamond Down walkthrough (cited via SRC-A ch.3).
+- Free practice tileset: `depth-strider/practice-iso-tiles` on itch.io, ships a
+  step-by-step Godot 4.3 setup guide (cited via SRC-A ch.3).
+
+---
+
+## 2. Unity — orthographic camera + Tilemap checklist
+
+Unity's Tilemap has isometric support (`Tilemap → Isometric` / `Isometric Z as Y`
+grid types), but the camera and rendering settings around it are the part teams
+consistently get wrong. Checklist, in the order you'll hit the problems:
+
+| # | Setting | Value | Why |
+|---|---|---|---|
+| 1 | Camera → **Projection** | `Orthographic` | No perspective foreshortening; matches the flat-scale iso convention. |
+| 2 | Coordinate plane | Build the map on the **XZ plane**, Y = up | Unity's own constants (`Vector3.up`, physics, lighting) assume Y-up. A 2D-style XY map fights the engine at every turn once you add real 3D lighting/shadows/physics. Convert 2D `(x, y)` tile data to `(x, 0, y)` world positions. |
+| 3 | Camera → **Near Clipping Plane** | large negative, e.g. **−1000** to **−100000** | Orthographic cameras default to a `0.1` near-plane tuned for perspective cameras. Panning an iso camera with a small near-plane clips geometry that's technically "behind" the camera's z but still meant to render (a common symptom: buildings vanish when the camera pans past their origin). |
+| 4 | `Edit → Project Settings → Quality` → **Shadow Projection** | `Close Fit` (not `Stable Fit`) | `Stable Fit` computes shadow cascades from the near/far clip planes. Once #3 pushes the near plane to a large negative number, `Stable Fit` produces wildly oversized/broken cascades. `Close Fit` recomputes tightly around visible geometry each frame instead. |
+| 5 | Shadow cascades | **Disable** (`No Cascades`), and raise **Shadow Distance** to a large value (e.g. `10000`) | Cascades are a perspective-camera optimization (higher detail near camera, coarser far away) that doesn't map cleanly onto an orthographic view; disabling them plus a large shadow distance avoids shadows popping/clipping at cascade boundaries as the camera pans. |
+| 6 | PBR / lighting | Verify the camera's **actual 3D world position**, not just its 2D-looking framing | Unity's PBR pipeline (specular, reflections) computes off the camera's real spatial position. If you built the rig by eyeballing a "looks isometric" angle without confirming the true 3D transform, reflections and specular highlights will look subtly wrong even though the silhouette reads as iso. Confirm in the Scene view that camera position/rotation match your intended rig (see the dual dimetric/true-iso rig table in [blender-prerender.md](blender-prerender.md) — the same two rotations apply to a live 3D Unity camera, not just a Blender render camera). |
+| 7 | Draw order | `SpriteRenderer.sortingOrder`, driven by the same **(x+y) ascending** key from [coordinates-depth.md](coordinates-depth.md#draw-order) | Unity does not auto-sort by depth for 2D sprites; you compute `sortingOrder` (or `sortingLayer` + an order-in-layer) per the y-sort doctrine and assign it every frame an object moves. For moving platforms / multi-floor stacks, this is exactly the AABB-in-iso-space case in coordinates-depth.md, not a single-point sort. |
+
+Kenney's free tilesets (see [asset-sourcing.md](asset-sourcing.md)) ship Unity-ready
+samples alongside Tiled `.tmx` samples, useful as a known-good starting rig to diff
+your own project's settings against.
+
+Source: SRC-B "Setting Up an Orthographic Camera in Unity"; Envato Tuts+
+"Isometric Depth Sorting for Moving Platforms" (SRC-A ch.2, sortingOrder treatment).
+
+---
+
+## 3. Phaser 3
+
+Two paths, pick based on how much of the iso math you want Phaser to own for you:
+
+### 3a. Isometric plugin
+
+A community plugin family exists under the "Phaser 3 isometric/axonometric plugin"
+name — `koreezgames/phaser3-isometric-plugin` is the commonly-cited example — providing
+an `IsoSprite` game object, iso physics (`Arcade`-style AABB in iso space), and
+cart↔iso projection helpers wired into the Phaser API (`this.add.isoSprite(...)`,
+`this.iso.projector`). Its lineage traces to the Phaser-2-era
+`lewster32/phaser-plugin-isometric`, the original `IsoSprite`/isometric-physics plugin
+that the Phaser 3 forks in this space adapted forward. **Verify the plugin's current
+npm/GitHub status before depending on it in a new project** — this is exactly the kind
+of small-community plugin that can go quiet; check last-publish date and open issues,
+not just star count. As of this writing (July 2026), `koreezgames/phaser3-isometric-plugin`
+itself was last pushed 2018-11-07 (per the GitHub API) — nearly eight years stale despite
+sporadic metadata updates since — so treat the whole "Phaser 3 isometric plugin" family
+as dormant/unmaintained rather than active, and expect to patch or fork rather than
+pull `latest`. If you adopt one anyway, pin an exact commit, not a version range.
+
+### 3b. Manual cart↔iso (no plugin)
+
+The lower-risk long-term choice for a production game: keep tiles as ordinary Phaser
+`Image`/`Sprite` objects on a standard (non-isometric) Phaser scene, and do the
+projection yourself using the exact transforms in
+[coordinates-depth.md](coordinates-depth.md):
+
+```js
+// Tile (x, y[, z]) -> screen position, using the canonical 2:1 dimetric transform.
+function tileToScreen(x, y, z = 0, tileW = 64, tileH = 32, elevStep = 16) {
+  return {
+    screenX: (x - y) * (tileW / 2),
+    screenY: (x + y) * (tileH / 2) - z * elevStep,
+  };
+}
+```
+
+Set each sprite's `depth` (Phaser's explicit render-order property, distinct from
+scene z-position) to the `(x + y)` y-sort key each frame an object moves, exactly per
+[coordinates-depth.md](coordinates-depth.md#draw-order):
+
+```js
+sprite.setDepth(tileX + tileY);
+```
+
+Manual picking (screen→tile, for mouse/touch input) uses the inverse transform from the
+same reference, including its within-diamond correction for the ambiguous
+2×2-screen-tile overlap region.
+
+Reference: Generalist Programmer, "Phaser Isometric Game Tutorial (2026)" — covers
+cart→iso transform, tile rendering, depth sorting, mouse picking, and A* movement on
+top of manual placement (cited via SRC-A ch.3).
+
+---
+
+## 4. PixiJS — Traviso.js and manual approaches
+
+PixiJS has no first-party isometric mode; the ecosystem answer is **Traviso.js**, an
+open-source isometric engine built on top of PixiJS (path-finding, tile-based scene
+management, object placement — a fuller "engine" layer than the Phaser plugin above).
+As with the Phaser plugin, check Traviso's current maintenance status before adopting it
+for new work; it is a smaller, single-maintainer-style project historically, not a
+Foundation-backed package.
+
+For anything Traviso doesn't cover, or if you'd rather not add the dependency, PixiJS is
+low-level enough that the manual approach in §3b (plain sprites + the
+[coordinates-depth.md](coordinates-depth.md) transforms + `sprite.zIndex` with
+`container.sortableChildren = true` for the y-sort key) applies directly — PixiJS's
+`zIndex`/`sortableChildren` mechanism is the direct analogue of Phaser's `setDepth`.
+
+Source: SRC-A ch.2/ch.3 (Traviso.js, 197 GitHub stars at time of source research; PixiJS
+iso game repos under the GitHub `isometric` topic).
+
+---
+
+## 5. Tiled as interchange format
+
+[Tiled](https://www.mapeditor.org/) is the de facto interchange format when a tileset
+or level needs to move between tools/engines rather than live natively in one editor:
+
+- Tiled supports an **Isometric** map orientation (menu name; the same "commonly called
+  isometric" caveat applies — set your map's tile width/height to the 2:1 module, e.g.
+  64×32, to get the dimetric grid) and a **Staggered**/**Hexagonal** family for other
+  grid types, which this skill does not cover.
+- Export path: author the tile layout in Tiled (`.tmx`/`.tsx`, or the JSON map format),
+  then import into Godot (native Tiled-map importer plugins exist in the Asset Library),
+  Unity (Tiled import packages, or hand-roll a `.tmj`→`Tilemap` loader), or a custom web
+  renderer (the JSON format is a straightforward parse — object layers, tile layers, and
+  properties all serialize cleanly).
+- Kenney's free tile packs (see [asset-sourcing.md](asset-sourcing.md)) ship **both**
+  Tiled and engine-native (Unity) samples for the same tileset — a good way to confirm
+  your own Tiled export pipeline against a known-good reference before trusting it on
+  original art.
+- Tiled is the right interchange point specifically when: (a) level design happens in a
+  tool separate from the target engine, (b) the same tileset needs to ship to more than
+  one engine, or (c) you want a human-editable map format under version control that
+  isn't a binary engine scene file.
+
+---
+
+## 6. Importing `sheet-pack.py` atlases — anchor/pivot mapping
+
+[`scripts/sheet-pack.py`](../scripts/sheet-pack.py) packs a directory of tiles into one
+spritesheet PNG plus a JSON atlas. The real schema (see that script's docstring/`--help`
+for the authoritative version — this mirrors the TexturePacker/Phaser "hash" atlas
+family, not a flat rect) is nested per frame:
+
+```json
+{
+  "meta": { "...": "..." },
+  "frames": {
+    "<name>": {
+      "frame":            {"x": 0, "y": 0, "w": 0, "h": 0},
+      "sourceSize":       {"w": 0, "h": 0},
+      "spriteSourceSize": {"x": 0, "y": 0, "w": 0, "h": 0},
+      "sourceW": 0,
+      "sourceH": 0,
+      "trimmed": false,
+      "rotated": false
+    }
+  }
+}
+```
+
+`frame` is the packed rectangle (post-trim, if `--trim` was used). `sourceSize` is the
+original untrimmed image dimensions. `spriteSourceSize` is where the trimmed frame sits
+inside that original canvas — its `x`/`y` is the trim *offset* from the untrimmed
+top-left. `sourceW`/`sourceH` are flat duplicates of `sourceSize.w`/`sourceSize.h`,
+provided for importers that don't want to descend into the nested object — they alone
+cannot recover the trim offset, only `spriteSourceSize` can.
+
+Getting sprites to sit correctly once imported into an engine is entirely an
+**anchor/pivot mapping** problem: the atlas records each frame's trimmed pixel rect, but
+the engine needs to know *where in that rect the tile's logical anchor point is* — which,
+per the [tile-spec.md](tile-spec.md) discipline, is the visual-feet point, not the frame
+center.
+
+General mapping recipe, engine-agnostic:
+
+1. Author every source tile with its anchor at a **known, fixed offset** from the image
+   bottom — e.g. "anchor = bottom-center of the untrimmed canvas" — and record that
+   offset once in the tile spec, not per-tile.
+2. When `--trim` removes transparent margin, recover the anchor position in the
+   *trimmed* frame as:
+   `anchor_in_frame = anchor_in_source − spriteSourceSize.{x,y}`
+   (`spriteSourceSize.x`/`.y` is exactly the trim offset the packer cropped from the
+   untrimmed top-left; `sourceW`/`sourceH` tell you the original canvas size but not
+   which corner was trimmed from, so use `spriteSourceSize` for the offset itself, not
+   the flat `sourceW`/`sourceH` aliases).
+3. Feed that per-frame anchor into the engine's own pivot/origin field:
+   - **Godot**: `Sprite2D.offset` (in local pixels, or normalize and use `centered =
+     false` with an explicit offset).
+   - **Unity**: `Sprite.pivot` (normalized 0–1 within the sprite rect) when slicing the
+     packed PNG in the Sprite Editor — set pivot mode to "Custom" and enter the
+     normalized coordinate.
+   - **Phaser**: `Sprite.setOrigin(x, y)` (normalized 0–1).
+   - **PixiJS**: `Sprite.anchor.set(x, y)` (normalized 0–1).
+4. Verify by placing one test sprite at a known tile coordinate and confirming its feet
+   land exactly on the tile-grid line the engine draws for debug/grid overlays — a
+   half-pixel anchor error is invisible on a single sprite and only shows up as
+   accumulated seam drift once dozens of tiles are placed, so check this once per new
+   tileset before mass-placing.
+
+If every tile in a set shares the same canvas size and anchor convention (the
+[tile-spec.md](tile-spec.md) template enforces this), steps 1–3 reduce to one constant
+offset applied uniformly at import time rather than a per-tile lookup.
+
+---
+
+## Related references
+
+- [projection-math.md](projection-math.md) — the projection decision and mislabel table
+  referenced throughout this file.
+- [coordinates-depth.md](coordinates-depth.md) — the cart↔iso transforms and y-sort
+  doctrine every engine integration ultimately reduces to.
+- [tile-spec.md](tile-spec.md) — the anchor/footprint/naming discipline that makes
+  atlas import (§6) a one-time constant instead of per-tile guesswork.
+- [asset-sourcing.md](asset-sourcing.md) — Kenney and other CC0 packs used above as
+  known-good reference rigs for Unity/Tiled.
+- `scripts/sheet-pack.py` — produces the atlas format consumed in §6.
+- `threejs-ops` skill — for a real-time 3D engine target instead of a 2D
+  tile engine; see [threejs-orthographic.md](threejs-orthographic.md) for the iso-camera
+  delta this skill owns on top of that sibling skill's general three.js scaffolding.

+ 451 - 0
skills/isometric-ops/references/iso-studio.md

@@ -0,0 +1,451 @@
+# iso-studio — the Scene Composer App Manual
+
+**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.
+
+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:
+
+```
+node assets/iso-studio/server.mjs        # then open http://localhost:4323
+PORT=8080 node assets/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).

+ 250 - 0
skills/isometric-ops/references/pixel-art-workflow.md

@@ -0,0 +1,250 @@
+# Pixel-Art Isometric Workflow
+
+Hand-authoring isometric pixel art for game tilesets: the pixel-stepping discipline,
+the Aseprite toolchain, outline/dithering/seam craft, and how to measure curved shapes
+in a projection that has none. This file owns the **pixel-art production workflow**;
+the projection math it depends on lives in
+[`projection-math.md`](projection-math.md) and [`coordinates-depth.md`](coordinates-depth.md) —
+link to those rather than re-deriving angles here.
+
+Source: SRC-A pixel-art section (`compass_artifact_wf-75a0e032-3465-48c7-84ea-e104bae213c2_text_markdown.md`).
+
+---
+
+## 1. Projection discipline: commit to 2:1 dimetric, not true isometric
+
+Pixel-art tilesets are drawn in **2:1 dimetric (commonly called isometric in games)** —
+never true 30° isometric. This is the first decision (see
+[`projection-math.md` §1, the projection decision table](projection-math.md)) and it is
+non-negotiable for pixel art specifically, for a reason true-iso vector work doesn't
+have: **integer pixel stepping**.
+
+- Ground-axis angle: **26.565°** (arctan(1/2), not 30°).
+- Tile aspect: **2:1** — a tile that is 64px wide is 32px tall; 128px wide is 64px tall.
+- Why: a line that steps **2 pixels across, 1 pixel up** repeated N times lands on an
+  exact integer pixel every step. A line at true iso's 30° (tan 30° ≈ 0.57735 px up per
+  px across) never lands on an integer pixel boundary — it drifts, forcing anti-aliased
+  or blurry diagonal edges at every single tile seam. At pixel-art resolutions (a 64×32
+  or 128×64 tile) that drift is visible immediately as a staircase that doesn't repeat
+  cleanly, edge fringing between adjacent tiles, or a seam that "shivers" when the
+  camera pans one pixel.
+- This is why SimCity 2000, Diablo II, and Age of Empires all standardized on 2:1 rather
+  than true isometric — it was chosen for exactly this alignment property (and it ran
+  fast on 1990s hardware doing only integer pixel math), not because anyone mistook it
+  for true isometric.
+
+Write the exact angle and tile module into the asset spec before drawing a single
+pixel — see [`tile-spec.md`](tile-spec.md). Every downstream tool in this file
+(Aseprite's guide, Easymetric, the seam checks) assumes 2:1 unless you deliberately
+choose a different integer ratio (4:1, 3:1 — rarer, steeper "true-iso-flavored" pixel
+looks used by some strategy games, at the cost of taller sprite sheets).
+
+### Why 30° lines staircase (the failure mode, concretely)
+
+Draw a diagonal edge at true-iso's 30° in a pixel grid: the ideal line has slope
+tan(30°) ≈ 0.57735 px vertical per px horizontal — an irrational, non-repeating ratio
+in pixel space. Rasterizing it forces a **non-periodic** stair pattern: some steps are
+1px, others 2px, with no small repeating unit. Two tiles butted edge-to-edge each
+rasterize their shared boundary independently and the results don't match pixel-for-
+pixel, so you get a hairline gap or overlap at the seam. At 2:1 (slope exactly 0.5),
+the stair pattern is **2-across-1-up repeating forever** — deterministic, so every tile
+drawn to the same module rasterizes its diagonal identically and seams close perfectly.
+
+---
+
+## 2. Aseprite workflow
+
+[Aseprite](https://www.aseprite.org/) is the de facto standard pixel-art editor for
+isometric tile production. Three techniques, in order of how most artists actually work:
+
+### 2.1 Shift-pencil 2:1 live preview
+
+Hold **Shift** while using the Pencil tool in Aseprite: this activates a live straight-
+line preview constrained to common angle steps, and at pixel-art zoom the 2:1 diagonal
+(2px across, 1px up) is one of the snap angles. Draw the diamond tile outline this way
+first — it guarantees the top-plane diamond corners land on exact 2:1 boundaries before
+you commit any interior pixels. This is the fastest, zero-plugin way to get a
+pixel-perfect diamond and is the technique documented in SLYNYRD's Pixelblog 54.
+
+### 2.2 Guide layers
+
+Set up a dedicated **guide layer** (a normal layer marked non-exporting, or Aseprite's
+reference-layer feature) containing:
+
+- The full tile diamond outline at the target tile module (e.g. 64×32).
+- A center cross-hair at the tile's screen-space center, matching the anchor convention
+  from [`tile-spec.md`](tile-spec.md) (feet/base anchor, not visual center).
+- Optionally, elevation gridlines if the tile is a multi-z-step object (a wall, a tree)
+  so each z-step aligns to the same 1px-per-step-up cadence as the ground diamond.
+
+Keep the guide layer at low opacity, lock it, and duplicate it into every new tile
+document so every artist on a set draws against the identical grid — this is the
+single highest-leverage habit for tile-seam consistency across a team.
+
+### 2.3 Aseprite isometric guideline Lua script
+
+Rokugatsua's **Aseprite isometric guideline script** (Lua, community blog) auto-
+generates a 2:1 four-corner-pixel guide layer procedurally, parameterized by tile
+width — faster than hand-drawing the guide layer above for a new tile module, and
+guarantees the corners are computed (not eyeballed) at the exact 2px:1px ratio. Load
+it via Aseprite's **File → Scripts → Open Scripts Folder**, drop the `.lua` file in,
+then run it from the Scripts menu; it will prompt for tile width and draw the guide
+onto a new layer in the active sprite.
+
+### 2.4 Easymetric plugin (Oroshibu)
+
+**Easymetric (Oroshibu)** is a purpose-built Aseprite plugin for isometric pixel art
+production, going well beyond a guide layer. ⚠ Unverified as a shipped, linkable
+product — public signals (an 80.lv writeup and Oroshibu's own mid-2025 social posts)
+describe it as still in development ("easy isometric pixel art is still coming...
+soon") as of the source date. Do not link to a specific product page until you have
+verified a real release exists: check Oroshibu's itch.io profile
+(itch.io/profile/oroshibu) or an official announcement for a live listing, and
+date-stamp the check.
+
+- **Colored and textured drawing modes** — paint directly onto iso-projected cube
+  faces (top/left/right) with automatic perspective-correct placement, rather than
+  manually shearing pixels by hand.
+- **Auto-outline generation** — traces a consistent single-pixel outline around drawn
+  shapes (see outline rules, §3 below) instead of hand-placing every border pixel.
+- **Per-layer geometry** — each layer can represent a distinct 3D volume (a block, a
+  slope, a step), composited by the plugin into the final iso projection, which keeps
+  complex multi-part tiles (buildings, staircases) editable as separate pieces.
+- **Dithering support** — built-in dither patterns for shading transitions that respect
+  the pixel grid (see §3).
+- **Animation support** — for animated tile elements (flags, water, machinery) within
+  the iso projection.
+- **`.obj` export** — exports the constructed geometry as a 3D mesh, useful for
+  round-tripping into Blender for a pre-render pass (see
+  [`blender-prerender.md`](blender-prerender.md)) or for generating a normal/depth map
+  reference without hand-building the geometry twice.
+
+Easymetric is the highest-leverage single tool for teams producing more than a handful
+of iso pixel tiles — it replaces most of the manual shear/rotate/guide-layer discipline
+below with a purpose-built editor, at the cost of a plugin dependency and its own
+learning curve.
+
+---
+
+## 3. Outline, anti-aliasing, and dithering rules
+
+Iso pixel art has its own house style conventions, distinct from general pixel art,
+because tiles must remain legible and seam-clean at small sizes and must composite
+against a checkerboard/other tiles without visible fringing:
+
+- **Single-pixel outline.** Outline every silhouette edge with exactly 1px of a single
+  dark ink color (see the three-tone doctrine and `ink` token in
+  [`style-guide.md`](style-guide.md)). Do not vary outline weight — a 2px outline reads
+  as a rendering error at tile scale, not a style choice, once tiles are laid out in a
+  grid together.
+- **No blanket anti-aliasing.** Full-shape anti-aliasing (soft edges everywhere) blurs
+  tile-to-tile seams and defeats the alpha-halo checks in `tile-validate.py` (see
+  [`ai-refinement.md`](ai-refinement.md) for the halo-detection rationale — the same
+  check flags hand-drawn AA fringe, not just AI output). Instead use **selective
+  anti-aliasing**: a small number of hand-placed intermediate-tone pixels only at
+  shallow-angle curves (a shoulder on a rounded silhouette, a curb corner) where a
+  strict 1px stairstep would look worse than a single blended pixel. Never anti-alias
+  along the tile's own diamond boundary — that edge must stay a hard, opaque, exactly-
+  2:1 stair so it tiles seamlessly against its neighbor.
+- **Dithering** for shading transitions (not gradients): use an ordered dither pattern
+  (checkerboard or Bayer-style, at the pixel-art scale a simple 2x1/1x2 alternating
+  pattern) to transition between two flat shade tones on a plane, rather than
+  introducing a third intermediate color that would blow the palette budget (see
+  `--max-colors` in [`tile-validate.py`](../scripts/tile-validate.py) and the palette
+  grammar in [`style-guide.md`](style-guide.md)). Dither only within a single plane's
+  tone band (e.g. within the "top" tone), never across the top/left/right plane
+  boundary — that boundary must stay a hard edge or the three-tone read collapses.
+
+### Tile-seam hygiene
+
+- Every tile's outer diamond boundary pixels must be **bit-identical in shape** across
+  every tile in the set that shares that footprint — copy the guide-layer boundary,
+  never redraw it freehand per tile.
+- Decorative overhang (a roof lip, a canopy) that extends past the tile's own diamond
+  footprint into a neighboring tile's visual space is allowed **only** if every
+  neighboring tile in the set is drawn to tolerate that overhang (documented in the
+  tile spec's footprint grammar — see [`tile-spec.md`](tile-spec.md)). An un-agreed
+  overhang is a common cause of z-fighting/occlusion bugs at map-assembly time, not a
+  drawing error — catch it at spec time, not QA time.
+- Run [`tile-validate.py`](../scripts/tile-validate.py) (edge-bleed + alpha-halo checks)
+  on every exported tile before it enters the atlas — the same script gates both
+  hand-drawn and AI-generated tiles for exactly this class of seam defect.
+
+---
+
+## 4. Measuring iso shapes: circles, spheres, and the skew-to-plane trick
+
+True circles and spheres have no direct isometric or dimetric equivalent — a circle
+lying flat on the ground plane projects to an **ellipse**, and getting that ellipse's
+proportions wrong is one of the most common "this looks almost right but off" pixel-art
+errors. Canonical treatment: **Angus Coolan** — isometric measurement
+(anguscoolan.com; Coolan is also an artist on the game *Unpacking*) — covers measuring
+circles, spheres, and the skew-to-plane technique below.
+
+- **Circle → ellipse (ground plane, 2:1 dimetric).** A circle of diameter D lying flat
+  on the ground plane projects to an ellipse whose **major axis stays D** (along the
+  plane's unconstrained direction) and whose **minor axis is D × the plane's foreshortening
+  factor**. At 2:1 dimetric's 26.565° ground angle, the ground plane's vertical
+  foreshortening is exactly the tile ratio itself: **minor:major = 1:2**, i.e. a circle
+  drawn on the ground reads as an ellipse exactly half as tall as it is wide — the same
+  2:1 ratio as the tile diamond itself, which is a convenient rule of thumb for
+  freehand drawing (whatever grid you used for the tile diamond also frames the
+  ellipse). At true isometric's 30°/tan(30°) family, the minor:major ratio is 57.74%
+  (see the canonical constants table in
+  [`projection-math.md`](projection-math.md#full-constants-tables) — same tan(30°)
+  family as the Figma-hack height scale and the top-plane-circle constant).
+- **Spheres.** A sphere projects to a **true circle** in *screen* space regardless of
+  projection (a sphere has no "up" axis to foreshorten against) — this is the one
+  isometric shape that does NOT need the ellipse correction. The trap is shading it:
+  the sphere's highlight and terminator (light/shadow boundary) must still be drawn
+  consistent with the single fixed light direction used across the whole set (see
+  [`style-guide.md`](style-guide.md)'s three-tone doctrine), which is an ellipse-shaped
+  highlight placement on the sphere's screen-circle, not a circular one, because the
+  *light's* projection onto the sphere is still subject to the scene's projection
+  geometry even though the sphere's silhouette isn't.
+- **Skew-to-plane trick.** To draw any circular/curved detail (a window, a wheel, a
+  dial) sitting flush on one of the three cube planes (top/left/right): draw the shape
+  as an *undistorted* circle/curve in a separate, unrotated working layer, then apply
+  that plane's exact affine transform (shear + scale + rotate — the same matrices
+  documented per-plane in [`projection-math.md`](projection-math.md), e.g. the
+  Illustrator SSR per-face recipe or the 2D affine matrices section) to paste it onto
+  the target plane. Drawing the curve freehand directly in iso space at pixel-art
+  resolution is unreliable past ~8–10px in diameter; transforming a correct circle is
+  not. At small pixel sizes, snap the transformed result back onto the pixel grid by
+  hand afterward — an automated transform will almost always leave a few
+  anti-aliased edge pixels that need manual cleanup to match the outline rules in §3.
+
+---
+
+## 5. Canon and further reading
+
+- **SLYNYRD "Pixelblog 54 — More Isometric Pixels"** (Jan 2025) — the canonical modern
+  iso pixel-art blog post; documents the Aseprite Shift-pencil 2:1 preview technique
+  (§2.1 above) plus tile composition and shading craft.
+- **SLYNYRD Pixelblog 41** and **Pixelblog 4** — earlier entries in the same series,
+  covering foundational iso pixel-art technique and tile anatomy.
+- **Lospec "Pixel Art Isometric Tutorials"** — a curated, tagged index of roughly 30
+  community tutorials covering buildings, rooms, vehicles, animals, and interiors in
+  isometric pixel art; use as a technique-lookup index rather than a single linear
+  tutorial.
+- **Angus Coolan** (anguscoolan.com) — isometric shape-measurement reference (§4).
+
+## Related references in this skill
+
+- [`projection-math.md`](projection-math.md) — the constants and transform matrices
+  this file assumes (2:1 dimetric definition, per-plane affine matrices, the
+  tan(30°)/57.74% family used in the ellipse-ratio derivation above).
+- [`coordinates-depth.md`](coordinates-depth.md) — tile↔screen transforms, draw-order
+  and y-sort doctrine, and the anchor-at-feet rule referenced in §3's tile-seam
+  discussion.
+- [`tile-spec.md`](tile-spec.md) — the asset-spec template that pins projection,
+  tile module, anchor, and footprint grammar before any pixel-art production begins.
+- [`style-guide.md`](style-guide.md) — three-tone plane shading, palette-ramp grammar,
+  and the `ink` token referenced in §3's outline rule.
+- [`blender-prerender.md`](blender-prerender.md) — the 3D pre-render alternative to
+  hand-drawn pixel art, including consuming Easymetric's `.obj` export.
+- [`ai-refinement.md`](ai-refinement.md) — the alpha-halo/edge-bleed checks shared with
+  hand-drawn tile QA.
+- [`../scripts/tile-validate.py`](../scripts/tile-validate.py) — automated QA gate for
+  dimension conformance, alpha-halo, edge-bleed, and palette-size checks against tiles
+  produced by this workflow.

+ 405 - 0
skills/isometric-ops/references/projection-math.md

@@ -0,0 +1,405 @@
+# Projection Math — the geometric foundation
+
+The mathematical bedrock of `isometric-ops`. Every angle, every scale factor, every
+transform recipe in the rest of the skill traces back to a small number of exact
+constants derived here. Get the projection *decision* right at step zero and the whole
+pipeline aligns; get it wrong and you inherit the single most common isometric bug —
+tiles that don't tessellate.
+
+> **Sibling boundaries.** General three.js scaffolding lives in
+> [`genart-ops`](../../genart-ops/SKILL.md); app/game-scale three.js (GLTF, r3f,
+> `InstancedMesh`) lives in the `threejs-ops` skill. Colour science / perceptual ramps
+> live in [`color-ops`](../../color-ops/SKILL.md). This file owns **projection
+> geometry only** — it cross-links, it does not restate.
+
+---
+
+## 0. The projection decision — always the first step
+
+Before you draw, generate, or model a single asset, choose the projection. This is not a
+stylistic preference you can defer; it fixes the grid math, the tile aspect ratio, the
+camera rig, and the anti-aliasing strategy for everything downstream. Changing it later
+means re-cutting every asset.
+
+| Job | Projection | Ground-axis angle | Why | Where documented |
+|---|---|---|---|---|
+| Web / vector illustration, diagrams, icons, hero art | **True isometric** | 30° (120° between all three axes) | All three axes foreshorten equally → visually balanced, no privileged face; smooth vector edges don't care about pixel stepping | §1, [`svg-vector-generation.md`](svg-vector-generation.md), [`css-isometric.md`](css-isometric.md) |
+| Game tiles, tilemaps, sprite worlds, most "isometric" games | **2:1 dimetric** (commonly called isometric in games) | 26.565° = arctan(1/2) | Integer 2px:1px steps tessellate cleanly on a pixel grid; engine tilemaps assume the 2:1 diamond | §2, [`coordinates-depth.md`](coordinates-depth.md), [`engine-integration.md`](engine-integration.md) |
+| Hand-placed / library pixel-art primitives (cubes, bricks, slopes) | **Pixel-neat 1:2** | 22.6° (documented by obelisk.js; see §7) | The 1:2 pixel *dot* pattern that avoids staircasing at the primitive-drawing level | §7, [`pixel-art-workflow.md`](pixel-art-workflow.md) |
+
+### The failure mode this decision prevents
+
+Nearly every "isometric" game is actually **dimetric**. Per Wikipedia ("Isometric video
+game graphics"), the 2:1 form is "more accurately described as a variation of dimetric
+projection, since only two of the three angles between the axes are equal (≈116.565°,
+≈116.565°, ≈126.870°)." The trap: an artist draws a "30° isometric" tile, an engine
+places it on a 2:1 (26.565°) diamond grid, and the tile edges **do not meet the
+neighbouring tiles**. The seams are small at one tile and catastrophic across a 50×50
+map. The fix is not a nudge tool — it is deciding, up front, that game tiles are
+**2:1 dimetric at 26.565°** and writing that number into the asset spec
+([`tile-spec.md`](tile-spec.md)) so every asset is cut to the same grid.
+
+**Terminology discipline (applies to every file):** on first use per document, write
+**"2:1 dimetric (commonly called isometric in games)"**, then "2:1 dimetric"
+thereafter. Never call the 2:1 game projection "isometric" unqualified. Distinguish
+**"isometric drawing"** (100% scale) from **"isometric projection"** (81.65% scale)
+whenever the distinction affects a measurement.
+
+---
+
+## 1. True isometric — the exact constants
+
+True isometric is the axonometric projection in which all three coordinate axes are
+foreshortened **equally** and appear **120° apart** on the drawing plane. In 3D terms
+(per Wikipedia, "Isometric projection") it is a cube "rotated ±45° about the vertical
+axis, followed by a rotation of approximately 35.264° about the horizontal axis."
+
+| Quantity | Value | Derivation | Verified |
+|---|---|---|---|
+| Ground-axis angle from horizontal | **30°** | definition (axes at 30°, 150°, 270°) | — |
+| Axis separation | **120°** | definition | — |
+| Cube tilt angle | **35.264°** (35.2644°) | `arctan(1/√2) = arcsin(1/√3)` — the "magic angle" | `atan(1/√2) = atan(1/1.41421) = 35.264390°`; `asin(1/√3) = 35.264390°` ✓ |
+| Axonometric foreshortening (**projection** scale) | **81.65%** (0.81650) | `cos(35.264°) = √(2/3)` | `cos(35.2644°) = 0.816496`; `√(2/3) = 0.816497` ✓ |
+| Isometric **drawing** scale | **100%** | full-scale convention | "projection" = 81.65%, "drawing" = 100% — **always distinguish** |
+| Top-plane circle → ellipse (minor/major) | **57.74%** | `tan(30°)` family; a flat circle on the top plane becomes an ellipse whose minor axis is `tan(30°)` of the major | `tan(30°) = 0.577350` ✓ |
+| Axis rise factor | **0.5** | `sin(30°)` — vertical rise per unit along a left/right ground axis | `sin(30°) = 0.5` ✓ |
+
+> ⚠ SRC-B's constants table prints the foreshortening as "81.64%" and the tilt as
+> "35.27°" — both are looser roundings of the same exact values (`√(2/3)=0.816497`,
+> `arctan(1/√2)=35.2644°`). Use the canonical figures above. See **Flags** at the
+> end for the full list of source roundings this file corrects.
+
+### Why 35.264° is the magic angle
+
+Stand a unit cube on one corner and tilt it until the body diagonal is vertical. The
+diagonal has length √3; its projection onto the tilt axis gives `sin(θ) = 1/√3`, so
+`θ = arcsin(1/√3) = arctan(1/√2) = 35.2644°`. At this tilt the three visible faces
+project to identical rhombi and the three edges meeting at the top vertex sit exactly
+120° apart — the defining property of *iso*metric ("equal measure").
+
+---
+
+## 2. 2:1 dimetric ("game isometric") — the exact constants
+
+The projection used by SimCity 2000, Diablo II, Age of Empires, and the overwhelming
+majority of "isometric" tile games. It is **dimetric**, not isometric: two axis
+separations are equal and one differs.
+
+| Quantity | Value | Derivation | Verified |
+|---|---|---|---|
+| Ground-axis angle | **26.565°** (26.5651°) | `arctan(1/2)` | `atan(0.5) = 26.565051°` ✓ |
+| Axis separations | **≈116.565°, 116.565°, 126.870°** | two equal, one different → *dimetric* | `116.565×2 + 126.870 = 360.000°` ✓ |
+| Tile aspect | **2:1** (e.g. 64×32, 128×64, 256×128) | integer 2px:1px steps tile cleanly | — |
+| Ground-line screen slope | **0.5** | `tileH/tileW = 1/2` — a tile-space `+x` move is `(+tileW/2, +tileH/2)` on screen | `32/64 = 0.5` ✓ |
+
+Why 2:1 was chosen historically: a `+1` step along a tile axis is exactly `(±tileW/2,
+±tileH/2)` pixels, and on 1990s CPUs the `/2` was a single bit-shift. The angle
+`arctan(1/2) = 26.565°` falls out of the 2:1 ratio, not the other way round.
+
+### tile ↔ screen transform (canonical)
+
+```
+screenX = (x − y) · (tileW / 2)
+screenY = (x + y) · (tileH / 2)
+```
+
+Inverse (screen → tile):
+
+```
+x = ( screenX / (tileW/2) + screenY / (tileH/2) ) / 2
+y = ( screenY / (tileH/2) − screenX / (tileW/2) ) / 2
+```
+
+**Numeric ground-truth** (tileW=64, tileH=32), round-trip verified:
+
+| tile (x, y) | → screen (sx, sy) | → tile (round-trip) |
+|---|---|---|
+| (0, 0) | (0, 0) | (0, 0) ✓ |
+| (3, 1) | (64, 64) | (3, 1) ✓ |
+| (5, 2) | (96, 112) | (5, 2) ✓ |
+| (2.5, 4.0) | (−48, 104) | (2.5, 4.0) ✓ |
+
+Full derivation, elevation offsets, within-diamond picking, and the depth-sort doctrine
+live in [`coordinates-depth.md`](coordinates-depth.md). This file states only the base
+transform so the constants have a single home.
+
+---
+
+## 3. Transform recipes with ground-truth checks
+
+Each recipe below produces an exact projection. "Ground-truth check" states where a unit
+vector must land so a reader can verify the recipe numerically without trusting the prose.
+
+### 3.1 CSS 3D — true isometric
+
+The full stack (see [`css-isometric.md`](css-isometric.md) for the DOM plumbing and
+`preserve-3d` requirements):
+
+```css
+transform: rotateX(54.7356deg) rotateZ(-45deg) scale3d(1.22474, 1.22474, 1.22474);
+```
+
+| Component | Value | Meaning | Verified |
+|---|---|---|---|
+| `rotateX` | **54.7356°** | `arctan(√2) = 90° − 35.264°` — tips the top plane back to the iso viewing angle | `atan(√2) = 54.735610°`; `90 − 35.2644 = 54.7356°` ✓ |
+| `rotateZ` | **−45°** | spins the square footprint so its diagonal faces the viewer | — |
+| `scale3d` | **1.22474** | `√(3/2) = 1/cos(35.264°)` — **undoes** the 0.81650 foreshortening so on-screen edges read at 100% ("drawing" scale, not "projection" scale) | `√(3/2) = 1.224745`; `1/cos(35.2644°) = 1.224745` ✓ |
+
+**Per-face composition.** With `transform-style: preserve-3d` on the cube parent, each
+of the six faces is a child placed by a *local* transform, then the whole cube receives
+the stack above:
+
+- Top: `rotateX(90deg) translateZ(halfSize)`
+- Bottom: `rotateX(-90deg) translateZ(halfSize)`
+- Front / Back: `translateZ(±halfSize)`
+- Left / Right: `rotateY(±90deg) translateZ(halfSize)`
+
+The single outer `rotateX(54.7356) rotateZ(-45) scale3d(1.22474…)` then views that
+assembled cube isometrically. Do **not** apply the scale to individual faces — it goes
+on the container only, or faces double-scale.
+
+### 3.2 2D affine matrices — the three iso planes (SSR-derived)
+
+For flat 2D vector art (Illustrator, SVG, canvas) there is no true 3D — instead each
+plane is a 2×2 affine map from flat coordinates to screen coordinates. Derive them from
+the **Scale → Shear → Rotate (SSR)** pipeline: scale vertically by `cos(30°) = 0.86603`,
+then a horizontal shear of ±30°, then a rotation of ±30°. Composed as a matrix,
+`M = R · Shear · ScaleY`. (Math convention: **y-up**. Column vectors. Add the y-down
+sign flip for SVG in §3.4.)
+
+Building blocks:
+
+```
+ScaleY(0.86603) = [1        0     ]      Shear_x(θ) = [1   tan(θ)]      Rot(φ) = [cosφ  −sinφ]
+                  [0        0.86603]                  [0   1     ]               [sinφ   cosφ]
+```
+
+| Plane | Shear | Rotate | Resulting 2×2 matrix (y-up) | Ground-truth check (where unit x, unit y land) |
+|---|---|---|---|---|
+| **Top** | +30° | −30° | `[ 0.86603  0.86603 ; −0.5  0.5 ]` | unit x → **(0.86603, −0.5)**; unit y → **(0.86603, +0.5)** — the two ground axes at ∓30° |
+| **Left** | −30° | −30° | `[ 0.86603  0.00000 ; −0.5  1.0 ]` | unit x → **(0.86603, −0.5)** (a +30° ground line); unit y → **(0, 1)** (vertical) |
+| **Right** | +30° | +30° | `[ 0.86603  0.00000 ;  0.5  1.0 ]` | unit x → **(0.86603, +0.5)** (a −30° ground line); unit y → **(0, 1)** (vertical) |
+
+The elegance is the check: on the **top** plane a unit x maps to `(cos30°, −sin30°) =
+(0.86603, −0.5)` and a unit y to `(cos30°, +sin30°) = (0.86603, +0.5)` — precisely the
+two isometric ground axes. Left and right planes each keep their y-edge **vertical**
+`(0, 1)` (walls stand up straight) while the x-edge rakes at ±30°. If your derived
+matrix doesn't reproduce these two vectors to 5 decimals, the SSR order or a sign is
+wrong.
+
+> These are **drawing-scale** matrices: each ground-axis vector has length **1.0** (the
+> ground-truth checks above are all unit-length — `hypot(0.86603, 0.5) = 1.0`), so
+> on-screen edges read at **100%** ("drawing" scale). The 0.86603 vertical squash is
+> baked in, but it only sets the **top-plane compression** and keeps the walls'
+> y-edges vertical — it is *not* the axonometric foreshortening, which would shrink
+> every axis to 0.81650. To reach true **projection** scale (0.81650 edges) you
+> **multiply** the whole matrix by 0.81650 (not divide — dividing gives 1.22474-length
+> axes, which is neither convention). Most 2D iso vector art wants drawing scale, so
+> these matrices are used as-is. See §1 on the drawing-vs-projection distinction.
+
+### 3.3 Illustrator SSR per face (the operator recipe)
+
+The step-by-step operator sequence, canonical since the classic Illustrator iso
+workflow. Apply each as a separate action, in order, **after** the vertical scale:
+
+| Face | Step 1 — Scale (vertical) | Step 2 — Shear | Step 3 — Rotate |
+|---|---|---|---|
+| **Top** | **86.602%** | **+30°** | **−30°** |
+| **Left** | **86.602%** | **−30°** | **−30°** |
+| **Right** | **86.602%** | **+30°** | **+30°** |
+
+Save the three as named Actions ("Iso Top / Left / Right") for one-click conversion.
+
+> ⚠ **SRC-B typo:** SRC-B lists the vertical scale as "86.062%" in its SSR section
+> (then correctly gives "86.602% for standard vector drawings" in the same sentence).
+> **86.062% is a transposition typo.** The canonical value is `cos(30°) = 0.86603 =
+> **86.602%**`. Never use 86.062%. (Logged in **Flags**.)
+
+**Why scale before shear.** A bare shear preserves horizontal extent but not vertical
+foreshortening; skipping the 0.86603 scale is the "skew-without-scale" bug — the object
+lands on the iso axes but is the wrong height, so it won't stack with correctly-built
+neighbours. Experienced illustrators build directly on the iso plane or run the full SSR;
+they never skew a flat asset and call it done.
+
+### 3.4 SVG `matrix()` equivalents
+
+SVG's `transform="matrix(a, b, c, d, e, f)"` maps `(x, y) → (a·x + c·y + e, b·x + d·y +
+f)` and uses a **y-down** coordinate system (screen convention). To convert the y-up 2×2
+matrices from §3.2, flip the sign of the second row (the y-output components) so a
+positive math-y (up) becomes a negative screen-y:
+
+| Plane | SVG `matrix(a, b, c, d, e, f)` | Note |
+|---|---|---|
+| **Top** | `matrix(0.86603, 0.5, 0.86603, -0.5, 0, 0)` | columns are the two ground axes; `e=f=0` places the plane origin at the element origin — translate with `e,f` |
+| **Left** | `matrix(0.86603, 0.5, 0, -1, 0, 0)` | x-edge rakes down-right, y-edge points straight **up** the screen (−1) |
+| **Right** | `matrix(0.86603, -0.5, 0, -1, 0, 0)` | x-edge rakes up-right, y-edge straight up |
+
+`a,b` is the image of unit x; `c,d` is the image of unit y — read them back against the
+§3.2 check vectors (sign-flipped d). [`svg-vector-generation.md`](svg-vector-generation.md)
+uses these to emit diamond / cube / prism paths — it links here rather than re-deriving.
+
+### 3.5 The Figma isometric hack (no native shear)
+
+Figma has no shear tool, so it fakes the iso squash with a **bounding-box reset**:
+
+1. **Draft flat** — build the 2D card / UI mockup upright on the canvas.
+2. **Rotate 45°** — into a diamond.
+3. **Group** (`Ctrl/Cmd + G`) — this **resets the group's bounding box** to axis-aligned
+   width/height, which is the whole trick: the group now has a clean, upright height
+   value you can scale.
+4. **Scale group height ×0.57735** (`tan(30°)`; enter the height as `H × 0.5774`). This
+   compresses the diamond into an exact 30° isometric **top plane**.
+5. **Side planes** — duplicate the top plane and rotate the duplicate ±60° (or flip
+   horizontally) to build the left/right faces.
+
+| Figma constant | Value | Derivation | Verified |
+|---|---|---|---|
+| Group height multiplier | **0.57735** (57.735%) | `tan(30°)` — applied to the 45°-rotated, **grouped** diamond | `tan(30°) = 0.577350` ✓ |
+
+> The `0.57735` height factor is correct **only because of the group step** — it acts on
+> the diamond's axis-aligned bounding box (which is `√2 ×` the original side), and
+> `tan(30°)` is exactly the compression that turns that bounding box into a 30° top
+> plane. Scaling an *un*grouped, rotated shape by 0.57735 does **not** work — the
+> bounding box hasn't reset. SRC-A/SRC-B both round this to "57.73%"; `tan(30°) =
+> 0.577350` is the exact figure.
+
+**Non-destructive nested-component workflow.** Build flat UI as a **master component**,
+then drop **instances** inside the iso transform group. Edits to the master cascade
+automatically into every transformed iso view — update a device screen once, all
+isometric mockups follow. This is the production pattern for keeping mockups live.
+
+### 3.6 Affinity Designer — native isometric grid (pointer)
+
+Affinity Designer ships a **built-in isometric grid and plane-switching** system, so the
+SSR/Figma hacks are unnecessary there. `View ▸ Show Grid`, then `Grid and Axis Manager
+▸ Isometric` gives true 30° planes; the **Isometric** panel (or `1`/`2`/`3` keys) snaps
+drawing and fitting to the top / left / right plane, and `Fit to plane` maps existing
+art onto the active plane. If your team uses Affinity, prefer the native grid over any
+manual transform — it enforces the geometry for you. (Pointer only; full workflow is out
+of scope for this math reference.)
+
+---
+
+## 4. Mislabel table — isometric vs dimetric vs trimetric vs "2.5D"
+
+The vocabulary is almost universally misused. Use these definitions precisely; the whole
+skill's terminology discipline rests on them.
+
+| Term | Axis foreshortening | Axis separations | Ground angle | In this skill |
+|---|---|---|---|---|
+| **Isometric** (true) | all three **equal** | 120°, 120°, 120° | 30° | Web/vector/illustration route |
+| **Dimetric** | **two** equal, one differs | 116.565°, 116.565°, 126.870° (the 2:1 case) | 26.565° | Game-tile route — the real "game isometric" |
+| **Trimetric** | **all three different** | all three differ | arbitrary | "Just looks nice" ortho camera angles are usually trimetric, not true iso — a common Blender-Artists correction |
+| **"2.5D"** | n/a (a *rendering* strategy, not a projection) | n/a | n/a | Pre-rendered 3D → 2D sprites, or 2D art faking depth; describes the pipeline, says nothing about the angle |
+| **Axonometric** | umbrella term | — | — | Parent family; isometric/dimetric/trimetric are all axonometric (parallel, no vanishing point) |
+
+The load-bearing correction: a "2:1 pixel isometric" tileset is **2:1 dimetric at
+26.565°**. Writing "isometric" in the spec and then cutting to a 2:1 grid is how tiles
+end up misaligned.
+
+---
+
+## 5. Quick derivation summary (the whole file on one screen)
+
+| Symbol | Exact form | Decimal | Appears as |
+|---|---|---|---|
+| Cube tilt | `arctan(1/√2) = arcsin(1/√3)` | 35.2644° | 3D iso rotation |
+| Foreshortening | `cos(35.264°) = √(2/3)` | 0.81650 | true-projection scale |
+| True ground angle | definition | 30° | vector iso |
+| SSR / top-plane squash | `cos(30°)` | 0.86603 | Illustrator scale, 2D matrices |
+| Figma / circle-ellipse | `tan(30°)` | 0.57735 | Figma height, ellipse minor axis |
+| CSS back-tip | `arctan(√2) = 90° − 35.264°` | 54.7356° | CSS `rotateX` |
+| CSS un-foreshorten | `√(3/2) = 1/cos(35.264°)` | 1.22474 | CSS `scale3d` |
+| Dimetric ground angle | `arctan(1/2)` | 26.5651° | game tiles |
+| Dimetric slope | `tileH/tileW` (2:1) | 0.5 | screen slope |
+| Pixel-neat angle | 1:2 dot pattern (obelisk.js) | 22.6° | §7 |
+
+Every figure above was recomputed and checked to ≥6 decimal places against the canonical
+constants table; the same values are emitted machine-readably by
+[`scripts/iso-math.py constants`](../scripts/iso-math.py) so the doc and the code cannot
+drift.
+
+---
+
+## 6. Blender camera rigs (both projections)
+
+Wherever an orthographic camera rig is discussed, **both** must appear — this distinction
+is one of the skill's signature clarifications and is glossed over by most tutorials.
+
+| Target | Camera rotation (X, Y, Z) | Verification test | Verified |
+|---|---|---|---|
+| **2:1 dimetric** game tiles | **RotX 60°, RotY 0°, RotZ 45°** | rendered cube top is exactly **2× wide as tall** (elevation 30° → `sin(30°) = 0.5`) | `sin(30°) = 0.5` → 2:1 ✓ |
+| **True isometric** | **RotX 54.736°, RotY 0°, RotZ 45°** | `90° − 35.264°`; **all three cube faces equal** | `90 − 35.2644 = 54.7356°` ✓ |
+
+Both are correct — for **different projections**. SRC-A's Bellanger/QWeb tutorials use
+**60°** (dimetric game tiles). SRC-B's ControlNet workflow uses **54.736°** (true iso).
+Neither source is wrong; they target different outputs. If you copy a rotation from a
+tutorial without knowing which projection it targets, you get the other one's grid. Full
+rig setup, 8-direction batching, and depth/normal passes live in
+[`blender-prerender.md`](blender-prerender.md) and drive
+[`assets/blender-iso-rig.py`](../assets/blender-iso-rig.py); this file owns only the two
+rotation numbers and their check.
+
+---
+
+## 7. Contested fact resolved — the obelisk.js angle
+
+**Question:** sources give both "22.6°" and `arctan(1/2) = 26.565°` for 1:2 pixel
+stepping. Which does obelisk.js use?
+
+**Resolution (verified against the source, 2026-07-03):** the
+[obelisk.js README](https://github.com/nosir/obelisk.js) states it *"strictly follows
+pixel neat pattern: lines with 1:2 pixel dot arrangement, leading to an angle of **22.6
+degrees**."* So obelisk.js **documents 22.6°**.
+
+**The nuance worth stating** (this is where sources talk past each other):
+
+- The *mathematically exact* angle of a 1:2 (rise:run) line is `arctan(1/2) = 26.565°`.
+- obelisk.js's **22.6°** is the angle of the *complementary* 2:1 (rise 2, run 1) pixel
+  dot pattern it actually rasterises — `arctan(2/... )` rounded — the value the pixel-art
+  community historically quotes for the "pixel-neat" primitive stepping. It is a
+  **documented library constant, not the ground-axis angle of a 2:1 tile grid.**
+- Do **not** conflate the two. A 2:1 dimetric *tile grid* has ground axes at **26.565°**
+  (§2). obelisk.js's **22.6°** refers to its internal pixel-dot line pattern for drawing
+  primitives. Both figures are "correct" for different things; the skill uses **26.565°**
+  for tile-grid math and cites **22.6°** only when describing obelisk.js's own pattern.
+
+obelisk.js is MIT-licensed and effectively **unmaintained** (last release v1.2.1, May
+2016) — treat it as decoration-only, never a game engine
+([`svg-vector-generation.md`](svg-vector-generation.md) covers the maintenance caveats).
+
+---
+
+## Sources
+
+- **Wikipedia — "Isometric projection"** and **"Isometric video game graphics"**
+  (en.wikipedia.org) — the authoritative isometric/dimetric/trimetric distinction, the
+  ±45° + 35.264° double rotation, and the ≈116.565°/126.870° dimetric separations.
+- **Pikuma — "Isometric Projection in Game Development"**
+  (https://pikuma.com/blog/isometric-projection-in-games) — why 2:1 was chosen (integer
+  steps, bit-shift `/2`).
+- **yal.cc — "Understanding isometric grids"** (https://yal.cc/understanding-isometric-grids)
+  — the cleanest tile↔screen grid math.
+- **obelisk.js README** (https://github.com/nosir/obelisk.js) — the 22.6° / 1:2
+  pixel-dot statement; MIT; last release v1.2.1 (May 2016). *Verified 2026-07-03.*
+- **CodePen scootman `QWvYoyY` — "CSS 3D transforms: true isometric"** — derives the
+  `rotateX(54.736°) rotateZ(-45°) scale3d(1.2247)` stack (SRC-A, Ch.2 CSS section).
+- **SRC-A** — `compass_artifact…text_markdown.md` (chaptered resource library, Ch.1
+  Foundations & Math).
+- **SRC-B** — *Engineering and Aesthetic Standards for Isometric Design* (constants
+  table, SSR method, Figma hack, Blender ControlNet rig).
+- Constants independently recomputed in Python (stdlib `math`) to ≥6 decimals and
+  mirrored by [`scripts/iso-math.py`](../scripts/iso-math.py).
+
+---
+
+## Flags — source roundings and errors this file corrects
+
+- **SRC-B "86.062%"** vertical-scale figure in the SSR section is a transposition typo;
+  canonical is **86.602%** = `cos(30°)`. (Also flagged in the brief.)
+- **SRC-B "81.64%"** foreshortening → canonical **81.65%** = `√(2/3) = 0.816497`.
+- **SRC-B "35.27°"** tilt → canonical **35.264°** = `arctan(1/√2)`.
+- **SRC-A / SRC-B "57.73%"** (Figma / circle) → exact **57.735%** = `tan(30°)`.
+- **obelisk.js 22.6°** is the library's documented pixel-dot pattern, **not** the
+  26.565° ground-axis angle of a 2:1 dimetric tile grid — the two must not be conflated
+  (§7).

+ 333 - 0
skills/isometric-ops/references/style-guide.md

@@ -0,0 +1,333 @@
+# Isometric Style Guide
+
+The craft doctrine for isometric illustration and game-asset sets: how to shade a plane,
+build a palette, hold a consistent scale, compose a diamond, cut away a wall, and label a
+diagram — so a set of 200 tiles drawn over six months by three different hands still
+reads as one object. This file is the **reviewer's rubric**: everything in it maps onto
+the consistency checklist at the bottom, and that checklist is what an art lead runs
+against a batch before it ships.
+
+Projection math lives in
+[`projection-math.md`](projection-math.md) and
+[`coordinates-depth.md`](coordinates-depth.md) — this file assumes you've already made
+the projection decision (true isometric vs 2:1 dimetric vs pixel-neat 1:2; see
+`projection-math.md`'s decision table) and covers only what happens *after* the geometry
+is correct: light, colour, scale, composition, type, and review discipline.
+
+> **Terminology reminder.** "2:1 dimetric (commonly called isometric in games)" is the
+> correct term for game-tile work; true isometric (30°/120°) is what most illustration
+> and CSS/SVG work uses. This guide's shading and palette doctrine applies to both — the
+> difference is geometric, not aesthetic.
+
+---
+
+## 1. Three-tone plane shading
+
+The single highest-leverage rule in isometric illustration: **every visible plane of an
+object gets exactly one flat tone, and the three tones are fixed by which plane they're
+on, not by which object they belong to.**
+
+| Plane | Tone | Why |
+|---|---|---|
+| Top | **Lightest** | Receives the most simulated light in a top-down/three-quarter lighting model; reads as "closest to the light source" |
+| Left *or* right (pick one, per your fixed light direction) | **Mid** | The plane roughly perpendicular to the light |
+| Right *or* left (the other of the pair) | **Darkest** | The plane facing away from the light |
+
+Rules that make this work at scale:
+
+1. **Pick ONE light direction for the entire asset set and never change it.** If light
+   comes from the upper-left, the left plane is always mid-tone and the right plane is
+   always dark, on every single asset — a barrel, a building, a tree, a UI card. Mixing
+   light directions within one scene is the single fastest way to make a set look
+   amateurish; the eye detects inconsistent shading before it detects almost anything
+   else.
+2. **Flat tones, not gradients, per plane.** A gradient across a single flat plane
+   implies a light source close enough to fall off within the object's own dimensions —
+   wrong for the parallel, infinitely-distant light that isometric/axonometric
+   projection assumes. Reserve gradients for large environmental surfaces (sky, distant
+   fog) or intentional ambient-occlusion accents at contact edges, not for the core
+   three-tone system.
+3. **Soft, long shadows cast along the plane angles, not the light-source azimuth
+   naively.** A cast shadow in an isometric scene should itself be drawn as a flat shape
+   on the ground plane, skewed along the same axis family as the ground grid (i.e. its
+   silhouette follows the 30°/150° or dimetric-equivalent lines), not a raster drop
+   shadow with a blur radius. Keep shadow opacity low (typically 15–30%) and shadow hue
+   a darkened, desaturated version of the ground tone — never pure black — to avoid
+   muddying the palette.
+4. **Ambient occlusion, not new light sources.** Contact shadows where two planes meet
+   (an object sitting on a tile, a window recessed into a wall) darken the *receiving*
+   surface slightly at the seam. This is a fourth, narrow tonal step, not a fourth
+   light — keep it inside the same ramp (see §2) as the plane it's darkening.
+5. **Cel/hard shading, not smooth PBR shading**, is the default for the illustrative iso
+   style this guide targets. If you are pre-rendering from Blender/three.js with real
+   lighting (see `blender-prerender.md`, `threejs-orthographic.md`), match the *result*
+   to this same three-tone read even though the underlying render is continuous —
+   flatten in post if the renderer's falloff is too soft, or use a toon/cel shader.
+
+**Verification test**: pick any two objects in the set at random. Do their top planes
+match in relative lightness? Do their left planes match each other, and their right
+planes match each other? If an artist has to check which way "the sun" is pointing for a
+specific asset, the doctrine has already broken down — it should be a fixed, memorised
+constant for the whole project, ideally written into the tile spec
+(`tile-spec.md`'s "light direction" field).
+
+---
+
+## 2. Palette grammar
+
+### The three-ladder pattern
+
+Don't "pick five colours." Design three **ramps** (ladders of 3–6 perceptually-even
+steps each):
+
+| Ladder | Purpose | Example use |
+|---|---|---|
+| **Light/tonal ladder** | The neutral-to-near-neutral steps that carry the three-tone shading (top/mid/dark) for any given hue | Applied per-material: a red barrel's top/left/right are three steps down the *red* material's own tonal ladder, not three unrelated colours |
+| **Material ladder** | One ramp per distinct material/surface type in the set (wood, stone, metal, foliage, water, fabric) | Keeps "all the wood" visually related across 50 different props even though each prop's exact hue varies slightly |
+| **Accent ladder** | A small, tightly-controlled set of high-saturation colours reserved for focal points — UI highlights, quest markers, glowing windows, signage | Used sparingly; if everything is an accent, nothing is |
+
+This is the resolution to "design a light ladder, a material ladder, and one accent
+ladder so the three planes of the object remain readable under repetition" — the winning
+pattern over ad hoc colour picking, because it scales: a new prop doesn't need a new
+colour decision, it needs a material-ladder lookup plus the fixed top/mid/dark tonal
+offset.
+
+### Perceptual ramps: cross-reference, don't restate
+
+Generate each ladder as a **perceptually uniform ramp in OKLCH** (equal lightness steps,
+controlled chroma, held hue) rather than naive RGB/HSL interpolation, which produces
+muddy or unevenly-spaced midtones. Full OKLCH mechanics, the `oklch()` CSS syntax,
+gamut/P3 handling, and ramp-generation recipes are owned by
+[`color-ops`](../../color-ops/SKILL.md) — **use it directly** rather than duplicating
+color science here. The isometric-specific rule this guide adds on top:
+
+- Derive the three-tone shading steps (top/mid/dark) as **fixed lightness deltas within
+  one hue's OKLCH ramp**, not as independently chosen colours. A typical delta is
+  top = base L, mid = base L − 0.12–0.18, dark = base L − 0.25–0.35, with a small hue
+  shift toward blue/violet on the darkest step (cool shadows read as more natural than
+  simply-darkened same-hue shadows).
+- Keep chroma (`C`) roughly constant or very slightly reduced on the dark step — dropping
+  chroma too far reads as "grey," dropping it too little reads as "neon shadow."
+
+### Ready-made presets
+
+Eight ready-to-use three-tone JSON presets (`kenney-prototype-grey`,
+`pastel-dollhouse`, `industrial-muted`, `cyberpunk-teal-violet`, `blueprint`,
+`earthy-game`, `mono-ink`, `brand-neutral`) ship at
+[`../assets/palettes/three-tone-presets.json`](../assets/palettes/three-tone-presets.json).
+Each preset supplies `{name, description, ink, top, left, right, shadow, accent, bg}` as
+hex values already satisfying the top-lightest → left-mid → right-dark ordering (or its
+mirror, for a right-side light source) — load one as a starting ramp instead of
+hand-tuning from scratch, then adjust the accent ladder to the project's brand colours.
+
+### Limited palettes read better than large ones
+
+A 6–10 hue palette (each with its own 3–6 step ramp) reads as more intentional and more
+"designed" than a 30-hue palette, and it is dramatically easier to keep three-tone
+discipline over. If the source material (a brand kit, a licensed IP) hands you a large
+palette, the job is to *map* it down onto ladders — decide which brand colours become
+material ladders, which become the single accent ladder, and resist using every provided
+swatch just because it exists.
+
+---
+
+## 3. Scale grammar
+
+**One fixed reference: "one human figure = N tiles/units tall."** Pick this number once,
+document it in the tile spec (`tile-spec.md`), and derive every other object's scale
+from it — a doorway is slightly taller than the reference human, a car is roughly
+0.6–0.8 human-widths wide, a two-storey building is ~2.2–2.5 human-heights including
+roofline. This is the isometric-art equivalent of an architectural scale figure, and
+skipping it is how asset packs end up with a "dollhouse door next to a cathedral-sized
+barrel."
+
+Rules:
+
+1. **No mixed-scale objects sharing one scene without deliberate intent.** If a
+   composition genuinely needs a "hero" object rendered larger than strict scale would
+   allow (a stylised oversized moon, a exaggerated hero prop), that is a *composition*
+   decision made once and clearly, not scale drift creeping in prop-by-prop across a
+   production run.
+2. **Footprint grammar ties into scale grammar.** An object's footprint (1×1, 2×1, 2×2
+   tiles — see `tile-spec.md`) should be consistent with its human-scale height: a 2×2
+   footprint building reads as roughly building-sized *because* the human reference
+   makes the tile module legible, not because the artist eyeballed it.
+3. **Re-check scale after AI generation or upscaling.** Generative pipelines
+   (`ai-generation.md`) are especially prone to scale drift between otherwise
+   style-matched outputs — a "cozy cottage" and "cozy cottage, larger" prompt pair can
+   produce wildly different implied scales. Composite a new asset next to a known-good
+   reference object before accepting it into a set.
+4. **State the scale grammar in the tile spec once per asset set**, not per asset — it
+   is a project-wide constant, like the light direction.
+
+---
+
+## 4. Composition
+
+### Diamond flow
+
+Isometric scenes compose naturally along the **diamond** — the rotated-square silhouette
+that the ground plane traces in true iso, or the flatter rhombus a 2:1 dimetric ground
+plane traces. Strong isometric compositions work *with* this shape rather than fighting
+it:
+
+- Anchor the primary subject near the diamond's centre or along one of its two long
+  diagonals (the natural "read" lines an eye follows in an isometric image), not in a
+  corner.
+- Let secondary elements recede toward the diamond's points, naturally creating depth
+  without needing perspective convergence (which true iso and dimetric explicitly don't
+  have — see `projection-math.md`).
+- For scenes built from a tile grid, keep the *occupied* footprint roughly
+  diamond-shaped or centred, even if the canvas itself is a wider rectangle — an
+  isometric scene cropped to a hard rectangle around a diamond-shaped layout reads as
+  more deliberate than one that fills the rectangle edge-to-edge with tiles.
+
+### Focal density and negative space
+
+- One clear focal point per composition — the tallest structure, the brightest accent
+  colour, the most detailed prop. Everything else is support.
+- Isometric illustrations tolerate, and usually benefit from, **generous negative
+  space** (background/sky/void) around a tightly detailed focal cluster — this is the
+  opposite instinct from "fill every tile," and it's why a lot of professional isometric
+  work (icon sets, hero illustrations) reads as more premium than tile-packed game
+  screenshots. For dense game maps this rule relaxes (see §5 for the different
+  discipline that applies once a map exceeds a single-screen composition), but even
+  there, vary local density — cluster detail, leave breathing room, avoid uniform
+  coverage.
+- Detail density should correlate with visual importance, not be uniform: the focal
+  object gets the most surface detail (trim, texture, small props); background/support
+  objects are simpler silhouettes in the same three-tone system.
+
+### Cutaway conventions
+
+Cutaway (interior-reveal) compositions — "room with the front wall removed" — are one of
+the most common isometric illustration formats and have their own small rule set:
+
+1. **Clean wall removal, never a jagged/torn edge**, unless the piece is deliberately
+   stylised as damage. The convention is a perfectly flat cut, as if a wall simply isn't
+   there — not a broken-off fragment.
+2. **Uniform wall thickness** across every cut wall in the scene. Varying thickness
+   between the left-cut wall and the back-cut wall (if both are shown) breaks the
+   illusion that this is one consistent piece of architecture.
+3. **Show the cut edge as a thin, distinctly-toned strip** (often the material ladder's
+   darkest or a dedicated neutral) so the cut reads intentionally rather than looking
+   like a rendering error.
+4. **Interior lighting stays consistent with the fixed light direction** (§1) even
+   though the "camera" is notionally inside a room that wouldn't naturally receive
+   exterior light — isometric cutaways are a diagram convention, not a physical
+   simulation, and audiences accept the convention as long as it's applied uniformly.
+5. Furniture and props inside a cutaway follow the same scale grammar (§3) as exterior
+   objects — a cutaway is not licence to eyeball interior scale independently.
+
+---
+
+## 5. Typography and labelling
+
+For isometric diagrams, dashboards, and annotated illustrations (as opposed to pure game
+tile art, where in-engine UI systems typically own type rendering):
+
+- **Inter** — a tall-x-height, UI-oriented variable neo-grotesque — is the default
+  choice for dense labels, data callouts, and captions layered over an isometric scene;
+  it holds up at small sizes and pairs cleanly with the flat, geometric character of
+  isometric illustration. (rsms.me/inter)
+- **Space Grotesk** — a more characterful geometric grotesk — is the better choice for
+  hero headings, product branding, or a more editorial/sci-fi voice, where a slightly
+  more stylised letterform is wanted without sacrificing legibility.
+  (fonts.floriankarsten.com/space-grotesk)
+- Both ship as free, open-source variable fonts under the SIL Open Font License —
+  no licensing friction for commercial isometric diagram or product work.
+- **Never rotate or skew text into the isometric plane.** Labels, annotations, and
+  callouts should always sit flat/horizontal in screen space (with a leader line or
+  simple background chip connecting them to the isometric element they describe), even
+  when everything else in the scene is projected. Text skewed into a 30° or dimetric
+  plane becomes measurably harder to read and gains nothing — this is a hard rule, not
+  a style choice.
+- Reserve the accent ladder (§2) for label backgrounds/chips and connector lines, so
+  annotation layers visually belong to the same palette system as the illustration
+  itself rather than looking bolted on.
+- Keep label density low relative to the composition's focal density (§4) — an
+  over-labelled isometric diagram competes with itself.
+
+---
+
+## 6. Consistency checklist (the reviewer's rubric)
+
+Run this against any batch before it ships. Every "no" is a named, fixable defect —
+this is deliberately phrased so it can be read one item at a time against a sheet of
+thumbnails.
+
+- [ ] **Projection**: every asset in the batch uses the same projection and exact angle
+      declared in the tile spec (no true-iso pieces mixed into a 2:1 dimetric set or
+      vice versa — see `projection-math.md` §mislabel table for how this happens).
+- [ ] **Light direction**: is the light direction identical across every asset (same
+      plane is always lightest, same plane always mid, same plane always darkest)?
+- [ ] **Tone count**: does every object stick to the three flat tones per plane
+      (plus contact AO where relevant), with no stray gradients simulating a second
+      light source?
+- [ ] **Palette discipline**: does every colour used trace back to one of the defined
+      ladders (light/tonal, material, accent)? Any off-ladder colours are either a
+      missing ladder entry or a mistake.
+- [ ] **Scale grammar**: does every object's size make sense against the fixed
+      "N tiles = one human" reference? Spot-check by compositing a suspect asset next
+      to a known-good reference object.
+- [ ] **Anchor/footprint**: does every sprite's anchor sit at the visual feet (see
+      `coordinates-depth.md`'s anchor-at-feet rule), and does its declared footprint
+      (1×1, 2×1, 2×2…) match its actual silhouette?
+- [ ] **Edge/alpha hygiene**: no semi-transparent halo fringe, no edge-bleed opaque
+      pixels at the tile boundary (mechanically checkable — see `tile-validate.py` and
+      `ai-refinement.md`'s edge-halo cleanup section for AI-generated tiles).
+- [ ] **Composition**: does the piece have one clear focal point, and is detail density
+      concentrated there rather than spread uniformly?
+- [ ] **Cutaway integrity** (if applicable): clean wall removal, uniform wall thickness,
+      consistent interior lighting.
+- [ ] **Typography** (if applicable): labels flat in screen space, not skewed into the
+      isometric plane; label density restrained.
+- [ ] **Naming/spec conformance**: filenames and metadata match the tile spec's naming
+      convention (`name_direction_variant.png`) and every spec field is actually
+      satisfied, not just declared.
+
+A batch that passes every line here is what "one object drawn by three different hands
+over six months" looks like when the system worked.
+
+---
+
+## 7. Inspiration canon
+
+Study these when calibrating a new project's style, not to copy directly but to see the
+range of what disciplined three-tone, fixed-light isometric work can look like:
+
+| Artist / studio | Known for | Reference |
+|---|---|---|
+| **Peter Tarka** | Bold-colour Cinema 4D isometric work for Apple, Nike, Google — a strong example of confident, limited-palette, high-production-value commercial iso illustration | petertarka.com |
+| **eBoy** | Pioneers of pixel-isometric art ("Pixoramas") — dense, saturated, mega-city compositions built from a rigorous pixel-iso grid; a foundational reference for pixel-art iso specifically | eboy.com |
+| **Rod Hunt** | Detailed isometric maps and pixel-influenced illustration, strong sense of narrative density within a diamond composition | rodhunt.com |
+| **SLYNYRD (Arne / the Pixelblog series)** | The most current, technically rigorous modern pixel-iso tutorial series (Pixelblog 4, 41, 54 — 2:1 dimetric discipline, Aseprite workflow); see `pixel-art-workflow.md` for the technical detail this canon entry points to | slynyrd.com |
+| **r/isometric** | Community showcase and critique — useful for range-finding "what does professional vs hobbyist iso work look like" across many hands and styles, and for spotting common failure modes in the wild | reddit.com/r/isometric |
+
+Additional working artists worth a look when researching a specific niche: **Marcelo
+Colmenero** (@isometricpixelart), **Totto Renna / Supertotto**, **Gustavo Zambelli /
+zamax** (Dribbble), and **Jude Buffum** — via the curated list "26 Best Isometric
+Artists" (Huntlancer) if a broader survey is needed. Dribbble and Behance remain the
+highest-volume public showcases for isometric work generally; useful for portfolio
+benchmarking, less useful for technical problem-solving (use tool-specific
+communities/docs for that — Adobe Community, the Affinity forum, project GitHub repos).
+
+---
+
+## Sources
+
+- Engineering and Aesthetic Standards for Isometric Design (SRC-B) — three-tone shading
+  terminology, AI prompt "three-tone shading" keyword usage, fonts/colour-system survey
+  distilled into the three-ladder pattern.
+- compass_artifact isometric resource library (SRC-A) — artist canon (Peter Tarka, Rod
+  Hunt, eBoy, SLYNYRD Pixelblog, curated artist lists), r/isometric and
+  Dribbble/Behance community pointers.
+- Isometric Design Resource Library (SRC-C, converted PDF) — fonts/colour/grid resource
+  table (Inter, Space Grotesk, SIL Open Font License terms), the "design a light ladder,
+  a material ladder, and one accent ladder" palette doctrine, and the canonical learning
+  sequence "grid logic → primitive solids → plane switching → material and shadow
+  logic → scene composition → typography and annotations → export discipline."
+- [color-ops SKILL.md](../../color-ops/SKILL.md) — OKLCH mechanics, perceptual ramp
+  generation, gamut handling (cross-linked, not restated here).
+- [rsms.me/inter](https://rsms.me/inter/) — Inter font family and license.
+- [fonts.floriankarsten.com/space-grotesk](https://fonts.floriankarsten.com/space-grotesk) — Space Grotesk font family and license.

+ 388 - 0
skills/isometric-ops/references/svg-vector-generation.md

@@ -0,0 +1,388 @@
+# SVG & Programmatic Vector Generation for Isometric Assets
+
+Scope: libraries and hand-rolled techniques for producing isometric artwork as **vector
+code** — SVG paths, CSS-driven DOM transforms, and diagram-editor primitives — rather
+than pixel/raster output. This file is the library survey and the export pipeline; the
+underlying plane matrices and angle derivations live in
+[`projection-math.md`](projection-math.md) — link to them, don't restate them here.
+
+For pixel-canvas (non-vector) isometric drawing, see
+[`pixel-art-workflow.md`](pixel-art-workflow.md). For 3D-scene-to-sprite rendering, see
+[`threejs-orthographic.md`](threejs-orthographic.md) (three.js) and
+[`blender-prerender.md`](blender-prerender.md) (Blender). For DOM/CSS-only isometric
+layout (no SVG), see [`css-isometric.md`](css-isometric.md).
+
+**Projection first.** Every library below defaults to **true isometric** (30° ground
+axes, 120° separation) unless stated otherwise — none of them natively emit **2:1
+dimetric (commonly called isometric in games)** tile art. If your target is a tile
+engine, generate the SVG at true-iso proportions for illustration use, or drive the
+affine matrices yourself with the 2:1 dimetric constants from `projection-math.md` —
+do not expect these libraries to produce game-tile-aligned output out of the box.
+
+---
+
+## 1. Decision table — which vector tool for which job
+
+| Need | Tool | Why |
+|---|---|---|
+| Clean SVG isometric shapes, typed API, engineering/diagram precision | **`@elchininet/isometric`** | Actively maintained, TypeScript, SVG-native, Apache-2.0 |
+| Declarative "just add data attributes to HTML" transforms | **`isometric-css`** | Zero imperative code; reads `data-*` attrs, applies CSS transforms at load |
+| Diagramming/editor tool (nodes, links, interactive graphs) rendered in iso | **JointJS** | Full diagramming library; iso is a technique applied on top, not a primitive |
+| Retro pixel-neat primitives (bricks, cubes, slopes) on `<canvas>` | **obelisk.js** | Still referenced for its exact pixel-neat renderer; **10 years stale — decoration only** |
+| Simple "hello cube" canvas demos, lighting-shaded primitives | **Isomer.js** | Friendliest API for teaching/prototyping; **10 years stale — decoration only** |
+| Hand-rolled control over every path/curve | **Raw SVG + the plane matrices** | No dependency; full control; more code to own |
+
+The two "classic" libraries (obelisk.js, Isomer.js) and the modern pair
+(`@elchininet/isometric`, `isometric-css`) solve different problems — the modern pair is
+vector/DOM output for illustration and UI; the classic pair is pixel-canvas rendering.
+Pick based on **output medium** (SVG/DOM vs `<canvas>` pixels) first, maintenance status
+second.
+
+---
+
+## 2. `@elchininet/isometric` — SVG-native isometric library
+
+**Package:** [`@elchininet/isometric`](https://www.npmjs.com/package/@elchininet/isometric)
+on npm · source at [github.com/elchininet/isometric](https://github.com/elchininet/isometric).
+Verified live against the npm registry (2026-07): **version 4.0.0, license Apache-2.0**,
+written in TypeScript, ships both CJS (`index.js`) and ESM (`esm/index.js`) builds plus
+a Node-specific entry point (`./node`, needs `jsdom` as a peer dependency for
+server-side SVG generation without a browser DOM). This resolves the brief's contested
+fact #2 — earlier survey material (SRC-A) lists a separately-named "Isometric.js" (46
+GitHub stars, TS, SVG, last major update Dec 2021) as a *different*, less-maintained
+project; do not confuse the two. `@elchininet/isometric` is the actively maintained one
+and the one this skill recommends.
+
+### What it models
+
+The library's core abstraction is an **isometric canvas** onto which you place
+**planes** (the visible faces of a 3D-looking object) built from **paths** — sequences
+of moves in one of three plane orientations (top, right, left — matching the three
+visible faces of a cube under true isometric projection). It does the projection math
+internally so you author in a simple 2D-per-plane coordinate space and the library
+emits the correctly transformed SVG.
+
+### Basic usage pattern
+
+```javascript
+import {
+  IsometricCanvas,
+  IsometricGroup,
+  IsometricPlane,
+  PLANES
+} from '@elchininet/isometric';
+
+const canvas = new IsometricCanvas({
+  container: '#scene',
+  backgroundColor: '#F0F0F0',
+  scale: 60
+});
+
+const cubeGroup = new IsometricGroup({ planes: 'TopLeftRight' });
+
+const top = new IsometricPlane({
+  planeView: PLANES.TOP,
+  height: 1, width: 1,
+  fillColor: '#EDC951',
+  strokeColor: '#000000',
+  strokeWidth: 2
+});
+const left = new IsometricPlane({
+  planeView: PLANES.LEFT,
+  height: 1, width: 1,
+  fillColor: '#CC333F',
+  strokeColor: '#000000',
+  strokeWidth: 2
+});
+const right = new IsometricPlane({
+  planeView: PLANES.RIGHT,
+  height: 1, width: 1,
+  fillColor: '#00A0B0',
+  strokeColor: '#000000',
+  strokeWidth: 2
+});
+
+cubeGroup.addChildren(top, left, right);
+canvas.addChild(cubeGroup);
+```
+
+This produces a shaded cube face-set consistent with the **three-tone plane doctrine**
+(top lightest, one side mid, one side dark — see [`style-guide.md`](style-guide.md)):
+assign the top plane your lightest tone, one side plane your mid tone, and the
+remaining side plane your darkest tone, matching a single fixed light direction across
+every asset in a set.
+
+### When to choose it
+
+- You need SVG output that stays **editable** (paths, not baked pixels) for a design
+  system, icon set, or diagram tool.
+- You're generating isometric charts/dashboards/explainer graphics programmatically
+  (data-driven SVG, e.g. rendering a warehouse layout from a JSON manifest).
+- You want engineering-drawing precision — the library targets exactly this use case
+  (its own description: "optimized for engineering drawings and responsive vector
+  design systems").
+- You're building **within a modern JS/TS toolchain** (bundler-friendly ESM export) and
+  want a typed API rather than raw path arithmetic.
+
+### When *not* to choose it
+
+- Game tile art at 2:1 dimetric proportions — the library's projection is true
+  isometric; retargeting it to 2:1 dimetric means overriding its internal scale
+  parameters in ways the library isn't designed around. Use the raw affine matrices
+  from `projection-math.md` instead, or bake pixel art per `pixel-art-workflow.md`.
+- Photorealistic or heavily styled illustration — this is a geometric-primitive
+  library, not a rendering engine. Pair it with hand-tuned fills/gradients or fall back
+  to Blender/AI pipelines (`blender-prerender.md`, `ai-generation.md`) for that register.
+
+---
+
+## 3. Hand-rolled SVG — diamond, cube, and prism path recipes
+
+When you need output the libraries above don't give you directly (custom curves,
+non-cube footprints, tight file-size budgets, zero runtime dependency), construct the
+paths yourself from the plane matrices already derived in `projection-math.md` §
+"2D affine matrices for top/left/right planes." Do not re-derive those matrices here —
+this section only shows how to turn them into `<path>` data.
+
+### The three-plane decomposition
+
+Any iso "box" (tile, cube, wall segment, prop) decomposes into up to three visible
+rhombus/parallelogram faces: **top**, **left**, **right**. Each face is a unit square
+in local plane space, mapped to screen space by the corresponding 2×2 matrix from
+`projection-math.md`. For a unit cube at true isometric scale (100% "drawing" scale,
+not the 81.65% "projection" foreshortening — see `projection-math.md` for that
+distinction), the screen-space vertices work out to the classic hexagon-of-three-
+rhombi silhouette.
+
+### Diamond (single ground tile, top face only)
+
+The ground-plane diamond used for a single game tile is the **top** plane transform
+applied to a unit square, at whichever tile aspect you've chosen:
+
+```
+true isometric (illustration):  vertices at (0,0) (0.5, 0.2887) (0, 0.5774) (-0.5, 0.2887)
+2:1 dimetric (game tile, tileW×tileH): vertices at (0,0) (tileW/2, tileH/2) (0, tileH) (-tileW/2, tileH/2)
+```
+
+As an SVG path (2:1 dimetric, `tileW=64`, `tileH=32`, origin at the top vertex):
+
+```xml
+<path d="M 0,0 L 32,16 L 0,32 L -32,16 Z" fill="#8B9A46" stroke="#000" stroke-width="1"/>
+```
+
+Generate this programmatically rather than by hand for any real tile set — see
+`scripts/iso-math.py grid-svg` in this skill, which emits exactly this path shape at
+any `--tile-w`/`--projection` combination and is the canonical ground truth to match
+against.
+
+### Cube / prism (three-plane box)
+
+A cube of edge length `s` at true isometric drawing scale, with the top vertex at the
+origin and axes matching the 30°/120° layout from `projection-math.md`:
+
+```xml
+<g id="cube">
+  <!-- top face -->
+  <path d="M 0,0 L 43.3,25 L 0,50 L -43.3,25 Z" fill="var(--tone-top)"/>
+  <!-- left face -->
+  <path d="M -43.3,25 L 0,50 L 0,100 L -43.3,75 Z" fill="var(--tone-left)"/>
+  <!-- right face -->
+  <path d="M 43.3,25 L 0,50 L 0,100 L 43.3,75 Z" fill="var(--tone-right)"/>
+</g>
+```
+
+(`43.3 ≈ 50·cos(30°)`; this is the top-plane transform from `projection-math.md`
+applied to a unit square scaled by `s=50`.) For an elongated **prism** (a wall, a
+crate that's taller than it is wide), keep the top-face rhombus fixed and extend the
+vertical run of the left/right faces by the extra height — the top-face math never
+changes, only the height term in the side-face paths.
+
+**Verification discipline:** whenever you hand-author a path like the above, check it
+against `scripts/iso-math.py transforms --target svg-top` (and `svg-left`/`svg-right`)
+for the numeric matrix, and against a unit-square round trip — the check vectors in
+`projection-math.md` exist precisely so a hand-written path can be verified rather than
+eyeballed.
+
+### `matrix()` vs raw path coordinates
+
+Two equally valid authoring styles:
+
+1. **Bake the transform into path coordinates** (as above) — portable, no `transform`
+   attribute needed, easiest to hand-edit vertex-by-vertex.
+2. **Draw in local unit-square space, apply `transform="matrix(a,b,c,d,e,f)"`** using
+   the exact matrix values from `projection-math.md`'s SVG `matrix()` equivalents —
+   better when you're programmatically instancing many copies of the same base shape
+   (stamp a `<use>` reference and vary only the matrix's translation terms).
+
+Prefer (2) for anything you're generating in bulk (a tileset, a repeated prop); prefer
+(1) for one-off hand-authored illustrations you'll hand-tune afterward.
+
+---
+
+## 4. `isometric-css` — declarative HTML-attribute transforms
+
+**Package:** [`isometric-css` (elchininet/isometric-css)](https://github.com/elchininet/isometric-css)
+— by the same author as `@elchininet/isometric`, but a different tool solving a
+different problem: instead of generating SVG programmatically, it reads **declarative
+HTML `data-*` attributes** on existing DOM elements and applies the CSS 2D/3D
+transforms (`skew`, `scale`, `rotate`) needed to make them render isometrically, with
+no imperative JavaScript required at the call site.
+
+### When to choose it over the CSS recipes in `css-isometric.md`
+
+`css-isometric.md` documents the *raw* CSS transform recipes (the `rotate/skewX/scaleY`
+family and the `preserve-3d` stack) that you write and maintain by hand.
+`isometric-css` is worth adding as a dependency when:
+
+- You have **many** DOM elements needing the same class of iso transform and want a
+  single declarative convention (`data-isometric="left"` etc.) instead of hand-copied
+  CSS classes per element.
+- You want the library to keep the transform math correct as you iterate on markup,
+  rather than re-deriving `skewX`/`scaleY` values by hand each time.
+- The project is markup-heavy (a marketing site, a component library) rather than
+  canvas/SVG-heavy.
+
+Skip it — and just write the CSS directly per `css-isometric.md` — for a handful of
+one-off tiles/cards, or when you need tight control over exact transform-origin and
+stacking-context behaviour that a declarative layer would obscure.
+
+---
+
+## 5. JointJS — isometric diagrams in a full diagramming library
+
+[JointJS](https://www.jointjs.com/) is a general-purpose **diagramming and node-link
+graph library**, not an isometric-specific tool — but it ships a documented technique
+for rendering its diagrams in isometric perspective, described in the JointJS blog
+post ["Isometric diagrams"](https://www.jointjs.com/blog/isometric-diagrams)
+(verified live, 2026-07). The approach: JointJS elements are laid out on a normal 2D
+canvas, then an isometric transform is applied to the SVG group containing the
+diagram, exploiting the same top/left/right plane math as everything else in this
+file — JointJS's contribution is the interactive diagram-editing layer (draggable
+nodes, routed links, custom cell views) on top.
+
+### When to choose it
+
+Choose JointJS specifically when the deliverable is an **editable diagram/tool**
+rather than static artwork — e.g. an internal warehouse-layout editor, a network
+topology visualizer rendered in iso, a floor-plan tool where users drag rooms around
+and the tool keeps the isometric perspective consistent. JointJS is dual-licensed
+(open-source core plus a commercial Pro tier for advanced diagramming features) —
+check current licensing at [jointjs.com](https://www.jointjs.com/) before committing a
+production build to it.
+
+Do **not** reach for JointJS just to draw static isometric illustrations or game
+tiles — it's a full diagramming framework with a proportionally large footprint; the
+plane matrices plus raw SVG (§3) or `@elchininet/isometric` (§2) are lighter and more
+direct for that job.
+
+---
+
+## 6. Maintenance caveats — obelisk.js and Isomer.js
+
+Both **obelisk.js** ([github.com/nosir/obelisk.js](https://github.com/nosir/obelisk.js))
+and **Isomer.js** ([jdan.github.io/isomer](https://jdan.github.io/isomer/)) are
+**roughly a decade stale** — obelisk.js's README still documents its "1.2.0 Release"
+CommonJS rewrite as the newest news, and Isomer.js's last meaningful activity predates
+most of the modern TypeScript tooling ecosystem. Both remain MIT-licensed and both
+still work (they're small, dependency-light, and the isometric math they implement
+doesn't change), which is why they still show up in tutorials and demos.
+
+**Rule for this skill: decoration only, never games.** Acceptable uses:
+
+- A one-off explainer graphic, a blog-post demo, a teaching example, a nostalgia
+  effect (the well-known [jasonlong/isometric-contributions](https://github.com/jasonlong/isometric-contributions)
+  browser extension, which renders a GitHub contribution graph as an interactive iso
+  pixel model, is exactly this register).
+- Server-side pixel rendering via `node-canvas` for a static, rarely-regenerated asset.
+
+Unacceptable uses:
+
+- Any shipping game or interactive tileset — an unmaintained rendering core is a
+  liability the moment you need a new browser API, a security patch, or a performance
+  fix that will never come. Use engine-native tilemaps (Godot 4 `TileMapLayer`, Unity,
+  Phaser 3's isometric plugin) per [`engine-integration.md`](engine-integration.md), or
+  hand-roll the transform yourself (§3 above, or `coordinates-depth.md`) with a
+  dependency you actually control.
+- Anything expected to receive ongoing feature work — there is no upstream to send a
+  PR to that will plausibly get merged.
+
+### obelisk.js's pixel-neat angle — resolving the ~22.6° vs 26.565° confusion
+
+The brief's contested fact #1: sources disagree between "22.6°" and arctan(1/2) =
+26.565° for obelisk.js's pixel stepping. **Verified against the obelisk.js README
+directly** (github.com/nosir/obelisk.js, 2026-07): the library states its own angle as
+**22.6°**, derived from its literal "1:2 pixel dot arrangement" — i.e. the actual
+measured slope produced by its pixel-level line-rasterization algorithm, not the ideal
+geometric ratio. This is numerically distinct from — and should not be conflated with —
+the **26.565°** (`arctan(1/2)`) ground-truth angle used everywhere else in this skill
+for "2:1 dimetric" tile geometry (see the canonical constants table in
+`projection-math.md`). The obelisk.js README asserts 22.6° as the outcome of its
+pixel-dot algorithm without publishing the exact derivation; treat that figure as
+empirically/implementation-specific to the library's rasterizer rather than a value
+you can re-derive from a clean arctan ratio the way 26.565° (`arctan(1/2)`) or 35.264°
+(`arctan(1/√2)`) can be. **When citing an angle for 2:1 dimetric tile design, use
+26.565° (the canonical figure).** Cite obelisk.js's 22.6° only when
+specifically discussing obelisk.js's own rendering behavior, and always attribute it
+to the library's README, not to "2:1 isometric" generally — conflating the two is
+exactly the kind of mislabeling this skill exists to prevent.
+
+---
+
+## 7. Export & optimisation order
+
+SVG produced by any of the above — or exported from Illustrator/Affinity/Figma per the
+plane-matrix and SSR techniques in `projection-math.md` — degrades in one predictable
+way if you skip steps: **"compressed but still bloated"**, where a cleanup pass shrinks
+file size somewhat but the underlying path data stays fragmented (hundreds of tiny
+segments instead of a few clean curves), because the artwork itself was never
+simplified before export.
+
+**The canonical order** (do not compress before you simplify):
+
+1. **Simplify paths in the authoring tool** — reduce anchor points, merge redundant
+   sub-paths, flatten unnecessary groups, *before* exporting. This is the step that
+   actually removes path fragmentation; no downstream optimiser can undo poor
+   authoring-time path hygiene.
+2. **Export SVG** (or PNG, if you're producing a raster derivative alongside).
+3. **Run [SVGO](https://github.com/svg/svgo)** (CLI/Node, MIT license — current major
+   version 4.x as of 2026-07, verified against the npm registry) — the canonical
+   automated SVG optimiser: strips editor metadata, collapses redundant groups,
+   rounds coordinate precision, merges paths where safe. Or use
+   **[SVGOMG](https://jakearchibald.github.io/svgomg/)**, the browser GUI built on the
+   same SVGO engine, for one-off files or visually verifying the optimisation's effect
+   before committing to a build-pipeline config.
+4. **Compress raster derivatives** (any PNG/WebP/AVIF exports generated alongside or
+   from the SVG) with a dedicated raster optimiser — ImageOptim (macOS, lossless,
+   bundles SVG tooling too) or Tinify/TinyPNG (API-backed, cross-platform, good for
+   CI pipelines) — SVGO does not touch raster data.
+5. **Validate in-browser/devtools** — confirm the optimised SVG still renders
+   identically (no clipped viewBox, no dropped `fill`/`stroke` inheritance from a
+   collapsed group) and check the actual delivered byte size, not just "SVGO ran
+   successfully."
+
+This order preserves editability the longest (you still have clean, simplified source
+paths if you need to revisit the artwork) and is the one order that reliably avoids
+the bloated-after-compression failure mode. Skipping step 1 — running SVGO directly on
+an unsimplified export — is the single most common cause of "why is this icon still
+40KB after I ran SVGO."
+
+For a config starting point, run SVGO with its default preset first and only add
+custom plugin overrides (e.g. disabling `removeViewBox` if you rely on implicit
+sizing, or `convertPathData`'s precision setting for very small icon grids) once you've
+confirmed the default output visually.
+
+---
+
+## Related references in this skill
+
+- [`projection-math.md`](projection-math.md) — the plane matrices, angle derivations,
+  and numeric ground-truth checks that every recipe in this file builds on.
+- [`css-isometric.md`](css-isometric.md) — DOM/CSS-only isometric layout (the sibling
+  of `isometric-css` §4, for hand-rolled recipes).
+- [`coordinates-depth.md`](coordinates-depth.md) — cart↔iso coordinate transforms and
+  depth-sort doctrine, for when your SVG output represents a *scene* (multiple tiles/
+  props) rather than a single illustration.
+- [`pixel-art-workflow.md`](pixel-art-workflow.md) — the raster/pixel-canvas sibling
+  workflow for 2:1 dimetric pixel art (obelisk.js's actual home turf, done properly).
+- [`style-guide.md`](style-guide.md) — the three-tone plane-shading doctrine referenced
+  in §2's cube example.

+ 441 - 0
skills/isometric-ops/references/threejs-orthographic.md

@@ -0,0 +1,441 @@
+# Three.js Orthographic — the isometric delta
+
+This file owns **only what is isometric-specific** about three.js: the orthographic
+camera rig, the two iso idioms, frustum sizing and its resize gotcha, pixel-perfect
+sprite export, constraining `OrbitControls` to keep the iso feel, iso-consistent
+lighting/shadows, and baking 8-direction sprites from a scene.
+
+**Boundary — cross-link, do not duplicate:**
+- General three.js scaffolding (minimal scene, animation loop, `OrbitControls` basics,
+  three-point lighting, `InstancedMesh`, post-processing/bloom, custom `ShaderMaterial`)
+  lives in the sibling **`genart-ops`** skill (§1 "Three.js — Scene Scaffolding"). This
+  file assumes you already have a `renderer`, `scene`, and render loop from there.
+- App/game-scale three.js (GLTF asset pipeline, react-three-fiber, `InstancedMesh` for
+  large maps, draw-call budgeting) is the sibling **`threejs-ops`** skill's territory.
+- Directional-light colour ramps and perceptual palette work belong to **`color-ops`**;
+  the three-tone plane doctrine referenced here is codified in
+  `references/style-guide.md`.
+- The projection constants (35.264°, 81.65 %, √(3/2), the rig-angle table) are derived in
+  `references/projection-math.md` — this file cites them, it does not re-derive them.
+
+> **Terminology.** "Isometric" here means the **true isometric projection** (all three
+> cube faces equal). Where a rig instead produces the **2:1 dimetric** look (commonly
+> called isometric in games — top face twice as wide as tall), it is labelled
+> **2:1 dimetric** explicitly. See `references/projection-math.md`.
+
+---
+
+## 1. Why orthographic, and the projection decision first
+
+An isometric image has **no perspective foreshortening**: distant geometry is drawn at
+the same scale as near geometry, parallel world lines stay parallel on screen. A
+`PerspectiveCamera` cannot do this — its whole job is foreshortening. So **step one of any
+three.js iso scene is to use `THREE.OrthographicCamera`**, never `PerspectiveCamera`.
+
+The second decision — made before you touch the rig — is *which projection*:
+
+| Goal | Projection | Camera pitch (rotX) | Result |
+|---|---|---|---|
+| Web/vector "true iso" look, all faces equal | **True isometric** | **54.736°** | cube's three visible faces render identical |
+| Game tiles, pixel art, "isometric" games | **2:1 dimetric** | **60°** | cube top renders exactly 2× wide as tall |
+
+Both are correct three.js orthographic rigs; they are *different projections*. Picking the
+wrong one is the most common iso-in-three.js mistake — a "true iso" 54.736° rig will make
+your 64×32 game tiles fail to tile, and a 60° dimetric rig will not give the symmetric
+all-faces-equal illustration look. Decide, then rig.
+
+Source: SRC-A ch.2 (Three.js section); rig-angle table in `references/projection-math.md`.
+OrthographicCamera reference: <https://threejs.org/docs/#api/en/cameras/OrthographicCamera>.
+
+---
+
+## 2. The two idioms — position/lookAt vs exact rotation
+
+There are two ways to point an orthographic camera into iso. **Both are used in the wild;
+they are not equivalent.** Prototype with the first, switch to the second when you need
+mathematically exact iso.
+
+### Idiom A — position/lookAt (the fast prototype)
+
+Place the camera on the scene diagonal and aim it at the origin. Simple, forgiving, and
+what most CodePen iso demos use.
+
+```js
+// genart-ops gives you renderer + scene; this is the iso camera on top of that.
+const aspect = container.clientWidth / container.clientHeight;
+const d = 60;                                   // half-height of the frustum in world units
+const camera = new THREE.OrthographicCamera(
+  -d * aspect, d * aspect,                       // left, right
+   d,          -d,                               // top, bottom
+   1, 2000                                        // near, far
+);
+camera.position.set(10, 10, 10);                 // equal on all three axes => iso diagonal
+camera.lookAt(scene.position);                   // (0, 0, 0)
+```
+
+`position.set(10,10,10)` sits the camera on the (1,1,1) diagonal. Because the three
+components are **equal**, the view direction is the iso diagonal — this yields a *true*
+isometric view (all faces foreshortened equally). For a 2:1 dimetric look with this idiom,
+raise the Y component relative to X/Z until the cube top renders 2× wide as tall (an
+`(x, y, z)` with `y/√(x²+z²) = tan 30° = 0.577…`, e.g. `(10, 8.165, 10)`), or use Idiom B
+with `rotX = 60°`, which is cleaner and exact.
+
+*Source: CodePen `notjiam/EqyGOa` "Isometric Camera and Colors" (SRC-A). Matt DesLauriers'
+Frontend Masters "Creative Coding with Canvas & WebGL" iso lesson uses the same
+switch-Perspective-to-Orthographic move for the Monument Valley look (SRC-A).*
+
+### Idiom B — exact rotation (mathematically true iso)
+
+Set the tilt directly instead of trusting eyeballed positions. This is the
+copy-paste-exact true-iso rig.
+
+```js
+const camera = new THREE.OrthographicCamera(
+  -d * aspect, d * aspect, d, -d, 1, 2000
+);
+camera.rotation.order = 'YXZ';                   // yaw (Y) applied before pitch (X): order matters
+camera.rotation.y = -Math.PI / 4;                // -45°  azimuth (spin around the diagonal)
+camera.rotation.x = Math.atan(-1 / Math.sqrt(2));// -35.264°  the true-iso tilt
+camera.position.set(0, 0, 0);
+camera.translateZ(500);                          // pull the camera back along its own -Z
+```
+
+The two magic numbers:
+
+- **`rotation.y = -π/4` (−45°)** — spins the camera so a world corner faces the viewer.
+- **`rotation.x = atan(-1/√2) = −35.264389°`** — the **canonical true-iso tilt**. It is
+  `−arctan(1/√2) = −arcsin(1/√3)`, the same 35.264° that appears everywhere in
+  `references/projection-math.md` (cube-tilt angle; `cos 35.264° = √(2/3) = 0.81650`, the
+  axonometric foreshortening). `rotation.order = 'YXZ'` is required so the −45° yaw is
+  applied *before* the pitch; with the default `'XYZ'` order the axes compose in the wrong
+  sequence and the tilt is off.
+
+Verify numerically: `Math.atan(-1/Math.sqrt(2))` → `-0.6154797086703873` rad →
+`-35.26439°`. And `90° − 35.264° = 54.736°`, which is the Blender `rotX` for true iso —
+the two rigs describe the same projection from camera-space vs world-space (see
+`references/blender-prerender.md`).
+
+For **2:1 dimetric** with Idiom B, replace the pitch with the 30°-elevation value:
+`camera.rotation.x = -Math.atan(1/2)` is **not** what you want (that is the
+ground-line 26.565° angle) — the dimetric camera *pitch* is 60° from vertical / 30°
+elevation, i.e. `camera.rotation.x = -(Math.PI/2 - Math.PI/6) = -Math.PI/3` (−60°). The
+tell is the render: the cube top is exactly twice as wide as tall.
+
+*Source: react-three-fiber discussion #895 (SRC-A) for the `atan(-1/√2)` rotation idiom.*
+
+---
+
+## 3. Frustum sizing and the resize-recompute gotcha
+
+An `OrthographicCamera`'s `left/right/top/bottom` define a **fixed-size viewing box** in
+world units. Unlike a perspective camera (where you just update `.aspect`), an ortho
+camera has *no* aspect field — the aspect ratio is baked into the left/right vs top/bottom
+spread. **If you don't recompute the frustum on resize, the scene stretches.** This is the
+single most common iso-three.js bug (three.js forum #37894, cited in SRC-A).
+
+Pick a **`viewSize`** (the world-unit half-height the frustum should always show) and drive
+left/right from the container aspect:
+
+```js
+const viewSize = 60;                             // world units visible top-to-bottom (half)
+
+function resizeIsoCamera(camera, renderer, container) {
+  const w = container.clientWidth;
+  const h = container.clientHeight;
+  const aspect = w / h;
+
+  camera.left   = -viewSize * aspect;
+  camera.right  =  viewSize * aspect;
+  camera.top    =  viewSize;
+  camera.bottom = -viewSize;
+  camera.updateProjectionMatrix();               // MANDATORY after touching frustum bounds
+
+  renderer.setSize(w, h);
+  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+}
+
+const ro = new ResizeObserver(() => resizeIsoCamera(camera, renderer, container));
+ro.observe(container);
+resizeIsoCamera(camera, renderer, container);    // run once on init
+```
+
+Rules:
+
+- **Always call `camera.updateProjectionMatrix()`** after mutating `left/right/top/bottom`.
+  Forgetting it is why "I changed the frustum but nothing happened."
+- Anchor on **half-height** (`top = viewSize`, `bottom = -viewSize`) and let width follow
+  aspect, so the vertical scale of your iso content is stable across window sizes — content
+  reveals/hides horizontally instead of scaling.
+- `renderer.setPixelRatio(Math.min(devicePixelRatio, 2))` caps HiDPI cost; for
+  pixel-perfect sprite work (§4) you often force `1` instead.
+
+---
+
+## 4. Pixel-perfect ortho — mapping world units to CSS px, and sprite export
+
+For crisp sprite export (baking iso tiles/objects to PNG), you want an **exact,
+integer** relationship between world units and output pixels — otherwise edges land on
+fractional pixels and blur.
+
+### 4.1 Exact world→pixel scale
+
+Because ortho has no depth-scaling, the mapping is a single constant:
+
+```
+pixelsPerWorldUnit = outputHeightPx / (2 * viewSize)      // frustum is 2*viewSize tall
+```
+
+To make one world unit equal exactly *N* device pixels, invert it — choose `viewSize` from
+the desired output height:
+
+```js
+// Want a 512px-tall render where 1 world unit == 8 px?  viewSize = 512 / (2*8) = 32.
+const targetPx = 512;
+const pxPerUnit = 8;
+const viewSize = targetPx / (2 * pxPerUnit);     // = 32
+```
+
+Then render at `renderer.setPixelRatio(1)` and a canvas whose height is `targetPx`, and
+disable any anti-aliasing you don't want smearing sprite edges
+(`new THREE.WebGLRenderer({ antialias: false })`; set
+`texture.magFilter = THREE.NearestFilter` on pixel-art textures — see
+`references/pixel-art-workflow.md`).
+
+### 4.2 Render-to-target and export at 1×/2×/4×
+
+Bake to an offscreen `WebGLRenderTarget` (transparent background), read it back, and emit
+PNG data at multiple scales for @1x/@2x/@4x asset sets:
+
+```js
+function bakeSprite(renderer, scene, camera, sizePx, scale = 1) {
+  const px = sizePx * scale;
+  const rt = new THREE.WebGLRenderTarget(px, px, {
+    minFilter: THREE.NearestFilter,
+    magFilter: THREE.NearestFilter,
+    format: THREE.RGBAFormat,                     // alpha => transparent background
+  });
+
+  const prevClear = renderer.getClearAlpha();
+  renderer.setClearColor(0x000000, 0);            // fully transparent
+  renderer.setRenderTarget(rt);
+  renderer.clear();
+  renderer.render(scene, camera);
+
+  const buf = new Uint8Array(px * px * 4);
+  renderer.readRenderTargetPixels(rt, 0, 0, px, px, buf);
+  renderer.setRenderTarget(null);
+  renderer.setClearAlpha(prevClear);
+
+  // WebGL origin is bottom-left; flip rows to top-left for image/PNG conventions.
+  return flipRowsRGBA(buf, px, px);               // Uint8ClampedArray -> ImageData -> canvas -> toBlob
+}
+```
+
+Two export paths:
+
+- **`renderer.domElement.toDataURL('image/png')` / `.toBlob()`** — simplest: render to the
+  on-screen canvas with `preserveDrawingBuffer: true`, then grab a data URL/blob. Good for
+  one-off exports; `preserveDrawingBuffer` has a small perf cost, so gate it behind an
+  export flag rather than leaving it on.
+- **RenderTarget + `readRenderTargetPixels`** (above) — the robust path: transparent
+  background, exact size independent of the visible canvas, and you can loop scales/directions
+  without disturbing the live view. WebGL's pixel origin is **bottom-left**, so flip rows
+  before handing bytes to a top-left `ImageData`/PNG encoder.
+
+For power-of-two/padded packing of the resulting frames into a spritesheet + atlas, hand the
+PNGs to `scripts/sheet-pack.py`.
+
+*Source: SRC-A ch.2 (Three.js OrthographicCamera as the web-native sprite-baking route,
+the alternative to Blender in `references/blender-prerender.md`).*
+
+---
+
+## 5. Constraining OrbitControls to keep the iso feel
+
+`OrbitControls` (general setup is in `genart-ops` §1) will happily let the user drag the
+camera out of iso and re-introduce perspective-looking angles. To let users *orbit* while
+preserving the isometric character, **lock the polar angle** and (optionally) **snap the
+azimuth to 45° steps** — the four/eight canonical iso viewpoints.
+
+```js
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+
+const controls = new OrbitControls(camera, renderer.domElement);
+controls.enablePan     = true;
+controls.enableZoom    = true;                    // ortho zoom = camera.zoom, not dolly
+controls.enableRotate  = true;
+
+// Freeze the tilt to the true-iso polar angle so faces stay equal:
+const isoPolar = Math.acos(1 / Math.sqrt(3));     // = 54.7356°  (complement of 35.264°)
+controls.minPolarAngle = isoPolar;
+controls.maxPolarAngle = isoPolar;                // min == max => tilt is locked
+
+// Optional: allow only the 4 iso corners by snapping azimuth on change.
+controls.addEventListener('change', () => {
+  const step = Math.PI / 2;                        // 90° corners; use Math.PI/4 for 8 directions
+  const snapped = Math.round(controls.getAzimuthalAngle() / step) * step;
+  if (Math.abs(controls.getAzimuthalAngle() - snapped) > 1e-4) {
+    controls.setAzimuthalAngle(snapped);           // three r132+
+  }
+});
+```
+
+Notes:
+
+- **Ortho "zoom" is `camera.zoom`**, not a dolly — `OrbitControls` drives `camera.zoom` and
+  calls `updateProjectionMatrix()` for you. Set `controls.minZoom` / `controls.maxZoom` to
+  bound it. Physically moving the camera closer does nothing for an ortho camera (no
+  foreshortening), which is exactly the property that makes iso work.
+- **`minPolarAngle === maxPolarAngle`** freezes the tilt. Use the true-iso polar angle
+  `acos(1/√3) = 54.7356°` (that is `90° − 35.264°`, the angle down from the world +Y axis).
+  For a locked 2:1 dimetric feel use `acos` of the dimetric elevation instead (30°
+  elevation → polar `60°`).
+- **y-up convention.** three.js is **Y-up**; build your iso ground on the **XZ plane** and
+  treat +Y as elevation/height. This matches the Blender rigs (which are Z-up) once the
+  camera is placed, and keeps `screenY` growing "down the diamond" as tile `(x+z)`
+  increases. Keep this consistent with the coordinate math in
+  `references/coordinates-depth.md`.
+
+---
+
+## 6. Lighting and shadows for isometric
+
+Iso illustration lives or dies on **one consistent light direction across the entire asset
+set** (the three-tone plane doctrine — top lightest, one side mid, one side dark — is
+codified in `references/style-guide.md`). In three.js that means a single
+**`DirectionalLight`** whose angle is fixed and matches the projection, plus fill so the
+dark face never goes black.
+
+```js
+// Key light: fixed 45° azimuth, ~45-55° elevation => a top face + two clearly separated
+// side tones, consistent with the three-tone doctrine.
+const key = new THREE.DirectionalLight(0xffffff, 2.2);
+key.position.set(-6, 10, -6);                     // upper-back-left in XZ-ground space
+key.castShadow = true;
+
+const fill = new THREE.HemisphereLight(0xbfd4ff, 0x30303a, 0.6); // sky/ground fill
+const ambient = new THREE.AmbientLight(0xffffff, 0.25);          // lift the dark face off black
+scene.add(key, fill, ambient);
+```
+
+Shadow rig for iso (the shadow camera is itself orthographic — tighten it to the scene):
+
+```js
+key.shadow.mapSize.set(2048, 2048);
+const sc = key.shadow.camera;                     // an OrthographicCamera
+sc.left = -40; sc.right = 40; sc.top = 40; sc.bottom = -40;   // wrap the whole map footprint
+sc.near = 0.5; sc.far = 200;
+sc.updateProjectionMatrix();
+key.shadow.bias = -0.0005;                        // kill shadow acne on flat iso planes
+renderer.shadowMap.enabled = true;
+renderer.shadowMap.type = THREE.PCFSoftShadowMap; // soft long iso shadows
+```
+
+Doctrine points:
+
+- **One light direction, whole set.** If you bake sprites per-direction (§7), keep the light
+  in **world space fixed** and rotate the *subject*, so every direction is lit identically —
+  do **not** rotate the camera+light together, or each facing gets a different shadow logic.
+- **Soft, long shadows along the plane angles.** `PCFSoftShadowMap` + a low-angle key gives
+  the characteristic long iso shadow; cast onto a large receiver plane, or bake a separate
+  shadow sprite.
+- **Tighten the shadow ortho camera** to the map footprint — a loose shadow frustum wastes
+  resolution and produces chunky shadow edges on the flat iso faces.
+- Directional-light *colour* (warm key / cool fill temperature ramps) is a `color-ops`
+  concern — cross-link, don't tune colour science here.
+
+---
+
+## 7. Baking 8-direction sprites from a three.js scene
+
+The web-native alternative to the Blender 8-direction rig
+(`references/blender-prerender.md`): rotate the **subject** (not the camera) through 8
+yaw steps and export each with the §4 sprite-bake, giving `name_dir_variant.png` frames
+for engines/tilesets.
+
+```js
+// Parent the model to a pivot; rotate the PIVOT, keep camera + light fixed in world space.
+const pivot = new THREE.Group();
+pivot.add(model);
+scene.add(pivot);
+
+const DIRECTIONS = 8;                              // N, NE, E, SE, S, SW, W, NW
+const names = ['n','ne','e','se','s','sw','w','nw'];
+const frames = [];
+
+for (let i = 0; i < DIRECTIONS; i++) {
+  pivot.rotation.y = (i / DIRECTIONS) * Math.PI * 2;   // 45° per step
+  const png = bakeSprite(renderer, scene, camera, 128, 1);  // §4.2
+  frames.push({ name: `hero_${names[i]}`, png });
+}
+// -> write frames to disk, then pack with scripts/sheet-pack.py into a sheet + atlas.
+```
+
+Why rotate the subject and not the camera:
+
+- The **light and shadow stay fixed** in world space, so all 8 facings share identical
+  three-tone shading — a hard requirement for a coherent sprite set (§6).
+- 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.
+
+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
+atlas pivot maps cleanly into Godot/Unity (`references/engine-integration.md`).
+
+*Source: SRC-A ch.2 (Three.js as the programmatic 3D→iso route) and the Blender
+8-direction parented-empty rig it parallels.*
+
+---
+
+## 8. Gotcha recap (three.js iso-specific)
+
+| Gotcha | Fix |
+|---|---|
+| Used `PerspectiveCamera` → things get smaller with distance | Use `OrthographicCamera`; iso requires zero foreshortening (§1). |
+| Chose the wrong rig → game tiles don't tile / illustration not symmetric | Decide **true iso (54.736° pitch)** vs **2:1 dimetric (60° pitch)** first (§1). |
+| `rotation.x` tilt looks wrong | Set `rotation.order = 'YXZ'` before `y=-π/4; x=atan(-1/√2)` (§2 Idiom B). |
+| Scene stretches on window resize | Recompute `left/right/top/bottom` from aspect **and** call `updateProjectionMatrix()` (§3). |
+| Sprite edges blur on export | Force `pixelRatio = 1`, integer `pixelsPerWorldUnit`, `antialias:false` + `NearestFilter` (§4). |
+| Exported PNG is upside-down | WebGL pixel origin is bottom-left; flip rows before `ImageData` (§4.2). |
+| Ortho "dolly" does nothing | Ortho zoom is `camera.zoom` (bound with `min/maxZoom`), not camera distance (§5). |
+| OrbitControls breaks the iso angle | Lock `min/maxPolarAngle` to `acos(1/√3)=54.736°`; optionally snap azimuth (§5). |
+| Each baked direction lit differently | Rotate the **subject**, keep camera+light fixed in world space (§6, §7). |
+| Shadow edges chunky / shadow acne | Tighten the shadow ortho frustum to the footprint; `shadow.bias = -0.0005` (§6). |
+
+---
+
+## Related skills & references
+
+- **`genart-ops`** — general three.js scene scaffolding (renderer, loop, controls, lighting,
+  InstancedMesh, post-processing). Start there; this file is the iso overlay.
+- **`threejs-ops`** — app/game-scale three.js (GLTF pipeline, react-three-fiber,
+  InstancedMesh at map scale).
+- **`color-ops`** — light/palette colour science; directional-light temperature ramps.
+- `references/projection-math.md` — where 35.264°, 54.736°, 81.65 %, √(3/2) are derived.
+- `references/blender-prerender.md` — the Blender counterpart to §7's sprite bake (both rigs).
+- `references/coordinates-depth.md` — feet-anchor + depth-sort the baked frames feed into.
+- `references/engine-integration.md` — importing the packed sheet/atlas into Godot/Unity.
+- `references/pixel-art-workflow.md` — `NearestFilter` and pixel-neat stepping for §4 exports.
+- `references/style-guide.md` — the three-tone plane doctrine §6's lighting serves.
+
+### Source citations
+
+- SRC-A (compass artifact, ch.2 "Programmatic / Code-Based Generation", Three.js section):
+  OrthographicCamera as the base primitive; the position/lookAt vs exact-rotation idioms;
+  the resize-frustum gotcha; three.js as the web-native sprite-baking route.
+- three.js `OrthographicCamera`: <https://threejs.org/docs/#api/en/cameras/OrthographicCamera>
+- CodePen `notjiam/EqyGOa` "Isometric Camera and Colors" — position/lookAt idiom.
+- react-three-fiber discussion #895 — the `camera.rotation.x = Math.atan(-1/Math.sqrt(2))`
+  true-iso tilt.
+- three.js forum thread #37894 — the OrthographicCamera resize distortion.
+- Matt DesLauriers, Frontend Masters "Creative Coding with Canvas & WebGL", isometric lesson
+  — Perspective→Orthographic for the Monument Valley look.
+- OrbitControls: <https://threejs.org/docs/#examples/en/controls/OrbitControls>
+
+*Numeric anchors (verify against `references/projection-math.md`): true-iso tilt
+`atan(1/√2) = 35.26439°`; camera pitch complement `54.73561°`; `cos 35.264° = √(2/3) =
+0.81650`; undo-foreshorten factor `√(3/2) = 1.22474`; 2:1 dimetric camera pitch `60°`
+(30° elevation, `sin 30° = 0.5`).*

+ 336 - 0
skills/isometric-ops/references/tile-spec.md

@@ -0,0 +1,336 @@
+# The Tile Spec — the misaligned-tile vaccine
+
+A **tile spec** is the written contract that every asset in a set must satisfy. It is the
+single most effective defence against the most expensive and most common isometric bug:
+tiles that were each drawn "isometric" but do not align when placed on a grid, because
+each artist (or each AI generation) silently chose a slightly different angle, tile
+height, anchor, or elevation step.
+
+Write the spec **first** — before a single tile is drawn, rendered, or generated. Then
+every downstream step (hand-drawing, Blender pre-render, AI generation, procurement) is
+measured against it, and `scripts/tile-validate.py` mechanically enforces the parts a
+machine can check.
+
+> Boundary: this file owns the **spec discipline** — what to pin and why. The *numbers*
+> you pin come from [`projection-math.md`](projection-math.md) (angles, ratios,
+> transforms). The *runtime placement* rules — anchor-at-feet, y-sort draw order — are
+> derived in [`coordinates-depth.md`](coordinates-depth.md). The *aesthetic* rules —
+> three-tone plane shading, one light direction, palette ramps — live in
+> [`style-guide.md`](style-guide.md). This file references those; it does not restate
+> their derivations.
+
+---
+
+## Why an unspecified set drifts (the failure mode)
+
+Every field below has a default that "looks fine" in isolation and breaks a set in
+aggregate:
+
+| Left unspecified | What each contributor picks | Aggregate failure |
+|---|---|---|
+| Projection / angle | 30° here, 26.565° there, "eyeballed 27°" elsewhere | Grid lines that don't meet; visible seams; roofs that miss walls |
+| Tile W×H | 64×32 and 66×33 and 64×31 | Sub-pixel gaps and 1px overlaps that tile into moiré seams |
+| Unit elevation (px/z) | 8px per step vs 16px | Stacked blocks float or intersect; stairs don't land on floors |
+| Anchor position | center vs feet vs top-left | y-sort ordering flips; objects clip through each other |
+| Footprint grammar | ambiguous 2×2 vs 2×1 | Multi-tile props overlap neighbours or leave holes |
+| Bleed / transparent margin | trimmed tight vs padded | Atlas bleed artifacts; halos on scaled sprites |
+| Light direction | top-left here, top-right there | Set reads as lit by many suns; no visual cohesion |
+
+The vaccine is a spec that pins all of them, plus a validator that fails the build when a
+tile violates the machine-checkable subset.
+
+---
+
+## What a tile spec MUST pin
+
+Every field is mandatory. "Obvious" defaults are exactly what drift, so state them
+explicitly even when they feel redundant.
+
+### 1. Projection + exact angle (the first decision)
+
+Name the projection and its exact ground-axis angle to at least 3 decimal places. Do not
+write "isometric" unqualified — that word is the ambiguity this document exists to kill.
+
+| Projection | Ground-axis angle | Use when |
+|---|---|---|
+| True isometric | **30°** (axes 120° apart; cube tilt 35.264°) | Vector/web illustration where clean pixel tessellation is not required |
+| 2:1 dimetric (commonly called "isometric" in games) | **26.565°** (= arctan 1/2) | Game tiles / pixel art — integer 2px:1px steps tile cleanly |
+| Pixel-neat 1:2 (obelisk-style primitives) | **26.565°** stepping, drawn as a 2px-across / 1px-up dot pattern | Programmatic pixel primitives on a canvas |
+
+> obelisk.js reports **22.6°** for its 1:2 pixel-dot pattern (its README describes "lines
+> with 1:2 pixel dot arrangement, leading to an angle of 22.6 degrees"). That is the
+> pixel-stepping figure the library states for its projection, not the geometric ground-axis
+> angle. The geometric ground-axis angle of a true 2px:1px step is
+> `arctan(1/2) = 26.565°` — pin **26.565°** for the tiling grid. See
+> [`projection-math.md`](projection-math.md) for the derivation and the contested-fact
+> resolution.
+
+**Rule: W = 2·H for any 2:1 dimetric set.** If your tile is 64 wide it must be 32 tall.
+A spec that lists `128×64`, `64×32` is internally consistent; one that lists `64×30` is
+already broken.
+
+### 2. Tile dimensions — W × H
+
+Exact integer pixels. For 2:1 dimetric, W must be even and H = W/2 (so the diamond's
+half-steps land on whole pixels). Canonical modules: **64×32, 128×64, 256×128** (and
+their halves 32×16). Pick **one** base module for a set and derive multi-tile footprints
+from it — never mix 64×32 and 48×24 in the same set.
+
+### 3. Unit elevation — pixels per z-step
+
+How many vertical pixels one unit of height (one "level" / one z-step) occupies. This is
+independent of tile W×H and is the field most often forgotten. A common convention ties
+it to the tile: for a 64×32 tile, a full-height cube face is often the tile height
+(**32px**) or half of it — but **state the number**, do not imply it. If a character is
+one z-step tall and a wall is three, `unit_elevation_px × 3` must be an exact pixel count.
+
+### 4. Anchor position
+
+The pixel (or fractional coordinate) within the sprite that maps to the tile's logical
+grid cell. **Anchor at the visual feet** — the bottom of the ground contact, horizontally
+centered on the tile diamond — never the image center. Center anchors break y-sort depth
+ordering; the *why* is in
+[`coordinates-depth.md`](coordinates-depth.md#8-the-anchor-at-feet-rule-and-why-center-anchors-break-sorting).
+Pin it as one of:
+
+- a named convention: `bottom-center` (feet), or
+- explicit offset: `anchor = (W/2, H_full − footprint_bottom_px)` in image pixels, or
+- a normalized pivot: the value depends entirely on which corner is the origin, and
+  engines disagree — so **state the origin next to the value**. For the feet
+  (bottom-center) the pivot is `(0.5, 0.0)` with a **bottom-left** origin (Unity's
+  normalized-pivot convention, where `SpriteAlignment.BottomCenter = (0.5, 0)`), or
+  `(0.5, 1.0)` with a **top-left** origin. Never write a bare `(0.5, 1.0)`: under a
+  bottom-left origin that is the *top-center* (the head), the exact inverted anchor this
+  document exists to prevent.
+
+### 5. Footprint grammar
+
+The set of allowed ground footprints, in tile units, and how each maps to anchor + draw
+order. Enumerate them:
+
+- `1×1` — single-cell props, floor tiles.
+- `2×1` / `1×2` — walls, fences, benches (occupies two cells along one axis).
+- `2×2`, `3×3`, `N×M` — buildings, large machinery.
+
+For any footprint larger than `1×1`, the spec must say the sprite is depth-sorted by its
+**AABB in iso space**, not by a single anchor point (single-point sorting mis-orders
+large sprites against small ones straddling them — see
+[`coordinates-depth.md`](coordinates-depth.md#9-multi-tile-and-oversized-sprites--aabb-in-iso-space)).
+State the reference corner the footprint grows from (conventionally the back/top cell of
+the diamond).
+
+### 6. Transparent margin / bleed rules
+
+Two separate rules, both required:
+
+- **Transparent margin**: how much empty transparent space is allowed around the art.
+  Prefer **trim-to-content** (zero margin) for atlas packing efficiency, with the anchor
+  expressed relative to the trimmed bounds. If a fixed canvas is required (e.g. for
+  fixed-cell engines), state the exact canvas size and that the art is anchored, not
+  centered.
+- **Edge bleed**: whether opaque pixels are permitted to touch the outermost rows/columns.
+  For trim-to-content tiles this is expected; for fixed-canvas tiles opaque pixels on the
+  outer edge signal the art is clipped. State which regime applies. When packed by
+  [`sheet-pack.py`](../scripts/sheet-pack.py), use its `--padding N` to add a gutter so
+  bilinear filtering never samples a neighbour.
+
+### 7. Palette tokens
+
+Reference a named palette (a preset from
+[`assets/palettes/three-tone-presets.json`](../assets/palettes/three-tone-presets.json)
+or a project token set) and the **maximum colour count** per tile. A pinned `max_colors`
+is both an aesthetic guardrail and a machine check: AI-generated tiles routinely smuggle
+in hundreds of near-duplicate colours and anti-aliased fringes. Palette ramp construction
+(perceptual OKLCH ladders) is [`color-ops`](../../color-ops/SKILL.md) territory; the
+three-tone plane assignment (top lightest → sides mid/dark) is in
+[`style-guide.md`](style-guide.md).
+
+### 8. Light direction
+
+**One** fixed light direction for the entire set (e.g. "sun from upper-left, ~45°
+azimuth"), and which plane is therefore the top (lightest), the lit side (mid), and the
+shadow side (dark). This is a spec field, not a per-tile choice — a set lit by
+inconsistent light directions reads as incoherent no matter how good each tile is. The
+shading doctrine is in [`style-guide.md`](style-guide.md).
+
+### 9. Output format
+
+Pin the delivery format and bit depth: **PNG-32 (RGBA, 8-bit/channel)** for raster
+tiles; SVG for vector; whether pre-multiplied alpha is expected; colour profile (sRGB,
+no embedded ICC unless required). If multiple resolutions ship (`@1x/@2x/@4x`), state the
+base and that higher tiers are exact integer multiples rendered — **not upscaled** — from
+the base.
+
+### 10. Naming convention
+
+A strict, greppable filename grammar. The recommended default:
+
+```
+name_direction_variant.png
+```
+
+- `name` — asset id in `kebab-or-snake` (be consistent), e.g. `crate`, `wall-brick`.
+- `direction` — one of a fixed enum for directional sprites: `n e s w ne nw se sw`
+  (or `0 1 2 3 4 5 6 7` if numeric), matching the Blender/three.js rotation order in
+  [`blender-prerender.md`](blender-prerender.md) / [`threejs-orthographic.md`](threejs-orthographic.md).
+  Omit for non-directional tiles, or use `_flat`.
+- `variant` — palette/state/damage variant, e.g. `snow`, `lit`, `broken`.
+
+Examples: `crate_se_default.png`, `wall-brick_flat_mossy.png`,
+`hero_ne_walk-03.png`. A machine-parseable name lets `sheet-pack.py` group frames and
+lets tooling reason about direction sets without a manifest.
+
+### 11. Scale grammar
+
+The set's *human-scale reference*: **one human figure = N tiles tall** (and, if relevant,
+occupies a `1×1` footprint). Every other object's size is stated relative to this. A door
+is "1.2 humans tall"; a truck is "3 tiles long, 1.5 humans tall". Without a scale grammar,
+a set accumulates mixed-scale objects (a chair as tall as a car) that no amount of correct
+projection can rescue.
+
+---
+
+## The fill-in TEMPLATE (copy verbatim)
+
+Copy this block into `TILE-SPEC.md` at the root of any tileset and fill every field. Keep
+it in the repo next to the art; it is the contract the whole set is measured against.
+
+```markdown
+# Tile Spec — <SET NAME>
+
+version: 1.0
+last-updated: YYYY-MM-DD
+owner: <name / team>
+
+## Projection
+projection:        2:1 dimetric   # true-iso | 2:1-dimetric | pixel-1:2
+ground_angle_deg:  26.565         # 30 (true iso) | 26.565 (2:1 dimetric)
+notes:             "commonly called isometric in games; it is dimetric"
+
+## Grid module
+tile_w_px:         64
+tile_h_px:         32             # MUST equal tile_w_px / 2 for 2:1 dimetric
+unit_elevation_px: 16             # vertical px per z-step (height level)
+
+## Anchor
+anchor:            bottom-center  # feet, horizontally centered on the diamond
+pivot_normalized:  [0.5, 0.0]     # (x,y) with pivot_origin below: feet = 0.0 y
+pivot_origin:      bottom-left    # bottom-left | top-left  (engines disagree)
+                                  # bottom-left → feet = [0.5, 0.0] (Unity BottomCenter);
+                                  # top-left    → feet = [0.5, 1.0]
+
+## Footprints (tile units)
+footprints:        [1x1, 2x1, 2x2]
+large_sort:        aabb-iso       # >1x1 sorted by iso-space AABB, not a point
+footprint_anchor:  back-cell      # reference cell footprints grow from
+
+## Margins & bleed
+margin:            trim-to-content # trim | fixed-canvas
+edge_bleed:        allowed         # allowed (trimmed) | forbidden (fixed canvas)
+atlas_padding_px:  2               # gutter added by sheet-pack --padding
+
+## Palette & light
+palette_ref:       industrial-muted   # preset id or project token set
+max_colors:        24                  # per-tile hard cap
+light_direction:   upper-left-45       # ONE direction for the whole set
+plane_order:       top>light-side>shadow-side
+
+## Output
+format:            PNG-32          # RGBA 8-bit/channel, sRGB, straight alpha
+resolutions:       ["@1x", "@2x"]  # @2x is an exact 2x render, not upscaled
+
+## Naming
+name_grammar:      name_direction_variant.png
+directions:        [n, e, s, w, ne, nw, se, sw]   # or "flat" if non-directional
+variants:          [default, snow, night]
+
+## Scale grammar
+human_tiles_tall:  3               # one human figure = N tiles tall
+scale_notes:       "door 1.2 humans; truck 3 tiles long x 1.5 humans tall"
+```
+
+The block is intentionally YAML-in-Markdown: human-readable in a diff, and trivially
+parseable if you want to feed it to a linter. Keep comments — they carry the *why* that
+stops the next contributor re-litigating a field.
+
+---
+
+## How the spec feeds `tile-validate.py`
+
+Each machine-checkable spec line maps to a check in
+[`scripts/tile-validate.py`](../scripts/tile-validate.py). The validator is the
+enforcement arm of this document: it turns "the spec says 64×32" into a build-failing
+assertion. It exits `0` when a tile conforms and `10` when it finds violations (per the
+Resource Protocol), so it drops into CI as a gate.
+
+| Spec field | Validator flag / check | Violation condition |
+|---|---|---|
+| `tile_w_px` / `tile_h_px` | `--tile-w N --tile-h N` dimension conformance | Image dimensions not equal to (or an exact multiple of) the tile module |
+| `tile_h_px = tile_w_px/2` | implied by the two flags above | W ≠ 2·H for a 2:1 dimetric set |
+| `max_colors` | `--max-colors N` | Distinct colour count exceeds N |
+| `edge_bleed: forbidden` | edge-bleed check | Opaque pixels on the outermost rows/columns of a fixed-canvas tile |
+| `margin` / halo hygiene | alpha-halo detection | % of semi-transparent (`0 < a < 255`) pixels above threshold — the AI-fringe signal |
+| `anchor: bottom-center` | anchor heuristic | Lowest opaque row not roughly horizontally centered on the tile |
+
+Fields the validator **cannot** check mechanically — projection angle intent, light
+direction, palette *choice* (vs count), scale grammar, footprint semantics — remain
+human-review items. The spec still pins them so a reviewer has a concrete rubric; see the
+consistency checklist in [`style-guide.md`](style-guide.md).
+
+### Wiring it into a build
+
+```bash
+# Validate every tile in a set against the spec's numbers, machine-readable output.
+uv run scripts/tile-validate.py tiles/*.png \
+  --tile-w 64 --tile-h 32 --max-colors 24 --json
+# exit 0 = all conform ; exit 10 = at least one violation (fails CI)
+```
+
+Then pack the validated tiles into an atlas whose frame names and padding honour the same
+spec:
+
+```bash
+uv run scripts/sheet-pack.py tiles/ --out atlas.png \
+  --trim --padding 2 --pot
+```
+
+The `name_direction_variant.png` grammar (spec field 10) is what lets `sheet-pack.py`
+produce a deterministic, name-sorted atlas whose frame keys are meaningful to the engine
+importer described in [`engine-integration.md`](engine-integration.md).
+
+---
+
+## Spec review checklist (before a set is "locked")
+
+1. Projection named with an **exact** angle to ≥3 decimals; the word "isometric" is
+   never used unqualified.
+2. `tile_h_px == tile_w_px / 2` for every 2:1 dimetric module (or angles/scales
+   consistent for true iso).
+3. `unit_elevation_px` stated and consistent — a 3-high wall is exactly `3 ×
+   unit_elevation_px`.
+4. Anchor is at the feet and identical across the set; pivot origin (bottom-left vs
+   top-left) stated.
+5. Every allowed footprint enumerated; large sprites flagged for AABB-iso sorting.
+6. Margin/bleed regime chosen; atlas padding stated.
+7. One palette preset, one `max_colors`, one light direction for the whole set.
+8. Output format + resolution tiers pinned; higher tiers are rendered, not upscaled.
+9. Filename grammar fixed and greppable; direction enum matches the render rig.
+10. Scale grammar pins `human = N tiles`; no mixed-scale objects.
+11. `tile-validate.py` runs green in CI against the pinned numbers.
+
+---
+
+## Sources
+
+- Terminology, 2:1-vs-true distinction, and the alignment-bug rationale — Wikipedia
+  *"Isometric video game graphics"* and *"Isometric projection"*
+  (<https://en.wikipedia.org/wiki/Isometric_video_game_graphics>,
+  <https://en.wikipedia.org/wiki/Isometric_projection>); Gustavo Pezzi, *"Isometric
+  Projection in Game Development"*, Pikuma
+  (<https://pikuma.com/blog/isometric-projection-in-games>).
+- Anchor-at-feet and Y-sort depth ordering — Godot 4 `TileMapLayer` / Y-Sort docs; Envato
+  Tuts+ *"Isometric Depth Sorting for Moving Platforms"*.
+- Atlas bleed / padding rationale — texture-atlas bilinear-sampling practice (Kenney
+  tileset conventions; general sprite-packing guidance).
+- Constants (30°, 26.565°, 2:1 module) are pinned by this skill's canonical table; the
+  derivations live in [`projection-math.md`](projection-math.md).

+ 455 - 0
skills/isometric-ops/scripts/check-iso-facts.py

@@ -0,0 +1,455 @@
+#!/usr/bin/env python3
+"""Staleness verifier for isometric-ops's canonical constants and citation graph.
+
+Guards the fast-moving/easy-to-typo facts this skill depends on:
+  - the canonical projection-math constants (26.565, 35.264, 81.65, 86.602,
+    57.735/57.74, 1.22474, 54.736) actually appear in references/projection-math.md
+  - every references/*.md file is cited (linked or path-mentioned) from SKILL.md
+  - the third-party packages named in prose (@elchininet/isometric, isometric-css,
+    svgo) still exist on the npm registry
+
+Two modes (protocol SKILL-RESOURCE-PROTOCOL.md Section 7):
+  --offline (default): parse the shipped markdown, assert the constants are present
+                       and every reference is cited. No network. May block CI.
+  --live:              query the npm registry for the named packages. Advisory
+                       only — exits 7 on any network/registry unavailability,
+                       never blocks a PR.
+
+SKILL.md special-case: while the skill is mid-build its body is a short
+"BUILD IN PROGRESS" placeholder with no reference citations yet. --offline
+detects this placeholder (via the literal marker text) and SKIPS the citation
+check gracefully (noted on stderr, not a failure) rather than failing on an
+incomplete router. The constants check still runs regardless, since the
+reference files it inspects are independent of SKILL.md's state.
+
+Usage:   check-iso-facts.py [--offline | --live] [--json] [--skill-dir DIR] [-q]
+Input:   reads SKILL.md and every references/*.md (resolved relative to this
+         script's parent directory, or --skill-dir)
+Output:  stdout = data only (JSON envelope under --json, else a plain summary)
+Stderr:  headers, progress, notes, errors
+Exit:    0 ok/consistent, 2 usage, 3 not-found, 4 validation (missing constant /
+         uncited reference / unquotable package), 5 missing-dep (curl, --live
+         only), 7 unavailable (registry unreachable, --live only),
+         10 drift (a named package is gone/renamed on the live registry)
+
+Examples:
+  check-iso-facts.py --offline
+  check-iso-facts.py --offline --json | python -m json.tool
+  check-iso-facts.py --live
+  check-iso-facts.py --live -q   # exits 7 (advisory) when npm is unreachable
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import subprocess
+import sys
+from pathlib import Path
+from typing import NoReturn
+
+# Windows consoles default to cp1252; force UTF-8 so em-dashes/degree signs in
+# notes don't raise UnicodeEncodeError or print mojibake (repo-standard fix).
+for _stream in (sys.stdout, sys.stderr):
+    try:
+        _stream.reconfigure(encoding="utf-8")  # type: ignore[attr-defined]
+    except (AttributeError, ValueError):
+        pass
+
+
+class Term:
+    """Tiny ANSI helper mirroring skills/_lib/term.sh (bash-only per
+    TERMINAL-DESIGN.md Section 9; this is the matching Python inline port).
+    Honors FORCE_COLOR / NO_COLOR / TERM_ASCII; glyphs fall back to ASCII on
+    TERM_ASCII or a non-UTF stream encoding."""
+
+    _C = {"green": "\033[32m", "yellow": "\033[33m", "orange": "\033[38;5;208m",
+          "red": "\033[31m", "cyan": "\033[36m", "dim": "\033[2m", "off": "\033[0m"}
+    _GLYPH = {"ok": "✓", "bad": "✗", "warn": "▲", "skip": "—",
+              "na": "—", "unknown": "?"}
+    _ASCII = {"ok": "+", "bad": "x", "warn": "!", "skip": "-", "na": "-", "unknown": "?"}
+    _MARK_COLOR = {"ok": "green", "bad": "red", "warn": "orange", "skip": "dim",
+                   "na": "dim", "unknown": "yellow"}
+
+    def __init__(self, stream=sys.stderr):
+        enc = (getattr(stream, "encoding", "") or "").lower()
+        self.ascii = (os.environ.get("TERM_ASCII") == "1"
+                      or os.environ.get("FLEET_ASCII") == "1" or "utf" not in enc)
+        if os.environ.get("FORCE_COLOR"):
+            self.color = True
+        elif (os.environ.get("NO_COLOR") is not None or os.environ.get("TERM") == "dumb"
+              or not getattr(stream, "isatty", lambda: False)()):
+            self.color = False
+        else:
+            self.color = True
+
+    def c(self, name, text):
+        return f"{self._C.get(name, '')}{text}{self._C['off']}" if self.color else text
+
+    def mark(self, state):
+        return self.c(self._MARK_COLOR.get(state, ""),
+                      (self._ASCII if self.ascii else self._GLYPH).get(state, "."))
+
+    def hdr(self, text):
+        return self.c("cyan", f"=== {text} ===")
+
+
+TERM = Term(sys.stderr)
+
+EXIT_OK = 0
+EXIT_USAGE = 2
+EXIT_NOT_FOUND = 3
+EXIT_VALIDATION = 4
+EXIT_MISSING_DEP = 5
+EXIT_UNAVAILABLE = 7
+EXIT_DRIFT = 10
+
+SCHEMA = "claude-mods.isometric-ops.iso-facts/v1"
+
+# The literal placeholder marker check-iso-facts.py uses to detect that
+# SKILL.md's body has not been synthesized yet (see build brief: SKILL.md
+# body is [SYNTHESIS — after everything]).
+BUILD_PLACEHOLDER_MARKER = "BUILD IN PROGRESS"
+
+# Canonical constants (brief's "CANONICAL CONSTANTS" table) that MUST appear,
+# verbatim as substrings, somewhere in references/projection-math.md. Each
+# entry is (label, substring). Substring matching (not full-precision floats)
+# because the source docs cite these to varying decimal places (e.g. "26.565"
+# and "26.5651" both legitimately appear) — the invariant we guard is that the
+# canonical short-form figure is present at least once, not a specific
+# formatting.
+CANONICAL_CONSTANTS = [
+    ("2:1 dimetric ground-axis angle (arctan(1/2))", "26.565"),
+    ("cube tilt / magic angle (arctan(1/sqrt2))", "35.264"),
+    ("axonometric foreshortening (cos(35.264deg))", "81.65"),
+    ("SSR vertical scale (cos(30deg))", "86.602"),
+    ("Figma-hack height scale (tan(30deg))", "57.735"),
+    ("CSS scale3d un-foreshorten (sqrt(3/2))", "1.22474"),
+    ("CSS rotateX back-tip (90 - 35.264deg)", "54.736"),
+]
+# 86.062 is a documented typo (SRC-B misprint) for 86.602 — it must NOT be
+# asserted as canonical anywhere without the correction being present nearby.
+KNOWN_TYPO = "86.062"
+
+# Packages named in prose across references/*.md that this skill treats as
+# facts subject to drift (existence / rename on the npm registry).
+NPM_PACKAGES = ["@elchininet/isometric", "isometric-css", "svgo"]
+
+CONSTANTS_FILE = "projection-math.md"
+
+
+def note(msg: str, quiet: bool) -> None:
+    if not quiet:
+        print(msg, file=sys.stderr)
+
+
+def fail_validation(message: str, details: dict, json_mode: bool) -> NoReturn:
+    if json_mode:
+        print(json.dumps({"error": {"code": "VALIDATION", "message": message,
+                                    "details": details}}))
+    print(f"{TERM.mark('bad')} ERROR: {message}", file=sys.stderr)
+    for k, v in details.items():
+        print(f"  {k}: {v}", file=sys.stderr)
+    sys.exit(EXIT_VALIDATION)
+
+
+def _have(tool: str) -> bool:
+    from shutil import which
+    return which(tool) is not None
+
+
+# ---------------------------------------------------------------------------
+# Offline checks
+# ---------------------------------------------------------------------------
+
+def check_constants(skill_dir: Path, json_mode: bool, quiet: bool) -> dict:
+    """Assert every canonical constant appears in references/projection-math.md."""
+    const_path = skill_dir / "references" / CONSTANTS_FILE
+    if not const_path.is_file():
+        if json_mode:
+            print(json.dumps({"error": {"code": "NOT_FOUND",
+                                        "message": f"missing file: {const_path}",
+                                        "details": {}}}))
+        print(f"ERROR: required file not found: {const_path}", file=sys.stderr)
+        sys.exit(EXIT_NOT_FOUND)
+
+    text = const_path.read_text(encoding="utf-8")
+
+    missing = [{"label": label, "expected": needle}
+               for label, needle in CANONICAL_CONSTANTS if needle not in text]
+    if missing:
+        fail_validation(
+            f"{const_path.name} is missing canonical constant(s)",
+            {"missing": ", ".join(m["expected"] for m in missing),
+             "hint": "see the CANONICAL CONSTANTS table in the build brief"},
+            json_mode)
+
+    # The typo is allowed to appear ONLY as an explicitly-flagged correction
+    # (i.e. the canonical 86.602 figure must also be present — already
+    # asserted above — so a bare typo-only file would already have failed).
+    typo_hits = text.count(KNOWN_TYPO)
+
+    found = [{"label": label, "value": needle} for label, needle in CANONICAL_CONSTANTS]
+    note(f"  {len(found)}/{len(CANONICAL_CONSTANTS)} canonical constants present in "
+         f"{const_path.name}", quiet)
+    if typo_hits:
+        note(f"  note: {KNOWN_TYPO!r} (documented SRC-B typo) appears {typo_hits}x "
+             f"alongside the corrected 86.602 figure", quiet)
+    return {"file": str(const_path), "constants": found, "typo_mentions": typo_hits}
+
+
+REF_LINK_RE = re.compile(
+    r"\[(?:[^\]]*)\]\(\s*(?:references/)?([a-z0-9-]+\.md)\s*\)"  # [text](references/x.md) or [text](x.md)
+)
+REF_PATH_RE = re.compile(r"references/([a-z0-9-]+\.md)")
+REF_BACKTICK_RE = re.compile(r"`([a-z0-9-]+\.md)`")
+
+
+def _cited_basenames(text: str) -> set[str]:
+    """Collect every reference basename cited in a markdown text, across the
+    three citation styles seen in this repo's SKILL.md files: markdown links
+    (relative or references/-prefixed), bare `references/x.md` paths, and
+    backtick-quoted bare filenames (`x.md`)."""
+    names: set[str] = set()
+    for m in REF_LINK_RE.finditer(text):
+        names.add(m.group(1))
+    for m in REF_PATH_RE.finditer(text):
+        names.add(m.group(1))
+    for m in REF_BACKTICK_RE.finditer(text):
+        names.add(m.group(1))
+    return names
+
+
+def check_citations(skill_dir: Path, json_mode: bool, quiet: bool) -> dict | None:
+    """Assert every references/*.md is cited from SKILL.md.
+
+    Returns None (and notes a graceful skip) while SKILL.md's body is still
+    the BUILD IN PROGRESS placeholder — per the brief, the router is
+    synthesized last, after every reference lands, so there is nothing
+    meaningful to check yet.
+    """
+    skill_md = skill_dir / "SKILL.md"
+    refs_dir = skill_dir / "references"
+    if not skill_md.is_file():
+        if json_mode:
+            print(json.dumps({"error": {"code": "NOT_FOUND",
+                                        "message": f"missing file: {skill_md}",
+                                        "details": {}}}))
+        print(f"ERROR: required file not found: {skill_md}", file=sys.stderr)
+        sys.exit(EXIT_NOT_FOUND)
+
+    skill_text = skill_md.read_text(encoding="utf-8")
+    if BUILD_PLACEHOLDER_MARKER in skill_text:
+        note(f"  SKILL.md body is still the {BUILD_PLACEHOLDER_MARKER!r} placeholder "
+             "- skipping citation check (graceful, not a failure)", quiet)
+        return None
+
+    ref_files = sorted(p.name for p in refs_dir.glob("*.md")) if refs_dir.is_dir() else []
+    if not ref_files:
+        note("  no references/*.md files to check", quiet)
+        return {"references": [], "uncited": []}
+
+    cited = _cited_basenames(skill_text)
+    uncited = [name for name in ref_files if name not in cited]
+    if uncited:
+        fail_validation(
+            "reference file(s) not cited from SKILL.md",
+            {"uncited": ", ".join(uncited),
+             "hint": "every references/*.md must be linked or path-mentioned in SKILL.md"},
+            json_mode)
+
+    note(f"  {len(ref_files)}/{len(ref_files)} references/*.md cited from SKILL.md", quiet)
+    return {"references": ref_files, "uncited": []}
+
+
+def check_packages_named(skill_dir: Path, json_mode: bool, quiet: bool) -> dict:
+    """Assert the packages this skill discusses are actually named somewhere
+    under references/ — i.e. the prose hasn't drifted to omit/rename them
+    without the docs being updated to match."""
+    refs_dir = skill_dir / "references"
+    if not refs_dir.is_dir():
+        if json_mode:
+            print(json.dumps({"error": {"code": "NOT_FOUND",
+                                        "message": f"missing dir: {refs_dir}",
+                                        "details": {}}}))
+        print(f"ERROR: required directory not found: {refs_dir}", file=sys.stderr)
+        sys.exit(EXIT_NOT_FOUND)
+
+    blob = ""
+    for p in sorted(refs_dir.glob("*.md")):
+        blob += p.read_text(encoding="utf-8") + "\n"
+
+    missing = [pkg for pkg in NPM_PACKAGES if pkg not in blob]
+    if missing:
+        fail_validation(
+            "package(s) expected in prose are absent from references/*.md",
+            {"missing": ", ".join(missing),
+             "hint": "see the brief's contested-fact list (svg-vector-generation.md)"},
+            json_mode)
+
+    note(f"  {len(NPM_PACKAGES)}/{len(NPM_PACKAGES)} named packages found in prose",
+         quiet)
+    return {"packages": list(NPM_PACKAGES)}
+
+
+def validate_offline(skill_dir: Path, json_mode: bool, quiet: bool) -> dict:
+    note(TERM.hdr("offline iso-facts consistency check"), quiet)
+
+    constants_result = check_constants(skill_dir, json_mode, quiet)
+    citations_result = check_citations(skill_dir, json_mode, quiet)
+    packages_result = check_packages_named(skill_dir, json_mode, quiet)
+
+    note(f"{TERM.mark('ok')} OK: facts internally consistent.", quiet)
+
+    return {
+        "mode": "offline",
+        "constants": constants_result,
+        "citations": citations_result,
+        "citations_skipped": citations_result is None,
+        "packages": packages_result,
+        "consistent": True,
+    }
+
+
+# ---------------------------------------------------------------------------
+# Live checks
+# ---------------------------------------------------------------------------
+
+def fetch_npm_package(pkg: str, quiet: bool) -> dict | None:
+    """Return the npm registry metadata dict for pkg, or None if unavailable
+    (advisory — a network blip must never look like a real drift)."""
+    url = f"https://registry.npmjs.org/{pkg.replace('/', '%2f')}"
+    cmd = ["curl", "-fsS", "--max-time", "20", url]
+    try:
+        proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
+    except (subprocess.TimeoutExpired, OSError) as exc:
+        note(f"NOTE: registry lookup for {pkg} failed ({exc}) - advisory, not a failure.",
+             quiet)
+        return None
+    if proc.returncode != 0:
+        note(f"NOTE: registry unreachable for {pkg} (curl exit {proc.returncode}) "
+             "- advisory.", quiet)
+        return None
+    try:
+        payload = json.loads(proc.stdout)
+    except json.JSONDecodeError:
+        note(f"NOTE: registry returned non-JSON for {pkg} - advisory.", quiet)
+        return None
+    if not isinstance(payload, dict):
+        return None
+    return payload
+
+
+def validate_live(skill_dir: Path, json_mode: bool, quiet: bool) -> dict:
+    if not _have("curl"):
+        if json_mode:
+            print(json.dumps({"error": {"code": "PRECONDITION",
+                                         "message": "curl required for --live",
+                                         "details": {}}}))
+        print("ERROR: curl is required for --live", file=sys.stderr)
+        sys.exit(EXIT_MISSING_DEP)
+
+    note(TERM.hdr("live npm package-existence check"), quiet)
+
+    results: dict[str, dict] = {}
+    any_reachable = False
+    drifted: list[str] = []
+
+    for pkg in NPM_PACKAGES:
+        payload = fetch_npm_package(pkg, quiet)
+        if payload is None:
+            results[pkg] = {"status": "unavailable"}
+            continue
+        any_reachable = True
+        if payload.get("error") == "Not found" or "name" not in payload:
+            results[pkg] = {"status": "drift", "reason": "not found on registry"}
+            drifted.append(pkg)
+            note(f"{TERM.mark('bad')} {TERM.c('red', 'DRIFT:')} {pkg} not found on npm",
+                 quiet)
+            continue
+        latest = (payload.get("dist-tags") or {}).get("latest")
+        results[pkg] = {"status": "ok", "latest": latest}
+        note(f"  {pkg}: latest={latest}", quiet)
+
+    if not any_reachable:
+        # Every lookup failed to reach the network at all — advisory, exit 7.
+        result = {"mode": "live", "status": "unavailable", "packages": results}
+        if json_mode:
+            print(json.dumps({"data": result,
+                              "meta": {"schema": SCHEMA, "status": "unavailable"}}))
+        sys.exit(EXIT_UNAVAILABLE)
+
+    result = {
+        "mode": "live",
+        "status": "drift" if drifted else "ok",
+        "packages": results,
+        "drifted": drifted,
+    }
+
+    if drifted:
+        if json_mode:
+            print(json.dumps({"data": result, "meta": {"schema": SCHEMA,
+                                                        "status": "drift"}}))
+        else:
+            print(f"DRIFT: package(s) gone/renamed on npm: {', '.join(drifted)}")
+        sys.exit(EXIT_DRIFT)
+
+    note(f"{TERM.mark('ok')} OK: all named packages resolve on the npm registry.", quiet)
+    return result
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+def main(argv: list[str]) -> int:
+    parser = argparse.ArgumentParser(
+        prog="check-iso-facts.py", add_help=True,
+        description="Staleness verifier for isometric-ops canonical constants + citations.",
+        epilog=(
+            "EXAMPLES:\n"
+            "  check-iso-facts.py --offline\n"
+            "  check-iso-facts.py --offline --json | python -m json.tool\n"
+            "  check-iso-facts.py --live\n"
+            "  check-iso-facts.py --live -q   # exits 7 (advisory) when npm unreachable\n"
+        ),
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+    mode = parser.add_mutually_exclusive_group()
+    mode.add_argument("--offline", action="store_true",
+                      help="parse + assert internal consistency, no network (default)")
+    mode.add_argument("--live", action="store_true",
+                      help="check named packages against the live npm registry (advisory)")
+    parser.add_argument("--json", action="store_true",
+                        help="emit the JSON envelope on stdout")
+    parser.add_argument("--skill-dir", default=None,
+                        help="skill root (default: parent of this script's dir)")
+    parser.add_argument("-q", "--quiet", action="store_true",
+                        help="suppress stderr progress/notes")
+    args = parser.parse_args(argv)
+
+    if args.skill_dir:
+        skill_dir = Path(args.skill_dir).resolve()
+    else:
+        skill_dir = Path(__file__).resolve().parent.parent
+    if not skill_dir.is_dir():
+        print(f"ERROR: skill dir not found: {skill_dir}", file=sys.stderr)
+        return EXIT_NOT_FOUND
+
+    if args.live:
+        result = validate_live(skill_dir, args.json, args.quiet)
+    else:
+        result = validate_offline(skill_dir, args.json, args.quiet)
+
+    if args.json:
+        print(json.dumps({"data": result,
+                          "meta": {"schema": SCHEMA, "status": "ok"}}))
+    return EXIT_OK
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main(sys.argv[1:]))
+    except KeyboardInterrupt:
+        sys.exit(EXIT_USAGE)

+ 514 - 0
skills/isometric-ops/scripts/iso-math.py

@@ -0,0 +1,514 @@
+#!/usr/bin/env python3
+# Isometric projection math CLI: constants, coordinate transforms, grids, recipes.
+#
+# Usage:   iso-math.py <subcommand> [OPTIONS]
+# Input:   argv only (no stdin). Numbers are floats; tile sizes are positive ints.
+# Output:  stdout = data only. Plain text by default; JSON under --json
+#          (envelope: {"data": ..., "meta": {"count", "schema"}}). grid-svg emits SVG.
+# Stderr:  headers, warnings, errors, and the human line for --json errors.
+# Exit:    0 ok, 2 usage (bad/missing/unknown args), 4 validation (bad values), 10 unused.
+#
+# Subcommands:
+#   constants [--projection true|dimetric21|pixel] [--json]
+#   to-screen X Y [Z] --tile-w N --tile-h N [--elev-step N] [--json]
+#   to-tile   SX SY --tile-w N --tile-h N [--json]
+#   grid-svg  --projection P --tile-w N --extent N [--stroke COLOR] [--tile-h N]
+#   transforms --target T [--projection P] [--json]
+#
+# Examples:
+#   iso-math.py constants --projection true --json | jq '.data'
+#   iso-math.py to-screen 3 5 --tile-w 64 --tile-h 32
+#   iso-math.py to-tile -64 128 --tile-w 64 --tile-h 32 --json | jq '.data'
+#   iso-math.py grid-svg --projection dimetric21 --tile-w 64 --extent 8 > grid.svg
+#   iso-math.py transforms --target css-3d
+#
+# All figures match skills/isometric-ops references and the canonical constants table
+# to >= 4 decimal places. Derivations are computed from first principles here, not
+# hard-coded, so the numbers cannot drift from their mathematical definitions.
+#
+# Sources (see references/projection-math.md for full citations):
+#   - Wikipedia, "Isometric projection" / "Isometric video game graphics"
+#     https://en.wikipedia.org/wiki/Isometric_projection
+#   - Pikuma, "Isometric Projection in Game Development"
+#     https://pikuma.com/blog/isometric-projection-in-games
+#   - yal.cc, "Understanding isometric grids"  https://yal.cc/understanding-isometric-grids/
+#   - obelisk.js README (pixel-neat 22.6 deg)  https://github.com/nosir/obelisk.js
+
+"""Isometric projection math: constants, transforms, grids, and CSS/SVG recipes.
+
+Pure stdlib. See the module comment above for the full CLI contract and examples.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import sys
+from typing import Any
+
+SCHEMA_PREFIX = "claude-mods.isometric-ops.iso-math"
+
+# Exit codes (per docs/SKILL-RESOURCE-PROTOCOL.md section 5).
+EX_OK = 0
+EX_USAGE = 2
+EX_VALIDATION = 4
+
+# ---------------------------------------------------------------------------
+# Canonical geometry, derived from first principles (never hard-coded rounded).
+# ---------------------------------------------------------------------------
+# True isometric: a cube rotated +/-45 deg about vertical, then tilted about the
+# horizontal by the "magic angle" arctan(1/sqrt(2)) = arcsin(1/sqrt(3)) = 35.2644 deg.
+TILT_RAD = math.atan(1.0 / math.sqrt(2.0))              # 35.2643896... deg
+TILT_DEG = math.degrees(TILT_RAD)
+FORESHORTEN = math.cos(TILT_RAD)                        # sqrt(2/3) = 0.816497 (true projection)
+CSS_ROTATEX_DEG = math.degrees(math.atan(math.sqrt(2.0)))  # 54.7356 deg = 90 - 35.2644
+CSS_SCALE = 1.0 / FORESHORTEN                           # sqrt(3/2) = 1.224745 (undo foreshorten)
+SSR_VERTICAL = math.cos(math.radians(30.0))            # 0.866025 = cos(30)
+FIGMA_HEIGHT = math.tan(math.radians(30.0))            # 0.577350 = tan(30)
+GROUND_AXIS_DEG = 30.0                                  # true-iso ground-axis angle
+AXIS_SEPARATION_DEG = 120.0
+
+# 2:1 dimetric ("game isometric"): axis angle arctan(1/2).
+DIMETRIC_AXIS_RAD = math.atan(0.5)                      # 26.5651 deg
+DIMETRIC_AXIS_DEG = math.degrees(DIMETRIC_AXIS_RAD)
+
+# obelisk.js pixel-neat: its README states a 1:2 pixel-dot arrangement -> 22.6 deg.
+# Geometrically arctan(1/2) = 26.565; the 22.6 figure is the library's own stated
+# pixel-stepping angle. See references/projection-math.md, contested-fact #1.
+OBELISK_STATED_DEG = 22.6
+
+
+def r(value: float, places: int = 5) -> float:
+    """Round for display; keeps output stable and >= 4-decimal accurate."""
+    return round(value, places)
+
+
+# ---------------------------------------------------------------------------
+# Output helpers (stream separation: stdout = data, stderr = everything else).
+# ---------------------------------------------------------------------------
+def warn(msg: str) -> None:
+    print(msg, file=sys.stderr)
+
+
+def emit(data: Any, count: int, name: str, as_json: bool, plain: str) -> int:
+    """Emit either the plain data product or the --json envelope, both to stdout."""
+    if as_json:
+        envelope = {
+            "data": data,
+            "meta": {"count": count, "schema": f"{SCHEMA_PREFIX}.{name}/v1"},
+        }
+        print(json.dumps(envelope, indent=2))
+    else:
+        print(plain)
+    return EX_OK
+
+
+def fail(message: str, code: int, as_json: bool, err_code: str, details: Any = None) -> int:
+    """Emit a structured error to stdout (when --json) plus a human line to stderr."""
+    if as_json:
+        print(json.dumps({"error": {"code": err_code, "message": message,
+                                    "details": details or {}}}))
+    warn(f"error: {message}")
+    return code
+
+
+def positive_int(raw: str, name: str) -> int:
+    val = int(raw)
+    if val <= 0:
+        raise ValueError(f"{name} must be a positive integer, got {val}")
+    return val
+
+
+# ---------------------------------------------------------------------------
+# constants
+# ---------------------------------------------------------------------------
+def build_constants(projection: str) -> dict[str, Any]:
+    true_iso = {
+        "projection": "true",
+        "label": "true isometric projection",
+        "groundAxisAngleDeg": r(GROUND_AXIS_DEG),
+        "axisSeparationDeg": r(AXIS_SEPARATION_DEG),
+        "cubeTiltDeg": r(TILT_DEG),
+        "foreshortenProjection": r(FORESHORTEN),
+        "drawingScale": 1.0,
+        "ssrVerticalScale": r(SSR_VERTICAL),
+        "figmaHeightScale": r(FIGMA_HEIGHT),
+        "topCircleMinorOverMajor": r(FIGMA_HEIGHT),
+        "cssRotateXDeg": r(CSS_ROTATEX_DEG, 4),
+        "cssRotateZDeg": -45.0,
+        "cssScale3d": r(CSS_SCALE),
+        "derivation": {
+            "cubeTiltDeg": "arctan(1/sqrt(2)) = arcsin(1/sqrt(3))",
+            "foreshortenProjection": "cos(35.264) = sqrt(2/3)",
+            "ssrVerticalScale": "cos(30)",
+            "figmaHeightScale": "tan(30)",
+            "cssRotateXDeg": "arctan(sqrt(2)) = 90 - 35.264",
+            "cssScale3d": "sqrt(3/2) = 1/cos(35.264), undoes foreshortening",
+        },
+    }
+    dimetric = {
+        "projection": "dimetric21",
+        "label": "2:1 dimetric (commonly called isometric in games)",
+        "groundAxisAngleDeg": r(DIMETRIC_AXIS_DEG),
+        "axisSeparationsDeg": [116.565, 116.565, 126.870],
+        "tileAspect": "2:1",
+        "commonTileSizes": ["64x32", "128x64", "32x16"],
+        "toScreen": "screenX = (x - y) * tileW/2 ; screenY = (x + y) * tileH/2",
+        "toTile": ("x = (screenX/(tileW/2) + screenY/(tileH/2)) / 2 ; "
+                   "y = (screenY/(tileH/2) - screenX/(tileW/2)) / 2"),
+        "derivation": {
+            "groundAxisAngleDeg": "arctan(1/2)",
+            "note": ("dimetric, not isometric: only two of the three inter-axis "
+                     "angles are equal"),
+        },
+    }
+    pixel = {
+        "projection": "pixel",
+        "label": "pixel-neat 1:2 stepping (obelisk-style)",
+        "obeliskStatedAngleDeg": OBELISK_STATED_DEG,
+        "geometricArctanHalfDeg": r(DIMETRIC_AXIS_DEG),
+        "pixelStep": "2 px across : 1 px up",
+        "note": ("obelisk.js README states 22.6 deg for its 1:2 pixel-dot pattern; "
+                 "the pure geometric 1:2 slope is arctan(1/2) = 26.565 deg. Use "
+                 "26.565 for math, 22.6 only when matching obelisk output."),
+        "reference": "https://github.com/nosir/obelisk.js",
+    }
+    table = {"true": true_iso, "dimetric21": dimetric, "pixel": pixel}
+    if projection == "all":
+        return table
+    return {projection: table[projection]}
+
+
+def plain_constants(data: dict[str, Any]) -> str:
+    lines: list[str] = []
+    for key, block in data.items():
+        lines.append(f"[{key}] {block.get('label', '')}")
+        for k, v in block.items():
+            if k in ("projection", "label", "derivation", "reference", "note"):
+                continue
+            lines.append(f"  {k} = {v}")
+        if "note" in block:
+            lines.append(f"  note: {block['note']}")
+    return "\n".join(lines)
+
+
+def cmd_constants(args: argparse.Namespace) -> int:
+    projection = args.projection or "all"
+    data = build_constants(projection)
+    return emit(data, len(data), "constants", args.json, plain_constants(data))
+
+
+# ---------------------------------------------------------------------------
+# to-screen / to-tile  (2:1 dimetric canonical transform, parametrized by tile size)
+# ---------------------------------------------------------------------------
+def cmd_to_screen(args: argparse.Namespace) -> int:
+    tw, th = args.tile_w, args.tile_h
+    x, y, z = args.x, args.y, args.z
+    screen_x = (x - y) * (tw / 2.0)
+    # +z (elevation) lifts the sprite upward on screen (y-down => subtract).
+    screen_y = (x + y) * (th / 2.0) - z * args.elev_step
+    data = {
+        "tile": {"x": x, "y": y, "z": z},
+        "screen": {"x": r(screen_x, 4), "y": r(screen_y, 4)},
+        "tileW": tw, "tileH": th, "elevStep": args.elev_step,
+    }
+    plain = f"{r(screen_x, 4)} {r(screen_y, 4)}"
+    return emit(data, 1, "to-screen", args.json, plain)
+
+
+def cmd_to_tile(args: argparse.Namespace) -> int:
+    tw, th = args.tile_w, args.tile_h
+    sx, sy = args.sx, args.sy
+    hx, hy = tw / 2.0, th / 2.0
+    tile_x = (sx / hx + sy / hy) / 2.0
+    tile_y = (sy / hy - sx / hx) / 2.0
+    data = {
+        "screen": {"x": sx, "y": sy},
+        "tile": {"x": r(tile_x, 6), "y": r(tile_y, 6)},
+        "tileRounded": {"x": math.floor(tile_x + 0.5), "y": math.floor(tile_y + 0.5)},
+        "tileW": tw, "tileH": th,
+    }
+    plain = f"{r(tile_x, 6)} {r(tile_y, 6)}"
+    return emit(data, 1, "to-tile", args.json, plain)
+
+
+# ---------------------------------------------------------------------------
+# grid-svg
+# ---------------------------------------------------------------------------
+def cmd_grid_svg(args: argparse.Namespace) -> int:
+    tw = args.tile_w
+    extent = args.extent
+    stroke = args.stroke
+    projection = args.projection or "dimetric21"
+
+    if projection == "true":
+        # True iso: ground-axis slope tan(30). Half-height derived from tile width so
+        # the diamond edges sit at exactly 30 deg from horizontal.
+        th = tw * math.tan(math.radians(30.0))
+    else:
+        # dimetric21 / pixel: 2:1 => half-height = tileW/4 (slope 0.5).
+        th = args.tile_h if args.tile_h is not None else tw / 2.0
+
+    hw, hh = tw / 2.0, th / 2.0
+
+    def to_screen(x: float, y: float) -> tuple[float, float]:
+        return (x - y) * hw, (x + y) * hh
+
+    # Compute bounds over the full grid so we can translate into positive space.
+    corners = [to_screen(x, y) for x in (0, extent) for y in (0, extent)]
+    min_x = min(c[0] for c in corners)
+    min_y = min(c[1] for c in corners)
+    max_x = max(c[0] for c in corners)
+    max_y = max(c[1] for c in corners)
+    pad = 2.0
+    width = (max_x - min_x) + 2 * pad
+    height = (max_y - min_y) + 2 * pad
+    off_x = -min_x + pad
+    off_y = -min_y + pad
+
+    def pt(x: float, y: float) -> tuple[float, float]:
+        sx, sy = to_screen(x, y)
+        return sx + off_x, sy + off_y
+
+    lines: list[str] = []
+    for x in range(extent + 1):
+        x0, y0 = pt(x, 0)
+        x1, y1 = pt(x, extent)
+        lines.append(f'  <line x1="{r(x0,3)}" y1="{r(y0,3)}" '
+                     f'x2="{r(x1,3)}" y2="{r(y1,3)}"/>')
+    for y in range(extent + 1):
+        x0, y0 = pt(0, y)
+        x1, y1 = pt(extent, y)
+        lines.append(f'  <line x1="{r(x0,3)}" y1="{r(y0,3)}" '
+                     f'x2="{r(x1,3)}" y2="{r(y1,3)}"/>')
+
+    slope = hh / hw  # 0.5 dimetric, tan(30) true iso
+    svg = (
+        f'<svg xmlns="http://www.w3.org/2000/svg" '
+        f'width="{r(width,3)}" height="{r(height,3)}" '
+        f'viewBox="0 0 {r(width,3)} {r(height,3)}">\n'
+        f'  <!-- isometric-ops grid: projection={projection} tileW={tw} '
+        f'extent={extent} axis-slope={r(slope,5)} -->\n'
+        f'  <g fill="none" stroke="{stroke}" stroke-width="1" '
+        f'stroke-linecap="round">\n'
+        + "\n".join(lines)
+        + "\n  </g>\n</svg>"
+    )
+    # SVG is the data product -> stdout. No --json for this subcommand.
+    print(svg)
+    warn(f"grid-svg: {projection} tileW={tw} extent={extent} axis-slope={r(slope,5)}")
+    return EX_OK
+
+
+# ---------------------------------------------------------------------------
+# transforms
+# ---------------------------------------------------------------------------
+def build_transforms() -> dict[str, dict[str, Any]]:
+    rx = r(CSS_ROTATEX_DEG, 4)
+    scl = r(CSS_SCALE, 5)
+    sv = r(SSR_VERTICAL, 5)      # 0.86603
+    figh = r(FIGMA_HEIGHT, 5)    # 0.57735
+
+    # 2D affine plane matrices for the true-iso planes, unit basis mapped to screen
+    # (y-down). Derived from projecting world x/y/z onto the iso ground axes at +/-30
+    # deg with the 0.86603 vertical (cos 30) foreshortening. matrix(a,b,c,d,e,f) maps
+    # (x,y) -> (a*x + c*y + e, b*x + d*y + f).
+    cos30 = math.cos(math.radians(30.0))
+    sin30 = math.sin(math.radians(30.0))
+    # Top plane: world-x -> right-down axis (+30), world-y -> left-down axis (-30).
+    top = [r(cos30, 5), r(sin30, 5), r(-cos30, 5), r(sin30, 5), 0.0, 0.0]
+    # Left plane (facing left): x along the -30 ground axis, y is vertical.
+    left = [r(cos30, 5), r(sin30, 5), 0.0, r(-1.0, 5), 0.0, 0.0]
+    # Right plane (facing right): x along the +30 ground axis, y is vertical.
+    right = [r(cos30, 5), r(-sin30, 5), 0.0, r(-1.0, 5), 0.0, 0.0]
+
+    return {
+        "css-3d": {
+            "target": "css-3d",
+            "css": (f"transform: rotateX({rx}deg) rotateZ(-45deg) "
+                    f"scale3d({scl}, {scl}, {scl});"),
+            "requires": "transform-style: preserve-3d on the element and its 3D children",
+            "check": ("54.7356 = arctan(sqrt(2)) = 90 - 35.264; "
+                      f"{scl} = sqrt(3/2) undoes the 0.81650 foreshortening"),
+        },
+        "css-top": {
+            "target": "css-top",
+            "css": f"transform: rotate(-30deg) skewX(30deg) scaleY({sv});",
+            "check": "top plane; scaleY = cos(30) = 0.86603",
+        },
+        "css-left": {
+            "target": "css-left",
+            "css": f"transform: rotate(30deg) skewX(-30deg) scaleY({sv});",
+            "check": "left-facing wall plane; vertical edges stay vertical",
+        },
+        "css-right": {
+            "target": "css-right",
+            "css": f"transform: rotate(-30deg) skewX(-30deg) scaleY({sv});",
+            "check": "right-facing wall plane; mirror of left about the vertical",
+        },
+        "svg-top": {
+            "target": "svg-top",
+            "svg": f"matrix({top[0]} {top[1]} {top[2]} {top[3]} {top[4]} {top[5]})",
+            "matrix": top,
+            "check": ("unit x -> (cos30, +sin30) = (0.86603, 0.5) screen (y-down); "
+                      "unit y -> (-cos30, +sin30) = (-0.86603, 0.5)"),
+        },
+        "svg-left": {
+            "target": "svg-left",
+            "svg": f"matrix({left[0]} {left[1]} {left[2]} {left[3]} {left[4]} {left[5]})",
+            "matrix": left,
+            "check": "unit x -> (0.86603, 0.5); unit y (up) -> (0, -1)",
+        },
+        "svg-right": {
+            "target": "svg-right",
+            "svg": (f"matrix({right[0]} {right[1]} {right[2]} "
+                    f"{right[3]} {right[4]} {right[5]})"),
+            "matrix": right,
+            "check": "unit x -> (0.86603, -0.5); unit y (up) -> (0, -1)",
+        },
+        "illustrator": {
+            "target": "illustrator",
+            "note": ("SSR after a vertical scale of "
+                     f"{sv} (cos 30). SRC-B misprints this once as 86.062 -- that is a "
+                     "typo; the canonical value is 86.602%."),
+            "top": f"scaleY {sv} -> shear +30 deg -> rotate -30 deg",
+            "left": f"scaleY {sv} -> shear -30 deg -> rotate -30 deg",
+            "right": f"scaleY {sv} -> shear +30 deg -> rotate +30 deg",
+        },
+        "figma": {
+            "target": "figma",
+            "note": ("Figma has no shear tool. Rotate the flat asset 45 deg, group it "
+                     "(resets the bounding box to canvas axes), then set the group "
+                     f"height to x{figh} (tan 30). Duplicate and rotate +/-60 deg for "
+                     "the side planes."),
+            "heightScale": figh,
+            "sidePlaneRotationDeg": 60.0,
+        },
+    }
+
+
+def cmd_transforms(args: argparse.Namespace) -> int:
+    table = build_transforms()
+    target = args.target
+    if target not in table:
+        return fail(f"unknown --target '{target}'. Valid: {', '.join(sorted(table))}",
+                    EX_USAGE, args.json, "USAGE")
+    block = table[target]
+    plain_parts = [f"target: {target}"]
+    for k in ("css", "svg", "top", "left", "right", "note", "check",
+              "requires", "heightScale"):
+        if k in block:
+            plain_parts.append(f"{k}: {block[k]}")
+    return emit(block, 1, "transforms", args.json, "\n".join(plain_parts))
+
+
+# ---------------------------------------------------------------------------
+# Argument parsing
+# ---------------------------------------------------------------------------
+def build_parser() -> argparse.ArgumentParser:
+    epilog = (
+        "EXAMPLES:\n"
+        "  iso-math.py constants --projection true --json | jq '.data'\n"
+        "  iso-math.py to-screen 3 5 --tile-w 64 --tile-h 32\n"
+        "  iso-math.py to-screen 3 5 2 --tile-w 64 --tile-h 32 --elev-step 16\n"
+        "  iso-math.py to-tile -64 128 --tile-w 64 --tile-h 32 --json\n"
+        "  iso-math.py grid-svg --projection dimetric21 --tile-w 64 --extent 8 > g.svg\n"
+        "  iso-math.py grid-svg --projection true --tile-w 128 --extent 4 > iso.svg\n"
+        "  iso-math.py transforms --target css-3d\n"
+        "  iso-math.py transforms --target svg-top --json | jq '.data.matrix'\n"
+    )
+    p = argparse.ArgumentParser(
+        prog="iso-math.py",
+        description="Isometric projection math: constants, transforms, grids, recipes.",
+        epilog=epilog,
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+    sub = p.add_subparsers(dest="command", metavar="<subcommand>")
+
+    projections = ["true", "dimetric21", "pixel"]
+
+    pc = sub.add_parser("constants", help="Emit the canonical constants table.")
+    pc.add_argument("--projection", choices=projections,
+                    help="Limit to one projection (default: all).")
+    pc.add_argument("--json", action="store_true", help="Emit the JSON envelope.")
+    pc.set_defaults(func=cmd_constants)
+
+    ps = sub.add_parser("to-screen", help="tile (x,y[,z]) -> screen (2:1 dimetric).")
+    ps.add_argument("x", type=float)
+    ps.add_argument("y", type=float)
+    ps.add_argument("z", type=float, nargs="?", default=0.0)
+    ps.add_argument("--tile-w", type=int, required=True, dest="tile_w")
+    ps.add_argument("--tile-h", type=int, required=True, dest="tile_h")
+    ps.add_argument("--elev-step", type=float, default=None, dest="elev_step",
+                    help="Screen px per z-step (default: tileH/2).")
+    ps.add_argument("--json", action="store_true")
+    ps.set_defaults(func=cmd_to_screen)
+
+    pt = sub.add_parser("to-tile", help="screen (sx,sy) -> tile (inverse transform).")
+    pt.add_argument("sx", type=float)
+    pt.add_argument("sy", type=float)
+    pt.add_argument("--tile-w", type=int, required=True, dest="tile_w")
+    pt.add_argument("--tile-h", type=int, required=True, dest="tile_h")
+    pt.add_argument("--json", action="store_true")
+    pt.set_defaults(func=cmd_to_tile)
+
+    pg = sub.add_parser("grid-svg", help="Emit an SVG grid for a projection.")
+    pg.add_argument("--projection", choices=projections, default="dimetric21")
+    pg.add_argument("--tile-w", type=int, required=True, dest="tile_w")
+    pg.add_argument("--extent", type=int, required=True,
+                    help="Grid size in tiles per axis.")
+    pg.add_argument("--tile-h", type=int, default=None, dest="tile_h",
+                    help="Override half-height source (dimetric only).")
+    pg.add_argument("--stroke", default="#334155", help="Line color (default #334155).")
+    pg.set_defaults(func=cmd_grid_svg)
+
+    ptr = sub.add_parser("transforms", help="Emit an exact transform recipe/matrix.")
+    ptr.add_argument("--target", required=True,
+                     choices=["css-3d", "css-top", "css-left", "css-right",
+                              "svg-top", "svg-left", "svg-right",
+                              "illustrator", "figma"])
+    ptr.add_argument("--projection", choices=projections, default="true")
+    ptr.add_argument("--json", action="store_true")
+    ptr.set_defaults(func=cmd_transforms)
+
+    return p
+
+
+def main(argv: list[str]) -> int:
+    parser = build_parser()
+    # Pre-validate tile-size / extent semantics before argparse type coercion errors
+    # leak as tracebacks. argparse handles unknown flags/extra positionals as USAGE.
+    try:
+        args = parser.parse_args(argv)
+    except SystemExit as exc:  # argparse already printed usage to stderr.
+        return EX_USAGE if exc.code not in (0, None) else EX_OK
+
+    if not getattr(args, "command", None):
+        parser.print_help(sys.stderr)
+        return EX_USAGE
+
+    as_json = bool(getattr(args, "json", False))
+
+    # Domain validation of numeric inputs (positive tiles, non-negative extent).
+    for attr, label in (("tile_w", "--tile-w"), ("tile_h", "--tile-h")):
+        val = getattr(args, attr, None)
+        if val is not None and val <= 0:
+            return fail(f"{label} must be a positive integer, got {val}",
+                        EX_VALIDATION, as_json, "VALIDATION")
+    if getattr(args, "extent", None) is not None and args.extent <= 0:
+        return fail(f"--extent must be a positive integer, got {args.extent}",
+                    EX_VALIDATION, as_json, "VALIDATION")
+
+    # Default elevation step for to-screen: half tile height (one z-step = one tile row).
+    if getattr(args, "command", None) == "to-screen" and args.elev_step is None:
+        args.elev_step = args.tile_h / 2.0
+
+    try:
+        return args.func(args)
+    except ValueError as exc:
+        return fail(str(exc), EX_VALIDATION, as_json, "VALIDATION")
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv[1:]))

+ 423 - 0
skills/isometric-ops/scripts/sheet-pack.py

@@ -0,0 +1,423 @@
+# /// script
+# requires-python = ">=3.11"
+# dependencies = ["pillow>=10.0"]
+# ///
+"""Pack a directory of tile/sprite PNGs into one spritesheet + a JSON atlas.
+
+Deterministic, name-sorted shelf packer for isometric tile/sprite sets. Reads
+every image directly under a directory (non-recursive), optionally trims
+transparent margins per-sprite, pads each cell, optionally rounds the sheet to
+power-of-two dimensions, and writes one PNG sheet plus one JSON atlas describing
+where each source frame landed. Pair with tile-validate.py (QA before packing)
+and engine-integration.md (importing the atlas into Godot/Unity/Phaser/Tiled).
+
+Usage:   uv run sheet-pack.py <tiles-dir> [OPTIONS]
+Input:   argv positional <tiles-dir>: a directory of *.png (and other Pillow-
+         readable raster) tiles, read non-recursively, name-sorted (POSIX
+         collation on the filename, ties broken by filename).
+Output:  stdout = the two written paths (one per line), or the --json envelope
+         (schema claude-mods.isometric-ops.sheet-pack/v1) when --json is set.
+         The atlas JSON itself is written to disk, not printed.
+Stderr:  progress (frame count, sheet size), warnings, errors.
+Exit:    0 ok, 2 usage, 3 not found, 4 validation (unreadable/empty/oversized
+          input), 5 missing dependency (Pillow unavailable).
+
+Atlas schema (written to <out>.json, mirrors the TexturePacker/Phaser "hash"
+family so it drops into most 2D engines with minimal remapping):
+
+    {
+      "meta": {
+        "schema": "claude-mods.isometric-ops.sheet-pack/v1",
+        "image": "<sheet filename>",
+        "size": {"w": <int>, "h": <int>},
+        "padding": <int>,
+        "trimmed": <bool>,
+        "pot": <bool>,
+        "scale": 1,
+        "app": "isometric-ops/sheet-pack.py",
+        "generated_at": "<ISO-8601 Z>"
+      },
+      "frames": {
+        "<name>": {
+          "frame":     {"x": <int>, "y": <int>, "w": <int>, "h": <int>},
+          "sourceSize": {"w": <int>, "h": <int>},
+          "spriteSourceSize": {"x": <int>, "y": <int>, "w": <int>, "h": <int>},
+          "sourceW": <int>,
+          "sourceH": <int>,
+          "trimmed": <bool>,
+          "rotated": false
+        },
+        ...
+      }
+    }
+
+  "frame" is the rectangle within the packed sheet (post-trim if --trim was
+  used). "sourceSize" is the original untrimmed image dimensions. "spriteSourceSize"
+  is where the trimmed frame sits inside that original canvas (x, y = offset of
+  the trim box from the untrimmed top-left) — this is what lets an engine restore
+  the original anchor/pivot after trimming (see engine-integration.md, "anchor/pivot
+  mapping" under "importing sheet-pack.py atlases"). "sourceW"/"sourceH" are flat
+  duplicates of "sourceSize.w"/"sourceSize.h" (same untrimmed dimensions), provided
+  directly on the frame object for importers that don't want to descend into the
+  nested "sourceSize" object — prefer "spriteSourceSize" when you need the trim
+  *offset* (its x/y), since "sourceW"/"sourceH" alone cannot recover that. "name" is
+  the source filename without its extension; keep names engine-safe (no spaces, one
+  extension) — this script does not rewrite them.
+
+Examples:
+  uv run sheet-pack.py tiles/ --out atlas
+  uv run sheet-pack.py tiles/ --trim --padding 2 --pot --out dist/tileset
+  uv run sheet-pack.py tiles/ --json | jq -r '.data.sheet'
+  uv run sheet-pack.py tiles/ --max-width 2048 --out atlas --force
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, NoReturn
+
+SCHEMA = "claude-mods.isometric-ops.sheet-pack/v1"
+
+EXIT_OK = 0
+EXIT_USAGE = 2
+EXIT_NOT_FOUND = 3
+EXIT_VALIDATION = 4
+EXIT_MISSING_DEP = 5
+
+# Pillow-readable raster extensions we'll consider "tiles" for packing.
+# (Vector .svg is deliberately excluded — rasterize first; see
+# svg-vector-generation.md for the export step.)
+RASTER_EXTS = {".png", ".bmp", ".tga", ".tif", ".tiff", ".webp"}
+
+
+def err(json_mode: bool, code: str, message: str, exit_code: int) -> NoReturn:
+    """Print a structured error (stdout, --json only) + human line (stderr), then exit."""
+    if json_mode:
+        print(json.dumps({"error": {"code": code, "message": message, "details": {}}}))
+    print(f"ERROR: {message}", file=sys.stderr)
+    sys.exit(exit_code)
+
+
+def load_pillow(json_mode: bool):
+    try:
+        from PIL import Image  # noqa: PLC0415
+    except ImportError:
+        err(
+            json_mode,
+            "MISSING_DEPENDENCY",
+            "Pillow is required. This script declares it via PEP 723 inline "
+            "metadata — run it with `uv run sheet-pack.py ...` (uv installs "
+            "Pillow into an ephemeral env automatically). Do not invoke with "
+            "a bare `python`/`python3` that lacks Pillow.",
+            EXIT_MISSING_DEP,
+        )
+    return Image
+
+
+def discover_tiles(tiles_dir: Path) -> list[Path]:
+    """Non-recursive, name-sorted (by stem) list of raster files directly in tiles_dir."""
+    files = [
+        p
+        for p in tiles_dir.iterdir()
+        if p.is_file() and p.suffix.lower() in RASTER_EXTS
+    ]
+    # Deterministic order: sort by filename stem (case-insensitive), then full
+    # name as a tiebreaker so e.g. "Tile2" vs "tile2" is still stable.
+    return sorted(files, key=lambda p: (p.stem.lower(), p.name))
+
+
+def bbox_alpha(img) -> tuple[int, int, int, int] | None:
+    """Bounding box of non-fully-transparent pixels, or None if the image has no alpha."""
+    if img.mode != "RGBA":
+        return None
+    alpha = img.split()[-1]
+    return alpha.getbbox()
+
+
+def next_pot(n: int) -> int:
+    """Smallest power of two >= n (minimum 1)."""
+    if n <= 1:
+        return 1
+    return 1 << (n - 1).bit_length()
+
+
+def pack_shelves(
+    frames: list[dict[str, Any]], padding: int, max_width: int
+) -> tuple[int, int]:
+    """Simple deterministic shelf (row) packer.
+
+    Frames are placed left-to-right in name-sorted order, wrapping to a new
+    shelf when the running row width would exceed max_width. Shelf height is
+    the tallest frame placed on that shelf. This is intentionally NOT a
+    bin-packing optimizer (no MaxRects/skyline) — isometric tile/sprite sets
+    are near-uniform in size, so a shelf packer is simple, fully deterministic
+    (stable atlas diffs across builds), and wastes negligible space for that
+    shape of input. Mutates each frame dict in place, adding "x"/"y".
+    Returns the (width, height) of the packed sheet before any POT rounding.
+    """
+    x = padding
+    y = padding
+    shelf_h = 0
+    sheet_w = padding
+    for f in frames:
+        w, h = f["_pack_w"], f["_pack_h"]
+        if x + w + padding > max_width and x > padding:
+            # New shelf.
+            x = padding
+            y += shelf_h + padding
+            shelf_h = 0
+        f["x"] = x
+        f["y"] = y
+        x += w + padding
+        shelf_h = max(shelf_h, h)
+        sheet_w = max(sheet_w, x)
+    sheet_h = y + shelf_h + padding
+    return sheet_w, sheet_h
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(
+        prog="sheet-pack.py",
+        description=(
+            "Pack a directory of tile/sprite PNGs into one spritesheet PNG + "
+            "a JSON atlas (frames keyed by filename stem)."
+        ),
+        epilog=(
+            "Examples:\n"
+            "  uv run sheet-pack.py tiles/ --out atlas\n"
+            "  uv run sheet-pack.py tiles/ --trim --padding 2 --pot --out dist/tileset\n"
+            "  uv run sheet-pack.py tiles/ --json | jq -r '.data.sheet'\n"
+            "  uv run sheet-pack.py tiles/ --max-width 2048 --out atlas --force\n"
+        ),
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+    ap.add_argument("tiles_dir", help="directory of tile/sprite images (non-recursive)")
+    ap.add_argument(
+        "--out",
+        default="atlas",
+        help="output basename, no extension (writes <out>.png + <out>.json); default 'atlas'",
+    )
+    ap.add_argument(
+        "--trim",
+        action="store_true",
+        help="crop each sprite to its non-transparent bounding box before packing "
+        "(RGBA sources only; opaque/no-alpha sources are packed uncropped)",
+    )
+    ap.add_argument(
+        "--padding",
+        type=int,
+        default=1,
+        metavar="N",
+        help="pixels of empty space between packed frames (default 1)",
+    )
+    ap.add_argument(
+        "--pot",
+        action="store_true",
+        help="round the final sheet width and height up to the next power of two",
+    )
+    ap.add_argument(
+        "--max-width",
+        type=int,
+        default=4096,
+        metavar="N",
+        help="shelf-wrap width budget in px before rounding/POT (default 4096)",
+    )
+    ap.add_argument(
+        "--force",
+        action="store_true",
+        help="overwrite <out>.png/<out>.json if they already exist",
+    )
+    ap.add_argument("--json", action="store_true", help="emit the JSON envelope on stdout")
+    args = ap.parse_args()
+
+    if args.padding < 0:
+        err(args.json, "USAGE", "--padding must be >= 0", EXIT_USAGE)
+    if args.max_width < 1:
+        err(args.json, "USAGE", "--max-width must be >= 1", EXIT_USAGE)
+    if not args.out or any(c in args.out for c in ('"', "\x00")):
+        err(args.json, "USAGE", "--out must be a non-empty, safe basename", EXIT_USAGE)
+
+    tiles_dir = Path(args.tiles_dir)
+    if not tiles_dir.is_dir():
+        err(args.json, "NOT_FOUND", f"not a directory: {tiles_dir}", EXIT_NOT_FOUND)
+
+    out_base = Path(args.out).resolve()
+    sheet_path = out_base.with_suffix(".png")
+    atlas_path = out_base.with_suffix(".json")
+    if not args.force:
+        for p in (sheet_path, atlas_path):
+            if p.exists():
+                err(
+                    args.json,
+                    "VALIDATION",
+                    f"{p} already exists (pass --force to overwrite)",
+                    EXIT_VALIDATION,
+                )
+
+    Image = load_pillow(args.json)
+
+    sources = discover_tiles(tiles_dir)
+    if not sources:
+        err(
+            args.json,
+            "VALIDATION",
+            f"no raster tiles found directly in {tiles_dir} "
+            f"(looked for: {', '.join(sorted(RASTER_EXTS))})",
+            EXIT_VALIDATION,
+        )
+
+    print(f"packing {len(sources)} tile(s) from {tiles_dir}...", file=sys.stderr)
+
+    frames: list[dict[str, Any]] = []
+    opened: list[Any] = []
+    try:
+        for src in sources:
+            try:
+                img = Image.open(src)
+                img.load()
+            except Exception as exc:  # noqa: BLE001 - surface as a validation error, not a crash
+                err(
+                    args.json,
+                    "VALIDATION",
+                    f"unreadable image: {src} ({exc})",
+                    EXIT_VALIDATION,
+                )
+            if img.mode not in ("RGBA", "RGB", "P", "LA", "L"):
+                img = img.convert("RGBA")
+            if img.mode != "RGBA":
+                img = img.convert("RGBA")
+            opened.append(img)
+
+            source_w, source_h = img.size
+            trim_box = bbox_alpha(img) if args.trim else None
+            trimmed = trim_box is not None and trim_box != (0, 0, source_w, source_h)
+            if trim_box is None:
+                trim_box = (0, 0, source_w, source_h)
+            tx0, ty0, tx1, ty1 = trim_box
+            frame_w, frame_h = max(1, tx1 - tx0), max(1, ty1 - ty0)
+
+            frames.append(
+                {
+                    "name": src.stem,
+                    "_img": img,
+                    "_crop": trim_box,
+                    "_pack_w": frame_w,
+                    "_pack_h": frame_h,
+                    "sourceSize": {"w": source_w, "h": source_h},
+                    "spriteSourceSize": {
+                        "x": tx0,
+                        "y": ty0,
+                        "w": frame_w,
+                        "h": frame_h,
+                    },
+                    "trimmed": trimmed,
+                    "rotated": False,
+                }
+            )
+
+        # Guard against duplicate stems silently clobbering atlas entries
+        # (e.g. "crate.png" and "crate.PNG" in the same directory).
+        seen: dict[str, Path] = {}
+        for f, src in zip(frames, sources):
+            if f["name"] in seen:
+                err(
+                    args.json,
+                    "VALIDATION",
+                    f"duplicate frame name '{f['name']}' from {seen[f['name']]} and {src} "
+                    "(filenames must be unique ignoring extension)",
+                    EXIT_VALIDATION,
+                )
+            seen[f["name"]] = src
+
+        sheet_w, sheet_h = pack_shelves(frames, args.padding, args.max_width)
+        if args.pot:
+            sheet_w, sheet_h = next_pot(sheet_w), next_pot(sheet_h)
+
+        if sheet_w > 16384 or sheet_h > 16384:
+            err(
+                args.json,
+                "VALIDATION",
+                f"packed sheet would be {sheet_w}x{sheet_h}px, over the 16384px safety "
+                "ceiling (most GPUs cap texture dims there) — split the tile set or "
+                "raise --max-width to pack fewer shelves is not the fix; reduce input "
+                "count/size instead",
+                EXIT_VALIDATION,
+            )
+
+        sheet = Image.new("RGBA", (sheet_w, sheet_h), (0, 0, 0, 0))
+        for f in frames:
+            crop = f["_img"].crop(f["_crop"])
+            sheet.paste(crop, (f["x"], f["y"]), crop)
+
+        sheet_path.parent.mkdir(parents=True, exist_ok=True)
+        tmp_sheet = sheet_path.with_suffix(sheet_path.suffix + ".tmp")
+        sheet.save(tmp_sheet, format="PNG")
+        tmp_sheet.replace(sheet_path)
+    finally:
+        for img in opened:
+            try:
+                img.close()
+            except Exception:  # noqa: BLE001 - best-effort cleanup
+                pass
+
+    atlas_frames = {}
+    for f in frames:
+        atlas_frames[f["name"]] = {
+            "frame": {"x": f["x"], "y": f["y"], "w": f["_pack_w"], "h": f["_pack_h"]},
+            "sourceSize": f["sourceSize"],
+            "spriteSourceSize": f["spriteSourceSize"],
+            # Flat aliases of sourceSize.w/h so importers can read frame.sourceW/
+            # frame.sourceH directly (see the schema docstring above for why
+            # spriteSourceSize is still needed for trim-offset recovery).
+            "sourceW": f["sourceSize"]["w"],
+            "sourceH": f["sourceSize"]["h"],
+            "trimmed": f["trimmed"],
+            "rotated": f["rotated"],
+        }
+
+    atlas = {
+        "meta": {
+            "schema": SCHEMA,
+            "image": sheet_path.name,
+            "size": {"w": sheet_w, "h": sheet_h},
+            "padding": args.padding,
+            "trimmed": bool(args.trim),
+            "pot": bool(args.pot),
+            "scale": 1,
+            "app": "isometric-ops/sheet-pack.py",
+            "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
+        },
+        "frames": atlas_frames,
+    }
+    tmp_atlas = atlas_path.with_suffix(atlas_path.suffix + ".tmp")
+    tmp_atlas.write_text(json.dumps(atlas, indent=2) + "\n", encoding="utf-8")
+    tmp_atlas.replace(atlas_path)
+
+    print(
+        f"packed {len(frames)} frame(s) into {sheet_w}x{sheet_h} sheet "
+        f"({'trimmed, ' if args.trim else ''}padding={args.padding}"
+        f"{', pot' if args.pot else ''})",
+        file=sys.stderr,
+    )
+
+    data = {
+        "sheet": str(sheet_path),
+        "atlas": str(atlas_path),
+        "frame_count": len(frames),
+        "size": {"w": sheet_w, "h": sheet_h},
+    }
+    if args.json:
+        print(json.dumps({"data": data, "meta": {"schema": SCHEMA}}, indent=2))
+    else:
+        print(sheet_path)
+        print(atlas_path)
+
+    return EXIT_OK
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 383 - 0
skills/isometric-ops/scripts/tile-validate.py

@@ -0,0 +1,383 @@
+# /// script
+# requires-python = ">=3.9"
+# dependencies = ["pillow>=10.0"]
+# ///
+"""QA gate for isometric tile assets — dimension, alpha-halo, edge-bleed, anchor, palette checks.
+
+Usage:   uv run tile-validate.py [OPTIONS] <TILE.png> [<TILE2.png> ...]
+Input:   one or more raster tile files (PNG/WebP/etc — anything Pillow opens with an
+         alpha channel expected); a directory is NOT expanded automatically (pass a
+         glob from the shell, e.g. `uv run tile-validate.py tiles/*.png`).
+Output:  stdout = data only — one PASS/FAIL line per file (plain mode), or the
+         --json envelope (schema claude-mods.isometric-ops.tile_validate/v1).
+Stderr:  progress, per-check narration, warnings, errors.
+Exit:    0 all files clean
+         2 usage (bad/missing args, conflicting flags)
+         3 not-found (an input file does not exist)
+         4 validation (an input file exists but is not a readable image)
+         5 precondition (Pillow not installed / can't be imported)
+         10 domain signal — at least one file has at least one violation
+
+Checks (each individually toggleable with --skip-<name>; see --help):
+  dimension   Tile W×H are each an exact multiple of --tile-w/--tile-h (the tile
+              module). Skipped automatically if --tile-w/--tile-h not given.
+  halo        % of semi-transparent pixels (0 < alpha < 255) exceeds --halo-threshold
+              (default 0.5% of total pixels). Classic AI-generation edge fringe.
+  bleed       Any opaque pixel (alpha > --bleed-alpha, default 0) sits on the
+              outermost row/column of the canvas — the sprite has no transparent
+              margin and will visibly clip against neighbouring tiles/UI chrome.
+  anchor      Heuristic: the lowest (max-y) row containing an opaque pixel should be
+              roughly horizontally centered — sprites are anchored at the visual feet
+              (see references/tile-spec.md), and an off-center foot row usually means
+              the source asset was cropped or padded asymmetrically. Reported as an
+              offset percentage from center; flagged past --anchor-tolerance (default
+              15% of width).
+  colors      Distinct RGBA color count exceeds --max-colors (only run if set).
+
+Examples:
+  uv run tile-validate.py tiles/grass_n.png
+  uv run tile-validate.py --tile-w 64 --tile-h 32 tiles/*.png
+  uv run tile-validate.py --max-colors 32 --json tiles/*.png | jq '.data[] | select(.violations | length > 0)'
+  uv run tile-validate.py --skip-anchor --halo-threshold 1.0 wall_tiles/*.png
+  uv run tile-validate.py --json tiles/crate.png | jq -r '.data[0].violations[].check'
+
+Notes:
+  - Run via `uv run` so the PEP 723 block above resolves Pillow automatically. On
+    Windows, avoid the Microsoft Store `python3` stub (it exits 49 doing nothing) —
+    `uv run` or a real `python`/`py` on PATH both work.
+  - The dimension check only fires when --tile-w AND --tile-h are both supplied,
+    since not every asset in a set is a full ground tile (props/overlays vary).
+  - This script performs read-only inspection; it never rewrites the input file.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+from typing import Any
+
+# Semantic exit codes (SKILL-RESOURCE-PROTOCOL.md §5).
+EX_OK = 0
+EX_USAGE = 2
+EX_NOT_FOUND = 3
+EX_VALIDATION = 4
+EX_PRECONDITION = 5
+EX_DOMAIN = 10
+
+SCHEMA = "claude-mods.isometric-ops.tile_validate/v1"
+
+DEFAULT_HALO_THRESHOLD_PCT = 0.5   # % of total pixels allowed to be semi-transparent
+DEFAULT_BLEED_ALPHA = 0            # any alpha strictly greater than this on the border = bleed
+DEFAULT_ANCHOR_TOLERANCE_PCT = 15.0  # % of width the lowest-opaque-row centroid may drift
+
+
+def eprint(*args: Any, **kwargs: Any) -> None:
+    print(*args, file=sys.stderr, **kwargs)
+
+
+def build_parser() -> argparse.ArgumentParser:
+    p = argparse.ArgumentParser(
+        prog="tile-validate.py",
+        description=(
+            "QA gate for isometric tile assets: dimension conformance, alpha-halo, "
+            "edge-bleed, anchor-at-feet heuristic, and palette-size checks."
+        ),
+        epilog=(
+            "Examples:\n"
+            "  uv run tile-validate.py tiles/grass_n.png\n"
+            "  uv run tile-validate.py --tile-w 64 --tile-h 32 tiles/*.png\n"
+            "  uv run tile-validate.py --max-colors 32 --json tiles/*.png | "
+            "jq '.data[] | select(.violations | length > 0)'\n"
+            "  uv run tile-validate.py --skip-anchor --halo-threshold 1.0 wall_tiles/*.png\n"
+            "  uv run tile-validate.py --json tiles/crate.png | "
+            "jq -r '.data[0].violations[].check'\n"
+        ),
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+    p.add_argument("files", nargs="*", metavar="TILE", help="one or more image files to validate")
+    p.add_argument("--tile-w", type=int, default=None, metavar="N", help="expected tile-module width in px (dimension check)")
+    p.add_argument("--tile-h", type=int, default=None, metavar="N", help="expected tile-module height in px (dimension check)")
+    p.add_argument(
+        "--halo-threshold", type=float, default=DEFAULT_HALO_THRESHOLD_PCT, metavar="PCT",
+        help=f"max %% of semi-transparent pixels before flagging halo (default {DEFAULT_HALO_THRESHOLD_PCT})",
+    )
+    p.add_argument(
+        "--bleed-alpha", type=int, default=DEFAULT_BLEED_ALPHA, metavar="N",
+        help=f"alpha value (0-255) above which a border pixel counts as bleed (default {DEFAULT_BLEED_ALPHA})",
+    )
+    p.add_argument(
+        "--anchor-tolerance", type=float, default=DEFAULT_ANCHOR_TOLERANCE_PCT, metavar="PCT",
+        help=f"max %% horizontal drift of the foot-row centroid from center (default {DEFAULT_ANCHOR_TOLERANCE_PCT})",
+    )
+    p.add_argument("--max-colors", type=int, default=None, metavar="N", help="flag if distinct RGBA color count exceeds N (unset = skip)")
+    p.add_argument("--skip-dimension", action="store_true", help="skip the dimension-conformance check")
+    p.add_argument("--skip-halo", action="store_true", help="skip the alpha-halo check")
+    p.add_argument("--skip-bleed", action="store_true", help="skip the edge-bleed check")
+    p.add_argument("--skip-anchor", action="store_true", help="skip the anchor-at-feet heuristic")
+    p.add_argument("--json", action="store_true", help="emit the --json envelope to stdout instead of plain lines")
+    p.add_argument("-q", "--quiet", action="store_true", help="suppress per-check stderr narration (still reports violations)")
+    return p
+
+
+def load_image(path: Path):
+    """Open path as RGBA. Raises FileNotFoundError / PIL.UnidentifiedImageError-family."""
+    from PIL import Image  # deferred: exit 5 with a clean message if this import fails
+
+    img = Image.open(path)
+    img.load()  # force decode now so corrupt files fail here, not later mid-check
+    return img.convert("RGBA")
+
+
+def check_dimension(img, tile_w: int | None, tile_h: int | None) -> list[dict[str, Any]]:
+    if tile_w is None or tile_h is None:
+        return []
+    w, h = img.size
+    violations = []
+    if w % tile_w != 0 or h % tile_h != 0:
+        violations.append({
+            "check": "dimension",
+            "message": f"{w}x{h} is not an exact multiple of the {tile_w}x{tile_h} tile module",
+            "detail": {"width": w, "height": h, "tile_w": tile_w, "tile_h": tile_h},
+        })
+    return violations
+
+
+def check_halo(img, threshold_pct: float) -> list[dict[str, Any]]:
+    alpha = img.getchannel("A")
+    total = alpha.width * alpha.height
+    if total == 0:
+        return []
+    histogram = alpha.histogram()  # 256 buckets, index = alpha value
+    semi_transparent = sum(histogram[1:255])  # exclude fully transparent (0) and fully opaque (255)
+    pct = (semi_transparent / total) * 100.0
+    if pct > threshold_pct:
+        return [{
+            "check": "halo",
+            "message": f"{pct:.2f}% of pixels are semi-transparent (threshold {threshold_pct}%) — likely AI-generation edge fringe",
+            "detail": {"semi_transparent_pct": round(pct, 4), "threshold_pct": threshold_pct, "semi_transparent_pixels": semi_transparent, "total_pixels": total},
+        }]
+    return []
+
+
+def check_bleed(img, bleed_alpha: int) -> list[dict[str, Any]]:
+    alpha = img.getchannel("A")
+    w, h = alpha.size
+    if w == 0 or h == 0:
+        return []
+    px = alpha.load()
+    bleeding_pixels = 0
+    edge_coords = set()
+    for x in range(w):
+        for y in (0, h - 1):
+            if px[x, y] > bleed_alpha:
+                bleeding_pixels += 1
+                edge_coords.add((x, y))
+    for y in range(h):
+        for x in (0, w - 1):
+            if px[x, y] > bleed_alpha:
+                bleeding_pixels += 1
+                edge_coords.add((x, y))
+    if edge_coords:
+        return [{
+            "check": "bleed",
+            "message": f"{len(edge_coords)} border pixel(s) exceed alpha {bleed_alpha} — no transparent margin, will clip against neighbours",
+            "detail": {"bleeding_border_pixels": len(edge_coords), "bleed_alpha_threshold": bleed_alpha},
+        }]
+    return []
+
+
+def check_anchor(img, tolerance_pct: float) -> list[dict[str, Any]]:
+    alpha = img.getchannel("A")
+    w, h = alpha.size
+    if w == 0 or h == 0:
+        return []
+    px = alpha.load()
+    # Find the lowest (max-y) row containing at least one opaque-ish pixel (alpha > 0),
+    # then compute that row's opaque-pixel horizontal centroid vs. the canvas center.
+    foot_y = None
+    for y in range(h - 1, -1, -1):
+        if any(px[x, y] > 0 for x in range(w)):
+            foot_y = y
+            break
+    if foot_y is None:
+        return []  # fully transparent image — nothing to anchor-check
+    opaque_xs = [x for x in range(w) if px[x, foot_y] > 0]
+    if not opaque_xs:
+        return []
+    centroid_x = sum(opaque_xs) / len(opaque_xs)
+    canvas_center_x = w / 2.0
+    drift_pct = (abs(centroid_x - canvas_center_x) / w) * 100.0
+    if drift_pct > tolerance_pct:
+        return [{
+            "check": "anchor",
+            "message": f"foot-row centroid drifts {drift_pct:.1f}% of width from center (tolerance {tolerance_pct}%) — asset may be asymmetrically cropped/padded",
+            "detail": {
+                "foot_row_y": foot_y,
+                "centroid_x": round(centroid_x, 2),
+                "canvas_center_x": canvas_center_x,
+                "drift_pct": round(drift_pct, 2),
+                "tolerance_pct": tolerance_pct,
+            },
+        }]
+    return []
+
+
+def check_colors(img, max_colors: int | None) -> list[dict[str, Any]]:
+    if max_colors is None:
+        return []
+    w, h = img.size
+    # getcolors returns None if distinct-color count exceeds maxcolors; use total
+    # pixel count as the ceiling so we always get an exact count back.
+    colors = img.getcolors(maxcolors=w * h)
+    count = len(colors) if colors is not None else w * h
+    if count > max_colors:
+        return [{
+            "check": "colors",
+            "message": f"{count} distinct RGBA colors exceeds --max-colors {max_colors}",
+            "detail": {"distinct_colors": count, "max_colors": max_colors},
+        }]
+    return []
+
+
+def validate_file(path: Path, args: argparse.Namespace) -> dict[str, Any]:
+    record: dict[str, Any] = {"file": str(path), "ok": False, "violations": [], "error": None}
+
+    if not path.exists():
+        record["error"] = {"code": "NOT_FOUND", "message": f"file does not exist: {path}"}
+        return record
+    if not path.is_file():
+        record["error"] = {"code": "NOT_FOUND", "message": f"not a regular file: {path}"}
+        return record
+
+    try:
+        img = load_image(path)
+    except Exception as exc:  # noqa: BLE001 - any Pillow/OS decode failure funnels to VALIDATION
+        record["error"] = {"code": "VALIDATION", "message": f"could not open as an image: {exc}"}
+        return record
+
+    record["width"], record["height"] = img.size
+
+    violations: list[dict[str, Any]] = []
+    if not args.skip_dimension:
+        violations += check_dimension(img, args.tile_w, args.tile_h)
+    if not args.skip_halo:
+        violations += check_halo(img, args.halo_threshold)
+    if not args.skip_bleed:
+        violations += check_bleed(img, args.bleed_alpha)
+    if not args.skip_anchor:
+        violations += check_anchor(img, args.anchor_tolerance)
+    violations += check_colors(img, args.max_colors)
+
+    record["violations"] = violations
+    record["ok"] = len(violations) == 0
+    return record
+
+
+def emit_plain(records: list[dict[str, Any]], quiet: bool) -> None:
+    for rec in records:
+        if rec["error"] is not None:
+            print(f"ERROR\t{rec['file']}\t{rec['error']['code']}: {rec['error']['message']}")
+            continue
+        if rec["ok"]:
+            print(f"PASS\t{rec['file']}\t{rec['width']}x{rec['height']}")
+        else:
+            print(f"FAIL\t{rec['file']}\t{len(rec['violations'])} violation(s)")
+            for v in rec["violations"]:
+                print(f"  - {v['check']}: {v['message']}")
+
+
+def emit_json(records: list[dict[str, Any]], had_domain_hit: bool, had_hard_error: bool) -> None:
+    payload = {
+        "data": records,
+        "meta": {
+            "count": len(records),
+            "schema": SCHEMA,
+            "clean": not had_domain_hit and not had_hard_error,
+        },
+    }
+    print(json.dumps(payload))
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = build_parser()
+    args = parser.parse_args(argv)
+
+    if not args.files:
+        eprint("ERROR: no input files given (try --help)")
+        return EX_USAGE
+
+    if (args.tile_w is None) != (args.tile_h is None):
+        eprint("ERROR: --tile-w and --tile-h must be given together")
+        return EX_USAGE
+    if args.tile_w is not None and args.tile_w <= 0:
+        eprint("ERROR: --tile-w must be a positive integer")
+        return EX_USAGE
+    if args.tile_h is not None and args.tile_h <= 0:
+        eprint("ERROR: --tile-h must be a positive integer")
+        return EX_USAGE
+    if args.halo_threshold < 0:
+        eprint("ERROR: --halo-threshold must be >= 0")
+        return EX_USAGE
+    if args.anchor_tolerance < 0:
+        eprint("ERROR: --anchor-tolerance must be >= 0")
+        return EX_USAGE
+    if args.bleed_alpha < 0 or args.bleed_alpha > 255:
+        eprint("ERROR: --bleed-alpha must be in [0, 255]")
+        return EX_USAGE
+    if args.max_colors is not None and args.max_colors <= 0:
+        eprint("ERROR: --max-colors must be a positive integer")
+        return EX_USAGE
+
+    try:
+        import PIL  # noqa: F401
+    except ImportError:
+        eprint("ERROR: Pillow is not installed. Run this script via `uv run tile-validate.py ...`")
+        eprint("       so the PEP 723 inline metadata resolves it automatically, or:")
+        eprint("       uv pip install pillow")
+        return EX_PRECONDITION
+
+    if not args.quiet:
+        eprint(f"tile-validate: checking {len(args.files)} file(s)...")
+
+    records: list[dict[str, Any]] = []
+    any_not_found = False
+    any_validation_error = False
+    for raw_path in args.files:
+        path = Path(raw_path)
+        if not args.quiet:
+            eprint(f"  {path}")
+        rec = validate_file(path, args)
+        records.append(rec)
+        if rec["error"] is not None:
+            if rec["error"]["code"] == "NOT_FOUND":
+                any_not_found = True
+            else:
+                any_validation_error = True
+
+    had_domain_hit = any(rec["error"] is None and not rec["ok"] for rec in records)
+    had_hard_error = any_not_found or any_validation_error
+
+    if args.json:
+        emit_json(records, had_domain_hit, had_hard_error)
+    else:
+        emit_plain(records, args.quiet)
+
+    if any_not_found:
+        eprint(f"ERROR: {sum(1 for r in records if r['error'] and r['error']['code'] == 'NOT_FOUND')} input file(s) not found")
+        return EX_NOT_FOUND
+    if any_validation_error:
+        eprint(f"ERROR: {sum(1 for r in records if r['error'] and r['error']['code'] == 'VALIDATION')} input file(s) failed to open as images")
+        return EX_VALIDATION
+    if had_domain_hit:
+        if not args.quiet:
+            eprint(f"tile-validate: {sum(1 for r in records if not r['ok'])} of {len(records)} file(s) have violations")
+        return EX_DOMAIN
+
+    if not args.quiet:
+        eprint(f"tile-validate: all {len(records)} file(s) clean")
+    return EX_OK
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 339 - 0
skills/isometric-ops/tests/run.sh

@@ -0,0 +1,339 @@
+#!/usr/bin/env bash
+# Self-test for isometric-ops scripts + iso-studio app.
+#
+# 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/.
+#
+# 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.
+
+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).
+PYTHON=""
+for c in python python3 py; do
+  if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PYTHON="$c"; break; fi
+done
+[[ -z "$PYTHON" ]] && { echo "no working python found" >&2; exit 1; }
+
+SB="$(mktemp -d)"; trap 'rm -rf "$SB"' EXIT
+
+PASS=0; FAIL=0
+ok() { PASS=$((PASS+1)); printf '  PASS  %s\n' "$1"; }
+no() { FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1"; }
+expect_exit() { [[ "$2" == "$3" ]] && ok "$1 (exit $3)" || no "$1 (want $2 got $3)"; }
+expect_has()  { case "$3" in *"$2"*) ok "$1";; *) no "$1 (missing '$2')";; esac; }
+
+echo "=== isometric-ops self-test ==="
+
+# ── --help + EXAMPLES for every script ─────────────────────────────────────
+echo "-- --help / EXAMPLES contract --"
+for s in iso-math tile-validate sheet-pack check-iso-facts; do
+  out="$("$PYTHON" "$SCRIPTS/$s.py" --help 2>&1)"; rc=$?
+  expect_exit "$s.py --help" 0 "$rc"
+  case "$out" in
+    *EXAMPLES*|*Examples*) ok "$s.py --help mentions EXAMPLES" ;;
+    *) no "$s.py --help missing EXAMPLES section" ;;
+  esac
+done
+
+# ── iso-math.py: constants ──────────────────────────────────────────────────
+echo "-- iso-math.py constants --"
+true_out="$("$PYTHON" "$SCRIPTS/iso-math.py" constants --projection true 2>&1)"; rc=$?
+expect_exit "constants --projection true" 0 "$rc"
+# grep-tolerant of formatting: the script prints decimal fractions (0.8165,
+# 0.86603, 0.57735) rather than the brief's percent notation (81.65%,
+# 86.602%, 57.735%) -- match on the digit runs common to both forms.
+for v in 35.264 8165 86603 57735 1.22474 54.7356 30.0 120.0; do
+  expect_has "true-iso constants contain $v" "$v" "$true_out"
+done
+
+dim_out="$("$PYTHON" "$SCRIPTS/iso-math.py" constants --projection dimetric21 2>&1)"; rc=$?
+expect_exit "constants --projection dimetric21" 0 "$rc"
+expect_has "dimetric constants contain 26.565" "26.565" "$dim_out"
+expect_has "dimetric constants mention 2:1 tile aspect" "2:1" "$dim_out"
+
+pix_out="$("$PYTHON" "$SCRIPTS/iso-math.py" constants --projection pixel 2>&1)"; rc=$?
+expect_exit "constants --projection pixel" 0 "$rc"
+expect_has "pixel constants contain 22.6 (obelisk stated)" "22.6" "$pix_out"
+expect_has "pixel constants contain 26.565 (geometric arctan)" "26.565" "$pix_out"
+
+json_out="$("$PYTHON" "$SCRIPTS/iso-math.py" constants --projection true --json 2>&1)"; rc=$?
+expect_exit "constants --json" 0 "$rc"
+expect_has "constants --json parses" '"data"' "$json_out"
+
+# ── iso-math.py: to-screen / to-tile round-trips at 3 distinct points ──────
+echo "-- iso-math.py to-screen/to-tile round-trips --"
+roundtrip() { # tx ty tile_w tile_h
+  local tx="$1" ty="$2" tw="$3" th="$4"
+  local sxy sx sy tjson rtx rty
+  sxy="$("$PYTHON" "$SCRIPTS/iso-math.py" to-screen "$tx" "$ty" --tile-w "$tw" --tile-h "$th" 2>&1)"
+  read -r sx sy <<<"$sxy"
+  tjson="$("$PYTHON" "$SCRIPTS/iso-math.py" to-tile "$sx" "$sy" --tile-w "$tw" --tile-h "$th" --json 2>&1)"
+  rtx="$("$PYTHON" -c "import json,sys; d=json.loads(sys.argv[1]); print(d['data']['tileRounded']['x'])" "$tjson" 2>/dev/null)"
+  rty="$("$PYTHON" -c "import json,sys; d=json.loads(sys.argv[1]); print(d['data']['tileRounded']['y'])" "$tjson" 2>/dev/null)"
+  if [[ "$rtx" == "$tx" && "$rty" == "$ty" ]]; then
+    ok "round-trip ($tx,$ty) tileW=$tw tileH=$th -> screen($sx,$sy) -> tile($rtx,$rty)"
+  else
+    no "round-trip ($tx,$ty) tileW=$tw tileH=$th -> got tile($rtx,$rty) via screen($sx,$sy)"
+  fi
+}
+roundtrip 3 5 64 32
+roundtrip -4 7 128 64
+roundtrip 0 0 32 16
+
+# elevation-aware to-screen (z / --elev-step) sanity: higher z should move screenY up (smaller)
+z0="$("$PYTHON" "$SCRIPTS/iso-math.py" to-screen 3 5 0 --tile-w 64 --tile-h 32 --elev-step 16 2>&1)"
+z2="$("$PYTHON" "$SCRIPTS/iso-math.py" to-screen 3 5 2 --tile-w 64 --tile-h 32 --elev-step 16 2>&1)"
+z0y="$(awk '{print $2}' <<<"$z0")"; z2y="$(awk '{print $2}' <<<"$z2")"
+if "$PYTHON" -c "import sys; sys.exit(0 if float('$z2y') < float('$z0y') else 1)" 2>/dev/null; then
+  ok "elevation raises screen position (z=2 screenY < z=0 screenY)"
+else
+  no "elevation did not raise screen position ($z0y vs $z2y)"
+fi
+
+# ── iso-math.py: grid-svg emits parseable SVG with expected line count ─────
+echo "-- iso-math.py grid-svg --"
+svg="$("$PYTHON" "$SCRIPTS/iso-math.py" grid-svg --projection dimetric21 --tile-w 64 --extent 4 2>/dev/null)"
+case "$svg" in "<svg"*|*"<svg "*) ok "grid-svg starts with <svg" ;; *) no "grid-svg did not start with <svg" ;; esac
+case "$svg" in *"</svg>") ok "grid-svg ends with </svg>" ;; *) no "grid-svg did not end with </svg>" ;; esac
+line_count="$(grep -o '<line ' <<<"$svg" | wc -l | tr -d ' ')"
+# extent N dimetric grid: N+1 lines per axis direction, two axis directions -> 2*(N+1)
+expected=$(( (4 + 1) * 2 ))
+if [[ "$line_count" == "$expected" ]]; then
+  ok "grid-svg line count == $expected for extent=4"
+else
+  no "grid-svg line count $line_count != expected $expected"
+fi
+"$PYTHON" -c "
+import sys, xml.etree.ElementTree as ET
+try:
+    ET.fromstring(sys.stdin.read())
+    sys.exit(0)
+except Exception as e:
+    print(e, file=sys.stderr); sys.exit(1)
+" <<<"$svg" >/dev/null 2>&1
+expect_exit "grid-svg is well-formed XML" 0 $?
+
+svg_true="$("$PYTHON" "$SCRIPTS/iso-math.py" grid-svg --projection true --tile-w 128 --extent 3 2>/dev/null)"
+expect_has "true-iso grid-svg is an <svg>" "<svg" "$svg_true"
+
+# ── iso-math.py: transforms --target recipes ────────────────────────────────
+echo "-- iso-math.py transforms --"
+css3d="$("$PYTHON" "$SCRIPTS/iso-math.py" transforms --target css-3d 2>&1)"; rc=$?
+expect_exit "transforms --target css-3d" 0 "$rc"
+expect_has "css-3d contains rotateX(54.7356deg)" "54.7356" "$css3d"
+expect_has "css-3d contains rotateZ(-45deg)" "rotateZ(-45deg)" "$css3d"
+expect_has "css-3d contains scale3d(1.22474" "1.22474" "$css3d"
+
+illus="$("$PYTHON" "$SCRIPTS/iso-math.py" transforms --target illustrator 2>&1)"; rc=$?
+expect_exit "transforms --target illustrator" 0 "$rc"
+expect_has "illustrator recipe contains 0.86603 (SSR)" "0.86603" "$illus"
+expect_has "illustrator recipe notes the SRC-B 86.062 typo" "86.062" "$illus"
+expect_has "illustrator recipe has +30/-30 shear-rotate figures" "30" "$illus"
+
+for tgt in css-top css-left css-right svg-top svg-left svg-right figma; do
+  out="$("$PYTHON" "$SCRIPTS/iso-math.py" transforms --target "$tgt" 2>&1)"; rc=$?
+  expect_exit "transforms --target $tgt" 0 "$rc"
+done
+
+figma_out="$("$PYTHON" "$SCRIPTS/iso-math.py" transforms --target figma --json 2>&1)"; rc=$?
+expect_exit "transforms --target figma --json" 0 "$rc"
+expect_has "figma recipe json parses" '"data"' "$figma_out"
+
+# ── tile-validate.py: fixtures generated at test time via uv run + Pillow ─
+echo "-- tile-validate.py (fixture-driven) --"
+if command -v uv >/dev/null 2>&1; then
+  cat > "$SB/gen_tiles.py" <<'PYEOF'
+# /// script
+# requires-python = ">=3.11"
+# dependencies = ["pillow>=10.0"]
+# ///
+import sys
+from PIL import Image
+
+outdir = sys.argv[1]
+
+# GOOD: correct 2:1 tile (64x32), clean alpha diamond, transparent margin, no
+# halo/bleed, feet centered.
+w, h = 64, 32
+img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
+px = img.load()
+cx, cy = w / 2, h / 2
+for y in range(h):
+    for x in range(w):
+        nx = abs(x - cx) / (w / 2 - 4)
+        ny = abs(y - cy) / (h / 2 - 4)
+        if nx + ny <= 1.0:
+            px[x, y] = (120, 140, 160, 255)
+img.save(outdir + "/good_tile.png")
+
+# BAD: wrong ratio (48x48, not a 64x32 multiple) + alpha-halo fringe + edge bleed.
+w2, h2 = 48, 48
+img2 = Image.new("RGBA", (w2, h2), (0, 0, 0, 0))
+px2 = img2.load()
+for y in range(h2):
+    for x in range(w2):
+        px2[x, y] = (200, 80, 80, 255)  # opaque all the way to the border -> bleed
+for y in range(h2):
+    for x in range(w2):
+        d = ((x - w2 / 2) ** 2 + (y - h2 / 2) ** 2) ** 0.5
+        if d > w2 / 2 - 6:
+            px2[x, y] = (200, 80, 80, 80)  # semi-transparent ring -> halo
+img2.save(outdir + "/bad_tile.png")
+PYEOF
+  uv run "$SB/gen_tiles.py" "$SB" >/dev/null 2>"$SB/gen.err"
+  if [[ -f "$SB/good_tile.png" && -f "$SB/bad_tile.png" ]]; then
+    out="$(uv run "$SCRIPTS/tile-validate.py" --tile-w 64 --tile-h 32 "$SB/good_tile.png" 2>&1)"; rc=$?
+    expect_exit "good fixture -> 0" 0 "$rc"
+    out="$(uv run "$SCRIPTS/tile-validate.py" --tile-w 64 --tile-h 32 "$SB/bad_tile.png" 2>&1)"; rc=$?
+    expect_exit "bad fixture -> 10" 10 "$rc"
+    expect_has "bad fixture flags dimension" "dimension" "$out"
+    expect_has "bad fixture flags halo" "halo" "$out"
+    expect_has "bad fixture flags bleed" "bleed" "$out"
+    jout="$(uv run "$SCRIPTS/tile-validate.py" --tile-w 64 --tile-h 32 --json "$SB/good_tile.png" "$SB/bad_tile.png" 2>/dev/null)"
+    expect_has "tile-validate --json parses" '"data"' "$jout"
+  else
+    no "tile fixture generation did not produce expected files (see $SB/gen.err)"
+  fi
+else
+  echo "  SKIP  uv not found — tile-validate.py fixture tests skipped"
+fi
+
+# ── sheet-pack.py: pack fixtures, assert atlas JSON schema ─────────────────
+echo "-- sheet-pack.py (fixture-driven) --"
+if command -v uv >/dev/null 2>&1 && [[ -f "$SB/good_tile.png" && -f "$SB/bad_tile.png" ]]; then
+  mkdir -p "$SB/packdir"
+  cp "$SB/good_tile.png" "$SB/packdir/"
+  cp "$SB/bad_tile.png" "$SB/packdir/"
+  out="$(uv run "$SCRIPTS/sheet-pack.py" "$SB/packdir" --out "$SB/atlas" --force --json 2>&1)"; rc=$?
+  expect_exit "sheet-pack on fixtures -> 0" 0 "$rc"
+  if [[ -f "$SB/atlas.json" && -f "$SB/atlas.png" ]]; then
+    ok "sheet-pack wrote atlas.png + atlas.json"
+    "$PYTHON" - "$SB/atlas.json" <<'PYEOF' >"$SB/atlas_check.out" 2>&1
+import json, sys
+with open(sys.argv[1]) as f:
+    atlas = json.load(f)
+assert atlas["meta"]["schema"] == "claude-mods.isometric-ops.sheet-pack/v1", "bad schema"
+frames = atlas["frames"]
+assert "good_tile" in frames and "bad_tile" in frames, "missing frame names"
+for name, fr in frames.items():
+    for k in ("frame", "sourceSize"):
+        assert k in fr, f"{name} missing {k}"
+    for k in ("x", "y", "w", "h"):
+        assert k in fr["frame"], f"{name} frame missing {k}"
+    for k in ("w", "h"):
+        assert k in fr["sourceSize"], f"{name} sourceSize missing {k}"
+    assert "sourceW" in fr and "sourceH" in fr, f"{name} missing flat sourceW/sourceH"
+print("ok")
+PYEOF
+    if [[ "$(cat "$SB/atlas_check.out")" == "ok" ]]; then
+      ok "atlas.json parses and frame fields (frame.x/y/w/h, sourceSize, sourceW/sourceH) present"
+    else
+      no "atlas.json schema check failed: $(cat "$SB/atlas_check.out")"
+    fi
+  else
+    no "sheet-pack did not write atlas.png/atlas.json"
+  fi
+else
+  echo "  SKIP  uv (or tile fixtures) not available — sheet-pack.py fixture tests skipped"
+fi
+
+# ── check-iso-facts.py ───────────────────────────────────────────────────────
+echo "-- check-iso-facts.py --"
+"$PYTHON" "$SCRIPTS/check-iso-facts.py" --offline >/dev/null 2>&1
+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
+  echo "  SKIP  no assertions ran on this platform"
+  exit 0
+fi
+[[ "$FAIL" -eq 0 ]] || exit 1

+ 6 - 1
tests/check-resources.sh

@@ -78,6 +78,10 @@ echo "== threejs-ops: three.js fact/staleness verifier"
 run "three-facts --offline consistent" 0 "$PY" skills/threejs-ops/scripts/check-three-facts.py --offline
 run "three-facts --help"               0 "$PY" skills/threejs-ops/scripts/check-three-facts.py --help
 
+echo "== isometric-ops: projection-constant/staleness verifier"
+run "iso-facts --offline consistent" 0 "$PY" skills/isometric-ops/scripts/check-iso-facts.py --offline
+run "iso-facts --help"               0 "$PY" skills/isometric-ops/scripts/check-iso-facts.py --help
+
 echo "== protocol: every new verifier is executable + compiles"
 for s in skills/claude-api-ops/scripts/check-model-table.py \
          skills/claude-code-ops/scripts/validate-hooks-json.py \
@@ -85,7 +89,8 @@ for s in skills/claude-api-ops/scripts/check-model-table.py \
          skills/mapbox-ops/scripts/check-mapbox-facts.py \
          skills/loop-ops/scripts/check-pricing-sync.py \
          skills/r-ops/scripts/check-r-facts.py \
-         skills/threejs-ops/scripts/check-three-facts.py; do
+         skills/threejs-ops/scripts/check-three-facts.py \
+         skills/isometric-ops/scripts/check-iso-facts.py; do
     "$PY" -m py_compile "$s" 2>/dev/null && pass "py_compile $(basename "$s")" || bad "py_compile $(basename "$s")"
 done
 bash -n skills/terraform-ops/scripts/check-action-refs.sh 2>/dev/null \