Adds a 3-phase pipeline to populate a HexMap from a source image using Claude's vision capabilities instead of naive pixel-color matching: Phase 1 (extract-submaps): crops annotated submap PNGs per hex, including center + 6 neighbours with SVG overlay. Phase 2: Claude classifies submaps in a Code session, writing classifications.json — resumable across sessions. Phase 3 (import-from-json): reads classifications.json into the DB. Also adds assemble-map.ts to reconstruct a full image from a Leaflet tile pyramid (used to recover the Aventurien source map from kiepenkerl). Adds CLAUDE.md documenting the approach, scale constants (PIXELS_PER_MEILE=8, hexSize=40 = 10 Meilen/Hex), and the Nord/Mitte/Süd region split for the Aventurien map. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
192 lines
6.1 KiB
Markdown
192 lines
6.1 KiB
Markdown
# Hexifyer — CLAUDE.md
|
||
|
||
## Projektübersicht
|
||
|
||
Hexifyer ist ein Hex-Grid-Overlay-Tool für Fantasy-Karten.
|
||
Eine Leaflet-Karte (Kacheln aus einem Quellbild) wird mit einem bearbeitbaren Hex-Raster überlagert.
|
||
Jedes Hex speichert Terrain-Typ (Basis + lineare Features wie Flüsse, Straßen).
|
||
|
||
**Stack:** Vanilla TypeScript · Vite · Leaflet · Express · sql.js (SQLite WASM)
|
||
|
||
---
|
||
|
||
## Architektur
|
||
|
||
```
|
||
core/ — Pure Logik: Koordinaten, Terrain-Typen, HexMap-Datenstruktur
|
||
server/ — Express API + sql.js DB (hex_maps, hexes, hex_features)
|
||
src/ — Frontend: Leaflet-Karte, SVG-Renderer, UI-Komponenten
|
||
pipeline/ — CLI-Scripts für Daten-Import und Karten-Aufbereitung
|
||
tests/ — Vitest-Tests für core/
|
||
```
|
||
|
||
---
|
||
|
||
## Hex-Koordinatensystem
|
||
|
||
**Flat-top Axial-Koordinaten** (q, r).
|
||
|
||
```typescript
|
||
// Pixel → Axial
|
||
x = origin.x + size * (3/2) * q
|
||
y = origin.y + size * (√3/2 * q + √3 * r)
|
||
```
|
||
|
||
Axiale Koordinaten sind im Pixelraum **geschert** — konstante r-Linien
|
||
sind diagonal. Das ist relevant für die Regionsaufteilung (siehe unten).
|
||
|
||
---
|
||
|
||
## Pipeline: Hex-Map aus Quellbild generieren
|
||
|
||
### Warum nicht pixelbasiert?
|
||
|
||
Der naive Ansatz — Pixelfarbe des Hex-Mittelpunkts → nächste Terrain-Farbe —
|
||
scheitert in der Praxis:
|
||
|
||
- **Visuell ähnliche Terrains:** Hügel, Wiesen und Waldränder haben oft
|
||
überlappende Farbwerte. Eine Wiese im Abendlicht sieht farblich wie
|
||
ein Wald aus; ein kahler Hügel wie Gebirge.
|
||
- **Kein Kontextwissen:** Ob ein Pixel zu einem Hügel oder einer Ebene
|
||
gehört, ist oft nur aus dem Umfeld erkennbar — der Schattenwurf,
|
||
die umgebenden Höhenlinien, angrenzende Gewässer.
|
||
- **Lineare Features nicht ableitbar:** Ob durch ein Hex ein Fluss oder
|
||
eine Straße führt — und vor allem: durch welche Kanten — ist aus
|
||
einem Einzelpixel nicht bestimmbar.
|
||
|
||
### Submap-Ansatz mit Vision-LLM
|
||
|
||
Für jedes zu klassifizierende Hex wird ein **Submap-Crop** aus dem
|
||
Quellbild erzeugt:
|
||
|
||
```
|
||
Zentrum-Hex (rot umrandet) + alle 6 Nachbar-Hexes (grau)
|
||
→ ca. 220×220 px bei hexSize=40
|
||
→ annotiertes PNG: pipeline/submaps/<map-id>/<q>_<r>.png
|
||
```
|
||
|
||
Das Vision-LLM (Claude) bekommt:
|
||
1. Das annotierte Bild — für visuelle Mustererkennung (Texturen, Formen, Schatten)
|
||
2. Bereits klassifizierte Nachbarn als Text-Kontext — für Feature-Kontinuität
|
||
|
||
```
|
||
Bereits klassifizierte Nachbarn:
|
||
NE: forest
|
||
E: plains + river(edges: W+E) ← Fluss kommt von W, geht nach E
|
||
SE: unknown
|
||
SW: plains
|
||
...
|
||
```
|
||
|
||
Dadurch kann das Modell z.B. erkennen: *„der Fluss, der beim östlichen
|
||
Nachbar via W-Kante eintritt, muss hier via E-Kante den Hex verlassen"* —
|
||
selbst wenn die Farbe im Bild alleine das nicht eindeutig zeigt.
|
||
|
||
### Verarbeitungsreihenfolge
|
||
|
||
Hexes werden **spaltenweise** verarbeitet (q aufsteigend, r aufsteigend
|
||
innerhalb jedes q). Damit sind beim Erreichen von Hex (q, r) die
|
||
Nachbarn **NW** und **W** immer bereits klassifiziert.
|
||
|
||
### Drei-Phasen-Pipeline
|
||
|
||
```
|
||
Phase 1 — Extract (pipeline/extract-submaps.ts)
|
||
Quellbild → annotierte PNG-Submaps + manifest.json
|
||
Kein API-Key nötig.
|
||
|
||
Phase 2 — Classify (Claude Code Session)
|
||
Claude liest die PNGs mit dem Read-Tool,
|
||
klassifiziert in Batches, schreibt pipeline/submaps/<id>/classifications.json.
|
||
Kann über mehrere Sessions fortgesetzt werden.
|
||
|
||
Phase 3 — Import (pipeline/import-from-json.ts)
|
||
classifications.json → SQLite-DB (hexes + hex_features)
|
||
Kein API-Key nötig.
|
||
```
|
||
|
||
**Alternative:** `pipeline/import-from-image.ts` kombiniert alle drei Phasen
|
||
in einem Script, benötigt aber einen eigenen `ANTHROPIC_API_KEY`.
|
||
|
||
---
|
||
|
||
## Aventurien-Karte (DSA 4.1, 2016)
|
||
|
||
### Quellbild
|
||
|
||
`pipeline/source/aventurien-8000x12000.jpg` — aus Tile-Pyramide
|
||
des kiepenkerl-Projekts (git.davoryn.de/calic/kiepenkerl) rekonstruiert.
|
||
Nicht im Repo (`.gitignore`), lokal erzeugen:
|
||
|
||
```bash
|
||
# Tiles holen (zoom 6, 1504 Tiles)
|
||
docker cp kiepenkerl:/app/dist-server/public/tiles/6 pipeline/source/aventurien-tiles-z6
|
||
# Bild zusammensetzen
|
||
npm run pipeline:assemble -- pipeline/source/aventurien-tiles-z6 pipeline/source/aventurien.jpg
|
||
# Auf exakte 8000×12000 cropppen
|
||
node -e "import('sharp').then(({default:s})=>s('pipeline/source/aventurien.jpg').extract({left:0,top:0,width:8000,height:12000}).jpeg({quality:92}).toFile('pipeline/source/aventurien-8000x12000.jpg').then(i=>console.log(i)))"
|
||
```
|
||
|
||
### Skala
|
||
|
||
`PIXELS_PER_MEILE = 8` (aus kiepenkerl `src/engine/route-calc.ts`)
|
||
|
||
| Skala | `hexSize` | Hexes (8000×12000) |
|
||
|---|---|---|
|
||
| 10 Meilen/Hex | **40 px** | ~17.800 |
|
||
| 5 Meilen/Hex | 20 px | ~71.000 |
|
||
|
||
Standard für diese Karte: **10 Meilen/Hex, hexSize=40**.
|
||
|
||
### Regionsaufteilung (axiale r-Koordinate)
|
||
|
||
Axiale Grenzen schneiden durch den Pixelraum **diagonal** (r-Isolinien
|
||
sind im Bild von links-oben nach rechts-unten geneigt). Das ist korrekt
|
||
und erwünscht — Grenzen sind konsistent und erweiterbar.
|
||
|
||
| Region | r-Bereich | Inhalt |
|
||
|---|---|---|
|
||
| **Nord** | r < 25 | Ifirns Ozean, Thorwal, Gjalskerland |
|
||
| **Mitte** | 25 ≤ r ≤ 90 | Mittelreich, Horasreich, Bornland, Raschtulswall, Khom |
|
||
| **Süd** | r > 90 | Al'Anfa, Südmeer-Inseln |
|
||
|
||
Jede Session kann eine Region hinzufügen, ohne die anderen zu berühren.
|
||
|
||
### Map-Eintrag in der DB
|
||
|
||
```bash
|
||
# Einmalig anlegen (z.B. über API beim laufenden Server, oder direkt):
|
||
curl -X POST http://localhost:3001/api/maps \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"name":"Aventurien 10M/Hex","image_width":8000,"image_height":12000,
|
||
"hex_size":40,"origin_x":0,"origin_y":0,"min_zoom":0,"max_zoom":6}'
|
||
```
|
||
|
||
### Workflow (Mitte-Region)
|
||
|
||
```bash
|
||
# 1. Submaps extrahieren (Mitte: r=25..90)
|
||
npm run pipeline:extract -- \
|
||
pipeline/source/aventurien-8000x12000.jpg <map-id> \
|
||
--region 0,25,133,90
|
||
|
||
# 2. Claude klassifiziert in dieser Session
|
||
# (submaps/<map-id>/classifications.json wird schrittweise gefüllt)
|
||
|
||
# 3. Ergebnisse importieren
|
||
npm run pipeline:import -- \
|
||
pipeline/submaps/<map-id>/classifications.json <map-id>
|
||
```
|
||
|
||
---
|
||
|
||
## Entwicklung
|
||
|
||
```bash
|
||
npm run dev # Vite + Express parallel
|
||
npm run test # Vitest
|
||
npm run pipeline:assemble -- <tiles-dir> <output.jpg>
|
||
npm run pipeline:extract -- <image> <map-id> [--region q0,r0,q1,r1]
|
||
npm run pipeline:import -- <classifications.json> <map-id>
|
||
```
|