isometric-ops
Create, refine, compose, and export isometric/dimetric assets for web and games: projection math, SVG/CSS/three.js generation, pixel-art and Blender workflows, engine tilemaps, ControlNet AI generation, and the iso-studio composer. Triggers on: isometric, dimetric, tileset, y-sor
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/isometric-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
git clone https://github.com/0xDarkMatter/claude-mods.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
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 companion iso-studio scene composer (a standalone app that grew out of this skill). Every constant is derived and machine-checked; every workflow is runnable; every claim is sourced in its reference file.
Boundary — what this skill does NOT own. General three.js / creative-coding scaffolding lives in
genart-ops; app- and game-scale three.js (GLTF pipeline, react-three-fiber,InstancedMesh) lives in thethreejs-opsskill. Colour science and perceptual (OKLCH) ramp construction live incolor-ops.isometric-opsowns 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 §1, svg-vector-generation.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; tileW = 2·tileH |
projection-math.md §2, coordinates-depth.md, 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 §7, 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 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; full derivations and
ground-truth checks in 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. Seeblender-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°).
- Decide true iso (§1). Pin the light direction and palette up front
(
style-guide.md). - 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.mdandsvg-vector-generation.md. - CSS route. 3D:
transform-style: preserve-3don the container, per-face children, then the outer stackrotateX(54.7356deg) rotateZ(-45deg) scale3d(1.22474,…)— the scale goes on the container only (or faces double-scale). 2D affine for flat cards: therotate(-30deg) skewX(30deg) scaleY(0.866)recipe family, derived per plane incss-isometric.md. - SVG route. Reach for
@elchininet/isometric(SVG-native, planes/paths) or hand-roll diamond/cube/prism paths using the planematrix()recipes (Topmatrix(0.86603, 0.5, 0.86603, -0.5, 0, 0), etc.). Grab a ready grid fromassets/grids/or emit one withiso-math.py grid-svg. - Optimise & export. Simplify paths in-tool → export → SVGO/SVGOMG → raster
derivatives, in that order (
svg-vector-generation.md§7). - Verify visually. For map-heavy web work, headless-screenshot checks belong to
playwright-ops; tie the render back to the style checklist.
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.
- Write the spec first. Copy the fill-in template from
tile-spec.mdand 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.pngnaming, and scale grammar (one human = N tiles). - Generate the tiles — draw them (
pixel-art-workflow.md), pre-render from 3D (Route D), or AI-generate (Route C). Every asset obeys the spec's numbers. - 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"How the spec feeds tile-validate.py"). - 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§11). - 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 inengine-integration.md. - The runtime math — tile↔screen, picking, and the
(x+y, z, layer, zBias)depth sort with anchor-at-feet — lives incoordinates-depth.mdand is mirrored byiso-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.
- Climb the decision ladder (
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. - Prompt from the ready scaffolds in
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 inai-generation.md§6. - 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§4; blockout export via Route D or iso-studio, Route E). - 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.pydetects them (ai-refinement.md). - 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§4,style-guide.md). - 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.
- Rig the ortho camera at the correct rotation for your projection — both rigs are
in
blender-prerender.md§1 (60/0/45 dimetric vs 54.736/0/45 true iso) with the cube-top verification test. - 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--passesfor the depth + camera-space normal maps that feed ControlNet (Route C). - three.js route. Owns only the iso delta (
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×, constrainedOrbitControls, and 8-direction sprite baking in the browser. General scene scaffolding →genart-ops. - 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 companion iso-studio scene composer (standalone app, local checkout github.com/0xDarkMatter/iso-studio) stages assets on a snap-to-grid isometric canvas with automatic depth sorting and a blockout-to-ControlNet export path. See §5 below for the launch command and status.
- Launch the app (§5), pick a projection, set tile width and grid extent.
- Import PNG/SVG/WebP by drag-drop, paste, or file picker; assets land in the tray.
- Place & snap with full / half / quarter / free snap modes; set each asset's anchor and footprint so snapping and sorting stay correct.
- Depth sorts automatically by
(tileX + tileY), then elevation, then zBias, across ground / props / overlay layers. - Export PNG at 1×/2×/4× (transparent, cropped) or save the scene as JSON conforming
to the app repo's
scene-schema.json(version "1.0"). - 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.
- CC0 first — Kenney iso packs, itch.io CC0 sets (Screaming Brain's 1,008 floors,
etc.), OpenGameArt (
asset-sourcing.md). - Marketplaces — IconScout, Flaticon (attribution on free), Icons8, Streamline, Iconify, DrawKit, Blush, Storyset, Icograms.
- 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:
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 |
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 |
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 |
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 |
§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 |
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 |
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 |
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 |
Headless Blender ortho-rig + N-direction sprite baker + optional depth/normal passes | Route D, step 2 |
| iso-studio (external) | The zero-dependency scene composer — standalone repo at github.com/0xDarkMatter/iso-studio, owns scene-schema.json + the asset library; pointer: iso-studio.md |
Route E, §5 |
5. iso-studio — the scene composer (standalone app)
iso-studio is a zero-dependency, no-build isometric scene composer that grew out of
this skill and now lives in its own repository — clone it wherever you keep checkouts,
remote github.com/0xDarkMatter/iso-studio (index.html + server.mjs, no npm deps).
Launch it, then work the docked palettes:
node <iso-studio>/server.mjs # then open http://localhost:4323
PORT=8080 node <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 → zBiassort across ground / props / overlay, matching the doctrine incoordinates-depth.mdexactly. - 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 the app repo's
scene-schema.json(version "1.0"). - Blockout mode (signature feature) — place flat-shaded three-tone grey primitives
(box / slab / ramp / cylinder) and export a depth map and a lineart render sized
to the canvas; both condition the ControlNet step of the AI pipeline
(
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, elevation-aware but not per-face;
flipX mirrors a ramp's slope, no-op on symmetric primitives) — lives in the app repo
at docs/MANUAL.md; this skill's references/iso-studio.md
is the quickstart pointer.
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 §2/§4, 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 §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 §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 §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 §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 §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 §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 |
| 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 §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 §1, ai-refinement.md §1 |
Related skills
genart-ops (general three.js / creative coding) ·
threejs-ops (app/game-scale three.js: GLTF, r3f, InstancedMesh) ·
color-ops (colour science, OKLCH ramps) ·
frontend-design (Claude-Code-bundled; production UI craft) ·
playwright-ops (headless render verification).
Files (claude-mods)
-
assets
-
grids
-
dimetric-2to1-128.svg 1.6 KB · in bundle
-
dimetric-2to1-32.svg 1.6 KB · in bundle
-
dimetric-2to1-64.svg 1.6 KB · in bundle
-
true-iso-128.svg 1.7 KB · in bundle
-
true-iso-32.svg 1.7 KB · in bundle
-
true-iso-64.svg 1.7 KB · in bundle
-
-
palettes
-
three-tone-presets.json 5.7 KB
{ "_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" } ] }
-
-
blender-iso-rig.py 26.6 KB
#!/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() -
prompt-library.md 20.7 KB
# 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).
-
-
references
-
ai-generation.md 25.7 KB
# 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 companion **iso-studio** app (standalone repo, local checkout github.com/0xDarkMatter/iso-studio, launch with `node server.mjs`) exports a blockout **depth map** (per-instance flat grey, elevation-aware, normalized near-white → far-black — massing-grade conditioning) and a **lineart render** (visible edges, black on white) directly from a scene composed against its `scene-schema.json` — the same dual-control idea without a full Blender rig. Stage grey primitives on the grid, export both maps at 1×/2×/4×, and feed them to the depth + lineart/MLSD ControlNet units exactly as in the Blender workflow above. Full walkthrough: [`iso-studio.md`](iso-studio.md) §"Blockout → ControlNet". 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). -
ai-refinement.md 15.8 KB
# 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. -
asset-sourcing.md 12.2 KB
# 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. -
blender-prerender.md 20.8 KB
# 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). -
coordinates-depth.md 29.6 KB
# 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. -
css-isometric.md 18.8 KB
# 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. -
engine-integration.md 17.1 KB
# 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. -
iso-studio.md 2.7 KB
# iso-studio — the Companion Scene Composer (standalone app) **iso-studio** is the zero-dependency browser scene composer that grew out of this skill. It is now a **standalone app in its own repository** — it outgrew the skill (an app with a roadmap and an asset library is a product, not a reference) and was extracted so the plugin stays lean while the app evolves on its own release cadence. | | | |---|---| | Local checkout | wherever you cloned it — see Repository below | | Repository | `https://github.com/0xDarkMatter/iso-studio` | | Launch | `node server.mjs` → http://localhost:4323 (`PORT` env overrides) | | Manual | `docs/MANUAL.md` in the app repo — workspace tour, hotkeys, scene schema, known limits | | Scene format | `scene-schema.json` in the app repo (draft-07, version `"1.0"`) | ## What it does - **Stage** — snap-to-grid placement (full/half/quarter/free) on true-isometric, 2:1 dimetric, or custom-angle grids; drag-drop/paste/pick PNG/SVG/WebP; per-asset anchor (feet by default) and tile footprint - **Compose** — automatic y-sort implementing this skill's [depth doctrine](coordinates-depth.md) (`(x+y)`, elevation, layer, zBias), three layers, marquee/nudge/flip, undo/redo, tri-tone tint (per scene or per instance, presets shared with [`assets/palettes/`](../assets/palettes/three-tone-presets.json)) - **Blockout → ControlNet** — the signature workflow: place parametric grey primitives (box/slab/ramp/cylinder), export an elevation-aware **depth map** and a **lineart render**, and feed both into the ControlNet conditioning workflow in [`ai-generation.md`](ai-generation.md) §4 — the lightweight, web-native alternative to the [Blender depth/normal pipeline](blender-prerender.md) §3 - **Export** — PNG 1×/2×/4× (transparent, crop-to-content), SVG when the composition is all-vector, and versioned scene JSON that round-trips with assets embedded ## How this skill and the app relate The app implements this skill's math and doctrine — its `MATH` section is a direct port of [`coordinates-depth.md`](coordinates-depth.md) and is required to agree with [`projection-math.md`](projection-math.md)'s canonical constants. The skill owns the knowledge (references, tile-spec, AI pipeline, CLI scripts); the app repo owns the software, the scene schema, and the starter asset library. When composing scenes: write the [tile spec](tile-spec.md) first, validate AI-generated tiles with [`tile-validate.py`](../scripts/tile-validate.py), compose in iso-studio, then pack shipping sheets with [`sheet-pack.py`](../scripts/sheet-pack.py). If the local checkout is missing, clone it: ``` git clone https://github.com/0xDarkMatter/iso-studio node iso-studio/server.mjs ``` -
pixel-art-workflow.md 15.2 KB
# 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. -
projection-math.md 23.2 KB
# 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). -
style-guide.md 20.3 KB
# 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. -
svg-vector-generation.md 19.9 KB
# 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. -
threejs-orthographic.md 21.6 KB
# 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 the iso-studio app's `scene-schema.json` for exactly this. Anchor/pivot: keep the model's **visual feet at the pivot origin** so every baked frame shares the feet-anchor the depth sort expects (`references/coordinates-depth.md`), and the 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`).* -
tile-spec.md 16.6 KB
# 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).
-
-
scripts
-
check-iso-facts.py 18.7 KB
#!/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) -
iso-math.py 22.1 KB
#!/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:])) -
sheet-pack.py 15.4 KB
# /// 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()) -
tile-validate.py 16 KB
# /// 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())
-
-
tests
-
run.sh 12.5 KB
#!/usr/bin/env bash # Self-test for isometric-ops scripts. # # Offline-deterministic (no network, no live registry calls — check-iso-facts # is exercised only in --offline mode). Builds throwaway tile fixtures via # `uv run` + Pillow, asserts documented exit codes and key output of each # script, then cleans up. Resolves paths relative to itself so it works both # in the repo and once installed to ~/.claude/skills/isometric-ops/. # # The companion iso-studio app was extracted to its own repository # (github.com/0xDarkMatter/iso-studio) and carries its own tests/run.sh. # # Usage: bash tests/run.sh # Exit: 0 all pass (including graceful skips), 1 one or more failures # # Skips gracefully (with a message, not a failure) when uv is unavailable — # see the tile-validate/sheet-pack sections below. set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SKILL="$(dirname "$HERE")" SCRIPTS="$SKILL/scripts" # 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" # ── 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
-
-
SKILL.md 23.3 KB
--- name: isometric-ops description: "Create, refine, compose, and export isometric/dimetric assets for web and games: projection math, SVG/CSS/three.js generation, pixel-art and Blender workflows, engine tilemaps, ControlNet AI generation, and the iso-studio composer. Triggers on: isometric, dimetric, tileset, y-sort." 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 companion **iso-studio** scene composer (a standalone app that grew out of this skill). Every constant is derived and machine-checked; every workflow is runnable; every claim is sourced in its reference file. > **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 companion **iso-studio** scene composer (standalone app, local checkout github.com/0xDarkMatter/iso-studio) stages assets on a snap-to-grid isometric canvas with automatic depth sorting and a blockout-to-ControlNet export path. See §5 below for the launch command and status. 1. **Launch** the app (§5), pick a projection, set tile width and grid extent. 2. **Import** PNG/SVG/WebP by drag-drop, paste, or file picker; assets land in the tray. 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 the app repo's `scene-schema.json` (version "1.0"). 6. **Blockout → ControlNet** (v2 feature): place flat-shaded grey primitives and export a depth-map / lineart render that conditions the AI pipeline (Route C, step 3). ### 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 | | **iso-studio** (external) | The zero-dependency scene composer — standalone repo at github.com/0xDarkMatter/iso-studio, owns `scene-schema.json` + the asset library; pointer: [`iso-studio.md`](references/iso-studio.md) | Route E, §5 | ## 5. iso-studio — the scene composer (standalone app) **iso-studio** is a zero-dependency, no-build isometric scene composer that grew out of this skill and now lives in its own repository — clone it wherever you keep checkouts, remote `github.com/0xDarkMatter/iso-studio` (`index.html` + `server.mjs`, no npm deps). Launch it, then work the docked palettes: ``` node <iso-studio>/server.mjs # then open http://localhost:4323 PORT=8080 node <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 the app repo's `scene-schema.json` (version "1.0"). - **Blockout mode (signature feature)** — place flat-shaded three-tone grey primitives (box / slab / ramp / cylinder) and export a **depth map** and a **lineart** render sized to the canvas; both condition the ControlNet step of the AI pipeline ([`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, elevation-aware but not per-face; `flipX` mirrors a ramp's slope, no-op on symmetric primitives) — lives in the app repo at `docs/MANUAL.md`; this skill's [`references/iso-studio.md`](references/iso-studio.md) is the quickstart pointer. --- ## 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` (Claude-Code-bundled; production UI craft) · [`playwright-ops`](../playwright-ops/SKILL.md) (headless render verification).
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.