- controls ist eine Pflichtoption von player.init; der Beispielcode übergab sie nicht, wodurch die App mit einem TypeError startete - der git-add-Befehl hätte den Submodul-Pin still auf den lokalen Branch-Commit umgesetzt Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2284 lines
86 KiB
Markdown
2284 lines
86 KiB
Markdown
# Leonida Vice-City-Ausschnitt: Umsetzungsplan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Ein 3 × 3 km großer, zu Fuß und mit dem Auto spielbarer 3D-Blockout von Vice City und Vice Beach, erzeugt aus den Kacheln der Community-Karte YANIS v15.
|
||
|
||
**Architecture:** Python liest die Kartenkacheln einmalig aus und schreibt Vektordaten je 250-m-Chunk. Die Vite-App lädt diese Chunks im Umkreis des Spielers, baut daraus zur Laufzeit Meshes (Land, Straßen, Brücken, Gebäude) und meldet je Chunk genau einen Collider beim `three-player-controller` an. Der Geometrie-Baustein ist reines JavaScript ohne Browser-Abhängigkeiten und läuft deshalb auch in Node, damit derselbe Code später GLB-Chunks vorbacken kann.
|
||
|
||
**Tech Stack:** Node 26, Vite, three 0.184.0, three-mesh-bvh, three-player-controller (lokal), vitest, @gltf-transform/cli 4.5.0 · Python 3.12 über uv mit numpy, scipy, scikit-image, shapely, networkx, pillow, pytest
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-09-12-leonida-vice-slice-design.md`
|
||
|
||
## Global Constraints
|
||
|
||
- Eine Spieleinheit ist ein Meter. Umrechnung in three.js: `x = X`, `z = −Y`. Norden liegt bei `−Z`.
|
||
- Ausschnitt A: `X −1000 … 2000`, `Y −500 … 2500`. Kacheln: Spalten 60–71, Zeilen 54–65, Dateiname `5,{zeile},{spalte}.jpg`.
|
||
- Kartenquelle ist ausschließlich `github.com/rolux/gtadb.org` beim Commit `4093e8c874e480d47c38b37fe7a404f8b0c5275e`. Niemals `map.stateofleonida.net` abfragen.
|
||
- three exakt auf `0.184.0` pinnen. In `vite.config.js` sind `resolve.dedupe: ['three','three-mesh-bvh']` und `optimizeDeps.exclude: ['three-mesh-bvh']` zwingend.
|
||
- Verboten als Abhängigkeit: `skan`, `buildingregulariser`, OpenCV, GDAL, rasterio.
|
||
- Chunkgröße 250 m. Laden ab 400 m, Entladen ab 600 m. Pro Frame höchstens ein Chunk-Bau, eine Collider-Anmeldung und vier Abmeldungen.
|
||
- Budgets: mindestens 55 FPS, unter 150 Draw Calls, Chunk-Bau unter 20 ms im Hauptthread.
|
||
- Die Namensnennung im HUD ist Pflicht (CC BY 4.0) und darf in keinem Schritt entfernt werden.
|
||
- Jeder Commit endet mit `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`.
|
||
|
||
## Dateistruktur
|
||
|
||
| Datei | Verantwortung |
|
||
|---|---|
|
||
| `package.json`, `vite.config.js`, `index.html` | App-Gerüst, Abhängigkeiten, Vite-Sonderfälle |
|
||
| `src/main.js` | Renderer, Kamera, Licht, Spielschleife, Zusammenbau |
|
||
| `src/world/coords.js` | Umrechnung Welt ↔ Kachel ↔ Chunk. Reine Funktionen |
|
||
| `src/world/geometry.js` | Chunk-Daten → BufferGeometry (Land, Straßen, Brücken, Gebäude) |
|
||
| `src/world/streaming.js` | Laden und Entladen von Chunks, Collider-Verwaltung |
|
||
| `src/world/materials.js` | Poly-Haven-Materialien, Welt-UVs, Wasser |
|
||
| `src/world/props.js` | Palmen und Requisiten als InstancedMesh |
|
||
| `src/characters/index.js` | Figurenauswahl, Retargeting, Hitbox (aus showcase.js) |
|
||
| `src/ui/panel.js`, `src/ui/hud.js` | Debug-Panel, Namensnennung, Ladeanzeige |
|
||
| `tools/extract/leonida/coords.py` | dieselbe Umrechnung wie `coords.js`, Referenz für Tests |
|
||
| `tools/extract/leonida/tiles.py` | Kacheln laden, Cache, Mosaik |
|
||
| `tools/extract/leonida/classify.py` | Palette, Lab-Zuordnung, Auffüllen |
|
||
| `tools/extract/leonida/vectors.py` | Masken → Polygone (Land, Straßen, Gebäude) |
|
||
| `tools/extract/leonida/heights.py` | Höhen aus Wahrzeichen und Vierteln |
|
||
| `tools/extract/leonida/emit.py` | Chunk-JSON, Manifest, Debug-PNG |
|
||
| `tools/extract/run.py` | Ablaufsteuerung |
|
||
| `tools/assets/fetch.mjs` | Poly-Haven-Downloads, Palme wandeln, vereinfachen |
|
||
|
||
---
|
||
|
||
### Task 1: App-Gerüst mit laufendem Spieler
|
||
|
||
**Files:**
|
||
- Create: `package.json`, `vite.config.js`, `index.html`, `src/main.js`, `src/world/coords.js`, `tests/coords.test.js`
|
||
- Modify: `.gitmodules` (durch `git submodule add`)
|
||
- Test: `tests/coords.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nichts
|
||
- Produces: `worldToThree(X, Y) -> {x, z}`, `threeToWorld(x, z) -> {X, Y}`, `tileForWorld(X, Y) -> {row, col}`, `chunkForWorld(X, Y) -> {i, j}`, `CHUNK_SIZE = 250`, `SLICE = {x0:-1000, x1:2000, y0:-500, y1:2500}` aus `src/world/coords.js`
|
||
|
||
- [ ] **Step 1: Controller als Submodul einbinden**
|
||
|
||
```bash
|
||
cd /Users/Kenso/Developer/gta6
|
||
git -C three-player-controller checkout ac39682 # unveränderter Upstream-Stand
|
||
git submodule add https://github.com/hh-hang/three-player-controller.git three-player-controller
|
||
git -C three-player-controller checkout leonida/showcase-figuren # Arbeitskopie zurück auf den Branch
|
||
```
|
||
|
||
Der Submodul-Eintrag zeigt damit auf `ac39682`; die Arbeitskopie steht auf dem Branch, damit die Showcase weiter läuft. `git status` meldet das Submodul dann als „modified content" — das ist erwartet.
|
||
|
||
- [ ] **Step 2: Abhängigkeiten anlegen**
|
||
|
||
`package.json`:
|
||
|
||
```json
|
||
{
|
||
"name": "leonida",
|
||
"private": true,
|
||
"type": "module",
|
||
"scripts": {
|
||
"dev": "vite",
|
||
"build": "vite build",
|
||
"test": "vitest run"
|
||
},
|
||
"dependencies": {
|
||
"three": "0.184.0",
|
||
"three-mesh-bvh": "^0.9.14",
|
||
"three-player-controller": "file:three-player-controller",
|
||
"lil-gui": "^0.21.0"
|
||
},
|
||
"devDependencies": {
|
||
"vite": "^7.3.0",
|
||
"vitest": "^3.2.0"
|
||
}
|
||
}
|
||
```
|
||
|
||
`vite.config.js`:
|
||
|
||
```js
|
||
import { defineConfig } from "vite";
|
||
|
||
export default defineConfig({
|
||
// Ohne dedupe lädt der Controller three aus seinem eigenen node_modules -> zwei Kopien.
|
||
resolve: { dedupe: ["three", "three-mesh-bvh"] },
|
||
// Der BVH-Worker wird dynamisch aus three-mesh-bvh/src/workers/ geladen und darf nicht vorgebündelt werden.
|
||
optimizeDeps: { exclude: ["three-mesh-bvh", "three-player-controller"] },
|
||
server: { host: true },
|
||
});
|
||
```
|
||
|
||
Dann `npm install` ausführen.
|
||
|
||
- [ ] **Step 3: Den fehlschlagenden Test schreiben**
|
||
|
||
`tests/coords.test.js`:
|
||
|
||
```js
|
||
import { describe, expect, it } from "vitest";
|
||
import { CHUNK_SIZE, chunkForWorld, threeToWorld, tileForWorld, worldToThree } from "../src/world/coords.js";
|
||
|
||
describe("coords", () => {
|
||
it("dreht Y zur three-z-Achse", () => {
|
||
expect(worldToThree(1699, 743)).toEqual({ x: 1699, z: -743 });
|
||
expect(threeToWorld(1699, -743)).toEqual({ X: 1699, Y: 743 });
|
||
});
|
||
|
||
it("findet die Kachel von Vice Beach", () => {
|
||
expect(tileForWorld(1409.4, 2803.2)).toEqual({ row: 53, col: 69 });
|
||
});
|
||
|
||
it("deckt den Ausschnitt mit Spalten 60..71 und Zeilen 54..65 ab", () => {
|
||
expect(tileForWorld(-1000, 2500)).toEqual({ row: 54, col: 60 });
|
||
expect(tileForWorld(1999, -499)).toEqual({ row: 65, col: 71 });
|
||
});
|
||
|
||
it("teilt den Ausschnitt in 250-m-Chunks", () => {
|
||
expect(CHUNK_SIZE).toBe(250);
|
||
expect(chunkForWorld(-1000, 2500)).toEqual({ i: 0, j: 0 });
|
||
expect(chunkForWorld(1999, -499)).toEqual({ i: 11, j: 11 });
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Test laufen lassen, er muss scheitern**
|
||
|
||
Run: `npm test`
|
||
Expected: FAIL, `Failed to resolve import "../src/world/coords.js"`
|
||
|
||
- [ ] **Step 5: `src/world/coords.js` schreiben**
|
||
|
||
```js
|
||
export const TILE_PX = 256;
|
||
export const WORLD_HALF = 16384; // gtadb-Weltgrenze in Metern
|
||
export const CHUNK_SIZE = 250;
|
||
export const SLICE = { x0: -1000, x1: 2000, y0: -500, y1: 2500 };
|
||
export const CHUNKS_PER_SIDE = (SLICE.x1 - SLICE.x0) / CHUNK_SIZE; // 12
|
||
|
||
export const worldToThree = (X, Y) => ({ x: X, z: -Y });
|
||
export const threeToWorld = (x, z) => ({ X: x, Y: -z });
|
||
|
||
export function tileForWorld(X, Y) {
|
||
return {
|
||
row: Math.floor((WORLD_HALF - Y) / TILE_PX),
|
||
col: Math.floor((X + WORLD_HALF) / TILE_PX),
|
||
};
|
||
}
|
||
|
||
export function chunkForWorld(X, Y) {
|
||
return {
|
||
i: Math.floor((X - SLICE.x0) / CHUNK_SIZE),
|
||
j: Math.floor((SLICE.y1 - Y) / CHUNK_SIZE),
|
||
};
|
||
}
|
||
|
||
/** Nordwest-Ecke eines Chunks in Weltkoordinaten. */
|
||
export function chunkOrigin(i, j) {
|
||
return { X: SLICE.x0 + i * CHUNK_SIZE, Y: SLICE.y1 - j * CHUNK_SIZE };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Test laufen lassen, er muss bestehen**
|
||
|
||
Run: `npm test`
|
||
Expected: PASS, 4 Tests
|
||
|
||
- [ ] **Step 7: Minimale Szene mit Spieler**
|
||
|
||
`public/models/ual.glb` aus `three-player-controller/example/public/glb/ual.glb` kopieren. `index.html` mit `<div id="container">` und `<script type="module" src="/src/main.js">`. `src/main.js`:
|
||
|
||
```js
|
||
import { ACESFilmicToneMapping, AmbientLight, DirectionalLight, Mesh, MeshStandardMaterial,
|
||
PerspectiveCamera, PlaneGeometry, Scene, Timer, Vector3, WebGLRenderer } from "three";
|
||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||
import { playerController } from "three-player-controller";
|
||
|
||
const container = document.querySelector("#container");
|
||
const scene = new Scene();
|
||
const renderer = new WebGLRenderer({ antialias: true });
|
||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||
renderer.toneMapping = ACESFilmicToneMapping;
|
||
renderer.shadowMap.enabled = true;
|
||
container.appendChild(renderer.domElement);
|
||
|
||
const camera = new PerspectiveCamera(55, container.clientWidth / container.clientHeight, 0.1, 2500);
|
||
// Pflicht: der Controller liest controls.domElement für Pointer-Lock und Eingaben.
|
||
const controls = new OrbitControls(camera, renderer.domElement);
|
||
scene.add(new AmbientLight(0xffffff, 1.5));
|
||
const sun = new DirectionalLight(0xffffff, 2);
|
||
sun.position.set(60, 120, 40);
|
||
sun.castShadow = true;
|
||
scene.add(sun);
|
||
|
||
const ground = new Mesh(new PlaneGeometry(400, 400), new MeshStandardMaterial({ color: 0x9aa88a }));
|
||
ground.rotation.x = -Math.PI / 2;
|
||
ground.receiveShadow = true;
|
||
scene.add(ground);
|
||
|
||
const gltf = await new GLTFLoader().loadAsync("./models/ual.glb");
|
||
const player = new playerController();
|
||
await player.init({
|
||
scene, camera, controls,
|
||
colliders: [{ motion: "static", shape: { kind: "mesh", source: ground } }],
|
||
initPos: new Vector3(0, 2, 0),
|
||
playerModelConfig: {
|
||
model: gltf.scene, animations: gltf.animations, scale: 0.005,
|
||
idleAnim: "Idle_Loop", walkAnim: "Walk_Loop", runAnim: "Sprint_Loop",
|
||
jumpAnim: ["Jump_Start", "Jump_Loop", "Jump_Land"],
|
||
headBoneName: "Head", rotateY: -Math.PI / 2, speed: 100, runSpeed: 600,
|
||
},
|
||
});
|
||
|
||
const timer = new Timer();
|
||
timer.connect(document);
|
||
renderer.setAnimationLoop((t) => {
|
||
timer.update(t);
|
||
player.update(Math.min(timer.getDelta(), 1 / 40));
|
||
renderer.render(scene, camera);
|
||
});
|
||
addEventListener("resize", () => {
|
||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||
camera.aspect = container.clientWidth / container.clientHeight;
|
||
camera.updateProjectionMatrix();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 8: Im Browser prüfen**
|
||
|
||
Run: `npm run dev`, dann `http://localhost:5173` öffnen.
|
||
Expected: Die Figur steht auf der Fläche, läuft mit WASD und fällt nicht durch. In der Konsole darf **nicht** „Multiple instances of Three.js" stehen — sonst greift `resolve.dedupe` nicht.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
# three-player-controller NICHT mit `git add` erfassen: das würde den Submodul-Pin still
|
||
# auf den gerade ausgecheckten Branch-Commit umsetzen. Der Pin wurde in Schritt 1 gesetzt.
|
||
git add package.json package-lock.json vite.config.js index.html src tests public/models/ual.glb .gitmodules
|
||
git commit -m "feat: App-Gerüst mit Spieler auf Testfläche
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
```
|
||
---
|
||
|
||
### Task 2: Python-Umgebung, Kacheln laden, Mosaik
|
||
|
||
**Files:**
|
||
- Create: `tools/extract/pyproject.toml`, `tools/extract/leonida/__init__.py`, `tools/extract/leonida/coords.py`, `tools/extract/leonida/tiles.py`, `tools/extract/tests/test_coords.py`, `tools/extract/tests/test_tiles.py`
|
||
- Test: `tools/extract/tests/`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nichts
|
||
- Produces: `SLICE`, `TILE_PX`, `WORLD_HALF`, `tile_for_world(X, Y) -> (row, col)`, `slice_tiles() -> list[(row, col)]`, `pixel_to_world(px, py) -> (X, Y)` aus `coords.py`; `download_tiles(cache: Path) -> None`, `load_mosaic(cache: Path) -> np.ndarray` (Form `(3000, 3000, 3)`, uint8, bereits auf den Ausschnitt beschnitten) aus `tiles.py`
|
||
|
||
- [ ] **Step 1: Projektdatei anlegen**
|
||
|
||
`tools/extract/pyproject.toml`:
|
||
|
||
```toml
|
||
[project]
|
||
name = "leonida-extract"
|
||
version = "0.1.0"
|
||
requires-python = ">=3.12"
|
||
dependencies = [
|
||
"numpy>=2.1", "scipy>=1.14", "scikit-image>=0.24",
|
||
"shapely>=2.0", "networkx>=3.3", "pillow>=10.4",
|
||
]
|
||
|
||
[dependency-groups]
|
||
dev = ["pytest>=8.3"]
|
||
|
||
[tool.pytest.ini_options]
|
||
testpaths = ["tests"]
|
||
```
|
||
|
||
Dann `cd tools/extract && uv sync`.
|
||
|
||
- [ ] **Step 2: Den fehlschlagenden Test für die Umrechnung schreiben**
|
||
|
||
`tools/extract/tests/test_coords.py`:
|
||
|
||
```python
|
||
from leonida.coords import SLICE, pixel_to_world, slice_tiles, tile_for_world
|
||
|
||
def test_tile_of_vice_beach():
|
||
assert tile_for_world(1409.4, 2803.2) == (53, 69)
|
||
|
||
def test_slice_covers_expected_grid():
|
||
tiles = slice_tiles()
|
||
assert len(tiles) == 144
|
||
assert min(c for _, c in tiles) == 60 and max(c for _, c in tiles) == 71
|
||
assert min(r for r, _ in tiles) == 54 and max(r for r, _ in tiles) == 65
|
||
|
||
def test_pixel_origin_is_slice_corner():
|
||
# Pixel (0,0) des beschnittenen Mosaiks ist die Nordwest-Ecke des Ausschnitts.
|
||
assert pixel_to_world(0, 0) == (SLICE["x0"], SLICE["y1"])
|
||
assert pixel_to_world(2999, 2999) == (SLICE["x0"] + 2999, SLICE["y1"] - 2999)
|
||
```
|
||
|
||
- [ ] **Step 3: Test laufen lassen, er muss scheitern**
|
||
|
||
Run: `cd tools/extract && uv run pytest tests/test_coords.py -v`
|
||
Expected: FAIL, `ModuleNotFoundError: No module named 'leonida'`
|
||
|
||
- [ ] **Step 4: `leonida/coords.py` schreiben**
|
||
|
||
```python
|
||
"""Umrechnung zwischen gtadb-Weltkoordinaten, Kacheln und Mosaikpixeln.
|
||
|
||
Spiegelt src/world/coords.js. Beide Seiten müssen dieselben Werte liefern.
|
||
"""
|
||
TILE_PX = 256
|
||
WORLD_HALF = 16384
|
||
CHUNK_SIZE = 250
|
||
SLICE = {"x0": -1000, "x1": 2000, "y0": -500, "y1": 2500}
|
||
|
||
|
||
def tile_for_world(x: float, y: float) -> tuple[int, int]:
|
||
return (int((WORLD_HALF - y) // TILE_PX), int((x + WORLD_HALF) // TILE_PX))
|
||
|
||
|
||
def slice_tiles() -> list[tuple[int, int]]:
|
||
r0, c0 = tile_for_world(SLICE["x0"], SLICE["y1"])
|
||
r1, c1 = tile_for_world(SLICE["x1"] - 1, SLICE["y0"] + 1)
|
||
return [(r, c) for r in range(r0, r1 + 1) for c in range(c0, c1 + 1)]
|
||
|
||
|
||
def mosaic_origin_px() -> tuple[int, int]:
|
||
"""Pixelversatz der Ausschnittsecke innerhalb des ungeschnittenen Kachelrasters."""
|
||
r0, c0 = tile_for_world(SLICE["x0"], SLICE["y1"])
|
||
return (SLICE["x0"] + WORLD_HALF - c0 * TILE_PX, (WORLD_HALF - SLICE["y1"]) - r0 * TILE_PX)
|
||
|
||
|
||
def pixel_to_world(px: int, py: int) -> tuple[float, float]:
|
||
return (SLICE["x0"] + px, SLICE["y1"] - py)
|
||
```
|
||
|
||
- [ ] **Step 5: Test laufen lassen, er muss bestehen**
|
||
|
||
Run: `cd tools/extract && uv run pytest tests/test_coords.py -v`
|
||
Expected: PASS, 3 Tests
|
||
|
||
- [ ] **Step 6: Den fehlschlagenden Test für das Mosaik schreiben**
|
||
|
||
`tools/extract/tests/test_tiles.py` (ohne Netz, mit eingespeisten Kacheln):
|
||
|
||
```python
|
||
import numpy as np
|
||
from leonida.tiles import assemble
|
||
|
||
def fake_tile(row: int, col: int) -> np.ndarray:
|
||
# Jede Kachel einfarbig, Farbe kodiert Zeile und Spalte.
|
||
return np.full((256, 256, 3), (row, col, 7), dtype=np.uint8)
|
||
|
||
def test_assemble_crops_to_slice():
|
||
mosaic = assemble(fake_tile)
|
||
assert mosaic.shape == (3000, 3000, 3)
|
||
# Nordwest-Ecke stammt aus Kachel (54, 60)
|
||
assert tuple(mosaic[0, 0]) == (54, 60, 7)
|
||
# Südost-Ecke stammt aus Kachel (65, 71)
|
||
assert tuple(mosaic[-1, -1]) == (65, 71, 7)
|
||
```
|
||
|
||
- [ ] **Step 7: Test laufen lassen, er muss scheitern**
|
||
|
||
Run: `cd tools/extract && uv run pytest tests/test_tiles.py -v`
|
||
Expected: FAIL, `ImportError: cannot import name 'assemble'`
|
||
|
||
- [ ] **Step 8: `leonida/tiles.py` schreiben**
|
||
|
||
```python
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
from PIL import Image
|
||
|
||
from .coords import SLICE, TILE_PX, mosaic_origin_px, slice_tiles
|
||
|
||
COMMIT = "4093e8c874e480d47c38b37fe7a404f8b0c5275e"
|
||
BASE = f"https://raw.githubusercontent.com/rolux/gtadb.org/{COMMIT}/maps/tiles/6/yanis%2C15/5"
|
||
UA = {"User-Agent": "leonida-extract/0.1 (local hobby project)"}
|
||
|
||
|
||
def download_tiles(cache: Path) -> None:
|
||
"""Lädt alle Kacheln des Ausschnitts; vorhandene werden übersprungen."""
|
||
cache.mkdir(parents=True, exist_ok=True)
|
||
missing = []
|
||
for row, col in slice_tiles():
|
||
name = f"5,{row},{col}.jpg"
|
||
dst = cache / name
|
||
if dst.exists():
|
||
continue
|
||
for attempt in range(3):
|
||
try:
|
||
req = urllib.request.Request(f"{BASE}/{name}", headers=UA)
|
||
dst.write_bytes(urllib.request.urlopen(req, timeout=30).read())
|
||
break
|
||
except (urllib.error.URLError, TimeoutError):
|
||
time.sleep(2 ** attempt)
|
||
else:
|
||
missing.append(name)
|
||
if missing:
|
||
raise RuntimeError(f"{len(missing)} Kacheln fehlen: {missing[:5]} ...")
|
||
|
||
|
||
def assemble(load_tile) -> np.ndarray:
|
||
"""Setzt die Kacheln zusammen und schneidet auf den Ausschnitt zu."""
|
||
tiles = slice_tiles()
|
||
r0 = min(r for r, _ in tiles)
|
||
c0 = min(c for _, c in tiles)
|
||
rows = max(r for r, _ in tiles) - r0 + 1
|
||
cols = max(c for _, c in tiles) - c0 + 1
|
||
full = np.zeros((rows * TILE_PX, cols * TILE_PX, 3), dtype=np.uint8)
|
||
for row, col in tiles:
|
||
y, x = (row - r0) * TILE_PX, (col - c0) * TILE_PX
|
||
full[y:y + TILE_PX, x:x + TILE_PX] = load_tile(row, col)
|
||
ox, oy = mosaic_origin_px()
|
||
w = SLICE["x1"] - SLICE["x0"]
|
||
h = SLICE["y1"] - SLICE["y0"]
|
||
return full[oy:oy + h, ox:ox + w]
|
||
|
||
|
||
def load_mosaic(cache: Path) -> np.ndarray:
|
||
def load_tile(row: int, col: int) -> np.ndarray:
|
||
with Image.open(cache / f"5,{row},{col}.jpg") as img:
|
||
return np.asarray(img.convert("RGB"))
|
||
return assemble(load_tile)
|
||
```
|
||
|
||
- [ ] **Step 9: Test laufen lassen, er muss bestehen**
|
||
|
||
Run: `cd tools/extract && uv run pytest -v`
|
||
Expected: PASS, 4 Tests
|
||
|
||
- [ ] **Step 10: Echte Kacheln laden und Größe prüfen**
|
||
|
||
Run:
|
||
```bash
|
||
cd tools/extract
|
||
uv run python -c "
|
||
from pathlib import Path
|
||
from leonida.tiles import download_tiles, load_mosaic
|
||
c = Path('cache'); download_tiles(c)
|
||
m = load_mosaic(c); print(m.shape, m.dtype)
|
||
"
|
||
```
|
||
Expected: `(3000, 3000, 3) uint8`, und `cache/` enthält 144 Dateien mit rund 1,2 MB.
|
||
|
||
- [ ] **Step 11: Commit**
|
||
|
||
```bash
|
||
git add tools/extract
|
||
git commit -m "feat(extract): Koordinaten, Kachel-Download und Mosaik
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
```
|
||
---
|
||
|
||
### Task 3: Klassifizierung der Pixel
|
||
|
||
**Files:**
|
||
- Create: `tools/extract/leonida/classify.py`, `tools/extract/tests/test_classify.py`
|
||
- Test: `tools/extract/tests/test_classify.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `load_mosaic` aus Task 2
|
||
- Produces: `CLASSES` (dict Name → Index), `PALETTE` (dict Name → RGB), `classify(rgb: np.ndarray) -> np.ndarray` (uint8-Labelbild), `class_shares(labels) -> dict[str, float]`
|
||
|
||
Die Palette stammt aus den gemessenen Kachelfarben. Sie ist der einzige Ort, an dem Farbwerte stehen.
|
||
|
||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||
|
||
`tools/extract/tests/test_classify.py`:
|
||
|
||
```python
|
||
import numpy as np
|
||
from leonida.classify import CLASSES, PALETTE, class_shares, classify
|
||
|
||
def block(color, size=8):
|
||
return np.full((size, size, 3), color, dtype=np.uint8)
|
||
|
||
def test_pure_colors_hit_their_class():
|
||
for name, color in PALETTE.items():
|
||
labels = classify(block(color))
|
||
assert (labels == CLASSES[name]).all(), name
|
||
|
||
def test_jpeg_noise_still_classifies():
|
||
rng = np.random.default_rng(0)
|
||
noisy = block(PALETTE["strasse"], 32).astype(np.int16)
|
||
noisy += rng.integers(-10, 11, noisy.shape) # JPEG-Rauschen
|
||
labels = classify(np.clip(noisy, 0, 255).astype(np.uint8))
|
||
assert (labels == CLASSES["strasse"]).mean() > 0.98
|
||
|
||
def test_unknown_pixels_are_filled_from_neighbours():
|
||
img = block(PALETTE["stadt"], 16)
|
||
img[7:9, 7:9] = (255, 0, 255) # Beschriftung, in keiner Klasse
|
||
labels = classify(img)
|
||
assert (labels == CLASSES["stadt"]).all()
|
||
|
||
def test_shares_sum_to_one():
|
||
img = np.concatenate([block(PALETTE["wasser"], 8), block(PALETTE["land"], 8)], axis=1)
|
||
shares = class_shares(classify(img))
|
||
assert abs(sum(shares.values()) - 1.0) < 1e-9
|
||
assert abs(shares["wasser"] - 0.5) < 1e-9
|
||
```
|
||
|
||
- [ ] **Step 2: Test laufen lassen, er muss scheitern**
|
||
|
||
Run: `cd tools/extract && uv run pytest tests/test_classify.py -v`
|
||
Expected: FAIL, `ModuleNotFoundError: No module named 'leonida.classify'`
|
||
|
||
- [ ] **Step 3: `leonida/classify.py` schreiben**
|
||
|
||
```python
|
||
import numpy as np
|
||
from scipy import ndimage as ndi
|
||
from skimage.color import rgb2lab
|
||
|
||
PALETTE = {
|
||
"wasser": (44, 103, 164),
|
||
"land": (192, 216, 128),
|
||
"gruen": (139, 195, 74),
|
||
"sand": (232, 216, 136),
|
||
"stadt": (216, 216, 216),
|
||
"gebaeude": (176, 176, 176),
|
||
"strasse": (80, 80, 80),
|
||
"weg": (192, 152, 96),
|
||
"spekulativ": (198, 96, 96),
|
||
}
|
||
CLASSES = {name: i for i, name in enumerate(PALETTE)}
|
||
UNKNOWN = 255
|
||
MAX_DELTA_E = 14.0 # darüber gilt ein Pixel als unbekannt (Beschriftung, Icon, Kante)
|
||
|
||
|
||
def classify(rgb: np.ndarray) -> np.ndarray:
|
||
"""Ordnet jedes Pixel der nächsten Palettenfarbe zu; Ausreißer werden aufgefüllt."""
|
||
lab = rgb2lab(rgb.astype(np.float32) / 255.0)
|
||
ref = rgb2lab(np.array([list(PALETTE.values())], dtype=np.float32) / 255.0)[0]
|
||
dist = np.linalg.norm(lab[:, :, None, :] - ref[None, None, :, :], axis=3)
|
||
labels = dist.argmin(axis=2).astype(np.uint8)
|
||
unknown = dist.min(axis=2) > MAX_DELTA_E
|
||
if unknown.any():
|
||
# Nächstes bekanntes Pixel übernehmen.
|
||
idx = ndi.distance_transform_edt(unknown, return_distances=False, return_indices=True)
|
||
labels = labels[tuple(idx)]
|
||
return labels
|
||
|
||
|
||
def class_shares(labels: np.ndarray) -> dict[str, float]:
|
||
total = labels.size
|
||
counts = np.bincount(labels.ravel(), minlength=len(PALETTE))
|
||
return {name: float(counts[i]) / total for name, i in CLASSES.items()}
|
||
```
|
||
|
||
- [ ] **Step 4: Test laufen lassen, er muss bestehen**
|
||
|
||
Run: `cd tools/extract && uv run pytest tests/test_classify.py -v`
|
||
Expected: PASS, 4 Tests
|
||
|
||
- [ ] **Step 5: Gegen den echten Ausschnitt prüfen**
|
||
|
||
Run:
|
||
```bash
|
||
cd tools/extract
|
||
uv run python -c "
|
||
from pathlib import Path
|
||
from leonida.tiles import load_mosaic
|
||
from leonida.classify import classify, class_shares
|
||
s = class_shares(classify(load_mosaic(Path('cache'))))
|
||
for k, v in sorted(s.items(), key=lambda kv: -kv[1]): print(f'{k:12s} {v*100:5.1f}%')
|
||
"
|
||
```
|
||
Expected: Wasser rund 32 %, Stadt rund 31 %, Gebäude rund 9 %, Straße rund 8 %. Weichen die Werte um mehr als die Toleranz aus Task 8 ab, stimmt die Palette nicht — dann die Farben aus einer Kachel nachmessen, statt die Toleranz zu erhöhen.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add tools/extract/leonida/classify.py tools/extract/tests/test_classify.py
|
||
git commit -m "feat(extract): Pixel-Klassifizierung mit Lab-Palette
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Küste, Straßenflächen und Brücken
|
||
|
||
**Files:**
|
||
- Create: `tools/extract/leonida/vectors.py`, `tools/extract/tests/test_vectors.py`
|
||
- Test: `tools/extract/tests/test_vectors.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `CLASSES`, `classify` aus Task 3
|
||
- Produces: `mask_polygons(mask: np.ndarray, min_area: float, simplify: float) -> list[Polygon]`, `land_polygons(labels)`, `road_polygons(labels) -> list[tuple[Polygon, bool]]` (Polygon, `ist_bruecke`), `centerlines(labels) -> list[LineString]` — alle in Weltkoordinaten (Meter)
|
||
|
||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||
|
||
`tools/extract/tests/test_vectors.py`:
|
||
|
||
```python
|
||
import numpy as np
|
||
from leonida.classify import CLASSES
|
||
from leonida.vectors import land_polygons, mask_polygons, road_polygons
|
||
|
||
def test_square_becomes_one_polygon_with_right_area():
|
||
mask = np.zeros((100, 100), dtype=bool)
|
||
mask[20:60, 30:80] = True # 40 x 50 Pixel = 2000 m²
|
||
polys = mask_polygons(mask, min_area=10, simplify=1.0)
|
||
assert len(polys) == 1
|
||
assert abs(polys[0].area - 2000) < 60 # Vereinfachung darf etwas abweichen
|
||
|
||
def test_island_keeps_its_lagoon():
|
||
labels = np.full((80, 80), CLASSES["wasser"], dtype=np.uint8)
|
||
labels[10:70, 10:70] = CLASSES["land"]
|
||
labels[30:50, 30:50] = CLASSES["wasser"] # Lagune
|
||
polys = land_polygons(labels)
|
||
assert len(polys) == 1
|
||
assert len(polys[0].interiors) == 1
|
||
|
||
def test_road_over_water_is_flagged_as_bridge():
|
||
labels = np.full((60, 60), CLASSES["wasser"], dtype=np.uint8)
|
||
labels[0:10, :] = CLASSES["land"]
|
||
labels[25:31, :] = CLASSES["strasse"] # Damm quer durchs Wasser
|
||
roads = road_polygons(labels)
|
||
assert len(roads) == 1
|
||
assert roads[0][1] is True
|
||
|
||
def test_crossing_roads_form_a_single_polygon():
|
||
labels = np.full((60, 60), CLASSES["land"], dtype=np.uint8)
|
||
labels[28:32, :] = CLASSES["strasse"]
|
||
labels[:, 28:32] = CLASSES["strasse"]
|
||
roads = road_polygons(labels)
|
||
assert len(roads) == 1 # Kreuzung, kein Zerfall in vier Arme
|
||
```
|
||
|
||
- [ ] **Step 2: Test laufen lassen, er muss scheitern**
|
||
|
||
Run: `cd tools/extract && uv run pytest tests/test_vectors.py -v`
|
||
Expected: FAIL, `ModuleNotFoundError: No module named 'leonida.vectors'`
|
||
|
||
- [ ] **Step 3: `leonida/vectors.py` schreiben**
|
||
|
||
```python
|
||
import numpy as np
|
||
from scipy import ndimage as ndi
|
||
from shapely.geometry import LineString, Polygon
|
||
from shapely.ops import unary_union
|
||
from skimage.measure import find_contours
|
||
from skimage.morphology import binary_closing, disk, skeletonize
|
||
|
||
from .classify import CLASSES
|
||
from .coords import SLICE
|
||
|
||
ROAD_CLASSES = ("strasse", "weg", "spekulativ")
|
||
|
||
|
||
def _to_world(contour: np.ndarray) -> list[tuple[float, float]]:
|
||
"""Konturpunkte (Zeile, Spalte) → Weltkoordinaten in Metern."""
|
||
return [(SLICE["x0"] + c, SLICE["y1"] - r) for r, c in contour]
|
||
|
||
|
||
def mask_polygons(mask: np.ndarray, min_area: float, simplify: float) -> list[Polygon]:
|
||
padded = np.pad(mask, 1)
|
||
outers, holes = [], []
|
||
for contour in find_contours(padded.astype(float), 0.5):
|
||
ring = _to_world(contour - 1)
|
||
if len(ring) < 4:
|
||
continue
|
||
poly = Polygon(ring).buffer(0)
|
||
if poly.is_empty or poly.area < min_area:
|
||
continue
|
||
(holes if not _is_outer(mask, contour - 1) else outers).append(poly)
|
||
result = []
|
||
for outer in outers:
|
||
inner = [h.exterior.coords for h in holes if outer.contains(h)]
|
||
result.append(Polygon(outer.exterior.coords, inner).buffer(0).simplify(simplify))
|
||
return [p for p in result if p.geom_type == "Polygon" and p.area >= min_area]
|
||
|
||
|
||
def _is_outer(mask: np.ndarray, contour: np.ndarray) -> bool:
|
||
"""Innen liegende Konturen umschließen Löcher."""
|
||
r, c = contour.mean(axis=0)
|
||
r = int(np.clip(r, 0, mask.shape[0] - 1))
|
||
c = int(np.clip(c, 0, mask.shape[1] - 1))
|
||
return bool(mask[r, c])
|
||
|
||
|
||
def land_polygons(labels: np.ndarray) -> list[Polygon]:
|
||
land = labels != CLASSES["wasser"]
|
||
land = ndi.binary_closing(ndi.binary_opening(land, disk(2)), disk(2))
|
||
return mask_polygons(land, min_area=50, simplify=1.0)
|
||
|
||
|
||
def road_mask(labels: np.ndarray) -> np.ndarray:
|
||
mask = np.zeros(labels.shape, dtype=bool)
|
||
for name in ROAD_CLASSES:
|
||
mask |= labels == CLASSES[name]
|
||
# Schließen überbrückt die eingezeichneten Straßennamen.
|
||
return binary_closing(mask, disk(3))
|
||
|
||
|
||
def road_polygons(labels: np.ndarray) -> list[tuple[Polygon, bool]]:
|
||
water = labels == CLASSES["wasser"]
|
||
polys = mask_polygons(road_mask(labels), min_area=20, simplify=0.8)
|
||
out = []
|
||
for poly in polys:
|
||
r0, c0, r1, c1 = _bbox_px(poly)
|
||
window = water[r0:r1 + 1, c0:c1 + 1]
|
||
out.append((poly, bool(window.mean() > 0.3)))
|
||
return out
|
||
|
||
|
||
def _bbox_px(poly: Polygon) -> tuple[int, int, int, int]:
|
||
minx, miny, maxx, maxy = poly.bounds
|
||
return (int(SLICE["y1"] - maxy), int(minx - SLICE["x0"]),
|
||
int(SLICE["y1"] - miny), int(maxx - SLICE["x0"]))
|
||
|
||
|
||
def centerlines(labels: np.ndarray) -> list[LineString]:
|
||
"""Skelett der Straßenflächen, nur zum Platzieren von Laternen und Palmen."""
|
||
skeleton = skeletonize(road_mask(labels))
|
||
lines = []
|
||
for contour in find_contours(skeleton.astype(float), 0.5):
|
||
pts = _to_world(contour)
|
||
if len(pts) >= 2:
|
||
line = LineString(pts).simplify(1.5)
|
||
if line.length >= 15:
|
||
lines.append(line)
|
||
return lines
|
||
```
|
||
|
||
- [ ] **Step 4: Test laufen lassen, er muss bestehen**
|
||
|
||
Run: `cd tools/extract && uv run pytest tests/test_vectors.py -v`
|
||
Expected: PASS, 4 Tests
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add tools/extract/leonida/vectors.py tools/extract/tests/test_vectors.py
|
||
git commit -m "feat(extract): Küste, Straßenflächen, Brückenerkennung, Mittellinien
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
```
|
||
---
|
||
|
||
### Task 5: Gebäude, Höhen und Palmenpunkte
|
||
|
||
**Files:**
|
||
- Create: `tools/extract/leonida/buildings.py`, `tools/extract/leonida/heights.py`, `tools/extract/tests/test_buildings.py`, `tools/extract/tests/test_heights.py`
|
||
- Test: `tools/extract/tests/test_buildings.py`, `tools/extract/tests/test_heights.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `mask_polygons`, `centerlines` aus Task 4, `CLASSES` aus Task 3
|
||
- Produces: `regularize(poly) -> Polygon | None`, `building_polygons(labels) -> list[tuple[Polygon, bool]]` (Polygon, `spekulativ`), `assign_heights(polys, landmarks, districts, seed) -> list[float]`, `height_sources(...) -> list[str]`, `palm_points(labels, lines, seed) -> list[tuple[float, float]]`
|
||
|
||
- [ ] **Step 1: Den fehlschlagenden Test für Gebäude schreiben**
|
||
|
||
`tools/extract/tests/test_buildings.py`:
|
||
|
||
```python
|
||
import numpy as np
|
||
from shapely.geometry import Polygon
|
||
from leonida.buildings import building_polygons, regularize
|
||
from leonida.classify import CLASSES
|
||
|
||
def test_rough_rectangle_snaps_to_rectangle():
|
||
poly = Polygon([(0, 0), (10.4, 0.3), (10.1, 20.2), (-0.2, 19.8)])
|
||
out = regularize(poly)
|
||
assert len(out.exterior.coords) == 5 # geschlossenes Rechteck
|
||
assert abs(out.area - 205) < 25
|
||
|
||
def test_l_shape_is_kept_not_boxed():
|
||
poly = Polygon([(0, 0), (30, 0), (30, 10), (10, 10), (10, 30), (0, 30)])
|
||
out = regularize(poly)
|
||
assert out.area < 0.8 * out.minimum_rotated_rectangle.area
|
||
|
||
def test_tiny_polygon_is_dropped():
|
||
assert regularize(Polygon([(0, 0), (2, 0), (2, 2), (0, 2)])) is None
|
||
|
||
def test_two_buildings_are_separated():
|
||
labels = np.full((60, 60), CLASSES["stadt"], dtype=np.uint8)
|
||
labels[10:25, 10:25] = CLASSES["gebaeude"]
|
||
labels[35:50, 35:50] = CLASSES["gebaeude"]
|
||
assert len(building_polygons(labels)) == 2
|
||
```
|
||
|
||
- [ ] **Step 2: Test laufen lassen, er muss scheitern**
|
||
|
||
Run: `cd tools/extract && uv run pytest tests/test_buildings.py -v`
|
||
Expected: FAIL, `ModuleNotFoundError: No module named 'leonida.buildings'`
|
||
|
||
- [ ] **Step 3: `leonida/buildings.py` schreiben**
|
||
|
||
```python
|
||
import numpy as np
|
||
from shapely.geometry import Polygon
|
||
|
||
from .classify import CLASSES
|
||
from .vectors import mask_polygons
|
||
|
||
MIN_AREA = 12.0
|
||
RECT_COVERAGE = 0.85
|
||
|
||
|
||
def regularize(poly: Polygon) -> Polygon | None:
|
||
"""Rechteck, wenn das Polygon sein gedrehtes Hüllrechteck gut ausfüllt; sonst vereinfacht."""
|
||
poly = poly.buffer(0)
|
||
if poly.geom_type != "Polygon" or poly.area < MIN_AREA:
|
||
return None
|
||
rect = poly.minimum_rotated_rectangle
|
||
if rect.area > 0 and poly.area / rect.area >= RECT_COVERAGE:
|
||
return rect
|
||
simple = poly.simplify(1.0)
|
||
return simple if simple.area >= MIN_AREA else None
|
||
|
||
|
||
def building_polygons(labels: np.ndarray) -> list[tuple[Polygon, bool]]:
|
||
out = []
|
||
for name, speculative in (("gebaeude", False), ("spekulativ", True)):
|
||
mask = labels == CLASSES[name]
|
||
for poly in mask_polygons(mask, min_area=MIN_AREA, simplify=0.5):
|
||
reg = regularize(poly)
|
||
if reg is not None:
|
||
out.append((reg, speculative))
|
||
return out
|
||
```
|
||
|
||
Hinweis: `spekulativ` liefert sowohl rote Straßen als auch rote Gebäude. Die roten Flächen, die `road_polygons` bereits als Straße übernommen hat, werden in Task 6 beim Zusammenschreiben abgezogen (Flächen, die zu über 50 % in einem Straßenpolygon liegen, gelten als Straße).
|
||
|
||
- [ ] **Step 4: Test laufen lassen, er muss bestehen**
|
||
|
||
Run: `cd tools/extract && uv run pytest tests/test_buildings.py -v`
|
||
Expected: PASS, 4 Tests
|
||
|
||
- [ ] **Step 5: Den fehlschlagenden Test für Höhen schreiben**
|
||
|
||
`tools/extract/tests/test_heights.py`:
|
||
|
||
```python
|
||
from shapely.geometry import Polygon
|
||
from leonida.heights import DISTRICT_RANGES, assign_heights
|
||
|
||
SQUARE = Polygon([(0, 0), (20, 0), (20, 20), (0, 20)])
|
||
FAR = Polygon([(500, 500), (520, 500), (520, 520), (500, 520)])
|
||
|
||
def test_landmark_height_wins():
|
||
res = assign_heights([SQUARE], landmarks=[{"xyz": [10, 10, 0], "height": 180.0}],
|
||
districts=[], seed=1)
|
||
assert res[0]["height"] == 180.0
|
||
assert res[0]["source"] == "skyline"
|
||
|
||
def test_district_range_is_used_without_landmark():
|
||
res = assign_heights([SQUARE], landmarks=[],
|
||
districts=[{"name": "Downtown", "x": 5, "y": 5}], seed=1)
|
||
lo, hi = DISTRICT_RANGES["Downtown"]
|
||
assert lo <= res[0]["height"] <= hi
|
||
assert res[0]["source"] == "district"
|
||
|
||
def test_same_seed_gives_same_height():
|
||
args = dict(landmarks=[], districts=[{"name": "Downtown", "x": 5, "y": 5}], seed=42)
|
||
assert assign_heights([SQUARE], **args)[0]["height"] == assign_heights([SQUARE], **args)[0]["height"]
|
||
|
||
def test_unknown_district_falls_back_to_default():
|
||
res = assign_heights([FAR], landmarks=[], districts=[], seed=3)
|
||
lo, hi = DISTRICT_RANGES["default"]
|
||
assert lo <= res[0]["height"] <= hi
|
||
```
|
||
|
||
- [ ] **Step 6: Test laufen lassen, er muss scheitern**
|
||
|
||
Run: `cd tools/extract && uv run pytest tests/test_heights.py -v`
|
||
Expected: FAIL, `ModuleNotFoundError: No module named 'leonida.heights'`
|
||
|
||
- [ ] **Step 7: `leonida/heights.py` schreiben**
|
||
|
||
```python
|
||
import hashlib
|
||
import math
|
||
|
||
from shapely.geometry import Point, Polygon
|
||
|
||
DISTRICT_RANGES = {
|
||
"Downtown": (20.0, 60.0),
|
||
"Ocean Beach": (8.0, 25.0),
|
||
"default": (6.0, 15.0),
|
||
}
|
||
LANDMARK_SNAP = 20.0 # Meter: so weit darf ein Wahrzeichenpunkt vom Grundriss entfernt liegen
|
||
|
||
|
||
def _seeded_unit(seed: int, key: str) -> float:
|
||
digest = hashlib.sha256(f"{seed}:{key}".encode()).digest()
|
||
return int.from_bytes(digest[:8], "big") / 2 ** 64
|
||
|
||
|
||
def assign_heights(polys: list[Polygon], landmarks: list[dict],
|
||
districts: list[dict], seed: int) -> list[dict]:
|
||
out = []
|
||
for poly in polys:
|
||
centroid = poly.centroid
|
||
key = f"{centroid.x:.1f},{centroid.y:.1f}"
|
||
hit = None
|
||
for lm in landmarks:
|
||
p = Point(lm["xyz"][0], lm["xyz"][1])
|
||
if poly.contains(p) or poly.distance(p) <= LANDMARK_SNAP:
|
||
if hit is None or lm["height"] > hit["height"]:
|
||
hit = lm
|
||
if hit is not None:
|
||
out.append({"height": float(hit["height"]), "source": "skyline"})
|
||
continue
|
||
name = "default"
|
||
best = math.inf
|
||
for d in districts:
|
||
dist = math.dist((centroid.x, centroid.y), (d["x"], d["y"]))
|
||
if dist < best:
|
||
best, name = dist, d["name"] if d["name"] in DISTRICT_RANGES else "default"
|
||
lo, hi = DISTRICT_RANGES[name]
|
||
# Kleine Grundrisse bleiben niedriger: Fläche skaliert den oberen Rand.
|
||
area_factor = min(1.0, poly.area / 400.0)
|
||
height = lo + (hi - lo) * _seeded_unit(seed, key) * (0.4 + 0.6 * area_factor)
|
||
out.append({"height": round(height, 1), "source": "district"})
|
||
return out
|
||
```
|
||
|
||
- [ ] **Step 8: Test laufen lassen, er muss bestehen**
|
||
|
||
Run: `cd tools/extract && uv run pytest -v`
|
||
Expected: PASS, alle Tests
|
||
|
||
- [ ] **Step 9: Palmenpunkte ergänzen**
|
||
|
||
An `leonida/heights.py` anfügen:
|
||
|
||
```python
|
||
PALM_SPACING = 12.0 # Meter entlang der Mittellinie
|
||
PALM_BEACH_RANGE = 40.0 # Meter Abstand zur nächsten Sandfläche
|
||
PALM_CLEARANCE = 6.0 # Meter Abstand zu Gebäuden und Straßen im Park
|
||
|
||
|
||
def palm_points(sand_polys, park_polys, road_lines, building_polys, road_polys, seed: int):
|
||
"""Palmen entlang strandnaher Mittellinien und verstreut in Parks."""
|
||
from shapely.ops import unary_union
|
||
sand = unary_union(sand_polys) if sand_polys else None
|
||
blocked = unary_union(list(building_polys) + list(road_polys)) if (building_polys or road_polys) else None
|
||
points = []
|
||
for line in road_lines:
|
||
if sand is None or line.distance(sand) > PALM_BEACH_RANGE:
|
||
continue
|
||
steps = max(1, int(line.length // PALM_SPACING))
|
||
for k in range(steps + 1):
|
||
p = line.interpolate(k * PALM_SPACING)
|
||
for side in (-1, 1):
|
||
off = line.parallel_offset(4.0 * side, "left" if side > 0 else "right")
|
||
if off.is_empty:
|
||
continue
|
||
q = off.interpolate(off.project(p))
|
||
points.append((round(q.x, 2), round(q.y, 2)))
|
||
for park in park_polys:
|
||
minx, miny, maxx, maxy = park.bounds
|
||
n = int(park.area // 250)
|
||
for k in range(n):
|
||
u = _seeded_unit(seed, f"park:{minx:.0f}:{k}:x")
|
||
v = _seeded_unit(seed, f"park:{miny:.0f}:{k}:y")
|
||
q = Point(minx + u * (maxx - minx), miny + v * (maxy - miny))
|
||
if park.contains(q) and (blocked is None or q.distance(blocked) > PALM_CLEARANCE):
|
||
points.append((round(q.x, 2), round(q.y, 2)))
|
||
return points
|
||
```
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add tools/extract/leonida/buildings.py tools/extract/leonida/heights.py tools/extract/tests
|
||
git commit -m "feat(extract): Gebäudepolygone, Höhen aus Wahrzeichen und Vierteln, Palmenpunkte
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Chunk-Ausgabe, Manifest, Debug-Bilder
|
||
|
||
**Files:**
|
||
- Create: `tools/extract/leonida/emit.py`, `tools/extract/run.py`, `tools/extract/tests/test_emit.py`
|
||
- Test: `tools/extract/tests/test_emit.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: alles aus Task 3 bis 5
|
||
- Produces: `public/data/vice-a/manifest.json` und `public/data/vice-a/chunk_{i}_{j}.json`. Schema eines Chunks:
|
||
|
||
```json
|
||
{
|
||
"chunk": [3, 7],
|
||
"origin": [-250, 750],
|
||
"land": [[[x, z], ...]],
|
||
"surfaces": [{"poly": [[x, z], ...], "kind": "sand"}],
|
||
"roads": [{"poly": [[x, z], ...], "bridge": false}],
|
||
"centerlines": [[[x, z], ...]],
|
||
"buildings": [{"poly": [[x, z], ...], "height": 24.5, "source": "district", "speculative": false}],
|
||
"palms": [[x, z], ...]
|
||
}
|
||
```
|
||
|
||
Alle Koordinaten sind three.js-Koordinaten **relativ zur Chunk-Ecke**, in Metern, auf zwei Stellen gerundet.
|
||
|
||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||
|
||
`tools/extract/tests/test_emit.py`:
|
||
|
||
```python
|
||
import json
|
||
from shapely.geometry import Polygon
|
||
from leonida.emit import chunk_payload, split_by_chunk
|
||
|
||
BIG = Polygon([(-1000, 2500), (-700, 2500), (-700, 2200), (-1000, 2200)]) # Chunk (0,0) und Nachbarn
|
||
|
||
def test_polygon_is_clipped_into_its_chunks():
|
||
parts = split_by_chunk(BIG)
|
||
assert set(parts) >= {(0, 0), (1, 0), (0, 1), (1, 1)}
|
||
assert abs(sum(p.area for p in parts.values()) - BIG.area) < 1.0
|
||
|
||
def test_coordinates_are_chunk_local_three_js():
|
||
payload = chunk_payload(0, 0, land=[BIG], surfaces=[], roads=[], centerlines=[], buildings=[], palms=[])
|
||
ring = payload["land"][0]
|
||
assert payload["origin"] == [-1000, -2500] # three-z = -Y
|
||
assert min(x for x, _ in ring) >= -0.01
|
||
assert max(x for x, _ in ring) <= 250.01
|
||
|
||
def test_payload_matches_schema():
|
||
payload = chunk_payload(2, 3, land=[], surfaces=[], roads=[], centerlines=[], buildings=[], palms=[])
|
||
assert set(payload) == {"chunk", "origin", "land", "surfaces", "roads", "centerlines", "buildings", "palms"}
|
||
assert payload["chunk"] == [2, 3]
|
||
```
|
||
|
||
- [ ] **Step 2: Test laufen lassen, er muss scheitern**
|
||
|
||
Run: `cd tools/extract && uv run pytest tests/test_emit.py -v`
|
||
Expected: FAIL, `ModuleNotFoundError: No module named 'leonida.emit'`
|
||
|
||
- [ ] **Step 3: `leonida/emit.py` schreiben**
|
||
|
||
```python
|
||
import json
|
||
from pathlib import Path
|
||
|
||
from shapely.geometry import Polygon, box
|
||
|
||
from .coords import CHUNK_SIZE, SLICE
|
||
|
||
N = (SLICE["x1"] - SLICE["x0"]) // CHUNK_SIZE # 12
|
||
|
||
|
||
def chunk_box(i: int, j: int) -> Polygon:
|
||
x0 = SLICE["x0"] + i * CHUNK_SIZE
|
||
y1 = SLICE["y1"] - j * CHUNK_SIZE
|
||
return box(x0, y1 - CHUNK_SIZE, x0 + CHUNK_SIZE, y1)
|
||
|
||
|
||
def split_by_chunk(poly: Polygon) -> dict[tuple[int, int], Polygon]:
|
||
out = {}
|
||
for j in range(N):
|
||
for i in range(N):
|
||
part = poly.intersection(chunk_box(i, j))
|
||
if not part.is_empty and part.area > 0.5:
|
||
out[(i, j)] = part
|
||
return out
|
||
|
||
|
||
def _ring(poly: Polygon, i: int, j: int) -> list[list[float]]:
|
||
ox = SLICE["x0"] + i * CHUNK_SIZE
|
||
oy = SLICE["y1"] - j * CHUNK_SIZE
|
||
# Welt (X, Y) -> three (x, z) -> relativ zur Chunk-Ecke
|
||
return [[round(x - ox, 2), round(-y - (-oy), 2)] for x, y in poly.exterior.coords]
|
||
|
||
|
||
def chunk_payload(i, j, *, land, surfaces, roads, centerlines, buildings, palms) -> dict:
|
||
ox = SLICE["x0"] + i * CHUNK_SIZE
|
||
oy = SLICE["y1"] - j * CHUNK_SIZE
|
||
return {
|
||
"chunk": [i, j],
|
||
"origin": [ox, -oy],
|
||
"land": [_ring(p, i, j) for p in land],
|
||
"surfaces": [{"poly": _ring(p, i, j), "kind": k} for p, k in surfaces],
|
||
"roads": [{"poly": _ring(p, i, j), "bridge": b} for p, b in roads],
|
||
"centerlines": [[[round(x - ox, 2), round(-y + oy, 2)] for x, y in ln.coords] for ln in centerlines],
|
||
"buildings": [{"poly": _ring(p, i, j), "height": h["height"],
|
||
"source": h["source"], "speculative": s} for p, h, s in buildings],
|
||
"palms": [[round(x - ox, 2), round(-y + oy, 2)] for x, y in palms],
|
||
}
|
||
|
||
|
||
def chunk_box_of(i: int, j: int) -> Polygon:
|
||
return chunk_box(i, j)
|
||
|
||
|
||
def split_by_chunk_all(polys, i: int, j: int):
|
||
"""Alle Anteile der übergebenen Polygone, die in Chunk (i, j) liegen."""
|
||
cell = chunk_box(i, j)
|
||
for poly in polys:
|
||
part = poly.intersection(cell)
|
||
if not part.is_empty and part.area > 0.5:
|
||
if part.geom_type == "Polygon":
|
||
yield part
|
||
else:
|
||
yield from (g for g in part.geoms if g.geom_type == "Polygon" and g.area > 0.5)
|
||
|
||
|
||
def in_chunk(point, i: int, j: int) -> bool:
|
||
x0 = SLICE["x0"] + i * CHUNK_SIZE
|
||
y1 = SLICE["y1"] - j * CHUNK_SIZE
|
||
return x0 <= point[0] < x0 + CHUNK_SIZE and y1 - CHUNK_SIZE <= point[1] < y1
|
||
|
||
|
||
def write_debug_png(out_dir: Path, mosaic, payloads: list[dict]) -> None:
|
||
"""Ein Bild je Chunk: die extrahierten Vektoren über der Karte, zur Sichtprüfung."""
|
||
from PIL import Image, ImageDraw
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
for payload in payloads:
|
||
i, j = payload["chunk"]
|
||
x0, y0 = i * CHUNK_SIZE, j * CHUNK_SIZE
|
||
crop = Image.fromarray(mosaic[y0:y0 + CHUNK_SIZE, x0:x0 + CHUNK_SIZE]).convert("RGB")
|
||
draw = ImageDraw.Draw(crop)
|
||
for ring in payload["land"]:
|
||
draw.line([tuple(pt) for pt in ring] + [tuple(ring[0])], fill=(0, 255, 255), width=1)
|
||
for road in payload["roads"]:
|
||
draw.line([tuple(pt) for pt in road["poly"]], fill=(255, 0, 255), width=1)
|
||
for b in payload["buildings"]:
|
||
if len(b["poly"]) >= 3:
|
||
draw.polygon([tuple(pt) for pt in b["poly"]], outline=(255, 220, 0))
|
||
for x, z in payload["palms"]:
|
||
draw.ellipse([x - 2, z - 2, x + 2, z + 2], outline=(0, 200, 0))
|
||
crop.save(out_dir / f"chunk_{i}_{j}.png")
|
||
|
||
|
||
def write_chunks(out_dir: Path, payloads: list[dict], meta: dict) -> None:
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
for payload in payloads:
|
||
i, j = payload["chunk"]
|
||
(out_dir / f"chunk_{i}_{j}.json").write_text(json.dumps(payload, separators=(",", ":")))
|
||
(out_dir / "manifest.json").write_text(json.dumps(meta, indent=1))
|
||
```
|
||
|
||
- [ ] **Step 4: Test laufen lassen, er muss bestehen**
|
||
|
||
Run: `cd tools/extract && uv run pytest tests/test_emit.py -v`
|
||
Expected: PASS, 3 Tests
|
||
|
||
- [ ] **Step 5: `run.py` als Ablaufsteuerung schreiben**
|
||
|
||
```python
|
||
"""Erzeugt aus den Kacheln die Chunk-Dateien. Aufruf: uv run python run.py"""
|
||
import json
|
||
from pathlib import Path
|
||
|
||
from leonida.buildings import building_polygons
|
||
from leonida.classify import CLASSES, class_shares, classify
|
||
from leonida.emit import (chunk_box_of, chunk_payload, in_chunk, split_by_chunk_all,
|
||
write_chunks, write_debug_png)
|
||
from leonida.heights import assign_heights, palm_points
|
||
from leonida.tiles import COMMIT, download_tiles, load_mosaic
|
||
from leonida.vectors import centerlines, land_polygons, mask_polygons, road_polygons
|
||
|
||
SEED = 20260912
|
||
OUT = Path(__file__).resolve().parents[2] / "public" / "data" / "vice-a"
|
||
TOLERANCE = {"wasser": (0.29, 0.35), "strasse": (0.06, 0.10), "gebaeude": (0.07, 0.11)}
|
||
|
||
|
||
def main() -> None:
|
||
cache = Path("cache")
|
||
download_tiles(cache)
|
||
mosaic = load_mosaic(cache)
|
||
labels, unknown_share = classify(mosaic, report_unknown=True)
|
||
if unknown_share > 0.05:
|
||
raise SystemExit(f"{unknown_share:.1%} der Pixel unbekannt. Palette kalibrieren, nicht die Grenze anheben.")
|
||
shares = class_shares(labels)
|
||
for name, (lo, hi) in TOLERANCE.items():
|
||
if not lo <= shares[name] <= hi:
|
||
raise SystemExit(f"Klassenanteil {name}={shares[name]:.3f} außerhalb {lo}..{hi}. Palette prüfen.")
|
||
|
||
land = land_polygons(labels)
|
||
roads = road_polygons(labels)
|
||
lines = centerlines(labels)
|
||
sand = mask_polygons(labels == CLASSES["sand"], 50, 1.0)
|
||
parks = mask_polygons(labels == CLASSES["gruen"], 50, 1.0)
|
||
urban = mask_polygons(labels == CLASSES["stadt"], 80, 1.5)
|
||
surfaces = [(p, "sand") for p in sand] + [(p, "park") for p in parks] + [(p, "urban") for p in urban]
|
||
|
||
road_shapes = [p for p, _ in roads]
|
||
raw = building_polygons(labels)
|
||
buildings = [(p, s) for p, s in raw
|
||
if not any(p.intersection(r).area > 0.5 * p.area for r in road_shapes)]
|
||
|
||
landmarks = json.loads(Path("data/skyline.json").read_text())["landmarks"]
|
||
districts = json.loads(Path("data/districts.json").read_text())
|
||
heights = assign_heights([p for p, _ in buildings], landmarks, districts, SEED)
|
||
palms = palm_points(sand, parks, lines, [p for p, _ in buildings], road_shapes, SEED)
|
||
|
||
payloads = []
|
||
for j in range(12):
|
||
for i in range(12):
|
||
payloads.append(chunk_payload(
|
||
i, j,
|
||
land=list(split_by_chunk_all(land, i, j)),
|
||
surfaces=[(part, kind) for (p, kind) in surfaces for part in split_by_chunk_all([p], i, j)],
|
||
roads=[(part, b) for (p, b) in roads for part in split_by_chunk_all([p], i, j)],
|
||
centerlines=[ln for ln in lines if ln.intersects(chunk_box_of(i, j))],
|
||
buildings=[(p, h, s) for (p, s), h in zip(buildings, heights)
|
||
if p.intersects(chunk_box_of(i, j))],
|
||
palms=[pt for pt in palms if in_chunk(pt, i, j)],
|
||
))
|
||
write_debug_png(Path("debug"), mosaic, payloads)
|
||
write_chunks(OUT, payloads, {
|
||
"slice": "vice-a", "source_commit": COMMIT, "seed": SEED,
|
||
"chunk_size": 250, "chunks": [p["chunk"] for p in payloads],
|
||
"class_shares": shares,
|
||
"attribution": "GTA VI Map Tiles & Landmarks (CC BY 4.0) — https://map.gtadb.org, YANIS v15",
|
||
})
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
```
|
||
|
||
Die Hilfsfunktionen `split_by_chunk_all`, `chunk_box_of` und `in_chunk` sind dünne Hüllen um `split_by_chunk` bzw. `chunk_box` aus `emit.py`; beim Umsetzen dort ergänzen, damit `run.py` keine eigene Geometrie-Logik enthält.
|
||
|
||
Die Datei `data/skyline.json` ist die aus `rolux/gtamaplib-vc` geholte `game/landmarks/landmarks.json`, `data/districts.json` enthält Name und Mittelpunkt je Viertel aus den gtadb-Landmarks. Beide werden in diesem Schritt einmalig heruntergeladen und mit committet.
|
||
|
||
- [ ] **Step 6: Durchlauf und Sichtprüfung**
|
||
|
||
Run: `cd tools/extract && uv run python run.py && ls ../../public/data/vice-a | head`
|
||
Expected: 144 Chunk-Dateien und ein Manifest, zusammen unter 10 MB.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add tools/extract public/data/vice-a
|
||
git commit -m "feat(extract): Chunk-Ausgabe, Manifest und Ablaufsteuerung
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
```
|
||
---
|
||
|
||
### Task 7: Geometrie-Baustein in JavaScript
|
||
|
||
**Files:**
|
||
- Create: `src/world/geometry.js`, `tests/geometry.test.js`
|
||
- Test: `tests/geometry.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Chunk-JSON aus Task 6
|
||
- Produces: `buildChunkGeometry(chunk) -> {ground, sand, park, urban, roads, bridges, buildings}` (je eine `BufferGeometry` oder `null`), `extrudePolygons(rings, {base, top, uvScale}) -> BufferGeometry`, `flatPolygons(rings, {y, uvScale}) -> BufferGeometry`
|
||
|
||
Reines JavaScript, nur `three` als Abhängigkeit, kein Zugriff auf `document` oder `window` — der Baustein muss in Node laufen.
|
||
|
||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||
|
||
`tests/geometry.test.js`:
|
||
|
||
```js
|
||
import { describe, expect, it } from "vitest";
|
||
import { Box3, BufferGeometry } from "three";
|
||
import { buildChunkGeometry, extrudePolygons, flatPolygons } from "../src/world/geometry.js";
|
||
|
||
const SQUARE = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||
|
||
describe("geometry", () => {
|
||
it("extrudiert ein Quadrat zu Wänden und Dach", () => {
|
||
const geo = extrudePolygons([SQUARE], { base: 0, top: 20, uvScale: 4 });
|
||
// 4 Wände à 2 Dreiecke + Dach aus 2 Dreiecken
|
||
expect(geo.getIndex().count / 3).toBe(10);
|
||
const box = new Box3().setFromBufferAttribute(geo.getAttribute("position"));
|
||
expect(box.max.y).toBeCloseTo(20);
|
||
expect(box.min.y).toBeCloseTo(0);
|
||
});
|
||
|
||
it("richtet Dachflächen nach oben aus", () => {
|
||
const geo = extrudePolygons([SQUARE], { base: 0, top: 5, uvScale: 4 });
|
||
geo.computeVertexNormals();
|
||
const n = geo.getAttribute("normal");
|
||
const up = [...Array(n.count).keys()].filter((i) => n.getY(i) > 0.9);
|
||
expect(up.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it("legt flache Flächen auf die gewünschte Höhe", () => {
|
||
const geo = flatPolygons([SQUARE], { y: 0.03, uvScale: 8 });
|
||
const box = new Box3().setFromBufferAttribute(geo.getAttribute("position"));
|
||
expect(box.min.y).toBeCloseTo(0.03);
|
||
expect(box.max.y).toBeCloseTo(0.03);
|
||
});
|
||
|
||
it("baut alle Teile eines Chunks", () => {
|
||
const chunk = {
|
||
chunk: [0, 0], origin: [0, 0],
|
||
land: [SQUARE],
|
||
surfaces: [{ poly: SQUARE, kind: "sand" }, { poly: SQUARE, kind: "park" }],
|
||
roads: [{ poly: SQUARE, bridge: false }, { poly: SQUARE, bridge: true }],
|
||
centerlines: [], palms: [],
|
||
buildings: [{ poly: SQUARE, height: 12, source: "district", speculative: false }],
|
||
};
|
||
const out = buildChunkGeometry(chunk);
|
||
for (const key of ["ground", "sand", "park", "roads", "bridges", "buildings"]) {
|
||
expect(out[key]).toBeInstanceOf(BufferGeometry);
|
||
}
|
||
expect(out.urban).toBeNull();
|
||
});
|
||
|
||
it("gibt Gebäuden eine Farbe je Haus", () => {
|
||
const chunk = { chunk: [0, 0], origin: [0, 0], land: [], surfaces: [], roads: [], centerlines: [], palms: [],
|
||
buildings: [{ poly: SQUARE, height: 9, source: "district", speculative: false }] };
|
||
expect(buildChunkGeometry(chunk).buildings.getAttribute("color")).toBeTruthy();
|
||
});
|
||
|
||
it("hält alle Koordinaten im Chunk", () => {
|
||
const chunk = { chunk: [0, 0], origin: [0, 0], land: [SQUARE], surfaces: [], roads: [], centerlines: [], buildings: [], palms: [] };
|
||
const box = new Box3().setFromBufferAttribute(buildChunkGeometry(chunk).ground.getAttribute("position"));
|
||
expect(box.min.x).toBeGreaterThanOrEqual(-0.01);
|
||
expect(box.max.x).toBeLessThanOrEqual(250.01);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Test laufen lassen, er muss scheitern**
|
||
|
||
Run: `npm test`
|
||
Expected: FAIL, `Failed to resolve import "../src/world/geometry.js"`
|
||
|
||
- [ ] **Step 3: `src/world/geometry.js` schreiben**
|
||
|
||
```js
|
||
import { BufferGeometry, Float32BufferAttribute, ShapeUtils } from "three";
|
||
|
||
const GROUND_THICKNESS = 1.0;
|
||
const SURFACE_OFFSET = 0.02; // Sand, Park und Stadtfläche liegen knapp über dem Boden
|
||
const ROAD_OFFSET = 0.03; // Straßen wiederum knapp über den Flächen
|
||
const BRIDGE_THICKNESS = 0.6;
|
||
export const SURFACE_KINDS = ["sand", "park", "urban"];
|
||
|
||
/** Pastellton je Gebäude, reproduzierbar aus seiner Lage. */
|
||
function buildingTint(ring) {
|
||
const [x, z] = ring[0];
|
||
const h = Math.abs(Math.sin(x * 12.9898 + z * 78.233) * 43758.5453) % 1;
|
||
return [0.82 + 0.18 * h, 0.80 + 0.20 * ((h * 3) % 1), 0.78 + 0.22 * ((h * 7) % 1)];
|
||
}
|
||
|
||
const signedArea = (ring) => ring.reduce((sum, [x0, z0], i) => {
|
||
const [x1, z1] = ring[(i + 1) % ring.length];
|
||
return sum + x0 * z1 - x1 * z0;
|
||
}, 0) / 2;
|
||
|
||
/** Ringe im Uhrzeigersinn, damit Wände nach außen zeigen. */
|
||
const clockwise = (ring) => (signedArea(ring) > 0 ? [...ring].reverse() : ring);
|
||
|
||
function toGeometry(pos, idx, uv, color) {
|
||
const geo = new BufferGeometry();
|
||
geo.setAttribute("position", new Float32BufferAttribute(pos, 3));
|
||
geo.setAttribute("uv", new Float32BufferAttribute(uv, 2));
|
||
if (color) geo.setAttribute("color", new Float32BufferAttribute(color, 3));
|
||
geo.setIndex(idx);
|
||
geo.computeVertexNormals();
|
||
return geo;
|
||
}
|
||
|
||
export function flatPolygons(rings, { y = 0, uvScale = 8 } = {}) {
|
||
const pos = [], idx = [], uv = [];
|
||
for (const raw of rings) {
|
||
const ring = clockwise(raw);
|
||
const base = pos.length / 3;
|
||
for (const [x, z] of ring) {
|
||
pos.push(x, y, z);
|
||
uv.push(x / uvScale, z / uvScale);
|
||
}
|
||
for (const tri of ShapeUtils.triangulateShape(ring.map(([x, z]) => ({ x, y: z })), [])) {
|
||
idx.push(base + tri[0], base + tri[2], base + tri[1]); // nach oben gedreht
|
||
}
|
||
}
|
||
return pos.length ? toGeometry(pos, idx, uv) : null;
|
||
}
|
||
|
||
export function extrudePolygons(rings, { base = 0, top = 10, uvScale = 4, tint = null } = {}) {
|
||
const pos = [], idx = [], uv = [], color = [];
|
||
for (const raw of rings) {
|
||
const ring = clockwise(raw);
|
||
const n = ring.length;
|
||
for (let i = 0; i < n; i++) {
|
||
const [x0, z0] = ring[i];
|
||
const [x1, z1] = ring[(i + 1) % n];
|
||
const len = Math.hypot(x1 - x0, z1 - z0);
|
||
const b = pos.length / 3;
|
||
pos.push(x0, base, z0, x1, base, z1, x1, top, z1, x0, top, z0);
|
||
uv.push(0, 0, len / uvScale, 0, len / uvScale, (top - base) / uvScale, 0, (top - base) / uvScale);
|
||
if (tint) for (let k = 0; k < 4; k++) color.push(...tint);
|
||
idx.push(b, b + 1, b + 2, b, b + 2, b + 3);
|
||
}
|
||
const b = pos.length / 3;
|
||
for (const [x, z] of ring) {
|
||
pos.push(x, top, z);
|
||
uv.push(x / uvScale, z / uvScale);
|
||
if (tint) color.push(...tint);
|
||
}
|
||
for (const tri of ShapeUtils.triangulateShape(ring.map(([x, z]) => ({ x, y: z })), [])) {
|
||
idx.push(b + tri[0], b + tri[2], b + tri[1]);
|
||
}
|
||
}
|
||
return pos.length ? toGeometry(pos, idx, uv, tint ? color : null) : null;
|
||
}
|
||
|
||
export function buildChunkGeometry(chunk) {
|
||
const roads = chunk.roads.filter((r) => !r.bridge).map((r) => r.poly);
|
||
const bridges = chunk.roads.filter((r) => r.bridge).map((r) => r.poly);
|
||
const out = {
|
||
ground: extrudePolygons(chunk.land, { base: -GROUND_THICKNESS, top: 0, uvScale: 8 }),
|
||
roads: flatPolygons(roads, { y: ROAD_OFFSET, uvScale: 8 }),
|
||
bridges: extrudePolygons(bridges, { base: -BRIDGE_THICKNESS, top: 0, uvScale: 8 }),
|
||
buildings: buildBuildings(chunk.buildings),
|
||
};
|
||
for (const kind of SURFACE_KINDS) {
|
||
const rings = (chunk.surfaces ?? []).filter((s) => s.kind === kind).map((s) => s.poly);
|
||
out[kind] = flatPolygons(rings, { y: SURFACE_OFFSET, uvScale: 8 });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function buildBuildings(buildings) {
|
||
const parts = buildings.map((b) =>
|
||
extrudePolygons([b.poly], { base: 0, top: b.height, uvScale: 4, tint: buildingTint(b.poly) }));
|
||
return parts.filter(Boolean).reduce((acc, geo) => (acc ? mergeInto(acc, geo) : geo), null);
|
||
}
|
||
|
||
function mergeInto(a, b) {
|
||
const offset = a.getAttribute("position").count;
|
||
const pos = [...a.getAttribute("position").array, ...b.getAttribute("position").array];
|
||
const uv = [...a.getAttribute("uv").array, ...b.getAttribute("uv").array];
|
||
const ca = a.getAttribute("color"), cb = b.getAttribute("color");
|
||
const color = ca && cb ? [...ca.array, ...cb.array] : null;
|
||
const idx = [...a.getIndex().array, ...[...b.getIndex().array].map((i) => i + offset)];
|
||
return toGeometry(pos, idx, uv, color);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Test laufen lassen, er muss bestehen**
|
||
|
||
Run: `npm test`
|
||
Expected: PASS, 5 Tests
|
||
|
||
- [ ] **Step 5: Alle 144 Chunks in Node durchrechnen**
|
||
|
||
`tools/checkChunks.mjs`:
|
||
|
||
```js
|
||
import { readdirSync, readFileSync } from "node:fs";
|
||
import { buildChunkGeometry } from "../src/world/geometry.js";
|
||
|
||
const dir = "public/data/vice-a";
|
||
let tris = 0;
|
||
for (const file of readdirSync(dir).filter((f) => f.startsWith("chunk_"))) {
|
||
const chunk = JSON.parse(readFileSync(`${dir}/${file}`, "utf8"));
|
||
for (const geo of Object.values(buildChunkGeometry(chunk))) {
|
||
if (!geo) continue;
|
||
const pos = geo.getAttribute("position").array;
|
||
if (pos.some((v) => !Number.isFinite(v))) throw new Error(`NaN in ${file}`);
|
||
tris += geo.getIndex().count / 3;
|
||
}
|
||
}
|
||
console.log("Dreiecke gesamt:", tris);
|
||
if (tris > 1_500_000) throw new Error("Budget überschritten");
|
||
```
|
||
|
||
Run: `node tools/checkChunks.mjs`
|
||
Expected: eine Zahl deutlich unter 1.500.000, kein Fehler.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/world/geometry.js tests/geometry.test.js tools/checkChunks.mjs
|
||
git commit -m "feat(world): Geometrie-Baustein für Land, Straßen, Brücken und Gebäude
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Streaming, Kollision, Respawn
|
||
|
||
**Files:**
|
||
- Create: `src/world/streaming.js`, `tests/streaming.test.js`
|
||
- Modify: `src/main.js`
|
||
- Test: `tests/streaming.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `buildChunkGeometry` aus Task 7, `chunkForWorld`, `chunkOrigin` aus Task 1
|
||
- Produces: `class ChunkStreamer { constructor({ scene, player, materials, baseUrl }); update(playerPosition); isReadyAt(position) -> boolean }`, `wantedChunks(position, {load, unload, active}) -> {add: [], remove: []}`
|
||
|
||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||
|
||
`tests/streaming.test.js`:
|
||
|
||
```js
|
||
import { describe, expect, it } from "vitest";
|
||
import { wantedChunks } from "../src/world/streaming.js";
|
||
|
||
describe("wantedChunks", () => {
|
||
it("lädt die Umgebung des Spielers", () => {
|
||
const { add } = wantedChunks({ x: 0, z: -1000 }, { load: 400, unload: 600, active: new Set() });
|
||
expect(add.length).toBeGreaterThan(4);
|
||
expect(add.length).toBeLessThan(30);
|
||
});
|
||
|
||
it("entlädt erst jenseits des größeren Radius", () => {
|
||
const active = new Set(["5_5"]);
|
||
const near = wantedChunks({ x: 1375, z: -1375 }, { load: 400, unload: 600, active });
|
||
expect(near.remove).not.toContain("5_5");
|
||
const far = wantedChunks({ x: -900, z: -2400 }, { load: 400, unload: 600, active });
|
||
expect(far.remove).toContain("5_5");
|
||
});
|
||
|
||
it("meldet nichts doppelt an", () => {
|
||
const active = new Set(wantedChunks({ x: 0, z: -1000 }, { load: 400, unload: 600, active: new Set() }).add);
|
||
expect(wantedChunks({ x: 0, z: -1000 }, { load: 400, unload: 600, active }).add).toHaveLength(0);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Test laufen lassen, er muss scheitern**
|
||
|
||
Run: `npm test`
|
||
Expected: FAIL, `Failed to resolve import "../src/world/streaming.js"`
|
||
|
||
- [ ] **Step 3: `src/world/streaming.js` schreiben**
|
||
|
||
```js
|
||
import { Group, Mesh } from "three";
|
||
import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE } from "./coords.js";
|
||
import { buildChunkGeometry } from "./geometry.js";
|
||
|
||
const key = (i, j) => `${i}_${j}`;
|
||
const DECALS = new Set(["roads", "sand", "park", "urban"]);
|
||
|
||
/** Mittelpunkt eines Chunks in three-Koordinaten. */
|
||
function chunkCenter(i, j) {
|
||
return { x: SLICE.x0 + (i + 0.5) * CHUNK_SIZE, z: -(SLICE.y1 - (j + 0.5) * CHUNK_SIZE) };
|
||
}
|
||
|
||
export function wantedChunks(position, { load, unload, active }) {
|
||
const add = [], remove = [];
|
||
for (let j = 0; j < CHUNKS_PER_SIDE; j++) {
|
||
for (let i = 0; i < CHUNKS_PER_SIDE; i++) {
|
||
const c = chunkCenter(i, j);
|
||
const d = Math.hypot(c.x - position.x, c.z - position.z);
|
||
const k = key(i, j);
|
||
if (d <= load && !active.has(k)) add.push(k);
|
||
if (d > unload && active.has(k)) remove.push(k);
|
||
}
|
||
}
|
||
add.sort((a, b) => dist(a, position) - dist(b, position));
|
||
return { add, remove };
|
||
}
|
||
|
||
function dist(k, position) {
|
||
const [i, j] = k.split("_").map(Number);
|
||
const c = chunkCenter(i, j);
|
||
return Math.hypot(c.x - position.x, c.z - position.z);
|
||
}
|
||
|
||
export class ChunkStreamer {
|
||
constructor({ scene, player, materials, props, baseUrl = "./data/vice-a" }) {
|
||
Object.assign(this, { scene, player, materials, props, baseUrl });
|
||
this.active = new Map(); // key -> { group, handle }
|
||
this.pending = new Set();
|
||
this.queue = [];
|
||
}
|
||
|
||
update(position) {
|
||
const { add, remove } = wantedChunks(position, {
|
||
load: 400, unload: 600, active: new Set([...this.active.keys(), ...this.pending]),
|
||
});
|
||
for (const k of add) if (!this.queue.includes(k)) this.queue.push(k);
|
||
// Höchstens ein Aufbau je Frame.
|
||
const next = this.queue.shift();
|
||
if (next) void this.#load(next);
|
||
// Höchstens vier Abmeldungen je Frame.
|
||
for (const k of remove.slice(0, 4)) this.#unload(k);
|
||
}
|
||
|
||
async #load(k) {
|
||
if (this.active.has(k) || this.pending.has(k)) return;
|
||
this.pending.add(k);
|
||
try {
|
||
const chunk = await this.#fetchChunk(k);
|
||
const [i, j] = k.split("_").map(Number);
|
||
const group = new Group();
|
||
group.position.set(chunk.origin[0], 0, chunk.origin[1]);
|
||
const geos = buildChunkGeometry(chunk);
|
||
const collide = new Group();
|
||
for (const [name, geo] of Object.entries(geos)) {
|
||
if (!geo) continue;
|
||
const mesh = new Mesh(geo, this.materials[name]);
|
||
mesh.castShadow = name === "buildings";
|
||
mesh.receiveShadow = true;
|
||
// Straßen und Bodenflächen sind reine Auflagen; Kollision trägt die Landplatte.
|
||
(DECALS.has(name) ? group : collide).add(mesh);
|
||
}
|
||
group.add(collide);
|
||
this.scene.add(group);
|
||
group.updateMatrixWorld(true);
|
||
const handle = this.player.addCollider({
|
||
motion: "static", useWorker: true, shape: { kind: "mesh", source: collide },
|
||
});
|
||
this.active.set(k, { group, handle, chunk });
|
||
} catch (err) {
|
||
console.warn(`Chunk ${k} konnte nicht geladen werden:`, err);
|
||
} finally {
|
||
this.pending.delete(k);
|
||
}
|
||
}
|
||
|
||
async #fetchChunk(k) {
|
||
const [i, j] = k.split("_").map(Number);
|
||
for (let attempt = 0; attempt < 3; attempt++) {
|
||
try {
|
||
const res = await fetch(`${this.baseUrl}/chunk_${i}_${j}.json`);
|
||
if (res.ok) return await res.json();
|
||
} catch { /* nächster Versuch */ }
|
||
}
|
||
throw new Error("dreimal fehlgeschlagen");
|
||
}
|
||
|
||
#unload(k) {
|
||
const entry = this.active.get(k);
|
||
if (!entry) return;
|
||
this.player.removeCollider(entry.handle);
|
||
this.scene.remove(entry.group);
|
||
entry.group.traverse((o) => o.geometry?.dispose());
|
||
this.active.delete(k);
|
||
}
|
||
|
||
/** Ist der Collider unter dieser Position einsatzbereit? */
|
||
isReadyAt(position) {
|
||
const i = Math.floor((position.x - SLICE.x0) / CHUNK_SIZE);
|
||
const j = Math.floor((SLICE.y1 + position.z) / CHUNK_SIZE);
|
||
const entry = this.active.get(key(i, j));
|
||
return Boolean(entry && this.player.collisionWorld.get(entry.handle.id)?.ready);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Test laufen lassen, er muss bestehen**
|
||
|
||
Run: `npm test`
|
||
Expected: PASS, alle Tests
|
||
|
||
- [ ] **Step 5: In `src/main.js` einhängen, mit Startschweben und Auffangnetz**
|
||
|
||
```js
|
||
const SPAWN = { x: 1699, y: 3, z: -743 };
|
||
const streamer = new ChunkStreamer({ scene, player, materials });
|
||
player.setSkipCapsuleCollision(true);
|
||
let settled = false;
|
||
const startedAt = performance.now();
|
||
let lastSafe = { ...SPAWN };
|
||
|
||
function tick(delta) {
|
||
streamer.update(player.playerCapsule.position);
|
||
if (!settled && (streamer.isReadyAt(player.playerCapsule.position) || performance.now() - startedAt > 15000)) {
|
||
settled = true;
|
||
player.setSkipCapsuleCollision(false);
|
||
player.reset(new Vector3(SPAWN.x, SPAWN.y, SPAWN.z));
|
||
}
|
||
const p = player.playerCapsule.position;
|
||
if (p.y < -30) player.reset(new Vector3(lastSafe.x, lastSafe.y, lastSafe.z));
|
||
else if (p.y > -1) lastSafe = { x: p.x, y: p.y + 1, z: p.z };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Im Browser prüfen**
|
||
|
||
Run: `npm run dev`
|
||
Expected: Die Figur landet auf Vice Beach, Chunks erscheinen beim Laufen, die Konsole bleibt leer. Über das Panel aus Task 10 die Chunk-Grenzen einblenden und prüfen, dass kein Loch entsteht.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/world/streaming.js tests/streaming.test.js src/main.js
|
||
git commit -m "feat(world): Chunk-Streaming mit Collider-Verwaltung und Auffangnetz
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
```
|
||
---
|
||
|
||
### Task 9: Materialien, Wasser, Himmel
|
||
|
||
**Files:**
|
||
- Create: `src/world/materials.js`, `tools/assets/textures.mjs`, `tests/materials.test.js`
|
||
- Modify: `src/main.js`
|
||
- Test: `tests/materials.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nichts aus früheren Tasks
|
||
- Produces: `MATERIAL_KEYS = ["ground", "sand", "park", "urban", "roads", "bridges", "buildings"]`, `createMaterials() -> Promise<{...MATERIAL_KEYS, water, seafloor}>`, `waterNormalData(size) -> Uint8Array`, `TEXTURES` (dict Materialname → Poly-Haven-Kennung)
|
||
|
||
Die Schlüssel müssen exakt denen entsprechen, die `buildChunkGeometry` aus Task 7 liefert. `ChunkStreamer` greift mit genau diesen Namen zu.
|
||
|
||
- [ ] **Step 1: Texturen holen**
|
||
|
||
`tools/assets/textures.mjs`:
|
||
|
||
```js
|
||
// Lädt die benötigten Poly-Haven-Texturen in 1k nach public/textures/.
|
||
import { mkdir, writeFile } from "node:fs/promises";
|
||
|
||
const UA = { "User-Agent": "leonida-assets/0.1 (local hobby project)" };
|
||
const SLUGS = ["asphalt_02", "concrete_pavement_03", "white_stucco", "sand_01", "leafy_grass", "grass_path_2", "palm_bark"];
|
||
const MAPS = ["Diffuse", "nor_gl", "arm"];
|
||
|
||
await mkdir("public/textures", { recursive: true });
|
||
for (const slug of SLUGS) {
|
||
const files = await (await fetch(`https://api.polyhaven.com/files/${slug}`, { headers: UA })).json();
|
||
for (const map of MAPS) {
|
||
const entry = files[map]?.["1k"]?.jpg;
|
||
if (!entry) { console.warn(`${slug}: ${map} fehlt`); continue; }
|
||
const buf = Buffer.from(await (await fetch(entry.url, { headers: UA })).arrayBuffer());
|
||
await writeFile(`public/textures/${slug}_${map}.jpg`, buf);
|
||
console.log(`${slug}_${map}.jpg ${(buf.length / 1e6).toFixed(1)} MB`);
|
||
}
|
||
}
|
||
```
|
||
|
||
Run: `node tools/assets/textures.mjs`
|
||
Expected: 21 Dateien, zusammen rund 18 MB.
|
||
|
||
- [ ] **Step 2: Den fehlschlagenden Test schreiben**
|
||
|
||
`tests/materials.test.js`:
|
||
|
||
```js
|
||
import { describe, expect, it } from "vitest";
|
||
import { MATERIAL_KEYS, TEXTURES, waterNormalData } from "../src/world/materials.js";
|
||
import { buildChunkGeometry } from "../src/world/geometry.js";
|
||
|
||
describe("materials", () => {
|
||
it("deckt genau die Geometrie-Teile ab", () => {
|
||
const chunk = { chunk: [0, 0], origin: [0, 0], land: [], roads: [], centerlines: [], buildings: [], palms: [] };
|
||
expect(Object.keys(buildChunkGeometry(chunk)).sort()).toEqual([...MATERIAL_KEYS].sort());
|
||
});
|
||
|
||
it("nennt für jedes Material eine Textur", () => {
|
||
for (const key of MATERIAL_KEYS) expect(TEXTURES[key]).toBeTruthy();
|
||
});
|
||
|
||
it("deckt Strand und Park mit eigenen Materialien ab", () => {
|
||
expect(MATERIAL_KEYS).toContain("sand");
|
||
expect(MATERIAL_KEYS).toContain("park");
|
||
});
|
||
|
||
it("erzeugt eine Wellen-Normalmap mit neutraler Grundfarbe", () => {
|
||
const data = waterNormalData(32);
|
||
expect(data.length).toBe(32 * 32 * 4);
|
||
// Normalen zeigen im Mittel nach oben: Blaukanal nahe 255.
|
||
const blue = [...data].filter((_, i) => i % 4 === 2);
|
||
expect(blue.reduce((a, b) => a + b, 0) / blue.length).toBeGreaterThan(200);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Test laufen lassen, er muss scheitern**
|
||
|
||
Run: `npm test`
|
||
Expected: FAIL, `Failed to resolve import "../src/world/materials.js"`
|
||
|
||
- [ ] **Step 4: `src/world/materials.js` schreiben**
|
||
|
||
```js
|
||
import { DataTexture, MeshStandardMaterial, RepeatWrapping, RGBAFormat, SRGBColorSpace, TextureLoader } from "three";
|
||
|
||
export const MATERIAL_KEYS = ["ground", "sand", "park", "urban", "roads", "bridges", "buildings"];
|
||
export const TEXTURES = {
|
||
ground: "grass_path_2", // unbebautes Land
|
||
sand: "sand_01", // Strand
|
||
park: "leafy_grass", // Grünflächen
|
||
urban: "concrete_pavement_03", // versiegelte Stadtfläche
|
||
roads: "asphalt_02",
|
||
bridges: "concrete_pavement_03",
|
||
buildings: "white_stucco",
|
||
};
|
||
const FALLBACK_COLORS = {
|
||
ground: 0xa8b878, sand: 0xe8d888, park: 0x8bc34a, urban: 0xd8d8d8,
|
||
roads: 0x505050, bridges: 0xb0b0b0, buildings: 0xe8e4dc,
|
||
};
|
||
|
||
/** Kachelbare Wellen-Normalmap, weil Poly Haven keine Wassertextur hat. */
|
||
export function waterNormalData(size = 128) {
|
||
const data = new Uint8Array(size * size * 4);
|
||
for (let y = 0; y < size; y++) {
|
||
for (let x = 0; x < size; x++) {
|
||
const i = (y * size + x) * 4;
|
||
const u = (x / size) * Math.PI * 2;
|
||
const v = (y / size) * Math.PI * 2;
|
||
const dx = Math.sin(u * 3) * 0.5 + Math.sin(v * 2 + 1.3) * 0.3;
|
||
const dz = Math.cos(v * 3) * 0.5 + Math.cos(u * 2 + 0.7) * 0.3;
|
||
data[i] = 128 + dx * 40;
|
||
data[i + 1] = 128 + dz * 40;
|
||
data[i + 2] = 245;
|
||
data[i + 3] = 255;
|
||
}
|
||
}
|
||
return data;
|
||
}
|
||
|
||
async function loadSet(loader, slug, fallback) {
|
||
let map, normalMap, arm;
|
||
try {
|
||
[map, normalMap, arm] = await Promise.all(
|
||
["Diffuse", "nor_gl", "arm"].map((m) => loader.loadAsync(`./textures/${slug}_${m}.jpg`)),
|
||
);
|
||
} catch (err) {
|
||
console.warn(`Textur ${slug} fehlt, einfarbiger Ersatz:`, err);
|
||
return new MeshStandardMaterial({ color: fallback, roughness: 0.9 });
|
||
}
|
||
for (const t of [map, normalMap, arm]) {
|
||
t.wrapS = t.wrapT = RepeatWrapping;
|
||
}
|
||
map.colorSpace = SRGBColorSpace;
|
||
// arm packt Ambient Occlusion, Rauheit und Metallgrad in R, G und B.
|
||
return new MeshStandardMaterial({ map, normalMap, aoMap: arm, roughnessMap: arm, metalnessMap: arm, metalness: 0 });
|
||
}
|
||
|
||
export async function createMaterials() {
|
||
const loader = new TextureLoader();
|
||
const entries = await Promise.all(
|
||
MATERIAL_KEYS.map(async (key) => [key, await loadSet(loader, TEXTURES[key], FALLBACK_COLORS[key])]),
|
||
);
|
||
const materials = Object.fromEntries(entries);
|
||
materials.buildings.vertexColors = true; // pastellige Tönung je Gebäude
|
||
|
||
const normal = new DataTexture(waterNormalData(128), 128, 128, RGBAFormat);
|
||
normal.wrapS = normal.wrapT = RepeatWrapping;
|
||
normal.repeat.set(60, 60);
|
||
normal.needsUpdate = true;
|
||
materials.water = new MeshStandardMaterial({
|
||
color: 0x2c67a4, normalMap: normal, roughness: 0.15, metalness: 0.2, transparent: true, opacity: 0.92,
|
||
});
|
||
materials.seafloor = new MeshStandardMaterial({ color: 0x1d4468, roughness: 1 });
|
||
return materials;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Test laufen lassen, er muss bestehen**
|
||
|
||
Run: `npm test`
|
||
Expected: PASS, 3 Tests
|
||
|
||
- [ ] **Step 6: Himmel, Nebel, Schatten und Wasserfläche in `src/main.js`**
|
||
|
||
```js
|
||
import { BoxGeometry, Fog, Mesh, PlaneGeometry } from "three";
|
||
import { Sky } from "three/examples/jsm/objects/Sky.js";
|
||
|
||
const materials = await createMaterials();
|
||
|
||
const sky = new Sky();
|
||
sky.scale.setScalar(45000);
|
||
scene.add(sky);
|
||
sky.material.uniforms.turbidity.value = 8;
|
||
sky.material.uniforms.rayleigh.value = 1.6;
|
||
sky.material.uniforms.sunPosition.value.copy(sun.position.clone().normalize());
|
||
scene.fog = new Fog(0xbcd7ea, 500, 2200);
|
||
|
||
const water = new Mesh(new PlaneGeometry(4000, 4000), materials.water);
|
||
water.rotation.x = -Math.PI / 2;
|
||
water.position.set(500, -0.6, -1000);
|
||
scene.add(water);
|
||
|
||
// Meeresboden: man watet hinein, statt endlos zu fallen. Einmalig, nicht je Chunk.
|
||
const seafloor = new Mesh(new BoxGeometry(4000, 1, 4000), materials.seafloor);
|
||
seafloor.position.set(500, -3.0, -1000); // Oberkante bei y = -2,5
|
||
scene.add(seafloor);
|
||
seafloor.updateMatrixWorld(true);
|
||
player.addCollider({ motion: "static", shape: { kind: "mesh", source: seafloor } });
|
||
|
||
// Schattenkamera folgt dem Spieler, statt eine feste Box zu benutzen.
|
||
function updateShadow(target) {
|
||
sun.position.set(target.x + 120, 240, target.z + 80);
|
||
sun.target.position.copy(target);
|
||
sun.target.updateMatrixWorld();
|
||
sun.shadow.camera.left = -150; sun.shadow.camera.right = 150;
|
||
sun.shadow.camera.top = 150; sun.shadow.camera.bottom = -150;
|
||
sun.shadow.camera.far = 600;
|
||
sun.shadow.camera.updateProjectionMatrix();
|
||
}
|
||
```
|
||
|
||
`updateShadow(player.playerCapsule.position)` und `materials.water.normalMap.offset.x += delta * 0.01` in die Schleife hängen.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/world/materials.js tools/assets/textures.mjs tests/materials.test.js src/main.js public/textures
|
||
git commit -m "feat(world): Poly-Haven-Materialien, Wasser, Himmel und mitlaufende Schatten
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Palmen und Requisiten
|
||
|
||
**Files:**
|
||
- Create: `src/world/props.js`, `tools/assets/props.mjs`, `tools/assets/palm.sh`, `tests/props.test.js`
|
||
- Modify: `src/world/streaming.js`
|
||
- Test: `tests/props.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `chunk.palms`, `chunk.centerlines` aus Task 6
|
||
- Produces: `lampPoints(centerlines, spacing) -> [{x, z, angle}]`, `palmTransforms(points, seed) -> [{x, z, scale, rotation}]`, `class PropLayer { async load(); build(chunk) -> {visual: Group, colliders: Mesh[]} }`
|
||
|
||
- [ ] **Step 1: Palme aufbereiten**
|
||
|
||
`tools/assets/palm.sh`:
|
||
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
# Nobiax-Palme (CC0) holen und die TGA-Texturen nach PNG wandeln.
|
||
set -e
|
||
mkdir -p public/models/palm && cd public/models/palm
|
||
curl -sL -o palm.7z "https://opengameart.org/sites/default/files/palm_tree_v2.7z"
|
||
tar -xf palm.7z
|
||
sips -s format png diffus.tga --out diffuse.png > /dev/null
|
||
sips -s format png normal.tga --out normal.png > /dev/null
|
||
rm -f palm.7z diffus.tga normal.tga specular.tga palm_tree.FBX preview.jpg
|
||
echo "Palme bereit: $(ls)"
|
||
```
|
||
|
||
Run: `bash tools/assets/palm.sh`
|
||
Expected: `palm_tree.obj`, `diffuse.png`, `normal.png`, `readme.txt`.
|
||
|
||
- [ ] **Step 2: Requisiten holen und vereinfachen**
|
||
|
||
`tools/assets/props.mjs` lädt die fünf Poly-Haven-Modelle in 1k (gleiches Muster wie `textures.mjs`, aber über `files[slug].gltf["1k"].gltf` samt `include`-Dateien) nach `public/models/props/raw/` und ruft danach je Modell:
|
||
|
||
```bash
|
||
npx --yes @gltf-transform/cli@4.5.0 optimize \
|
||
public/models/props/raw/<slug>/<slug>_1k.gltf public/models/props/<slug>.glb \
|
||
--texture-size 512 --simplify-error 0.01 --compress meshopt
|
||
```
|
||
|
||
Run: `node tools/assets/props.mjs`
|
||
Expected: fünf `.glb` unter je 1 MB. `street_lamp_02` sollte von 20.338 auf unter 4.000 Dreiecke fallen, `fire_hydrant` von 86.316 auf unter 5.000.
|
||
|
||
- [ ] **Step 3: Den fehlschlagenden Test schreiben**
|
||
|
||
`tests/props.test.js`:
|
||
|
||
```js
|
||
import { describe, expect, it } from "vitest";
|
||
import { lampPoints, palmTransforms } from "../src/world/props.js";
|
||
|
||
describe("props", () => {
|
||
it("setzt Laternen im gewünschten Abstand entlang der Linie", () => {
|
||
const pts = lampPoints([[[0, 0], [100, 0]]], 35);
|
||
expect(pts.length).toBe(3); // 0, 35, 70 Meter
|
||
expect(pts[1].x).toBeCloseTo(35);
|
||
});
|
||
|
||
it("ignoriert zu kurze Linien", () => {
|
||
expect(lampPoints([[[0, 0], [10, 0]]], 35)).toHaveLength(1);
|
||
});
|
||
|
||
it("skaliert Palmen reproduzierbar zwischen 10 und 18 Metern", () => {
|
||
const a = palmTransforms([[5, 5], [9, 9]], 7);
|
||
const b = palmTransforms([[5, 5], [9, 9]], 7);
|
||
expect(a).toEqual(b);
|
||
for (const t of a) {
|
||
expect(t.height).toBeGreaterThanOrEqual(10);
|
||
expect(t.height).toBeLessThanOrEqual(18);
|
||
}
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Test laufen lassen, er muss scheitern**
|
||
|
||
Run: `npm test`
|
||
Expected: FAIL, `Failed to resolve import "../src/world/props.js"`
|
||
|
||
- [ ] **Step 5: `src/world/props.js` schreiben**
|
||
|
||
```js
|
||
import { CylinderGeometry, Group, InstancedMesh, Matrix4, Mesh, MeshStandardMaterial,
|
||
Object3D, RepeatWrapping, SRGBColorSpace, TextureLoader } from "three";
|
||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
|
||
|
||
const PALM_MODEL_HEIGHT = 439.74; // Einheiten im OBJ, gemessen
|
||
const TRUNK_RADIUS = 0.22;
|
||
|
||
/** Kleiner, wiederholbarer Zufall aus einem Schlüssel. */
|
||
function hashUnit(seed, key) {
|
||
let h = 2166136261 ^ seed;
|
||
for (const ch of key) { h = Math.imul(h ^ ch.charCodeAt(0), 16777619); }
|
||
return ((h >>> 0) % 100000) / 100000;
|
||
}
|
||
|
||
export function lampPoints(lines, spacing = 35) {
|
||
const out = [];
|
||
for (const line of lines) {
|
||
let travelled = 0, next = 0;
|
||
for (let i = 0; i < line.length - 1; i++) {
|
||
const [x0, z0] = line[i], [x1, z1] = line[i + 1];
|
||
const len = Math.hypot(x1 - x0, z1 - z0);
|
||
while (next <= travelled + len) {
|
||
const t = (next - travelled) / (len || 1);
|
||
out.push({ x: x0 + (x1 - x0) * t, z: z0 + (z1 - z0) * t, angle: Math.atan2(z1 - z0, x1 - x0) });
|
||
next += spacing;
|
||
}
|
||
travelled += len;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function palmTransforms(points, seed = 1) {
|
||
return points.map(([x, z]) => {
|
||
const key = `${x.toFixed(1)},${z.toFixed(1)}`;
|
||
return {
|
||
x, z,
|
||
height: 10 + hashUnit(seed, key) * 8,
|
||
rotation: hashUnit(seed, `${key}:r`) * Math.PI * 2,
|
||
};
|
||
});
|
||
}
|
||
|
||
export class PropLayer {
|
||
async load() {
|
||
const texLoader = new TextureLoader();
|
||
const [obj, diffuse, normal] = await Promise.all([
|
||
new OBJLoader().loadAsync("./models/palm/palm_tree.obj"),
|
||
texLoader.loadAsync("./models/palm/diffuse.png"),
|
||
texLoader.loadAsync("./models/palm/normal.png"),
|
||
]);
|
||
diffuse.colorSpace = SRGBColorSpace;
|
||
for (const t of [diffuse, normal]) t.wrapS = t.wrapT = RepeatWrapping;
|
||
this.palmGeometry = null;
|
||
obj.traverse((o) => { if (o.isMesh && !this.palmGeometry) this.palmGeometry = o.geometry; });
|
||
this.palmMaterial = new MeshStandardMaterial({
|
||
map: diffuse, normalMap: normal, alphaTest: 0.5, side: 2, roughness: 0.9,
|
||
});
|
||
const gltfLoader = new GLTFLoader();
|
||
this.lamp = (await gltfLoader.loadAsync("./models/props/street_lamp_02.glb")).scene;
|
||
this.trunkGeometry = new CylinderGeometry(TRUNK_RADIUS, TRUNK_RADIUS * 1.2, 6, 6);
|
||
}
|
||
|
||
/** Sichtbare Instanzen plus einfache Stammzylinder für den Chunk-Collider. */
|
||
build(chunk) {
|
||
const visual = new Group();
|
||
const colliders = [];
|
||
const palms = palmTransforms(chunk.palms ?? [], 20260912);
|
||
if (palms.length) {
|
||
const mesh = new InstancedMesh(this.palmGeometry, this.palmMaterial, palms.length);
|
||
mesh.castShadow = true;
|
||
const dummy = new Object3D();
|
||
palms.forEach((p, i) => {
|
||
const s = p.height / PALM_MODEL_HEIGHT;
|
||
dummy.position.set(p.x, 0, p.z);
|
||
dummy.rotation.set(0, p.rotation, 0);
|
||
dummy.scale.setScalar(s);
|
||
dummy.updateMatrix();
|
||
mesh.setMatrixAt(i, dummy.matrix);
|
||
const trunk = new Mesh(this.trunkGeometry);
|
||
trunk.position.set(p.x, 3, p.z);
|
||
colliders.push(trunk);
|
||
});
|
||
mesh.userData.excludeFromCollider = true;
|
||
visual.add(mesh);
|
||
}
|
||
return { visual, colliders };
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Test laufen lassen, er muss bestehen**
|
||
|
||
Run: `npm test`
|
||
Expected: PASS, 3 Tests
|
||
|
||
- [ ] **Step 7: In `ChunkStreamer` einhängen**
|
||
|
||
In `#load`, direkt vor `this.player.addCollider(...)`:
|
||
|
||
```js
|
||
const props = this.props.build(chunk);
|
||
group.add(props.visual);
|
||
for (const trunk of props.colliders) collide.add(trunk); // wandern in dieselbe BVH
|
||
```
|
||
|
||
Der `PropLayer` kommt als `props` in den `ChunkStreamer`-Konstruktor (siehe Task 8) und wird
|
||
einmalig vor dem ersten Frame mit `await props.load()` bereitgestellt.
|
||
|
||
- [ ] **Step 8: Im Browser prüfen**
|
||
|
||
Expected: Palmen stehen an der Promenade und in Parks, sie lassen sich nicht durchlaufen, die Bildrate bleibt über 55.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add src/world/props.js src/world/streaming.js tools/assets tests/props.test.js public/models
|
||
git commit -m "feat(world): Palmen und Requisiten als Instanzen mit Stammkollision
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Figuren, Autos, Panel und Namensnennung
|
||
|
||
**Files:**
|
||
- Create: `src/characters/index.js`, `src/ui/panel.js`, `src/ui/hud.js`, `tests/characters.test.js`
|
||
- Modify: `src/main.js`
|
||
- Test: `tests/characters.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `player` aus Task 1, Manifest aus Task 6
|
||
- Produces: `retargetClips(clips, sourceRest, targetRest) -> AnimationClip[]`, `createCharacterModule(player) -> {switchCharacter, switchHair, applyHeight}`, `teleportTargets(landmarks) -> [{name, x, z}]`, `mountHud(root, attribution)`
|
||
|
||
Der Figurenteil wird aus `three-player-controller/example/showcase.js` übernommen: die Funktionen `collectRestPose`, `trackVaries`, `retargetClips`, `clipsForModel`, `measureTorsoRadiusRatio`, `applyCharacter`, `applyHair` samt der Konstanten `CHARACTERS`, `HAIRSTYLES`, `PLAYER_MODEL_BASE`. Die Assets liegen dort unter `example/public/glb/characters/` und werden nach `public/models/characters/` kopiert.
|
||
|
||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||
|
||
`tests/characters.test.js`:
|
||
|
||
```js
|
||
import { describe, expect, it } from "vitest";
|
||
import { AnimationClip, Quaternion, QuaternionKeyframeTrack, VectorKeyframeTrack } from "three";
|
||
import { retargetClips, teleportTargets } from "../src/characters/index.js";
|
||
|
||
const rest = (q) => new Map([["arm", { position: { x: 0, y: 1, z: 0 }, quaternion: q }]]);
|
||
|
||
describe("retargetClips", () => {
|
||
it("wirft konstante Positionsspuren weg", () => {
|
||
const clip = new AnimationClip("a", 1, [
|
||
new VectorKeyframeTrack("arm.position", [0, 1], [0, 1, 0, 0, 1, 0]),
|
||
new QuaternionKeyframeTrack("arm.quaternion", [0], [0, 0, 0, 1]),
|
||
]);
|
||
const out = retargetClips([clip], rest(new Quaternion()), rest(new Quaternion()));
|
||
expect(out[0].tracks.map((t) => t.name)).toEqual(["arm.quaternion"]);
|
||
});
|
||
|
||
it("überträgt Rotationen relativ zur Ruhepose", () => {
|
||
const src = new Quaternion().setFromAxisAngle({ x: 0, y: 1, z: 0 }, 0.3);
|
||
const dst = new Quaternion().setFromAxisAngle({ x: 0, y: 1, z: 0 }, 0.9);
|
||
const clip = new AnimationClip("a", 1, [
|
||
new QuaternionKeyframeTrack("arm.quaternion", [0], [src.x, src.y, src.z, src.w]),
|
||
]);
|
||
const out = retargetClips([clip], rest(src), rest(dst));
|
||
const v = out[0].tracks[0].values;
|
||
expect(new Quaternion(v[0], v[1], v[2], v[3]).angleTo(dst)).toBeLessThan(1e-6);
|
||
});
|
||
});
|
||
|
||
describe("teleportTargets", () => {
|
||
it("sortiert Wahrzeichen nach Höhe", () => {
|
||
const targets = teleportTargets([
|
||
{ name: "klein", xyz: [0, 0, 0], height: 30 },
|
||
{ name: "groß", xyz: [10, 20, 0], height: 200 },
|
||
]);
|
||
expect(targets[0].name).toBe("groß");
|
||
expect(targets[0].z).toBe(-20);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Test laufen lassen, er muss scheitern**
|
||
|
||
Run: `npm test`
|
||
Expected: FAIL, `Failed to resolve import "../src/characters/index.js"`
|
||
|
||
- [ ] **Step 3: Modul übernehmen und Test erfüllen**
|
||
|
||
Die genannten Funktionen unverändert aus `showcase.js` kopieren, als benannte Exporte bereitstellen und `teleportTargets` ergänzen:
|
||
|
||
```js
|
||
export function teleportTargets(landmarks) {
|
||
return [...landmarks]
|
||
.sort((a, b) => b.height - a.height)
|
||
.map((l) => ({ name: l.name, x: l.xyz[0], z: -l.xyz[1], height: l.height }));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Test laufen lassen, er muss bestehen**
|
||
|
||
Run: `npm test`
|
||
Expected: PASS, alle Tests
|
||
|
||
- [ ] **Step 5: Autos setzen**
|
||
|
||
Vier SUVs an Punkten der Mittellinien nahe dem Start, Konfiguration aus `spawnShowcaseVehicles` in `showcase.js`. Die Chunks dieser vier Punkte werden in `ChunkStreamer` festgehalten (`this.pinned = new Set([...])`, in `#unload` überspringen), weil ein Fahrzeug ohne Boden ins Nichts fällt.
|
||
|
||
- [ ] **Step 6: Panel und HUD**
|
||
|
||
`src/ui/panel.js` baut mit lil-gui: Figur, Frisur, Höhe, Hitbox anzeigen, Chunk-Grenzen anzeigen, Kartenbild auf dem Boden, Sichtweite (Nebel), Sprung zu Wahrzeichen (Auswahl aus `teleportTargets`, setzt `player.reset(...)`).
|
||
|
||
`src/ui/hud.js` hängt die Namensnennung dauerhaft unten links ein:
|
||
|
||
```js
|
||
export function mountHud(root, attribution) {
|
||
const el = document.createElement("div");
|
||
el.className = "attribution";
|
||
el.textContent = attribution;
|
||
root.appendChild(el);
|
||
return el;
|
||
}
|
||
```
|
||
|
||
Der Text kommt aus `manifest.json` und wird um die Modellquellen ergänzt: SUV von Boris731 (CC BY), Poly Haven, Nobiax und Quaternius (CC0).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/characters src/ui tests/characters.test.js src/main.js public/models/characters
|
||
git commit -m "feat: Figurenauswahl, Autos, Debug-Panel und Namensnennung
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Abnahme
|
||
|
||
**Files:**
|
||
- Create: `docs/abnahme-vice-a.md`
|
||
- Test: alle vorhandenen Suites
|
||
|
||
- [ ] **Step 1: Alle Tests laufen lassen**
|
||
|
||
Run: `npm test && cd tools/extract && uv run pytest -v && cd ../.. && node tools/checkChunks.mjs`
|
||
Expected: alles grün, Dreieckszahl unter dem Budget.
|
||
|
||
- [ ] **Step 2: Budgets messen**
|
||
|
||
Im Panel die Anzeige für Draw Calls (`renderer.info.render.calls`) und Dreiecke einblenden, dann auf Vice Beach, in Downtown und auf einer Dammstraße je 30 Sekunden messen.
|
||
Expected: mindestens 55 FPS, unter 150 Draw Calls.
|
||
|
||
- [ ] **Step 3: Abnahmeliste durchspielen und protokollieren**
|
||
|
||
In `docs/abnahme-vice-a.md` je Punkt Ergebnis und Screenshot festhalten:
|
||
1. Start auf der Promenade, Figur steht auf dem Boden
|
||
2. Mit dem SUV über eine Dammstraße auf das Festland fahren
|
||
3. Ins Wasser laufen, wieder herauskommen
|
||
4. Einen Wolkenkratzer in Downtown erreichen, Höhe stimmt mit den Daten überein
|
||
5. Den Ausschnitt einmal von Nord nach Süd durchqueren, ohne Ruckler und ohne Loch im Boden
|
||
6. Figur und Frisur wechseln, Hitbox einblenden
|
||
7. Unter die Karte fallen lassen, Respawn greift
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add docs/abnahme-vice-a.md
|
||
git commit -m "docs: Abnahmeprotokoll für den Vice-City-Ausschnitt
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
```
|