Die Sichtprüfung nach Task 6 hat gezeigt, dass die Beschriftungen der Originalkarte zu Geometrie werden — "VICE CITY (MIAMI)" steht als 1.600 m breiter Schriftzug in der Bucht. Task 6b entfernt sie, bevor Task 7 daraus Meshes baut. Der Brief nennt drei gemessene Sackgassen (Buntheit, Farbton, Zusammenhang nur der Straßenmaske) und den Weg, der trägt: auf Regionenebene über alle Nicht-Wasser-Klassen, wo die Buchstaben eigene Inseln ohne jedes natürliche Merkmal bilden. extrudePolygons und flatPolygons in Task 7 verarbeiten jetzt Ringlisten mit Löchern statt einzelner Ringe, passend zu Ruling 34. overview.py rendert den ganzen Ausschnitt aus den Chunk-Dateien; damit ist der Beschriftungsfehler überhaupt erst aufgefallen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2715 lines
109 KiB
Markdown
2715 lines
109 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"]
|
||
# Ohne [build-system] behandelt uv das Projekt als virtuell und installiert das lokale
|
||
# Paket nie; pythonpath legt tools/extract für die Tests auf den sys.path.
|
||
pythonpath = ["."]
|
||
```
|
||
|
||
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.coords import mosaic_origin_px
|
||
from leonida.tiles import assemble
|
||
|
||
def fake_tile(row: int, col: int) -> np.ndarray:
|
||
"""Kodiert die globale Pixelposition, damit ein falscher Versatz auffällt."""
|
||
ys, xs = np.mgrid[0:256, 0:256]
|
||
gy = (row * 256 + ys) % 251
|
||
gx = (col * 256 + xs) % 251
|
||
return np.dstack([gy, gx, np.full((256, 256), 7)]).astype(np.uint8)
|
||
|
||
def test_assemble_crops_to_slice():
|
||
mosaic = assemble(fake_tile)
|
||
assert mosaic.shape == (3000, 3000, 3)
|
||
|
||
def test_assemble_uses_the_exact_crop_offset():
|
||
# Einfarbige Kacheln würden jeden Versatz von 0 bis 72 px durchgehen lassen.
|
||
ox, oy = mosaic_origin_px()
|
||
assert (ox, oy) == (24, 60)
|
||
mosaic = assemble(fake_tile)
|
||
r0, c0 = 54, 60
|
||
for py, px in [(0, 0), (2999, 2999), (1234, 567)]:
|
||
gy, gx = r0 * 256 + oy + py, c0 * 256 + ox + px
|
||
assert tuple(mosaic[py, px]) == (gy % 251, gx % 251, 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_reports_the_share_of_unknown_pixels():
|
||
img = block(PALETTE["stadt"], 16)
|
||
img[7:9, 7:9] = (255, 0, 255) # 4 von 256 Pixeln passen zu keiner Farbe
|
||
labels, share = classify(img, report_unknown=True)
|
||
assert (labels == CLASSES["stadt"]).all()
|
||
assert abs(share - 4 / 256) < 1e-9
|
||
|
||
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": (48, 144, 192), # gemessen im Mosaik; tiles.py nennt die Kompositionsfarbe
|
||
"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 = 22.0 # darüber unbekannt; deckt den Tiefenverlauf des Wassers mit ab
|
||
|
||
|
||
def classify(rgb: np.ndarray, report_unknown: bool = False):
|
||
"""Ordnet jedes Pixel der nächsten Palettenfarbe zu; Ausreißer werden aufgefüllt.
|
||
|
||
Mit report_unknown=True kommt zusätzlich der Anteil der Pixel zurück, die vor dem
|
||
Auffüllen zu keiner Legendenfarbe passten. Aufgabe 6 bricht darüber bei über 5 % ab.
|
||
"""
|
||
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
|
||
share = float(unknown.mean())
|
||
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, share) if report_unknown else 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: unbekannt unter 5 %, Wasser rund 33 %, Stadt rund 33 %, Gebäude rund 11 %, Straße rund 12 % (am echten Mosaik gemessen). Weichen die Werte deutlich 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 networkx as nx
|
||
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 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
|
||
(outers if _signed_area(contour - 1) > 0 else holes).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 _signed_area(contour: np.ndarray) -> float:
|
||
"""Shoelace-Summe in Pixelkoordinaten (Zeile, Spalte).
|
||
|
||
find_contours liefert Außenkonturen und Löcher mit entgegengesetztem Umlaufsinn;
|
||
das Vorzeichen trennt sie zuverlässig. Eine Stichprobe der Maske am Mittelpunkt der
|
||
Kontur versagt bei konzentrischen Formen: bei einer Insel mit Lagune liegt der
|
||
Mittelpunkt der Außenkontur selbst in der Lagune.
|
||
"""
|
||
row, col = contour[:, 0], contour[:, 1]
|
||
return float(0.5 * np.sum(col * np.roll(row, -1) - np.roll(col, -1) * row))
|
||
|
||
|
||
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 closing(mask, disk(3))
|
||
|
||
|
||
# Wuchs der Landmaske in Pixeln (= Metern) für die Brückenprüfung. Am echten Mosaik
|
||
# gemessen: 3-5 px reichen nicht, weil Bordstein/Anti-Aliasing zwischen "land" und der
|
||
# angrenzenden Straße auf Karten mit echtem Rauschen mehr Lücke lassen als im
|
||
# künstlichen Test. Ist der Wuchs zu knapp, berührt eine ganz normale Küstenstraße
|
||
# nirgends "Land" und verkettet sich mit jeder anderen zu knappen Stelle zu einem
|
||
# Brückenstück, das (wie der ursprüngliche Fensteransatz) fast die ganze Karte
|
||
# überspannt. Ab 8 px reißt diese Kette am echten Mosaik; 10 px lässt zusätzlich Raum
|
||
# zum künstlichen Test, dessen 15 Pixel breite Wasserlücke sonst zu knapp anläge.
|
||
LAND_REACH_PX = 10
|
||
|
||
# Reichweite in Pixeln (= Metern) für die "nahe am Wasser"-Prüfung: fern von Land allein
|
||
# macht noch keine Brücke (siehe unten). Am echten Mosaik gemessen (WATER_REACH_PX →
|
||
# Brücken gesamt / davon "trocken" mit < 10 % Wasser im Umkreis: 6→40/1, 8→40/1,
|
||
# 10→41/2, 14→72/17) ist 6 bis 10 ein stabiles Plateau, das ab 14 abbricht und wieder
|
||
# trockene Flächen als Brücke mitreißt; 8 liegt in dessen Mitte.
|
||
WATER_REACH_PX = 8
|
||
|
||
|
||
def road_polygons(labels: np.ndarray) -> list[tuple[Polygon, bool]]:
|
||
"""Straßenflächen, aufgeteilt in Stücke auf Land, Brückenstücke über Wasser und
|
||
breite Flächen an Land, die nur zufällig weit von "Land" entfernt liegen.
|
||
|
||
"Land" ist hier die Geländeklasse ohne die Straße selbst, um LAND_REACH_PX nach
|
||
außen gewachsen. Ohne den Ausschluss der Straße wäre das zirkulär: land_polygons()
|
||
zählt "nicht Wasser" als Land, und eine Straße ist nie "wasser" – ein Damm quer
|
||
durchs Meer würde dann fast vollständig mit sich selbst überlappen und nie als
|
||
Brücke auffallen. Der Wuchs sorgt dafür, dass eine Straße, die direkt an echtem
|
||
Land beginnt, dort noch als "auf Land" erkannt wird, statt an ihrer eigenen Kante
|
||
ins Leere zu greifen.
|
||
|
||
"Fern von Land" reicht als Brückenkriterium allein nicht aus: eine breite Kreuzungs-
|
||
oder Parkfläche ist überall nur Straße, ihre Mitte liegt also zwangsläufig weiter als
|
||
LAND_REACH_PX von straßenfreiem Land entfernt, obwohl gar kein Wasser in der Nähe
|
||
ist. Ein Stück jenseits von "Land" zählt deshalb nur dann als Brücke, wenn es
|
||
zusätzlich innerhalb WATER_REACH_PX von Wasser liegt; der Rest – fern von Land, aber
|
||
auch fern von Wasser – bleibt "auf Land".
|
||
"""
|
||
solid_ground = ndi.binary_dilation(
|
||
(labels != CLASSES["wasser"]) & ~road_mask(labels), disk(LAND_REACH_PX)
|
||
)
|
||
land = unary_union(mask_polygons(solid_ground, min_area=50, simplify=1.0))
|
||
wet = unary_union(mask_polygons(
|
||
ndi.binary_dilation(labels == CLASSES["wasser"], disk(WATER_REACH_PX)),
|
||
min_area=50, simplify=1.0))
|
||
out = []
|
||
for poly in mask_polygons(road_mask(labels), min_area=20, simplify=0.8):
|
||
off = poly.difference(land)
|
||
for part, bridge in ((poly.intersection(land), False), # auf Land
|
||
(off.intersection(wet), True), # über Wasser: Brücke
|
||
(off.difference(wet), False)): # breite Fläche an Land
|
||
for piece in getattr(part, "geoms", [part]):
|
||
if piece.geom_type == "Polygon" and piece.area >= 20:
|
||
out.append((piece, bridge))
|
||
return out
|
||
|
||
|
||
def centerlines(labels: np.ndarray) -> list[LineString]:
|
||
"""Skelett der Straßenflächen, nur zum Platzieren von Laternen und Palmen.
|
||
|
||
Eine Kontur um das ein Pixel breite Skelett liefert jede Linie doppelt (sie umrundet
|
||
das Band und läuft auf der anderen Seite zurück). Stattdessen wird das Skelett als
|
||
Pixelgraph gelesen: Skelettpixel sind Knoten, 8er-Nachbarschaft ist eine Kante. Je
|
||
Zusammenhangskomponente werden die Pfade zwischen Knoten mit Grad ungleich 2 verfolgt
|
||
(Enden und Kreuzungen trennen die Äste); ein reiner Ring ohne solche Knoten wird
|
||
einmal umlaufen.
|
||
"""
|
||
graph = _skeleton_graph(skeletonize(road_mask(labels)))
|
||
lines = []
|
||
for path in _skeleton_paths(graph):
|
||
line = LineString(_to_world(path)).simplify(1.5)
|
||
if line.length >= 15:
|
||
lines.append(line)
|
||
return lines
|
||
|
||
|
||
def _skeleton_graph(skeleton: np.ndarray) -> nx.Graph:
|
||
"""Skelettpixel als Knoten, 8er-Nachbarschaft als Kanten."""
|
||
pixels = set(zip(*np.nonzero(skeleton)))
|
||
graph = nx.Graph()
|
||
graph.add_nodes_from(pixels)
|
||
for r, c in pixels:
|
||
for dr in (-1, 0, 1):
|
||
for dc in (-1, 0, 1):
|
||
neighbour = (r + dr, c + dc)
|
||
if (dr or dc) and neighbour in pixels:
|
||
graph.add_edge((r, c), neighbour)
|
||
return graph
|
||
|
||
|
||
def _skeleton_paths(graph: nx.Graph) -> list[list[tuple[int, int]]]:
|
||
"""Pfade zwischen Enden/Kreuzungen (Grad ≠ 2) je Zusammenhangskomponente;
|
||
ein reiner Ring aus lauter Grad-2-Knoten wird einmal umlaufen."""
|
||
paths, seen = [], set()
|
||
for component in nx.connected_components(graph):
|
||
sub = graph.subgraph(component)
|
||
branch_nodes = [n for n in sub if sub.degree(n) != 2]
|
||
if not branch_nodes:
|
||
start = next(iter(component))
|
||
ring = list(nx.dfs_preorder_nodes(sub, start))
|
||
ring.append(start)
|
||
paths.append(ring)
|
||
continue
|
||
for start in branch_nodes:
|
||
for first in sub.neighbors(start):
|
||
if frozenset((start, first)) in seen:
|
||
continue
|
||
path, prev, current = [start, first], start, first
|
||
seen.add(frozenset((start, first)))
|
||
while sub.degree(current) == 2:
|
||
nxt = next(n for n in sub.neighbors(current) if n != prev)
|
||
seen.add(frozenset((current, nxt)))
|
||
path.append(nxt)
|
||
prev, current = current, nxt
|
||
paths.append(path)
|
||
return paths
|
||
```
|
||
|
||
- [ ] **Step 4: Test laufen lassen, er muss bestehen**
|
||
|
||
Run: `cd tools/extract && uv run pytest tests/test_vectors.py -v`
|
||
Expected: PASS, 5 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[dict]` (je Gebäude `height` und `source`), `palm_points(sand_polys, park_polys, road_lines, building_polys, road_polys, 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
|
||
import pytest
|
||
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
|
||
|
||
@pytest.mark.filterwarnings("ignore:.*oriented_envelope.*:RuntimeWarning")
|
||
def test_l_shape_is_kept_not_boxed():
|
||
"""Das Assert unten ruft minimum_rotated_rectangle selbst auf und löst damit die
|
||
harmlose Shapely-RuntimeWarning aus (Winkelterm 0/0, Rechteck korrekt). Nur dieser
|
||
eine Test filtert sie — nicht die Suite, sonst wäre auch eine echte Division durch
|
||
Null künftig unsichtbar."""
|
||
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
|
||
|
||
def test_small_staircase_building_is_not_dropped():
|
||
"""regularize darf nicht doppelt vereinfachen.
|
||
|
||
mask_polygons liefert Kandidaten bereits mit 0,5 m simplify und mindestens MIN_AREA.
|
||
Ein Grundriss mit gestuftem Eck, der die Rechteckquote verfehlt, wurde bisher ein
|
||
zweites Mal und gröber (1,0 m) vereinfacht und damit unter MIN_AREA gedrückt. Am
|
||
echten Mosaik betraf das 202 von 2.085 Gebäuden.
|
||
"""
|
||
labels = np.full((40, 40), CLASSES["stadt"], dtype=np.uint8)
|
||
block = np.array([
|
||
[1, 1, 1, 0],
|
||
[1, 1, 1, 1],
|
||
[1, 1, 1, 1],
|
||
[0, 1, 1, 1],
|
||
], dtype=bool)
|
||
labels[10:14, 10:14][block] = CLASSES["gebaeude"]
|
||
assert len(building_polygons(labels)) == 1
|
||
|
||
def test_speculative_flag_matches_pixel_class():
|
||
"""Nur aus 'spekulativ' (rot) entstandene Grundrisse sind spekulativ."""
|
||
labels = np.full((60, 60), CLASSES["stadt"], dtype=np.uint8)
|
||
labels[10:25, 10:25] = CLASSES["gebaeude"] # grau -> kleineres Welt-x
|
||
labels[35:50, 35:50] = CLASSES["spekulativ"] # rot -> größeres Welt-x
|
||
result = building_polygons(labels)
|
||
assert len(result) == 2
|
||
result.sort(key=lambda item: item[0].centroid.x)
|
||
(_, spec_from_gebaeude), (_, spec_from_spekulativ) = result
|
||
assert spec_from_gebaeude is False
|
||
assert spec_from_spekulativ is True
|
||
```
|
||
|
||
- [ ] **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
|
||
# Gleiche Toleranz wie mask_polygons: ein zweiter, gröberer Durchgang schrumpfte
|
||
# kleine Grundrisse unter MIN_AREA und verwarf am echten Mosaik 202 von 2.085.
|
||
return poly.simplify(0.5)
|
||
|
||
|
||
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: Den fehlschlagenden Test für Palmenpunkte schreiben**
|
||
|
||
An `tools/extract/tests/test_heights.py` anfügen:
|
||
|
||
```python
|
||
from shapely.geometry import LineString, Point
|
||
from leonida.heights import PALM_KERB, palm_points
|
||
|
||
def test_no_palm_stands_on_the_carriageway():
|
||
"""Der Versatz muss vom Fahrbahnrand her gedacht werden, nicht von einem festen Wert.
|
||
|
||
Am echten Mosaik hat die Fahrbahn an den Mittellinienpunkten p50 5,4 m, p90 11,7 m
|
||
und p95 14,0 m Halbbreite. Ein fester Versatz von 4 m setzt 60 % der Palmen auf die
|
||
Fahrbahn, einer von 11 m immer noch 12,5 % – und auf der Hälfte der Straßen stünde
|
||
die Palme dann im Nachbargebäude.
|
||
"""
|
||
road = Polygon([(0, -9), (200, -9), (200, 9), (0, 9)]) # 18 m breite Fahrbahn
|
||
line = LineString([(0, 0), (200, 0)])
|
||
sand = Polygon([(0, 20), (200, 20), (200, 40), (0, 40)])
|
||
pts = palm_points([sand], [], [line], [], [road], [], seed=1)
|
||
assert pts
|
||
assert all(road.distance(Point(x, y)) >= PALM_KERB for x, y in pts)
|
||
|
||
def test_palms_do_not_pile_up_where_centerlines_meet():
|
||
"""Mittellinien sind an Kreuzungen getrennt (Task 4). Ein Start bei Position 0 setzt
|
||
auf zwei gegenüberliegenden Ästen zwei Palmen in genau denselben Punkt."""
|
||
sand = Polygon([(-80, 20), (80, 20), (80, 40), (-80, 40)])
|
||
arms = [LineString([(0, 0), (60, 0)]), LineString([(0, 0), (-60, 0)])]
|
||
pts = palm_points([sand], [], arms, [], [], [], seed=1)
|
||
assert pts
|
||
assert len(set(pts)) == len(pts)
|
||
|
||
def test_no_palm_stands_in_the_water():
|
||
"""Der Versatz nach außen schiebt Palmen an Küstenstraßen über die Uferlinie.
|
||
Am echten Mosaik standen so 218 von 2.433 Palmen im Wasser."""
|
||
land = Polygon([(-100, -100), (100, -100), (100, 0), (-100, 0)]) # Ufer bei y = 0
|
||
road = Polygon([(-100, -12), (100, -12), (100, 0), (-100, 0)])
|
||
line = LineString([(-100, -6), (100, -6)])
|
||
sand = Polygon([(-100, 0), (100, 0), (100, 10), (-100, 10)])
|
||
pts = palm_points([sand], [], [line], [], [road], [land], seed=1)
|
||
assert pts
|
||
assert all(land.contains(Point(x, y)) for x, y in pts)
|
||
|
||
def test_park_scatter_is_deterministic_and_inside_the_park():
|
||
"""Ohne Sperrflächen verstreut ein Park reproduzierbare Punkte."""
|
||
park = Polygon([(0, 0), (50, 0), (50, 50), (0, 50)]) # 2500 m² -> 10 Punkte
|
||
pts_a = palm_points([], [park], [], [], [], [], seed=7)
|
||
pts_b = palm_points([], [park], [], [], [], [], seed=7)
|
||
assert pts_a
|
||
assert all(park.contains(Point(x, y)) for x, y in pts_a)
|
||
assert pts_a == pts_b
|
||
```
|
||
|
||
- [ ] **Step 10: 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
|
||
PALM_KERB = 1.5 # Meter Abstand zum Fahrbahnrand
|
||
# Versatzkandidaten von innen nach außen; genommen wird der erste, der Fahrbahn und
|
||
# Gebäude freihält. Ein fester Wert geht nicht: die Fahrbahn ist an den Mittellinien-
|
||
# punkten p50 5,4 m, p90 11,7 m, p95 14,0 m halbbreit (am echten Mosaik gemessen).
|
||
PALM_VERGE = (6.0, 8.0, 10.0, 12.0, 14.0, 16.0)
|
||
|
||
|
||
def _verge_point(line, p, side: str, roads, buildings, land):
|
||
"""Erster Versatz von innen nach außen, der Fahrbahn und Gebäude freihält und
|
||
auf Land bleibt (sonst schiebt der Versatz Küstenpalmen ins Wasser)."""
|
||
for dist in PALM_VERGE:
|
||
off = line.parallel_offset(dist, side)
|
||
if off.is_empty:
|
||
continue
|
||
q = off.interpolate(off.project(p))
|
||
if roads is not None and q.distance(roads) < PALM_KERB:
|
||
continue
|
||
if buildings is not None and q.distance(buildings) < PALM_CLEARANCE:
|
||
continue
|
||
if land is not None and not land.contains(q):
|
||
continue
|
||
return [(round(q.x, 2), round(q.y, 2))]
|
||
return []
|
||
|
||
|
||
def palm_points(sand_polys, park_polys, road_lines, building_polys, road_polys,
|
||
land_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
|
||
roads = unary_union(list(road_polys)) if road_polys else None
|
||
buildings = unary_union(list(building_polys)) if building_polys else None
|
||
land = unary_union(land_polys) if land_polys else None
|
||
blocked = unary_union([g for g in (roads, buildings) if g is not None]) \
|
||
if (roads is not None or buildings is not None) else None
|
||
points = []
|
||
for line in road_lines:
|
||
if sand is None or line.distance(sand) > PALM_BEACH_RANGE:
|
||
continue
|
||
# Start bei halber Teilung: die Mittellinien sind an Kreuzungen getrennt, ein
|
||
# Start bei 0 setzt je Ast eine Palme in denselben Punkt.
|
||
pos = min(PALM_SPACING / 2, line.length / 2)
|
||
while pos <= line.length:
|
||
p = line.interpolate(pos)
|
||
for side in ("left", "right"):
|
||
points.extend(_verge_point(line, p, side, roads, buildings, land))
|
||
pos += PALM_SPACING
|
||
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 list(dict.fromkeys(points))
|
||
```
|
||
|
||
- [ ] **Step 11: Test laufen lassen, er muss bestehen**
|
||
|
||
Run: `cd tools/extract && uv run pytest -v`
|
||
Expected: PASS, alle Tests
|
||
|
||
- [ ] **Step 12: 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.30, 0.36), "strasse": (0.10, 0.14), "gebaeude": (0.09, 0.13)}
|
||
|
||
|
||
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,
|
||
land, 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": "Kartenkacheln: GTA VI Map (CC BY 4.0) — https://map.gtadb.org, YANIS v15. Wahrzeichen: rolux/gtamaplib-vc (Unlicense).",
|
||
})
|
||
|
||
|
||
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 beiden Datendateien liegen bereits unter `tools/extract/data/` im Repository und werden in diesem Schritt **nicht** heruntergeladen — sie sind einmalig erzeugt und mit committet worden, weil an ihnen die Lizenzlage hängt:
|
||
|
||
`data/skyline.json` enthält die 54 Wahrzeichen, die in den Ausschnitt fallen, aus `game/landmarks/landmarks.json` von [rolux/gtamaplib-vc](https://github.com/rolux/gtamaplib-vc) bei Commit `98b858fe6cbb3ea0bffa94aa53d86c1d6b0d5a02`. Das Repository steht unter der Unlicense (Public Domain; GitHub erkennt die Datei nicht automatisch, der Text ist wörtlich die Unlicense). Übernommen sind nur `name`, `id`, `xyz` und `height` — die Texturen des Quell-Repos sind Spiel-Screenshots und bleiben ausgeschlossen. `height` ist dort identisch mit `xyz[2]`, also die Höhe der angepeilten Spitze über Null; genau das liest `assign_heights`. Die Namen sind echte Miami-Bauten, weil gtamaplib die Karte gegen reale Referenzen trianguliert.
|
||
|
||
`data/districts.json` ist **selbst erstellt**, nicht extrahiert: in gtadb gibt es keine Viertelgrenzen. Die beiden Kerne sind aus den Wahrzeichen abgeleitet — `Downtown` als Mittelpunkt der 11 westlichen Wahrzeichen ab 150 m Höhe, `Ocean Beach` als Mittelpunkt der 27 Wahrzeichen auf der Nehrung. Dazu fünf Mittelpunkte `Suburbs` auf dem Raster x ∈ {−750, 250, 1250}, y ∈ {−250, 750, 1750}, wobei Rasterpunkte näher als 900 m an einem Kern entfallen. `Suburbs` steht absichtlich nicht in `DISTRICT_RANGES` und fällt damit auf `default` zurück — ohne diese Punkte wäre jedes Gebäude des Festlands dem Downtown-Kern am nächsten und die Vorstadt bekäme 20-60 m.
|
||
|
||
Gemessen an den 2.352 echten Grundrissen: 68 Gebäude bekommen eine Wahrzeichenhöhe (31-248 m, Median 114,5), 279 fallen auf Downtown (20-59 m, Median 29,6), 543 auf Ocean Beach (8-25 m, Median 12,5), 1.462 auf Suburbs (6-15 m, Median 8,4). Gesamtprofil p50 9,4 m, p90 27,2 m.
|
||
|
||
- [ ] **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 6b: Kartenbeschriftung aus der Klassifikation entfernen
|
||
|
||
**Warum diese Aufgabe existiert:** Die Sichtprüfung der Gesamtansicht nach Task 6 hat gezeigt,
|
||
dass die Beschriftungen der Originalkarte zu Geometrie werden. "VICE CITY (MIAMI)" steht als
|
||
rund 1.600 m breiter Schriftzug aus Straßenfläche mitten in der Bucht, die roten Ortsnamen
|
||
(STARFISH ISLAND, CATALAN BAY, PRAWN ISLAND) werden zu Brücken und spekulativen Gebäuden.
|
||
Task 7 extrudiert genau diese Daten zu Meshes; ohne diese Aufgabe stehen die Buchstaben als
|
||
Mauern in der Welt. Die 195 auffällig langen, dünnen "Gebäude" aus der Messung zu Task 5
|
||
(eines 586 m lang bei nur 1.576 m² Fläche, eines 384 m lang bei 8 m Breite) sind dieselbe
|
||
Ursache.
|
||
|
||
**Files:**
|
||
- Modify: `tools/extract/leonida/classify.py`
|
||
- Test: `tools/extract/tests/test_classify.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `CLASSES`, `classify(rgb)` aus Task 3
|
||
- Produces: `drop_map_labels(labels: np.ndarray) -> np.ndarray` — gibt ein neues Label-Bild
|
||
zurück, in dem als Beschriftung erkannte Bereiche auf `CLASSES["wasser"]` stehen.
|
||
`run.py` ruft es unmittelbar nach `classify` auf.
|
||
|
||
**Was schon ausprobiert und widerlegt ist.** Diese drei Wege sind gemessen und führen nicht
|
||
zum Ziel; probier sie nicht noch einmal:
|
||
|
||
1. *Buntheit.* Die als `strasse` klassifizierten Schriftpixel über Wasser haben Medianfarbe
|
||
(47, 79, 102), Buntheit p50 49 gegen p50 0 bei echten Straßen. Eine Schwelle von 30
|
||
entfernt aber nur 74 % der verdächtigen Pixel und dabei 5,8 % echter Straßenpixel.
|
||
2. *Farbton.* Die Palette trennt sich sauber (Wasser 201°, Gras 92°, Sand 53°, Stadt und
|
||
Straße und Gebäude neutral), aber die Regel erfasst nur 5.551 von 64.125 Nicht-Wasser-
|
||
Pixeln im Schriftzug: die hellen Buchstabenkörper sind farblich identisch mit echten
|
||
Stadtpixeln. Farbe allein kann das nicht lösen.
|
||
3. *Zusammenhang nur der Straßenmaske.* Scheitert daran, dass die hellen Teile der Buchstaben
|
||
als `stadt` klassifiziert werden und damit selbst "Land" um sich herum erzeugen.
|
||
|
||
**Der Weg, der trägt.** Auf Regionenebene über **alle** Nicht-Wasser-Klassen gemeinsam.
|
||
Gemessen am echten Mosaik: `~wasser` zerfällt in 52 Komponenten. Die Hauptlandmasse hat
|
||
5.852.263 px; die Buchstaben bilden eigene Komponenten von 4.876 bis 14.760 px, und ihr
|
||
Anteil natürlicher Merkmale (`gruen`, `sand`, `land`) ist 0,0 %. Echte Inseln haben Grün oder
|
||
Sand, Buchstaben nicht. Die Regel "nicht die Hauptlandmasse, höchstens 2 % natürliche
|
||
Merkmale" entfernt 106.800 px (1,77 % aller Nicht-Wasser-Pixel) und darin 63.199 von 64.125
|
||
Pixeln des Schriftzugs, also 98,6 %. Das Ergebnis ist zwischen den Größenschwellen 20.000 und
|
||
150.000 px identisch — die Trennung ist also nicht auf einen Schwellenwert gefittet.
|
||
|
||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||
|
||
An `tools/extract/tests/test_classify.py` anfügen:
|
||
|
||
```python
|
||
from leonida.classify import drop_map_labels
|
||
|
||
def test_letter_shaped_island_becomes_water():
|
||
"""Ein Buchstabe im Wasser ist Beschriftung, keine Stadt: keine natuerlichen Merkmale."""
|
||
labels = np.full((200, 200), CLASSES["wasser"], dtype=np.uint8)
|
||
labels[40:60, 40:120] = CLASSES["stadt"] # Querbalken
|
||
labels[40:120, 40:60] = CLASSES["strasse"] # Laengsbalken -> zusammen ein "L"
|
||
out = drop_map_labels(labels)
|
||
assert (out == CLASSES["wasser"]).all()
|
||
|
||
def test_real_island_survives():
|
||
"""Eine Insel mit Gruen und Sand bleibt, auch wenn sie klein und vom Festland getrennt ist."""
|
||
labels = np.full((200, 200), CLASSES["wasser"], dtype=np.uint8)
|
||
labels[40:120, 40:120] = CLASSES["stadt"]
|
||
labels[50:70, 50:70] = CLASSES["gruen"]
|
||
labels[100:115, 45:115] = CLASSES["sand"]
|
||
out = drop_map_labels(labels)
|
||
assert (out[40:120, 40:120] != CLASSES["wasser"]).all()
|
||
|
||
def test_mainland_is_never_dropped():
|
||
"""Die groesste Nicht-Wasser-Komponente bleibt immer, auch voellig ohne Gruen und Sand."""
|
||
labels = np.full((200, 200), CLASSES["wasser"], dtype=np.uint8)
|
||
labels[10:190, 10:100] = CLASSES["stadt"]
|
||
out = drop_map_labels(labels)
|
||
assert (out[10:190, 10:100] == CLASSES["stadt"]).all()
|
||
```
|
||
|
||
- [ ] **Step 2: Test laufen lassen, er muss scheitern**
|
||
|
||
Run: `cd tools/extract && uv run pytest tests/test_classify.py -v`
|
||
Expected: FAIL, `ImportError: cannot import name 'drop_map_labels'`
|
||
|
||
- [ ] **Step 3: `drop_map_labels` in `leonida/classify.py` ergänzen**
|
||
|
||
```python
|
||
import numpy as np
|
||
from scipy import ndimage as ndi
|
||
|
||
NATURAL_CLASSES = ("gruen", "sand", "land")
|
||
LABEL_MAX_NATURAL = 0.02 # Anteil natuerlicher Merkmale, ab dem eine Flaeche als echt gilt
|
||
|
||
|
||
def drop_map_labels(labels: np.ndarray) -> np.ndarray:
|
||
"""Beschriftungen der Originalkarte auf Wasser zuruecksetzen.
|
||
|
||
Die Schriftzuege der Karte werden pixelweise als stadt/strasse/gebaeude klassifiziert und
|
||
sind farblich von echten Flaechen nicht zu unterscheiden. Trennbar sind sie erst als
|
||
Region: ein Schriftzug im Wasser bildet eine eigene, vom Festland getrennte Nicht-Wasser-
|
||
Komponente ohne jedes natuerliche Merkmal, waehrend eine echte Insel Gruen oder Sand
|
||
traegt. Die groesste Komponente ist immer das Festland und bleibt unangetastet.
|
||
"""
|
||
solid = labels != CLASSES["wasser"]
|
||
if not solid.any():
|
||
return labels
|
||
components, count = ndi.label(solid, structure=np.ones((3, 3)))
|
||
sizes = ndi.sum(solid, components, range(1, count + 1))
|
||
mainland = int(np.argmax(sizes)) + 1
|
||
natural = np.zeros(labels.shape, dtype=bool)
|
||
for name in NATURAL_CLASSES:
|
||
natural |= labels == CLASSES[name]
|
||
shares = ndi.mean(natural.astype(np.float32), components, range(1, count + 1))
|
||
drop = np.zeros(count + 1, dtype=bool)
|
||
for index in range(1, count + 1):
|
||
drop[index] = index != mainland and shares[index - 1] <= LABEL_MAX_NATURAL
|
||
out = labels.copy()
|
||
out[drop[components]] = CLASSES["wasser"]
|
||
return out
|
||
```
|
||
|
||
- [ ] **Step 4: Test laufen lassen, er muss bestehen**
|
||
|
||
Run: `cd tools/extract && uv run pytest tests/test_classify.py -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: In `run.py` einhängen**
|
||
|
||
In `main()` direkt nach `labels = classify(mosaic)` einfügen:
|
||
|
||
```python
|
||
labels = drop_map_labels(labels)
|
||
```
|
||
|
||
Der Import kommt zu der bestehenden Zeile aus `leonida.classify` dazu. Die Prüfung der
|
||
Klassenanteile steht danach und misst damit die bereinigten Anteile — das ist gewollt.
|
||
|
||
- [ ] **Step 6: Durchlauf und Sichtprüfung**
|
||
|
||
Run: `cd tools/extract && uv run python run.py && uv run python overview.py`
|
||
Expected: 144 Chunks, und in `debug/overview.png` steht kein Schriftzug mehr in der Bucht.
|
||
|
||
Die Sichtprüfung ist hier die eigentliche Abnahme, nicht die Unit-Tests. Sieh dir das Bild an
|
||
und beschreib, was du siehst: ob "VICE CITY (MIAMI)" verschwunden ist, ob die roten Ortsnamen
|
||
weg sind, und vor allem, ob dabei etwas Echtes mitverschwunden ist — kleine Inseln, Molen,
|
||
Hafenanlagen. Eine vollständig gepflasterte kleine Insel ohne Grün wäre der Fall, in dem die
|
||
Regel zu viel nimmt. Wenn du so etwas findest, melde es mit Position und Größe, statt die
|
||
Schwelle zu verschieben.
|
||
|
||
Beschriftungen, die auf dem Festland liegen (etwa "BOHEMIA (DESIGN DISTRICT)"), gehören zur
|
||
Hauptlandmasse und bleiben bestehen. Das ist bewusst so: sie sind klein und stehen zwischen
|
||
echten Gebäuden, statt als 1.600-m-Schriftzug im Wasser. Melde im Bericht, wie störend sie im
|
||
Bild noch wirken.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add tools/extract/leonida/classify.py tools/extract/tests/test_classify.py \
|
||
tools/extract/run.py public/data/vice-a
|
||
git commit -m "fix(extract): Kartenbeschriftung nicht mehr als Geometrie ausgeben
|
||
|
||
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(polygons, {base, top, uvScale}) -> BufferGeometry`, `flatPolygons(polygons, {y, uvScale}) -> BufferGeometry` — je Fläche eine Liste von Ringen, Außenring zuerst
|
||
|
||
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]];
|
||
const PLOT = [SQUARE]; // Fläche ohne Loch: nur der Außenring
|
||
const COURTYARD = [[[0, 0], [30, 0], [30, 30], [0, 30]],
|
||
[[10, 10], [20, 10], [20, 20], [10, 20]]]; // Block mit Innenhof
|
||
|
||
describe("geometry", () => {
|
||
it("extrudiert ein Quadrat zu Wänden und Dach", () => {
|
||
const geo = extrudePolygons([PLOT], { 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([PLOT], { 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("lässt den Innenhof eines Blocks offen", () => {
|
||
// Außenring 30x30, Loch 10x10: 8 Wände à 2 Dreiecke, das Dach ist ein Ring aus
|
||
// 8 Dreiecken. Ohne Lochbehandlung wäre der Hof zugedeckt und das Dach hätte 2.
|
||
const geo = extrudePolygons([COURTYARD], { base: 0, top: 10, uvScale: 4 });
|
||
expect(geo.getIndex().count / 3).toBe(24);
|
||
});
|
||
|
||
it("legt flache Flächen auf die gewünschte Höhe", () => {
|
||
const geo = flatPolygons([PLOT], { 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: [PLOT],
|
||
surfaces: [{ poly: PLOT, kind: "sand" }, { poly: PLOT, kind: "park" }],
|
||
roads: [{ poly: PLOT, bridge: false }, { poly: PLOT, bridge: true }],
|
||
centerlines: [], palms: [],
|
||
buildings: [{ poly: PLOT, 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: PLOT, 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: [PLOT], 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(poly) {
|
||
const [x, z] = poly[0][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(polygons, { y = 0, uvScale = 8 } = {}) {
|
||
const pos = [], idx = [], uv = [];
|
||
for (const raw of polygons) {
|
||
const outer = clockwise(raw[0]);
|
||
const holes = raw.slice(1).map((hole) => clockwise(hole).reverse());
|
||
const base = pos.length / 3;
|
||
for (const ring of [outer, ...holes]) {
|
||
for (const [x, z] of ring) {
|
||
pos.push(x, y, z);
|
||
uv.push(x / uvScale, z / uvScale);
|
||
}
|
||
}
|
||
const toPoints = (ring) => ring.map(([x, z]) => ({ x, y: z }));
|
||
for (const tri of ShapeUtils.triangulateShape(toPoints(outer), holes.map(toPoints))) {
|
||
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(polygons, { base = 0, top = 10, uvScale = 4, tint = null } = {}) {
|
||
const pos = [], idx = [], uv = [], color = [];
|
||
for (const raw of polygons) {
|
||
// Jede Fläche ist eine Liste von Ringen: Außenring zuerst, danach die Löcher.
|
||
// triangulateShape erwartet die Löcher gegenläufig zum Außenring.
|
||
const outer = clockwise(raw[0]);
|
||
const holes = raw.slice(1).map((hole) => clockwise(hole).reverse());
|
||
for (const ring of [outer, ...holes]) {
|
||
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);
|
||
}
|
||
}
|
||
// Deckfläche: erst alle Punkte in derselben Reihenfolge, die triangulateShape indiziert.
|
||
const b = pos.length / 3;
|
||
for (const ring of [outer, ...holes]) {
|
||
for (const [x, z] of ring) {
|
||
pos.push(x, top, z);
|
||
uv.push(x / uvScale, z / uvScale);
|
||
if (tint) color.push(...tint);
|
||
}
|
||
}
|
||
const toPoints = (ring) => ring.map(([x, z]) => ({ x, y: z }));
|
||
for (const tri of ShapeUtils.triangulateShape(toPoints(outer), holes.map(toPoints))) {
|
||
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>"
|
||
```
|