palm_points bekommt die Landflächen und verwirft, was nicht darauf liegt: am echten Mosaik standen 218 von 2.433 Palmen im Wasser. regularize vereinfacht nicht mehr ein zweites Mal und gröber als mask_polygons, was 202 von 2.085 Gebäuden verworfen hatte. Dazu die vier neuen Tests, der Marker am einzelnen Test statt eines suiteweiten Filters, die Entdoppelung der Palmenpunkte und die korrigierte Signatur von assign_heights. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2524 lines
98 KiB
Markdown
2524 lines
98 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": "GTA VI Map Tiles & Landmarks (CC BY 4.0) — https://map.gtadb.org, YANIS v15",
|
||
})
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
```
|
||
|
||
Die Hilfsfunktionen `split_by_chunk_all`, `chunk_box_of` und `in_chunk` sind dünne Hüllen um `split_by_chunk` bzw. `chunk_box` aus `emit.py`; beim Umsetzen dort ergänzen, damit `run.py` keine eigene Geometrie-Logik enthält.
|
||
|
||
Die Datei `data/skyline.json` ist die aus `rolux/gtamaplib-vc` geholte `game/landmarks/landmarks.json`, `data/districts.json` enthält Name und Mittelpunkt je Viertel aus den gtadb-Landmarks. Beide werden in diesem Schritt einmalig heruntergeladen und mit committet.
|
||
|
||
- [ ] **Step 6: Durchlauf und Sichtprüfung**
|
||
|
||
Run: `cd tools/extract && uv run python run.py && ls ../../public/data/vice-a | head`
|
||
Expected: 144 Chunk-Dateien und ein Manifest, zusammen unter 10 MB.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add tools/extract public/data/vice-a
|
||
git commit -m "feat(extract): Chunk-Ausgabe, Manifest und Ablaufsteuerung
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
```
|
||
---
|
||
|
||
### Task 7: Geometrie-Baustein in JavaScript
|
||
|
||
**Files:**
|
||
- Create: `src/world/geometry.js`, `tests/geometry.test.js`
|
||
- Test: `tests/geometry.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Chunk-JSON aus Task 6
|
||
- Produces: `buildChunkGeometry(chunk) -> {ground, sand, park, urban, roads, bridges, buildings}` (je eine `BufferGeometry` oder `null`), `extrudePolygons(rings, {base, top, uvScale}) -> BufferGeometry`, `flatPolygons(rings, {y, uvScale}) -> BufferGeometry`
|
||
|
||
Reines JavaScript, nur `three` als Abhängigkeit, kein Zugriff auf `document` oder `window` — der Baustein muss in Node laufen.
|
||
|
||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||
|
||
`tests/geometry.test.js`:
|
||
|
||
```js
|
||
import { describe, expect, it } from "vitest";
|
||
import { Box3, BufferGeometry } from "three";
|
||
import { buildChunkGeometry, extrudePolygons, flatPolygons } from "../src/world/geometry.js";
|
||
|
||
const SQUARE = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||
|
||
describe("geometry", () => {
|
||
it("extrudiert ein Quadrat zu Wänden und Dach", () => {
|
||
const geo = extrudePolygons([SQUARE], { base: 0, top: 20, uvScale: 4 });
|
||
// 4 Wände à 2 Dreiecke + Dach aus 2 Dreiecken
|
||
expect(geo.getIndex().count / 3).toBe(10);
|
||
const box = new Box3().setFromBufferAttribute(geo.getAttribute("position"));
|
||
expect(box.max.y).toBeCloseTo(20);
|
||
expect(box.min.y).toBeCloseTo(0);
|
||
});
|
||
|
||
it("richtet Dachflächen nach oben aus", () => {
|
||
const geo = extrudePolygons([SQUARE], { base: 0, top: 5, uvScale: 4 });
|
||
geo.computeVertexNormals();
|
||
const n = geo.getAttribute("normal");
|
||
const up = [...Array(n.count).keys()].filter((i) => n.getY(i) > 0.9);
|
||
expect(up.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it("legt flache Flächen auf die gewünschte Höhe", () => {
|
||
const geo = flatPolygons([SQUARE], { y: 0.03, uvScale: 8 });
|
||
const box = new Box3().setFromBufferAttribute(geo.getAttribute("position"));
|
||
expect(box.min.y).toBeCloseTo(0.03);
|
||
expect(box.max.y).toBeCloseTo(0.03);
|
||
});
|
||
|
||
it("baut alle Teile eines Chunks", () => {
|
||
const chunk = {
|
||
chunk: [0, 0], origin: [0, 0],
|
||
land: [SQUARE],
|
||
surfaces: [{ poly: SQUARE, kind: "sand" }, { poly: SQUARE, kind: "park" }],
|
||
roads: [{ poly: SQUARE, bridge: false }, { poly: SQUARE, bridge: true }],
|
||
centerlines: [], palms: [],
|
||
buildings: [{ poly: SQUARE, height: 12, source: "district", speculative: false }],
|
||
};
|
||
const out = buildChunkGeometry(chunk);
|
||
for (const key of ["ground", "sand", "park", "roads", "bridges", "buildings"]) {
|
||
expect(out[key]).toBeInstanceOf(BufferGeometry);
|
||
}
|
||
expect(out.urban).toBeNull();
|
||
});
|
||
|
||
it("gibt Gebäuden eine Farbe je Haus", () => {
|
||
const chunk = { chunk: [0, 0], origin: [0, 0], land: [], surfaces: [], roads: [], centerlines: [], palms: [],
|
||
buildings: [{ poly: SQUARE, height: 9, source: "district", speculative: false }] };
|
||
expect(buildChunkGeometry(chunk).buildings.getAttribute("color")).toBeTruthy();
|
||
});
|
||
|
||
it("hält alle Koordinaten im Chunk", () => {
|
||
const chunk = { chunk: [0, 0], origin: [0, 0], land: [SQUARE], surfaces: [], roads: [], centerlines: [], buildings: [], palms: [] };
|
||
const box = new Box3().setFromBufferAttribute(buildChunkGeometry(chunk).ground.getAttribute("position"));
|
||
expect(box.min.x).toBeGreaterThanOrEqual(-0.01);
|
||
expect(box.max.x).toBeLessThanOrEqual(250.01);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Test laufen lassen, er muss scheitern**
|
||
|
||
Run: `npm test`
|
||
Expected: FAIL, `Failed to resolve import "../src/world/geometry.js"`
|
||
|
||
- [ ] **Step 3: `src/world/geometry.js` schreiben**
|
||
|
||
```js
|
||
import { BufferGeometry, Float32BufferAttribute, ShapeUtils } from "three";
|
||
|
||
const GROUND_THICKNESS = 1.0;
|
||
const SURFACE_OFFSET = 0.02; // Sand, Park und Stadtfläche liegen knapp über dem Boden
|
||
const ROAD_OFFSET = 0.03; // Straßen wiederum knapp über den Flächen
|
||
const BRIDGE_THICKNESS = 0.6;
|
||
export const SURFACE_KINDS = ["sand", "park", "urban"];
|
||
|
||
/** Pastellton je Gebäude, reproduzierbar aus seiner Lage. */
|
||
function buildingTint(ring) {
|
||
const [x, z] = ring[0];
|
||
const h = Math.abs(Math.sin(x * 12.9898 + z * 78.233) * 43758.5453) % 1;
|
||
return [0.82 + 0.18 * h, 0.80 + 0.20 * ((h * 3) % 1), 0.78 + 0.22 * ((h * 7) % 1)];
|
||
}
|
||
|
||
const signedArea = (ring) => ring.reduce((sum, [x0, z0], i) => {
|
||
const [x1, z1] = ring[(i + 1) % ring.length];
|
||
return sum + x0 * z1 - x1 * z0;
|
||
}, 0) / 2;
|
||
|
||
/** Ringe im Uhrzeigersinn, damit Wände nach außen zeigen. */
|
||
const clockwise = (ring) => (signedArea(ring) > 0 ? [...ring].reverse() : ring);
|
||
|
||
function toGeometry(pos, idx, uv, color) {
|
||
const geo = new BufferGeometry();
|
||
geo.setAttribute("position", new Float32BufferAttribute(pos, 3));
|
||
geo.setAttribute("uv", new Float32BufferAttribute(uv, 2));
|
||
if (color) geo.setAttribute("color", new Float32BufferAttribute(color, 3));
|
||
geo.setIndex(idx);
|
||
geo.computeVertexNormals();
|
||
return geo;
|
||
}
|
||
|
||
export function flatPolygons(rings, { y = 0, uvScale = 8 } = {}) {
|
||
const pos = [], idx = [], uv = [];
|
||
for (const raw of rings) {
|
||
const ring = clockwise(raw);
|
||
const base = pos.length / 3;
|
||
for (const [x, z] of ring) {
|
||
pos.push(x, y, z);
|
||
uv.push(x / uvScale, z / uvScale);
|
||
}
|
||
for (const tri of ShapeUtils.triangulateShape(ring.map(([x, z]) => ({ x, y: z })), [])) {
|
||
idx.push(base + tri[0], base + tri[2], base + tri[1]); // nach oben gedreht
|
||
}
|
||
}
|
||
return pos.length ? toGeometry(pos, idx, uv) : null;
|
||
}
|
||
|
||
export function extrudePolygons(rings, { base = 0, top = 10, uvScale = 4, tint = null } = {}) {
|
||
const pos = [], idx = [], uv = [], color = [];
|
||
for (const raw of rings) {
|
||
const ring = clockwise(raw);
|
||
const n = ring.length;
|
||
for (let i = 0; i < n; i++) {
|
||
const [x0, z0] = ring[i];
|
||
const [x1, z1] = ring[(i + 1) % n];
|
||
const len = Math.hypot(x1 - x0, z1 - z0);
|
||
const b = pos.length / 3;
|
||
pos.push(x0, base, z0, x1, base, z1, x1, top, z1, x0, top, z0);
|
||
uv.push(0, 0, len / uvScale, 0, len / uvScale, (top - base) / uvScale, 0, (top - base) / uvScale);
|
||
if (tint) for (let k = 0; k < 4; k++) color.push(...tint);
|
||
idx.push(b, b + 1, b + 2, b, b + 2, b + 3);
|
||
}
|
||
const b = pos.length / 3;
|
||
for (const [x, z] of ring) {
|
||
pos.push(x, top, z);
|
||
uv.push(x / uvScale, z / uvScale);
|
||
if (tint) color.push(...tint);
|
||
}
|
||
for (const tri of ShapeUtils.triangulateShape(ring.map(([x, z]) => ({ x, y: z })), [])) {
|
||
idx.push(b + tri[0], b + tri[2], b + tri[1]);
|
||
}
|
||
}
|
||
return pos.length ? toGeometry(pos, idx, uv, tint ? color : null) : null;
|
||
}
|
||
|
||
export function buildChunkGeometry(chunk) {
|
||
const roads = chunk.roads.filter((r) => !r.bridge).map((r) => r.poly);
|
||
const bridges = chunk.roads.filter((r) => r.bridge).map((r) => r.poly);
|
||
const out = {
|
||
ground: extrudePolygons(chunk.land, { base: -GROUND_THICKNESS, top: 0, uvScale: 8 }),
|
||
roads: flatPolygons(roads, { y: ROAD_OFFSET, uvScale: 8 }),
|
||
bridges: extrudePolygons(bridges, { base: -BRIDGE_THICKNESS, top: 0, uvScale: 8 }),
|
||
buildings: buildBuildings(chunk.buildings),
|
||
};
|
||
for (const kind of SURFACE_KINDS) {
|
||
const rings = (chunk.surfaces ?? []).filter((s) => s.kind === kind).map((s) => s.poly);
|
||
out[kind] = flatPolygons(rings, { y: SURFACE_OFFSET, uvScale: 8 });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function buildBuildings(buildings) {
|
||
const parts = buildings.map((b) =>
|
||
extrudePolygons([b.poly], { base: 0, top: b.height, uvScale: 4, tint: buildingTint(b.poly) }));
|
||
return parts.filter(Boolean).reduce((acc, geo) => (acc ? mergeInto(acc, geo) : geo), null);
|
||
}
|
||
|
||
function mergeInto(a, b) {
|
||
const offset = a.getAttribute("position").count;
|
||
const pos = [...a.getAttribute("position").array, ...b.getAttribute("position").array];
|
||
const uv = [...a.getAttribute("uv").array, ...b.getAttribute("uv").array];
|
||
const ca = a.getAttribute("color"), cb = b.getAttribute("color");
|
||
const color = ca && cb ? [...ca.array, ...cb.array] : null;
|
||
const idx = [...a.getIndex().array, ...[...b.getIndex().array].map((i) => i + offset)];
|
||
return toGeometry(pos, idx, uv, color);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Test laufen lassen, er muss bestehen**
|
||
|
||
Run: `npm test`
|
||
Expected: PASS, 5 Tests
|
||
|
||
- [ ] **Step 5: Alle 144 Chunks in Node durchrechnen**
|
||
|
||
`tools/checkChunks.mjs`:
|
||
|
||
```js
|
||
import { readdirSync, readFileSync } from "node:fs";
|
||
import { buildChunkGeometry } from "../src/world/geometry.js";
|
||
|
||
const dir = "public/data/vice-a";
|
||
let tris = 0;
|
||
for (const file of readdirSync(dir).filter((f) => f.startsWith("chunk_"))) {
|
||
const chunk = JSON.parse(readFileSync(`${dir}/${file}`, "utf8"));
|
||
for (const geo of Object.values(buildChunkGeometry(chunk))) {
|
||
if (!geo) continue;
|
||
const pos = geo.getAttribute("position").array;
|
||
if (pos.some((v) => !Number.isFinite(v))) throw new Error(`NaN in ${file}`);
|
||
tris += geo.getIndex().count / 3;
|
||
}
|
||
}
|
||
console.log("Dreiecke gesamt:", tris);
|
||
if (tris > 1_500_000) throw new Error("Budget überschritten");
|
||
```
|
||
|
||
Run: `node tools/checkChunks.mjs`
|
||
Expected: eine Zahl deutlich unter 1.500.000, kein Fehler.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/world/geometry.js tests/geometry.test.js tools/checkChunks.mjs
|
||
git commit -m "feat(world): Geometrie-Baustein für Land, Straßen, Brücken und Gebäude
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Streaming, Kollision, Respawn
|
||
|
||
**Files:**
|
||
- Create: `src/world/streaming.js`, `tests/streaming.test.js`
|
||
- Modify: `src/main.js`
|
||
- Test: `tests/streaming.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `buildChunkGeometry` aus Task 7, `chunkForWorld`, `chunkOrigin` aus Task 1
|
||
- Produces: `class ChunkStreamer { constructor({ scene, player, materials, baseUrl }); update(playerPosition); isReadyAt(position) -> boolean }`, `wantedChunks(position, {load, unload, active}) -> {add: [], remove: []}`
|
||
|
||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||
|
||
`tests/streaming.test.js`:
|
||
|
||
```js
|
||
import { describe, expect, it } from "vitest";
|
||
import { wantedChunks } from "../src/world/streaming.js";
|
||
|
||
describe("wantedChunks", () => {
|
||
it("lädt die Umgebung des Spielers", () => {
|
||
const { add } = wantedChunks({ x: 0, z: -1000 }, { load: 400, unload: 600, active: new Set() });
|
||
expect(add.length).toBeGreaterThan(4);
|
||
expect(add.length).toBeLessThan(30);
|
||
});
|
||
|
||
it("entlädt erst jenseits des größeren Radius", () => {
|
||
const active = new Set(["5_5"]);
|
||
const near = wantedChunks({ x: 1375, z: -1375 }, { load: 400, unload: 600, active });
|
||
expect(near.remove).not.toContain("5_5");
|
||
const far = wantedChunks({ x: -900, z: -2400 }, { load: 400, unload: 600, active });
|
||
expect(far.remove).toContain("5_5");
|
||
});
|
||
|
||
it("meldet nichts doppelt an", () => {
|
||
const active = new Set(wantedChunks({ x: 0, z: -1000 }, { load: 400, unload: 600, active: new Set() }).add);
|
||
expect(wantedChunks({ x: 0, z: -1000 }, { load: 400, unload: 600, active }).add).toHaveLength(0);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Test laufen lassen, er muss scheitern**
|
||
|
||
Run: `npm test`
|
||
Expected: FAIL, `Failed to resolve import "../src/world/streaming.js"`
|
||
|
||
- [ ] **Step 3: `src/world/streaming.js` schreiben**
|
||
|
||
```js
|
||
import { Group, Mesh } from "three";
|
||
import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE } from "./coords.js";
|
||
import { buildChunkGeometry } from "./geometry.js";
|
||
|
||
const key = (i, j) => `${i}_${j}`;
|
||
const DECALS = new Set(["roads", "sand", "park", "urban"]);
|
||
|
||
/** Mittelpunkt eines Chunks in three-Koordinaten. */
|
||
function chunkCenter(i, j) {
|
||
return { x: SLICE.x0 + (i + 0.5) * CHUNK_SIZE, z: -(SLICE.y1 - (j + 0.5) * CHUNK_SIZE) };
|
||
}
|
||
|
||
export function wantedChunks(position, { load, unload, active }) {
|
||
const add = [], remove = [];
|
||
for (let j = 0; j < CHUNKS_PER_SIDE; j++) {
|
||
for (let i = 0; i < CHUNKS_PER_SIDE; i++) {
|
||
const c = chunkCenter(i, j);
|
||
const d = Math.hypot(c.x - position.x, c.z - position.z);
|
||
const k = key(i, j);
|
||
if (d <= load && !active.has(k)) add.push(k);
|
||
if (d > unload && active.has(k)) remove.push(k);
|
||
}
|
||
}
|
||
add.sort((a, b) => dist(a, position) - dist(b, position));
|
||
return { add, remove };
|
||
}
|
||
|
||
function dist(k, position) {
|
||
const [i, j] = k.split("_").map(Number);
|
||
const c = chunkCenter(i, j);
|
||
return Math.hypot(c.x - position.x, c.z - position.z);
|
||
}
|
||
|
||
export class ChunkStreamer {
|
||
constructor({ scene, player, materials, props, baseUrl = "./data/vice-a" }) {
|
||
Object.assign(this, { scene, player, materials, props, baseUrl });
|
||
this.active = new Map(); // key -> { group, handle }
|
||
this.pending = new Set();
|
||
this.queue = [];
|
||
}
|
||
|
||
update(position) {
|
||
const { add, remove } = wantedChunks(position, {
|
||
load: 400, unload: 600, active: new Set([...this.active.keys(), ...this.pending]),
|
||
});
|
||
for (const k of add) if (!this.queue.includes(k)) this.queue.push(k);
|
||
// Höchstens ein Aufbau je Frame.
|
||
const next = this.queue.shift();
|
||
if (next) void this.#load(next);
|
||
// Höchstens vier Abmeldungen je Frame.
|
||
for (const k of remove.slice(0, 4)) this.#unload(k);
|
||
}
|
||
|
||
async #load(k) {
|
||
if (this.active.has(k) || this.pending.has(k)) return;
|
||
this.pending.add(k);
|
||
try {
|
||
const chunk = await this.#fetchChunk(k);
|
||
const [i, j] = k.split("_").map(Number);
|
||
const group = new Group();
|
||
group.position.set(chunk.origin[0], 0, chunk.origin[1]);
|
||
const geos = buildChunkGeometry(chunk);
|
||
const collide = new Group();
|
||
for (const [name, geo] of Object.entries(geos)) {
|
||
if (!geo) continue;
|
||
const mesh = new Mesh(geo, this.materials[name]);
|
||
mesh.castShadow = name === "buildings";
|
||
mesh.receiveShadow = true;
|
||
// Straßen und Bodenflächen sind reine Auflagen; Kollision trägt die Landplatte.
|
||
(DECALS.has(name) ? group : collide).add(mesh);
|
||
}
|
||
group.add(collide);
|
||
this.scene.add(group);
|
||
group.updateMatrixWorld(true);
|
||
const handle = this.player.addCollider({
|
||
motion: "static", useWorker: true, shape: { kind: "mesh", source: collide },
|
||
});
|
||
this.active.set(k, { group, handle, chunk });
|
||
} catch (err) {
|
||
console.warn(`Chunk ${k} konnte nicht geladen werden:`, err);
|
||
} finally {
|
||
this.pending.delete(k);
|
||
}
|
||
}
|
||
|
||
async #fetchChunk(k) {
|
||
const [i, j] = k.split("_").map(Number);
|
||
for (let attempt = 0; attempt < 3; attempt++) {
|
||
try {
|
||
const res = await fetch(`${this.baseUrl}/chunk_${i}_${j}.json`);
|
||
if (res.ok) return await res.json();
|
||
} catch { /* nächster Versuch */ }
|
||
}
|
||
throw new Error("dreimal fehlgeschlagen");
|
||
}
|
||
|
||
#unload(k) {
|
||
const entry = this.active.get(k);
|
||
if (!entry) return;
|
||
this.player.removeCollider(entry.handle);
|
||
this.scene.remove(entry.group);
|
||
entry.group.traverse((o) => o.geometry?.dispose());
|
||
this.active.delete(k);
|
||
}
|
||
|
||
/** Ist der Collider unter dieser Position einsatzbereit? */
|
||
isReadyAt(position) {
|
||
const i = Math.floor((position.x - SLICE.x0) / CHUNK_SIZE);
|
||
const j = Math.floor((SLICE.y1 + position.z) / CHUNK_SIZE);
|
||
const entry = this.active.get(key(i, j));
|
||
return Boolean(entry && this.player.collisionWorld.get(entry.handle.id)?.ready);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Test laufen lassen, er muss bestehen**
|
||
|
||
Run: `npm test`
|
||
Expected: PASS, alle Tests
|
||
|
||
- [ ] **Step 5: In `src/main.js` einhängen, mit Startschweben und Auffangnetz**
|
||
|
||
```js
|
||
const SPAWN = { x: 1699, y: 3, z: -743 };
|
||
const streamer = new ChunkStreamer({ scene, player, materials });
|
||
player.setSkipCapsuleCollision(true);
|
||
let settled = false;
|
||
const startedAt = performance.now();
|
||
let lastSafe = { ...SPAWN };
|
||
|
||
function tick(delta) {
|
||
streamer.update(player.playerCapsule.position);
|
||
if (!settled && (streamer.isReadyAt(player.playerCapsule.position) || performance.now() - startedAt > 15000)) {
|
||
settled = true;
|
||
player.setSkipCapsuleCollision(false);
|
||
player.reset(new Vector3(SPAWN.x, SPAWN.y, SPAWN.z));
|
||
}
|
||
const p = player.playerCapsule.position;
|
||
if (p.y < -30) player.reset(new Vector3(lastSafe.x, lastSafe.y, lastSafe.z));
|
||
else if (p.y > -1) lastSafe = { x: p.x, y: p.y + 1, z: p.z };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Im Browser prüfen**
|
||
|
||
Run: `npm run dev`
|
||
Expected: Die Figur landet auf Vice Beach, Chunks erscheinen beim Laufen, die Konsole bleibt leer. Über das Panel aus Task 10 die Chunk-Grenzen einblenden und prüfen, dass kein Loch entsteht.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/world/streaming.js tests/streaming.test.js src/main.js
|
||
git commit -m "feat(world): Chunk-Streaming mit Collider-Verwaltung und Auffangnetz
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
```
|
||
---
|
||
|
||
### Task 9: Materialien, Wasser, Himmel
|
||
|
||
**Files:**
|
||
- Create: `src/world/materials.js`, `tools/assets/textures.mjs`, `tests/materials.test.js`
|
||
- Modify: `src/main.js`
|
||
- Test: `tests/materials.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nichts aus früheren Tasks
|
||
- Produces: `MATERIAL_KEYS = ["ground", "sand", "park", "urban", "roads", "bridges", "buildings"]`, `createMaterials() -> Promise<{...MATERIAL_KEYS, water, seafloor}>`, `waterNormalData(size) -> Uint8Array`, `TEXTURES` (dict Materialname → Poly-Haven-Kennung)
|
||
|
||
Die Schlüssel müssen exakt denen entsprechen, die `buildChunkGeometry` aus Task 7 liefert. `ChunkStreamer` greift mit genau diesen Namen zu.
|
||
|
||
- [ ] **Step 1: Texturen holen**
|
||
|
||
`tools/assets/textures.mjs`:
|
||
|
||
```js
|
||
// Lädt die benötigten Poly-Haven-Texturen in 1k nach public/textures/.
|
||
import { mkdir, writeFile } from "node:fs/promises";
|
||
|
||
const UA = { "User-Agent": "leonida-assets/0.1 (local hobby project)" };
|
||
const SLUGS = ["asphalt_02", "concrete_pavement_03", "white_stucco", "sand_01", "leafy_grass", "grass_path_2", "palm_bark"];
|
||
const MAPS = ["Diffuse", "nor_gl", "arm"];
|
||
|
||
await mkdir("public/textures", { recursive: true });
|
||
for (const slug of SLUGS) {
|
||
const files = await (await fetch(`https://api.polyhaven.com/files/${slug}`, { headers: UA })).json();
|
||
for (const map of MAPS) {
|
||
const entry = files[map]?.["1k"]?.jpg;
|
||
if (!entry) { console.warn(`${slug}: ${map} fehlt`); continue; }
|
||
const buf = Buffer.from(await (await fetch(entry.url, { headers: UA })).arrayBuffer());
|
||
await writeFile(`public/textures/${slug}_${map}.jpg`, buf);
|
||
console.log(`${slug}_${map}.jpg ${(buf.length / 1e6).toFixed(1)} MB`);
|
||
}
|
||
}
|
||
```
|
||
|
||
Run: `node tools/assets/textures.mjs`
|
||
Expected: 21 Dateien, zusammen rund 18 MB.
|
||
|
||
- [ ] **Step 2: Den fehlschlagenden Test schreiben**
|
||
|
||
`tests/materials.test.js`:
|
||
|
||
```js
|
||
import { describe, expect, it } from "vitest";
|
||
import { MATERIAL_KEYS, TEXTURES, waterNormalData } from "../src/world/materials.js";
|
||
import { buildChunkGeometry } from "../src/world/geometry.js";
|
||
|
||
describe("materials", () => {
|
||
it("deckt genau die Geometrie-Teile ab", () => {
|
||
const chunk = { chunk: [0, 0], origin: [0, 0], land: [], roads: [], centerlines: [], buildings: [], palms: [] };
|
||
expect(Object.keys(buildChunkGeometry(chunk)).sort()).toEqual([...MATERIAL_KEYS].sort());
|
||
});
|
||
|
||
it("nennt für jedes Material eine Textur", () => {
|
||
for (const key of MATERIAL_KEYS) expect(TEXTURES[key]).toBeTruthy();
|
||
});
|
||
|
||
it("deckt Strand und Park mit eigenen Materialien ab", () => {
|
||
expect(MATERIAL_KEYS).toContain("sand");
|
||
expect(MATERIAL_KEYS).toContain("park");
|
||
});
|
||
|
||
it("erzeugt eine Wellen-Normalmap mit neutraler Grundfarbe", () => {
|
||
const data = waterNormalData(32);
|
||
expect(data.length).toBe(32 * 32 * 4);
|
||
// Normalen zeigen im Mittel nach oben: Blaukanal nahe 255.
|
||
const blue = [...data].filter((_, i) => i % 4 === 2);
|
||
expect(blue.reduce((a, b) => a + b, 0) / blue.length).toBeGreaterThan(200);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Test laufen lassen, er muss scheitern**
|
||
|
||
Run: `npm test`
|
||
Expected: FAIL, `Failed to resolve import "../src/world/materials.js"`
|
||
|
||
- [ ] **Step 4: `src/world/materials.js` schreiben**
|
||
|
||
```js
|
||
import { DataTexture, MeshStandardMaterial, RepeatWrapping, RGBAFormat, SRGBColorSpace, TextureLoader } from "three";
|
||
|
||
export const MATERIAL_KEYS = ["ground", "sand", "park", "urban", "roads", "bridges", "buildings"];
|
||
export const TEXTURES = {
|
||
ground: "grass_path_2", // unbebautes Land
|
||
sand: "sand_01", // Strand
|
||
park: "leafy_grass", // Grünflächen
|
||
urban: "concrete_pavement_03", // versiegelte Stadtfläche
|
||
roads: "asphalt_02",
|
||
bridges: "concrete_pavement_03",
|
||
buildings: "white_stucco",
|
||
};
|
||
const FALLBACK_COLORS = {
|
||
ground: 0xa8b878, sand: 0xe8d888, park: 0x8bc34a, urban: 0xd8d8d8,
|
||
roads: 0x505050, bridges: 0xb0b0b0, buildings: 0xe8e4dc,
|
||
};
|
||
|
||
/** Kachelbare Wellen-Normalmap, weil Poly Haven keine Wassertextur hat. */
|
||
export function waterNormalData(size = 128) {
|
||
const data = new Uint8Array(size * size * 4);
|
||
for (let y = 0; y < size; y++) {
|
||
for (let x = 0; x < size; x++) {
|
||
const i = (y * size + x) * 4;
|
||
const u = (x / size) * Math.PI * 2;
|
||
const v = (y / size) * Math.PI * 2;
|
||
const dx = Math.sin(u * 3) * 0.5 + Math.sin(v * 2 + 1.3) * 0.3;
|
||
const dz = Math.cos(v * 3) * 0.5 + Math.cos(u * 2 + 0.7) * 0.3;
|
||
data[i] = 128 + dx * 40;
|
||
data[i + 1] = 128 + dz * 40;
|
||
data[i + 2] = 245;
|
||
data[i + 3] = 255;
|
||
}
|
||
}
|
||
return data;
|
||
}
|
||
|
||
async function loadSet(loader, slug, fallback) {
|
||
let map, normalMap, arm;
|
||
try {
|
||
[map, normalMap, arm] = await Promise.all(
|
||
["Diffuse", "nor_gl", "arm"].map((m) => loader.loadAsync(`./textures/${slug}_${m}.jpg`)),
|
||
);
|
||
} catch (err) {
|
||
console.warn(`Textur ${slug} fehlt, einfarbiger Ersatz:`, err);
|
||
return new MeshStandardMaterial({ color: fallback, roughness: 0.9 });
|
||
}
|
||
for (const t of [map, normalMap, arm]) {
|
||
t.wrapS = t.wrapT = RepeatWrapping;
|
||
}
|
||
map.colorSpace = SRGBColorSpace;
|
||
// arm packt Ambient Occlusion, Rauheit und Metallgrad in R, G und B.
|
||
return new MeshStandardMaterial({ map, normalMap, aoMap: arm, roughnessMap: arm, metalnessMap: arm, metalness: 0 });
|
||
}
|
||
|
||
export async function createMaterials() {
|
||
const loader = new TextureLoader();
|
||
const entries = await Promise.all(
|
||
MATERIAL_KEYS.map(async (key) => [key, await loadSet(loader, TEXTURES[key], FALLBACK_COLORS[key])]),
|
||
);
|
||
const materials = Object.fromEntries(entries);
|
||
materials.buildings.vertexColors = true; // pastellige Tönung je Gebäude
|
||
|
||
const normal = new DataTexture(waterNormalData(128), 128, 128, RGBAFormat);
|
||
normal.wrapS = normal.wrapT = RepeatWrapping;
|
||
normal.repeat.set(60, 60);
|
||
normal.needsUpdate = true;
|
||
materials.water = new MeshStandardMaterial({
|
||
color: 0x2c67a4, normalMap: normal, roughness: 0.15, metalness: 0.2, transparent: true, opacity: 0.92,
|
||
});
|
||
materials.seafloor = new MeshStandardMaterial({ color: 0x1d4468, roughness: 1 });
|
||
return materials;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Test laufen lassen, er muss bestehen**
|
||
|
||
Run: `npm test`
|
||
Expected: PASS, 3 Tests
|
||
|
||
- [ ] **Step 6: Himmel, Nebel, Schatten und Wasserfläche in `src/main.js`**
|
||
|
||
```js
|
||
import { BoxGeometry, Fog, Mesh, PlaneGeometry } from "three";
|
||
import { Sky } from "three/examples/jsm/objects/Sky.js";
|
||
|
||
const materials = await createMaterials();
|
||
|
||
const sky = new Sky();
|
||
sky.scale.setScalar(45000);
|
||
scene.add(sky);
|
||
sky.material.uniforms.turbidity.value = 8;
|
||
sky.material.uniforms.rayleigh.value = 1.6;
|
||
sky.material.uniforms.sunPosition.value.copy(sun.position.clone().normalize());
|
||
scene.fog = new Fog(0xbcd7ea, 500, 2200);
|
||
|
||
const water = new Mesh(new PlaneGeometry(4000, 4000), materials.water);
|
||
water.rotation.x = -Math.PI / 2;
|
||
water.position.set(500, -0.6, -1000);
|
||
scene.add(water);
|
||
|
||
// Meeresboden: man watet hinein, statt endlos zu fallen. Einmalig, nicht je Chunk.
|
||
const seafloor = new Mesh(new BoxGeometry(4000, 1, 4000), materials.seafloor);
|
||
seafloor.position.set(500, -3.0, -1000); // Oberkante bei y = -2,5
|
||
scene.add(seafloor);
|
||
seafloor.updateMatrixWorld(true);
|
||
player.addCollider({ motion: "static", shape: { kind: "mesh", source: seafloor } });
|
||
|
||
// Schattenkamera folgt dem Spieler, statt eine feste Box zu benutzen.
|
||
function updateShadow(target) {
|
||
sun.position.set(target.x + 120, 240, target.z + 80);
|
||
sun.target.position.copy(target);
|
||
sun.target.updateMatrixWorld();
|
||
sun.shadow.camera.left = -150; sun.shadow.camera.right = 150;
|
||
sun.shadow.camera.top = 150; sun.shadow.camera.bottom = -150;
|
||
sun.shadow.camera.far = 600;
|
||
sun.shadow.camera.updateProjectionMatrix();
|
||
}
|
||
```
|
||
|
||
`updateShadow(player.playerCapsule.position)` und `materials.water.normalMap.offset.x += delta * 0.01` in die Schleife hängen.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/world/materials.js tools/assets/textures.mjs tests/materials.test.js src/main.js public/textures
|
||
git commit -m "feat(world): Poly-Haven-Materialien, Wasser, Himmel und mitlaufende Schatten
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Palmen und Requisiten
|
||
|
||
**Files:**
|
||
- Create: `src/world/props.js`, `tools/assets/props.mjs`, `tools/assets/palm.sh`, `tests/props.test.js`
|
||
- Modify: `src/world/streaming.js`
|
||
- Test: `tests/props.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `chunk.palms`, `chunk.centerlines` aus Task 6
|
||
- Produces: `lampPoints(centerlines, spacing) -> [{x, z, angle}]`, `palmTransforms(points, seed) -> [{x, z, scale, rotation}]`, `class PropLayer { async load(); build(chunk) -> {visual: Group, colliders: Mesh[]} }`
|
||
|
||
- [ ] **Step 1: Palme aufbereiten**
|
||
|
||
`tools/assets/palm.sh`:
|
||
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
# Nobiax-Palme (CC0) holen und die TGA-Texturen nach PNG wandeln.
|
||
set -e
|
||
mkdir -p public/models/palm && cd public/models/palm
|
||
curl -sL -o palm.7z "https://opengameart.org/sites/default/files/palm_tree_v2.7z"
|
||
tar -xf palm.7z
|
||
sips -s format png diffus.tga --out diffuse.png > /dev/null
|
||
sips -s format png normal.tga --out normal.png > /dev/null
|
||
rm -f palm.7z diffus.tga normal.tga specular.tga palm_tree.FBX preview.jpg
|
||
echo "Palme bereit: $(ls)"
|
||
```
|
||
|
||
Run: `bash tools/assets/palm.sh`
|
||
Expected: `palm_tree.obj`, `diffuse.png`, `normal.png`, `readme.txt`.
|
||
|
||
- [ ] **Step 2: Requisiten holen und vereinfachen**
|
||
|
||
`tools/assets/props.mjs` lädt die fünf Poly-Haven-Modelle in 1k (gleiches Muster wie `textures.mjs`, aber über `files[slug].gltf["1k"].gltf` samt `include`-Dateien) nach `public/models/props/raw/` und ruft danach je Modell:
|
||
|
||
```bash
|
||
npx --yes @gltf-transform/cli@4.5.0 optimize \
|
||
public/models/props/raw/<slug>/<slug>_1k.gltf public/models/props/<slug>.glb \
|
||
--texture-size 512 --simplify-error 0.01 --compress meshopt
|
||
```
|
||
|
||
Run: `node tools/assets/props.mjs`
|
||
Expected: fünf `.glb` unter je 1 MB. `street_lamp_02` sollte von 20.338 auf unter 4.000 Dreiecke fallen, `fire_hydrant` von 86.316 auf unter 5.000.
|
||
|
||
- [ ] **Step 3: Den fehlschlagenden Test schreiben**
|
||
|
||
`tests/props.test.js`:
|
||
|
||
```js
|
||
import { describe, expect, it } from "vitest";
|
||
import { lampPoints, palmTransforms } from "../src/world/props.js";
|
||
|
||
describe("props", () => {
|
||
it("setzt Laternen im gewünschten Abstand entlang der Linie", () => {
|
||
const pts = lampPoints([[[0, 0], [100, 0]]], 35);
|
||
expect(pts.length).toBe(3); // 0, 35, 70 Meter
|
||
expect(pts[1].x).toBeCloseTo(35);
|
||
});
|
||
|
||
it("ignoriert zu kurze Linien", () => {
|
||
expect(lampPoints([[[0, 0], [10, 0]]], 35)).toHaveLength(1);
|
||
});
|
||
|
||
it("skaliert Palmen reproduzierbar zwischen 10 und 18 Metern", () => {
|
||
const a = palmTransforms([[5, 5], [9, 9]], 7);
|
||
const b = palmTransforms([[5, 5], [9, 9]], 7);
|
||
expect(a).toEqual(b);
|
||
for (const t of a) {
|
||
expect(t.height).toBeGreaterThanOrEqual(10);
|
||
expect(t.height).toBeLessThanOrEqual(18);
|
||
}
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Test laufen lassen, er muss scheitern**
|
||
|
||
Run: `npm test`
|
||
Expected: FAIL, `Failed to resolve import "../src/world/props.js"`
|
||
|
||
- [ ] **Step 5: `src/world/props.js` schreiben**
|
||
|
||
```js
|
||
import { CylinderGeometry, Group, InstancedMesh, Matrix4, Mesh, MeshStandardMaterial,
|
||
Object3D, RepeatWrapping, SRGBColorSpace, TextureLoader } from "three";
|
||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
|
||
|
||
const PALM_MODEL_HEIGHT = 439.74; // Einheiten im OBJ, gemessen
|
||
const TRUNK_RADIUS = 0.22;
|
||
|
||
/** Kleiner, wiederholbarer Zufall aus einem Schlüssel. */
|
||
function hashUnit(seed, key) {
|
||
let h = 2166136261 ^ seed;
|
||
for (const ch of key) { h = Math.imul(h ^ ch.charCodeAt(0), 16777619); }
|
||
return ((h >>> 0) % 100000) / 100000;
|
||
}
|
||
|
||
export function lampPoints(lines, spacing = 35) {
|
||
const out = [];
|
||
for (const line of lines) {
|
||
let travelled = 0, next = 0;
|
||
for (let i = 0; i < line.length - 1; i++) {
|
||
const [x0, z0] = line[i], [x1, z1] = line[i + 1];
|
||
const len = Math.hypot(x1 - x0, z1 - z0);
|
||
while (next <= travelled + len) {
|
||
const t = (next - travelled) / (len || 1);
|
||
out.push({ x: x0 + (x1 - x0) * t, z: z0 + (z1 - z0) * t, angle: Math.atan2(z1 - z0, x1 - x0) });
|
||
next += spacing;
|
||
}
|
||
travelled += len;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function palmTransforms(points, seed = 1) {
|
||
return points.map(([x, z]) => {
|
||
const key = `${x.toFixed(1)},${z.toFixed(1)}`;
|
||
return {
|
||
x, z,
|
||
height: 10 + hashUnit(seed, key) * 8,
|
||
rotation: hashUnit(seed, `${key}:r`) * Math.PI * 2,
|
||
};
|
||
});
|
||
}
|
||
|
||
export class PropLayer {
|
||
async load() {
|
||
const texLoader = new TextureLoader();
|
||
const [obj, diffuse, normal] = await Promise.all([
|
||
new OBJLoader().loadAsync("./models/palm/palm_tree.obj"),
|
||
texLoader.loadAsync("./models/palm/diffuse.png"),
|
||
texLoader.loadAsync("./models/palm/normal.png"),
|
||
]);
|
||
diffuse.colorSpace = SRGBColorSpace;
|
||
for (const t of [diffuse, normal]) t.wrapS = t.wrapT = RepeatWrapping;
|
||
this.palmGeometry = null;
|
||
obj.traverse((o) => { if (o.isMesh && !this.palmGeometry) this.palmGeometry = o.geometry; });
|
||
this.palmMaterial = new MeshStandardMaterial({
|
||
map: diffuse, normalMap: normal, alphaTest: 0.5, side: 2, roughness: 0.9,
|
||
});
|
||
const gltfLoader = new GLTFLoader();
|
||
this.lamp = (await gltfLoader.loadAsync("./models/props/street_lamp_02.glb")).scene;
|
||
this.trunkGeometry = new CylinderGeometry(TRUNK_RADIUS, TRUNK_RADIUS * 1.2, 6, 6);
|
||
}
|
||
|
||
/** Sichtbare Instanzen plus einfache Stammzylinder für den Chunk-Collider. */
|
||
build(chunk) {
|
||
const visual = new Group();
|
||
const colliders = [];
|
||
const palms = palmTransforms(chunk.palms ?? [], 20260912);
|
||
if (palms.length) {
|
||
const mesh = new InstancedMesh(this.palmGeometry, this.palmMaterial, palms.length);
|
||
mesh.castShadow = true;
|
||
const dummy = new Object3D();
|
||
palms.forEach((p, i) => {
|
||
const s = p.height / PALM_MODEL_HEIGHT;
|
||
dummy.position.set(p.x, 0, p.z);
|
||
dummy.rotation.set(0, p.rotation, 0);
|
||
dummy.scale.setScalar(s);
|
||
dummy.updateMatrix();
|
||
mesh.setMatrixAt(i, dummy.matrix);
|
||
const trunk = new Mesh(this.trunkGeometry);
|
||
trunk.position.set(p.x, 3, p.z);
|
||
colliders.push(trunk);
|
||
});
|
||
mesh.userData.excludeFromCollider = true;
|
||
visual.add(mesh);
|
||
}
|
||
return { visual, colliders };
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Test laufen lassen, er muss bestehen**
|
||
|
||
Run: `npm test`
|
||
Expected: PASS, 3 Tests
|
||
|
||
- [ ] **Step 7: In `ChunkStreamer` einhängen**
|
||
|
||
In `#load`, direkt vor `this.player.addCollider(...)`:
|
||
|
||
```js
|
||
const props = this.props.build(chunk);
|
||
group.add(props.visual);
|
||
for (const trunk of props.colliders) collide.add(trunk); // wandern in dieselbe BVH
|
||
```
|
||
|
||
Der `PropLayer` kommt als `props` in den `ChunkStreamer`-Konstruktor (siehe Task 8) und wird
|
||
einmalig vor dem ersten Frame mit `await props.load()` bereitgestellt.
|
||
|
||
- [ ] **Step 8: Im Browser prüfen**
|
||
|
||
Expected: Palmen stehen an der Promenade und in Parks, sie lassen sich nicht durchlaufen, die Bildrate bleibt über 55.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add src/world/props.js src/world/streaming.js tools/assets tests/props.test.js public/models
|
||
git commit -m "feat(world): Palmen und Requisiten als Instanzen mit Stammkollision
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Figuren, Autos, Panel und Namensnennung
|
||
|
||
**Files:**
|
||
- Create: `src/characters/index.js`, `src/ui/panel.js`, `src/ui/hud.js`, `tests/characters.test.js`
|
||
- Modify: `src/main.js`
|
||
- Test: `tests/characters.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `player` aus Task 1, Manifest aus Task 6
|
||
- Produces: `retargetClips(clips, sourceRest, targetRest) -> AnimationClip[]`, `createCharacterModule(player) -> {switchCharacter, switchHair, applyHeight}`, `teleportTargets(landmarks) -> [{name, x, z}]`, `mountHud(root, attribution)`
|
||
|
||
Der Figurenteil wird aus `three-player-controller/example/showcase.js` übernommen: die Funktionen `collectRestPose`, `trackVaries`, `retargetClips`, `clipsForModel`, `measureTorsoRadiusRatio`, `applyCharacter`, `applyHair` samt der Konstanten `CHARACTERS`, `HAIRSTYLES`, `PLAYER_MODEL_BASE`. Die Assets liegen dort unter `example/public/glb/characters/` und werden nach `public/models/characters/` kopiert.
|
||
|
||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||
|
||
`tests/characters.test.js`:
|
||
|
||
```js
|
||
import { describe, expect, it } from "vitest";
|
||
import { AnimationClip, Quaternion, QuaternionKeyframeTrack, VectorKeyframeTrack } from "three";
|
||
import { retargetClips, teleportTargets } from "../src/characters/index.js";
|
||
|
||
const rest = (q) => new Map([["arm", { position: { x: 0, y: 1, z: 0 }, quaternion: q }]]);
|
||
|
||
describe("retargetClips", () => {
|
||
it("wirft konstante Positionsspuren weg", () => {
|
||
const clip = new AnimationClip("a", 1, [
|
||
new VectorKeyframeTrack("arm.position", [0, 1], [0, 1, 0, 0, 1, 0]),
|
||
new QuaternionKeyframeTrack("arm.quaternion", [0], [0, 0, 0, 1]),
|
||
]);
|
||
const out = retargetClips([clip], rest(new Quaternion()), rest(new Quaternion()));
|
||
expect(out[0].tracks.map((t) => t.name)).toEqual(["arm.quaternion"]);
|
||
});
|
||
|
||
it("überträgt Rotationen relativ zur Ruhepose", () => {
|
||
const src = new Quaternion().setFromAxisAngle({ x: 0, y: 1, z: 0 }, 0.3);
|
||
const dst = new Quaternion().setFromAxisAngle({ x: 0, y: 1, z: 0 }, 0.9);
|
||
const clip = new AnimationClip("a", 1, [
|
||
new QuaternionKeyframeTrack("arm.quaternion", [0], [src.x, src.y, src.z, src.w]),
|
||
]);
|
||
const out = retargetClips([clip], rest(src), rest(dst));
|
||
const v = out[0].tracks[0].values;
|
||
expect(new Quaternion(v[0], v[1], v[2], v[3]).angleTo(dst)).toBeLessThan(1e-6);
|
||
});
|
||
});
|
||
|
||
describe("teleportTargets", () => {
|
||
it("sortiert Wahrzeichen nach Höhe", () => {
|
||
const targets = teleportTargets([
|
||
{ name: "klein", xyz: [0, 0, 0], height: 30 },
|
||
{ name: "groß", xyz: [10, 20, 0], height: 200 },
|
||
]);
|
||
expect(targets[0].name).toBe("groß");
|
||
expect(targets[0].z).toBe(-20);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Test laufen lassen, er muss scheitern**
|
||
|
||
Run: `npm test`
|
||
Expected: FAIL, `Failed to resolve import "../src/characters/index.js"`
|
||
|
||
- [ ] **Step 3: Modul übernehmen und Test erfüllen**
|
||
|
||
Die genannten Funktionen unverändert aus `showcase.js` kopieren, als benannte Exporte bereitstellen und `teleportTargets` ergänzen:
|
||
|
||
```js
|
||
export function teleportTargets(landmarks) {
|
||
return [...landmarks]
|
||
.sort((a, b) => b.height - a.height)
|
||
.map((l) => ({ name: l.name, x: l.xyz[0], z: -l.xyz[1], height: l.height }));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Test laufen lassen, er muss bestehen**
|
||
|
||
Run: `npm test`
|
||
Expected: PASS, alle Tests
|
||
|
||
- [ ] **Step 5: Autos setzen**
|
||
|
||
Vier SUVs an Punkten der Mittellinien nahe dem Start, Konfiguration aus `spawnShowcaseVehicles` in `showcase.js`. Die Chunks dieser vier Punkte werden in `ChunkStreamer` festgehalten (`this.pinned = new Set([...])`, in `#unload` überspringen), weil ein Fahrzeug ohne Boden ins Nichts fällt.
|
||
|
||
- [ ] **Step 6: Panel und HUD**
|
||
|
||
`src/ui/panel.js` baut mit lil-gui: Figur, Frisur, Höhe, Hitbox anzeigen, Chunk-Grenzen anzeigen, Kartenbild auf dem Boden, Sichtweite (Nebel), Sprung zu Wahrzeichen (Auswahl aus `teleportTargets`, setzt `player.reset(...)`).
|
||
|
||
`src/ui/hud.js` hängt die Namensnennung dauerhaft unten links ein:
|
||
|
||
```js
|
||
export function mountHud(root, attribution) {
|
||
const el = document.createElement("div");
|
||
el.className = "attribution";
|
||
el.textContent = attribution;
|
||
root.appendChild(el);
|
||
return el;
|
||
}
|
||
```
|
||
|
||
Der Text kommt aus `manifest.json` und wird um die Modellquellen ergänzt: SUV von Boris731 (CC BY), Poly Haven, Nobiax und Quaternius (CC0).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/characters src/ui tests/characters.test.js src/main.js public/models/characters
|
||
git commit -m "feat: Figurenauswahl, Autos, Debug-Panel und Namensnennung
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Abnahme
|
||
|
||
**Files:**
|
||
- Create: `docs/abnahme-vice-a.md`
|
||
- Test: alle vorhandenen Suites
|
||
|
||
- [ ] **Step 1: Alle Tests laufen lassen**
|
||
|
||
Run: `npm test && cd tools/extract && uv run pytest -v && cd ../.. && node tools/checkChunks.mjs`
|
||
Expected: alles grün, Dreieckszahl unter dem Budget.
|
||
|
||
- [ ] **Step 2: Budgets messen**
|
||
|
||
Im Panel die Anzeige für Draw Calls (`renderer.info.render.calls`) und Dreiecke einblenden, dann auf Vice Beach, in Downtown und auf einer Dammstraße je 30 Sekunden messen.
|
||
Expected: mindestens 55 FPS, unter 150 Draw Calls.
|
||
|
||
- [ ] **Step 3: Abnahmeliste durchspielen und protokollieren**
|
||
|
||
In `docs/abnahme-vice-a.md` je Punkt Ergebnis und Screenshot festhalten:
|
||
1. Start auf der Promenade, Figur steht auf dem Boden
|
||
2. Mit dem SUV über eine Dammstraße auf das Festland fahren
|
||
3. Ins Wasser laufen, wieder herauskommen
|
||
4. Einen Wolkenkratzer in Downtown erreichen, Höhe stimmt mit den Daten überein
|
||
5. Den Ausschnitt einmal von Nord nach Süd durchqueren, ohne Ruckler und ohne Loch im Boden
|
||
6. Figur und Frisur wechseln, Hitbox einblenden
|
||
7. Unter die Karte fallen lassen, Respawn greift
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add docs/abnahme-vice-a.md
|
||
git commit -m "docs: Abnahmeprotokoll für den Vice-City-Ausschnitt
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||
```
|