docs: Task 13, 14 und 16 trocken erprobt und berichtigt

Die drei Browser- und Abnahmeaufgaben in einem Wegwerf-Arbeitsverzeichnis
durchgespielt, mit Gegenproben aus absichtlich falschem Code. Gefunden:

- Task 13: Höhen wurden exakt verglichen, die Positionen liegen aber als
  Float32 vor - die Tests wären auch mit richtigem Code rot gewesen.
- Task 13: Die Oberkante des Bordsteins lag auf y = 0 und verschwand damit
  unter dem Gehwegbelag; auf welcher Seite sie liegt, war offen. Sie liegt
  jetzt 1 cm über den Belägen, und die Seite kommt aus zwei Proben an der
  Fahrbahn. Die Wand steht 1 cm in der Fahrbahn, sonst fällt sie
  deckungsgleich auf die Lochwand der Bodenplatte.
- Task 13: Die Brückenmarkierung hatte weder Code noch Test; die Regel ist
  jetzt "alle Eckpunkte auf der Brücke" statt "Schwerpunkt", sonst schwebt
  der Rest einer halb aufliegenden Markierung über der Mulde.
- Task 14: Ein Laternentest wäre mit richtigem Code rot gewesen, ein
  zweiter konnte nicht fehlschlagen, und die geforderte Laterne aus einem
  Chunk im neuen Format hatte keinen Test.
- Task 14: Die Tönung ließ die grüne Parkfarbe aus Teil 1 fallen, die
  Ersatzfarben fehlten, DECALS ist nicht exportiert, und die Markierung
  sollte die oberste Versatzstufe bekommen, obwohl der Kommentar dort vor
  genau diesem Wachstum warnt.
- Task 14: tests/placesSite.test.js stand nicht in der Dateiliste und
  wäre gebrochen.
- Task 16: Die Erwartungszahlen stammten von vor dem Park, der vom Entwurf
  verlangte Integrationstest fehlte, und das Entfernen des Skips hatte
  keinen eigenen Schritt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5 committed 2026-09-17 20:52:41 +02:00
1 parent 2201be37bd
commit 3d39a9bd66
1 file changed
+553 -151
+553 -151
View File
@@ -2329,7 +2329,14 @@ Hier **nicht** `public/data` mitcommitten.
### Task 13: Browser — Geometrie der neuen Schichten
> **Brücken, im Plan bisher übersehen:** Die Fahrbahn liegt überall bei y = −0,15 — über einer Brücke also **im Inneren des Brückendecks**, das bis `BRIDGE_OFFSET` (+0,02) reicht. Fahrbahn und Markierungen wären auf allen Brücken unsichtbar, obwohl der Entwurf sie dort „obenauf" verspricht. So wird es gelöst: Markierungen, deren Schwerpunkt in einer Fläche aus `chunk.bridges` desselben Chunks liegt, bekommen y = `BRIDGE_OFFSET + MARKING_OFFSET` statt `-CARRIAGEWAY_DEPTH + MARKING_OFFSET`. Die Fahrbahnfläche selbst bleibt über Brücken im Deck verborgen — das Deck trägt ohnehin dieselbe Asphalttextur. Die 17-cm-Stufe am Brückenanfang deckt die Stirnwand des Decks ab, die `extrudePolygons` schon baut, und sie ist begehbar (gemessen: 15 und 25 cm werden ohne Halt erklommen). Test: eine Markierung über einer Brücke liegt auf Deckhöhe, eine daneben in der Mulde.
> **Brücken, im Plan bisher übersehen:** Die Fahrbahn liegt überall bei y = −0,15 — über einer Brücke also **im Inneren des Brückendecks**, das bis `BRIDGE_OFFSET` (+0,02) reicht. Fahrbahn und Markierungen wären auf allen Brücken unsichtbar, obwohl der Entwurf sie dort „obenauf" verspricht. So wird es gelöst: Markierungen, deren Eckpunkte **alle** in einer Fläche aus `chunk.bridges` desselben Chunks liegen, bekommen y = `BRIDGE_OFFSET + MARKING_OFFSET` statt `-CARRIAGEWAY_DEPTH + MARKING_OFFSET`. Eine Markierung, die nur teilweise auf der Brücke liegt, bleibt in der Mulde und verschwindet auf dem Brückenstück im Deck — auf Deckhöhe schwebte ihr übriger Teil 17 cm über der Fahrbahn. Die Fahrbahnfläche selbst bleibt über Brücken im Deck verborgen; das Deck trägt ohnehin dieselbe Asphalttextur. Die 17-cm-Stufe am Brückenanfang deckt die Stirnwand des Decks ab, die `extrudePolygons` schon baut, und sie ist begehbar (gemessen: 15 und 25 cm werden ohne Halt erklommen).
> **Vor dem Auftrag trocken erprobt, drei Fehler dieser Aufgabe sind hier schon berichtigt:**
> (1) Die Tests verglichen Höhen mit `toEqual`. Die Positionen liegen aber als Float32 vor, und −0,15 ist dort −0,15000000596 — die Tests wären auch mit richtigem Code rot gewesen. Jetzt `toBeCloseTo`, wie in den übrigen Tests dieser Datei.
> (2) Die Oberkante des Bordsteins lag auf y = 0. Der Gehweg beginnt genau an der Fahrbahnkante und liegt auf `SURFACE_OFFSET` (+0,02); er hätte die Oberkante überall zugedeckt, obwohl sie laut Entwurf dazu da ist, von oben gesehen zu werden. Sie liegt jetzt auf `KERB_HEIGHT`, 1 cm über den Belägen, und die Wand reicht bis dorthin.
> (3) Auf welche Seite die Oberkante gehört, war offen. `emit.py` legt die Laufrichtung jeder Linie fest, sie sagt also nichts über die Fahrbahnseite, und auf der falschen Seite hinge die Oberkante als Sims über der Mulde. Die Seite bestimmt jetzt `carriagewaySide()` mit je einer Probe links und rechts an den Fahrbahnflächen desselben Chunks; lässt sie sich nicht bestimmen, entsteht nur die Wand.
> Dazu steht die Wand 1 cm in der Fahrbahn (`KERB_INSET`): Auf der Linie selbst fiele sie genau auf die Lochwand der Bodenplatte, und zwei deckungsgleiche Flächen flimmern.
> Gegenprobe mit absichtlich falschem Code: Richtet sich die Oberkante nach der Laufrichtung statt nach den Proben, werden drei Tests rot; hebt man Markierungen nach ihrem Schwerpunkt statt nach allen Eckpunkten auf das Deck, wird der Brückentest rot.
> **Seit Task 10 gebrochen, hier zu reparieren:** `buildChunkGeometry()` liest `chunk.roads.filter(...)` ohne Rückfall und wirft mit Chunks im neuen Format einen `TypeError`. Die Fixtures in `tests/geometry.test.js` sind von Hand im alten Format gebaut und merken das nicht — stell sie mit um, sonst prüfen sie weiter eine Schnittstelle, die es nicht mehr gibt.
@@ -2340,9 +2347,9 @@ Hier **nicht** `public/data` mitcommitten.
**Schnittstellen:**
- Verbraucht: das Chunk-Format aus Task 10.
- Liefert:
- `CARRIAGEWAY_DEPTH = 0.15`, `KERB_TOP = 0.20`, `MARKING_OFFSET = 0.01`
- `CARRIAGEWAY_DEPTH = 0.15`, `KERB_TOP = 0.20`, `KERB_HEIGHT = SURFACE_OFFSET + 0.01`, `KERB_INSET = 0.01`, `MARKING_OFFSET = 0.01`
- `MARKING_COLORS = { gelb_gestrichelt, gelb_doppelt, weiss_gestrichelt, weiss_rand, haltelinie, zebra }`
- `kerbGeometry(lines)` und `markingGeometry(markings)`
- `kerbGeometry(lines, carriageway)` und `markingGeometry(markings, bridges)` — das zweite Argument sind jeweils die Flächen (Ringlisten) der Fahrbahn bzw. der Brücken desselben Chunks
- `buildChunkGeometry(chunk)` mit den Teilen `carriageway`, `curb`, `sidewalk`, `markings`, `lots`, `bridges` statt `roads`
- [ ] **Schritt 1: Den fehlschlagenden Test schreiben**
@@ -2356,50 +2363,88 @@ describe("Straßen aus Teil 2", () => {
carriageway: [], curb: [], sidewalk: [], markings: [], lots: [], bridges: [], edges: [],
...extra,
});
// Eine Fahrbahn nördlich der Bordsteinlinie z = 10, die Linie in beiden Laufrichtungen: emit.py
// legt die Richtung jeder Linie fest, sie sagt also nichts darüber, wo die Fahrbahn liegt.
const FAHRBAHN = [[[10, 10], [20, 10], [20, 20], [10, 20]]];
const kante = (umgedreht) => (umgedreht ? [[20, 10], [10, 10]] : [[10, 10], [20, 10]]);
const bounds = (geo) => new Box3().setFromBufferAttribute(geo.getAttribute("position"));
it("legt die Fahrbahn 15 cm unter den Boden", () => {
const geo = buildChunkGeometry(chunk({
carriageway: [{ poly: [[[0, 0], [10, 0], [10, -10], [0, -10]]], klasse: "wohnstrasse" }],
carriageway: [{ poly: FAHRBAHN, klasse: "wohnstrasse" }],
})).carriageway;
const y = geo.getAttribute("position").array.filter((_, i) => i % 3 === 1);
expect([...new Set(y)]).toEqual([-CARRIAGEWAY_DEPTH]);
// toBeCloseTo, nicht toEqual: Die Positionen liegen als Float32 vor, und -0,15 ist dort
// -0,15000000596.
expect(bounds(geo).min.y).toBeCloseTo(-CARRIAGEWAY_DEPTH);
expect(bounds(geo).max.y).toBeCloseTo(-CARRIAGEWAY_DEPTH);
});
it("baut aus einer Bordsteinlinie eine Wand und eine Oberkante", () => {
const geo = kerbGeometry([[[0, 0], [10, 0]]]);
const pos = geo.getAttribute("position").array;
const y = [...new Set(pos.filter((_, i) => i % 3 === 1))].sort((a, b) => a - b);
expect(y).toEqual([-CARRIAGEWAY_DEPTH, 0]);
it("baut aus einer Bordsteinlinie eine Wand aus der Mulde und eine Oberkante knapp über den Belägen", () => {
const geo = kerbGeometry([kante(false)], [FAHRBAHN]);
expect(bounds(geo).min.y).toBeCloseTo(-CARRIAGEWAY_DEPTH);
expect(bounds(geo).max.y).toBeCloseTo(KERB_HEIGHT);
// Wand (4 Punkte) und Oberkante (4 Punkte) je Abschnitt.
expect(geo.getAttribute("position").count).toBe(8);
});
it("legt die Oberkante des Bordsteins 20 cm zur Seite", () => {
const geo = kerbGeometry([[[0, 0], [10, 0]]]);
const pos = geo.getAttribute("position").array;
const z = [...new Set(pos.filter((_, i) => i % 3 === 2))].sort((a, b) => a - b);
expect(z.some((v) => Math.abs(Math.abs(v) - KERB_TOP) < 1e-6)).toBe(true);
});
it("zeigt die Oberkante nach oben", () => {
const geo = kerbGeometry([[[0, 0], [10, 0]]]);
const normals = geo.getAttribute("normal").array;
const oben = [];
for (let i = 0; i < geo.getAttribute("position").count; i++) {
if (geo.getAttribute("position").array[i * 3 + 1] === 0) oben.push(normals[i * 3 + 1]);
it("legt die Oberkante auf die Gehwegseite und die Wand 1 cm in die Fahrbahn, in beiden Laufrichtungen", () => {
for (const umgedreht of [false, true]) {
const geo = kerbGeometry([kante(umgedreht)], [FAHRBAHN]);
const pos = geo.getAttribute("position");
expect(bounds(geo).min.z, `umgedreht=${umgedreht}`).toBeCloseTo(10 - KERB_TOP);
expect(bounds(geo).max.z, `umgedreht=${umgedreht}`).toBeCloseTo(10 + KERB_INSET);
for (let i = 0; i < pos.count; i++) {
if (pos.getY(i) < 0) expect(pos.getZ(i), `Wandfuß, umgedreht=${umgedreht}`).toBeCloseTo(10 + KERB_INSET);
}
}
expect(Math.max(...oben)).toBeGreaterThan(0);
});
it("gibt Markierungen ihre Farbe als Vertexfarbe", () => {
it("zeigt die Oberkante nach oben, in beiden Laufrichtungen", () => {
for (const umgedreht of [false, true]) {
const geo = kerbGeometry([kante(umgedreht)], [FAHRBAHN]);
const pos = geo.getAttribute("position");
const normal = geo.getAttribute("normal");
const aussen = [];
for (let i = 0; i < pos.count; i++) {
if (pos.getZ(i) < 10 - KERB_TOP / 2) aussen.push(normal.getY(i)); // Außenkante der Oberkante
}
expect(aussen, `umgedreht=${umgedreht}`).toHaveLength(2);
for (const ny of aussen) expect(ny, `umgedreht=${umgedreht}`).toBeGreaterThan(0.99);
}
});
it("baut nur die Wand, wenn keine Fahrbahn neben der Linie liegt", () => {
// Ohne Fahrbahn lässt sich die Seite nicht bestimmen, und eine Oberkante auf der falschen
// Seite hinge als Sims über der Mulde.
const geo = kerbGeometry([kante(false)], []);
expect(geo.getAttribute("position").count).toBe(4);
expect(bounds(geo).min.z).toBeCloseTo(10);
expect(bounds(geo).max.z).toBeCloseTo(10);
});
it("gibt Markierungen ihre Farbe als Vertexfarbe und legt sie 1 cm über die Fahrbahn", () => {
const geo = markingGeometry([
{ poly: [[[0, 0], [3, 0], [3, -0.1], [0, -0.1]]], art: "gelb_doppelt" },
{ poly: [[[5, 0], [8, 0], [8, -0.6], [5, -0.6]]], art: "zebra" },
]);
const color = geo.getAttribute("color").array;
expect(color.slice(0, 3)).toEqual(new Float32Array(MARKING_COLORS.gelb_doppelt));
const y = geo.getAttribute("position").array.filter((_, i) => i % 3 === 1);
expect([...new Set(y)]).toEqual([-CARRIAGEWAY_DEPTH + MARKING_OFFSET]);
expect(color.slice(12, 15)).toEqual(new Float32Array(MARKING_COLORS.zebra));
expect(bounds(geo).min.y).toBeCloseTo(-CARRIAGEWAY_DEPTH + MARKING_OFFSET);
expect(bounds(geo).max.y).toBeCloseTo(-CARRIAGEWAY_DEPTH + MARKING_OFFSET);
});
it("legt Markierungen, die ganz auf einer Brücke liegen, auf das Deck und alle anderen in die Mulde", () => {
const strich = (x) => ({ poly: [[[x, 5], [x + 3, 5], [x + 3, 5.1], [x, 5.1]]], art: "gelb_gestrichelt" });
const out = buildChunkGeometry(chunk({
bridges: [{ poly: [[[0, 0], [20, 0], [20, 20], [0, 20]]] }],
markings: [strich(5), strich(18), strich(30)], // ganz auf der Brücke, halb, daneben
}));
const deck = bounds(out.bridges).max.y;
const pos = out.markings.getAttribute("position");
expect(pos.count).toBe(12);
for (let i = 0; i < 4; i++) expect(pos.getY(i), "ganz auf der Brücke").toBeCloseTo(deck + MARKING_OFFSET);
for (let i = 4; i < 12; i++) expect(pos.getY(i), "halb oder daneben").toBeCloseTo(-CARRIAGEWAY_DEPTH + MARKING_OFFSET);
});
it("lädt alte Chunks ohne die neuen Schlüssel weiter", () => {
@@ -2411,16 +2456,16 @@ describe("Straßen aus Teil 2", () => {
});
```
Der Import oben in der Datei bekommt `CARRIAGEWAY_DEPTH`, `KERB_TOP`, `MARKING_OFFSET`, `MARKING_COLORS`, `kerbGeometry`, `markingGeometry` dazu.
Der Import oben in der Datei bekommt `CARRIAGEWAY_DEPTH`, `KERB_HEIGHT`, `KERB_INSET`, `KERB_TOP`, `MARKING_COLORS`, `MARKING_OFFSET`, `kerbGeometry` und `markingGeometry` dazu; `Box3` ist schon importiert.
- [ ] **Schritt 2: Test laufen lassen, Fehlschlag sehen**
Ausführen: `npm test -- tests/geometry.test.js`
Erwartet: `kerbGeometry is not a function`
Erwartet: rot, weil die neuen Exporte fehlen (etwa `kerbGeometry is not a function`).
- [ ] **Schritt 3: `geometry.js` erweitern**
Oben bei den Konstanten:
Oben bei den Konstanten, direkt nach `SURFACE_KINDS` (sie brauchen `SURFACE_OFFSET`):
```js
// Die Fahrbahn liegt in einer Mulde, statt dass der Gehweg auf einem Sockel läge (Entwurf
@@ -2429,6 +2474,14 @@ Oben bei den Konstanten:
// y = 0, und der Bordstein ist die Wand zwischen Gehweg und Fahrbahn.
export const CARRIAGEWAY_DEPTH = 0.15;
export const KERB_TOP = 0.20; // sichtbare Breite der Bordstein-Oberkante
// Die Oberkante liegt 1 cm über den Belägen statt auf y = 0: Der Gehweg beginnt genau an der
// Fahrbahnkante und liegt auf SURFACE_OFFSET — eine Oberkante auf y = 0 deckte er vollständig zu.
export const KERB_HEIGHT = SURFACE_OFFSET + 0.01;
// Die Wand steht 1 cm in der Fahrbahn: Auf der Linie selbst fiele sie genau auf die Lochwand der
// Bodenplatte (extrudePolygons baut auch für Löcher Wände), und zwei deckungsgleiche Flächen
// flimmern im Tiefenpuffer.
export const KERB_INSET = 0.01;
const KERB_PROBE = 0.3; // Abstand der Proben links und rechts einer Bordsteinlinie
export const MARKING_OFFSET = 0.01; // Markierung über der Fahrbahn
export const MARKING_COLORS = {
gelb_gestrichelt: [0.94, 0.75, 0.13],
@@ -2440,50 +2493,108 @@ export const MARKING_COLORS = {
};
```
Dann die beiden neuen Bauer:
Dann die neuen Bauer, vor `buildChunkGeometry`:
```js
/** Bordstein: senkrechte Wand von der Fahrbahn auf Bodenhöhe, dazu eine schmale Oberkante.
/** Liegt (x, z) in einer der Flächen? Gerade-ungerade über alle Ringe, ein Loch zählt also als
* außen. Die Flächen eines Chunks überlappen sich nicht, sie sind Stücke einer Vereinigung. */
function insideAny(x, z, polys) {
let inside = false;
for (const poly of polys) {
for (const ring of poly) {
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, zi] = ring[i];
const [xj, zj] = ring[j];
if ((zi > z) !== (zj > z) && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) inside = !inside;
}
}
}
return inside;
}
/** Auf welcher Seite einer Bordsteinlinie liegt die Fahrbahn: +1 in Richtung der Normale
* (-dz, dx), -1 gegenüber, 0, wenn es sich nicht entscheiden lässt.
*
* Die Laufrichtung der Linie sagt darüber nichts: emit.py legt jede Linie auf eine feste Richtung
* fest, damit die Ausgabe nicht vom Rechenweg abhängt. Ein Bordsteinstück ist aber ein
* zusammenhängendes Stück Fahrbahnrand, die Fahrbahn liegt also auf seiner ganzen Länge auf
* derselben Seite. Geprüft wird Abschnitt für Abschnitt, bis genau eine der beiden Proben in der
* Fahrbahn liegt — an der Chunkgrenze kann eine Probe jenseits des Chunks landen, wo es keine
* Fahrbahnfläche dieses Chunks gibt. */
function carriagewaySide(line, carriageway) {
for (let i = 0; i + 1 < line.length; i++) {
const [x0, z0] = line[i];
const [x1, z1] = line[i + 1];
const len = Math.hypot(x1 - x0, z1 - z0);
if (len < 1e-6) continue;
const mx = (x0 + x1) / 2, mz = (z0 + z1) / 2;
const px = (-(z1 - z0) / len) * KERB_PROBE, pz = ((x1 - x0) / len) * KERB_PROBE;
const links = insideAny(mx + px, mz + pz, carriageway);
const rechts = insideAny(mx - px, mz - pz, carriageway);
if (links !== rechts) return links ? 1 : -1;
}
return 0;
}
/** Bordstein: senkrechte Wand aus der Mulde bis knapp über die Beläge, dazu eine schmale
* Oberkante auf der Gehwegseite.
*
* Die Oberkante ist kein Schmuck: Ohne sie endet der Bordstein von oben gesehen als
* Messerschneide zwischen Gehwegbelag und Wand, und aus der Ferne verschwindet er ganz.
* Die Wand bekommt in materials.js DoubleSide — welche Seite außen liegt, hängt am Umlaufsinn
* des Rings, aus dem die Linie stammt, und ein 15 cm hohes Band ist die Rechenarbeit nicht
* wert, das je Linie zu bestimmen. */
export function kerbGeometry(lines, { depth = CARRIAGEWAY_DEPTH, top = KERB_TOP } = {}) {
* Messerschneide zwischen Gehwegbelag und Wand, und aus der Ferne verschwindet er ganz. Auf
* welche Seite sie gehört, bestimmt carriagewaySide() an den Fahrbahnflächen desselben Chunks —
* auf der Fahrbahnseite hinge sie als Sims über der Mulde. Lässt sich die Seite nicht bestimmen,
* entsteht nur die Wand, auf der Linie selbst. Die Wand bekommt in materials.js DoubleSide. */
export function kerbGeometry(lines, carriageway = [],
{ depth = CARRIAGEWAY_DEPTH, height = KERB_HEIGHT, top = KERB_TOP,
inset = KERB_INSET } = {}) {
const pos = [], idx = [], uv = [];
for (const line of lines) {
const side = carriagewaySide(line, carriageway);
for (let i = 0; i + 1 < line.length; i++) {
const [x0, z0] = line[i];
const [x1, z1] = line[i + 1];
const dx = x1 - x0, dz = z1 - z0;
const len = Math.hypot(dx, dz);
if (len < 1e-6) continue;
const nx = (-dz / len) * top, nz = (dx / len) * top;
// Einheitsvektor zur Fahrbahn hin; (0, 0), wenn die Seite unbekannt ist.
const cx = (-dz / len) * side, cz = (dx / len) * side;
const ax = x0 + cx * inset, az = z0 + cz * inset;
const bx = x1 + cx * inset, bz = z1 + cz * inset;
const w = pos.length / 3;
pos.push(x0, -depth, z0, x1, -depth, z1, x1, 0, z1, x0, 0, z0);
uv.push(0, 0, len, 0, len, depth, 0, depth);
pos.push(ax, -depth, az, bx, -depth, bz, bx, height, bz, ax, height, az);
uv.push(0, 0, len, 0, len, depth + height, 0, depth + height);
idx.push(w, w + 1, w + 2, w, w + 2, w + 3);
if (side === 0) continue;
const t = pos.length / 3;
pos.push(x0, 0, z0, x1, 0, z1, x1 + nx, 0, z1 + nz, x0 + nx, 0, z0 + nz);
uv.push(0, 0, len, 0, len, top, 0, top);
// Gegen den Uhrzeigersinn in der xz-Ebene, damit die Normale nach oben zeigt.
idx.push(t, t + 2, t + 1, t, t + 3, t + 2);
pos.push(ax, height, az, bx, height, bz,
x1 - cx * top, height, z1 - cz * top, x0 - cx * top, height, z0 - cz * top);
uv.push(0, 0, len, 0, len, top + inset, 0, top + inset);
// Nach oben zeigen: Der Umlaufsinn des Vierecks hängt an der Seite.
if (side > 0) idx.push(t, t + 1, t + 2, t, t + 2, t + 3);
else idx.push(t, t + 2, t + 1, t, t + 3, t + 2);
}
}
return pos.length ? toGeometry(pos, idx, uv) : null;
}
/** Markierungen als flache Vierecke in einem Netz, Farbe je Art als Vertexfarbe. */
export function markingGeometry(markings) {
/** Markierungen als flache Vierecke in einem Netz, Farbe je Art als Vertexfarbe.
*
* Über einer Brücke liegt die Fahrbahn im Deck verborgen: Sie liegt überall auf
* -CARRIAGEWAY_DEPTH, das Deck reicht bis BRIDGE_OFFSET. Eine Markierung, deren Eckpunkte alle in
* einer Brückenfläche desselben Chunks liegen, kommt deshalb auf das Deck. Liegt sie nur
* teilweise darauf, bleibt sie in der Mulde und verschwindet auf der Brücke im Deck — auf
* Deckhöhe schwebte ihr übriger Teil 17 cm über der Fahrbahn. */
export function markingGeometry(markings, bridges = []) {
const pos = [], idx = [], uv = [], color = [];
for (const mark of markings) {
const tint = MARKING_COLORS[mark.art] ?? MARKING_COLORS.weiss_rand;
const ring = clockwise(openRing(mark.poly[0] ?? []));
if (ring.length < 3) continue;
const onDeck = bridges.length > 0 && ring.every(([x, z]) => insideAny(x, z, bridges));
const y = (onDeck ? BRIDGE_OFFSET : -CARRIAGEWAY_DEPTH) + MARKING_OFFSET;
const base = pos.length / 3;
for (const [x, z] of ring) {
pos.push(x, -CARRIAGEWAY_DEPTH + MARKING_OFFSET, z);
pos.push(x, y, z);
uv.push(x, z);
color.push(...tint);
}
@@ -2504,14 +2615,16 @@ export function buildChunkGeometry(chunk) {
ground: extrudePolygons(chunk.land, { base: -GROUND_THICKNESS, top: 0, uvScale: 8 }),
// Fahrbahn und Bordstein sind echte Geometrie in der Mulde, keine Auflagen.
carriageway: flatPolygons((chunk.carriageway ?? []).map((c) => c.poly),
{ y: -CARRIAGEWAY_DEPTH, uvScale: 8 }),
curb: kerbGeometry(chunk.curb ?? []),
{ y: -CARRIAGEWAY_DEPTH, uvScale: 8 }),
curb: kerbGeometry(chunk.curb ?? [], (chunk.carriageway ?? []).map((c) => c.poly)),
sidewalk: flatPolygons((chunk.sidewalk ?? []).map((s) => s.poly), { y: SURFACE_OFFSET, uvScale: 4 }),
markings: markingGeometry(chunk.markings ?? []),
markings: markingGeometry(chunk.markings ?? [], (chunk.bridges ?? []).map((b) => b.poly)),
lots: flatPolygons((chunk.lots ?? []).map((l) => l.poly), { y: SURFACE_OFFSET, uvScale: 8 }),
bridges: extrudePolygons((chunk.bridges ?? []).map((b) => b.poly),
{ base: -BRIDGE_THICKNESS, top: BRIDGE_OFFSET, uvScale: 8 }),
buildings: buildBuildings(chunk.buildings),
// Parkwege (docs/superpowers/specs/2026-09-15-flamingo-park-design.md): flach wie Straßen, Textur in
// echter Größe — Poly Haven misst concrete_pavement mit 1,8 × 1,8 m.
paths: flatPolygons((chunk.paths ?? []).map((path) => path.poly), { y: ROAD_OFFSET, uvScale: 1.8 }),
};
for (const kind of SURFACE_KINDS) {
@@ -2529,7 +2642,7 @@ Erwartet: alle neuen bestehen. Alte Tests, die `chunk.roads` erwarten, schlagen
- [ ] **Schritt 5: Die alten Geometrietests umstellen**
Jeden Test in `tests/geometry.test.js`, der `roads:` im Chunk setzt, auf `carriageway:` bzw. `bridges:` umstellen; die Zusicherung `out.roads` wird `out.carriageway`. Nichts an ihrer Aussage ändern.
Jeden Test in `tests/geometry.test.js`, der `roads:` im Chunk setzt, auf die neuen Schlüssel umstellen: Ein Eintrag `{ poly, bridge: false }` wird zu `carriageway: [{ poly, klasse: "wohnstrasse" }]`, ein Eintrag `{ poly, bridge: true }` zu `bridges: [{ poly }]`, und `roads: [], centerlines: []` in den übrigen Fixtures entfällt. In „baut alle Teile eines Chunks" wird der Schlüssel `roads` in der Liste zu `carriageway`. Nichts an der Aussage der Tests ändern.
Ausführen: `npm test -- tests/geometry.test.js`
Erwartet: alle grün.
@@ -2545,70 +2658,111 @@ git commit -m "feat(world): Fahrbahnmulde, Bordsteinwand und Markierungen bauen"
### Task 14: Browser — Materialien, Kollision, Laternen
> **Danach nachziehen:** Der Docstring von `_line()` in `tools/extract/leonida/emit.py` beschreibt den Stand vor dieser Aufgabe — er zitiert `props.js` mit einer festen Zeilennummer und sagt, heute lese noch niemand `curb` oder `edges`. Beides stimmt nach dieser Aufgabe nicht mehr. Die Zeilennummer entfernen (der Funktionsname genügt) und den Satz auf den neuen Stand bringen.
> **Seit Task 10 still gebrochen, hier zu reparieren:** `props.js` liest `chunk.centerlines ?? []` und `chunk.roads ?? []`. Beide Schlüssel gibt es nicht mehr — es entsteht kein Fehler, sondern einfach **keine einzige Laterne** auf der ganzen Karte. Die Fixtures in `tests/props.test.js` sind im alten Format gebaut und merken das nicht. Nach dieser Aufgabe muss eine Laterne aus einem Chunk im neuen Format entstehen; das prüft der Test zu `chunkLampPoints` in Schritt 1.
>
> **Seit Task 10 still gebrochen, hier zu reparieren:** `props.js` liest `chunk.centerlines ?? []` und `chunk.roads ?? []`. Beide Schlüssel gibt es nicht mehr — es entsteht kein Fehler, sondern einfach **keine einzige Laterne** auf der ganzen Karte. Die Fixtures in `tests/props.test.js` sind im alten Format gebaut und merken das nicht. Nach dieser Aufgabe muss eine Laterne aus einem Chunk im neuen Format entstehen; prüf das mit einem Test, der genau so einen Chunk benutzt.
> **Danach nachziehen:** Der Docstring von `_line()` in `tools/extract/leonida/emit.py` beschreibt den Stand vor dieser Aufgabe — er zitiert `props.js` mit einer festen Zeilennummer und sagt, heute lese noch niemand `curb` oder `edges`. Schritt 7 gibt den neuen Wortlaut vor.
> **Vor dem Auftrag trocken erprobt** (Scratch-Worktree mit Task 13 und dieser Aufgabe, ganze JS-Suite grün). **Acht Fehler dieser Aufgabe sind hier schon berichtigt:**
> (1) Der erste Laternentest legte den Gehweg auf die Mittellinie selbst (x 4 bis 12, z −1 bis 1), gesucht wird aber senkrecht zu ihr. Der Test wäre mit richtigem Code rot gewesen.
> (2) „hängt nicht an der Listenposition" konnte nicht fehlschlagen: Mit diesem Gehweg fanden beide Reihenfolgen gar keine Laterne, und zwei leere Listen sind gleich. Jetzt mit Längenzusicherung und mit einem Gehweg nur auf einer Seite, an dem die alte Index-Logik scheitert.
> (3) Die Forderung aus dem Kasten oben — eine Laterne aus einem Chunk im neuen Format — hatte keinen Test. `chunkLampPoints(chunk)` zieht die Verdrahtung aus `#buildLamps` heraus, damit sie prüfbar ist.
> (4) `DECALS` ist in `streaming.js` nicht exportiert. Der Test prüft das Verhalten jetzt wie der bestehende Test daneben: über den Kollisionskörper und die Szene.
> (5) Die Tönung ließ `park: PARK_TINT` fallen — die grüne Tönung des Parks, für die sich der Nutzer in Teil 1 entschieden hat.
> (6) `FALLBACK_COLORS` hatte keine Farben für die neuen Schichten; fehlt eine Textur, bekäme three `color: undefined`.
> (7) Die Markierung sollte die oberste Versatzstufe bekommen (−11). Der Kommentar über `POLYGON_OFFSET_ORDER` warnt aber genau davor: Der Faktor wird beim streifenden Blick mit der Tiefensteigung multipliziert und zieht eine Fläche vor Geometrie, die in Wahrheit davor steht, und wächst die Liste, ist die Stufenhöhe zu entkoppeln. Die Markierung berührt keinen Belag, ihr einziger Nachbar ist die Fahrbahn ohne Versatz — sie bekommt deshalb einen eigenen kleinen Versatz außerhalb der Rangfolge. Die Rangfolge selbst wächst nur um eine Stufe (die Wege stehen auf −10); den streifenden Blick sieht die Browserprüfung nach Task 16 nach.
> (8) `tests/placesSite.test.js` benutzt `vergeLampPoints` und `chunk.roads` und stand nicht in der Dateiliste. Ohne Umstellung bricht er hier mit `vergeLampPoints is not a function`.
> Dazu fehlte `emit.py` im Commit, obwohl der Kasten oben dort eine Änderung verlangt.
**Dateien:**
- Ändern: `src/world/materials.js`, `src/world/streaming.js`, `src/world/props.js`
- Test: `tests/materials.test.js`, `tests/streaming.test.js`, `tests/props.test.js`
- Ändern: `src/world/materials.js`, `src/world/streaming.js`, `src/world/props.js`, `tools/extract/leonida/emit.py` (nur der Docstring von `_line()`)
- Test: `tests/materials.test.js`, `tests/streaming.test.js`, `tests/props.test.js`, `tests/placesSite.test.js`
**Schnittstellen:**
- Verbraucht: die Teile aus Task 13.
- Liefert: `MATERIAL_KEYS` mit `carriageway`, `curb`, `sidewalk`, `markings`, `lots`, `bridges` statt `roads`; `sidewalkLampPoints(points, sidewalk)` in `props.js`.
- Liefert: `MATERIAL_KEYS` mit `carriageway`, `curb`, `sidewalk`, `markings`, `lots`, `bridges` statt `roads`; in `props.js` `LAMP_VERGE`, `sidewalkLampPoints(points, sidewalk)` und `chunkLampPoints(chunk)`.
- [ ] **Schritt 1: Den fehlschlagenden Test schreiben**
- [ ] **Schritt 1: Die fehlschlagenden Tests schreiben**
In `tests/materials.test.js`:
An `tests/materials.test.js` anhängen:
```js
it("hat für jede neue Schicht ein Material", () => {
for (const key of ["carriageway", "curb", "sidewalk", "markings", "lots"]) {
expect(MATERIAL_KEYS).toContain(key);
}
expect(MATERIAL_KEYS).not.toContain("roads");
});
describe("Schichten aus Teil 2", () => {
it("hat für jede neue Schicht ein Material", () => {
for (const key of ["carriageway", "curb", "sidewalk", "markings", "lots"]) {
expect(MATERIAL_KEYS).toContain(key);
}
expect(MATERIAL_KEYS).not.toContain("roads");
});
it("nimmt Bordstein und Gehweg aus dem Kit", () => {
expect(TEXTURES.curb).toBe("curb");
expect(TEXTURES.sidewalk).toBe("sidewalk");
expect(TEXTURES.carriageway).toBe("asphalt_02");
expect(TEXTURES.lots).toBe("asphalt_02");
});
it("nimmt Bordstein und Gehweg aus dem Kit", () => {
expect(TEXTURES.curb).toBe("curb");
expect(TEXTURES.sidewalk).toBe("sidewalk");
expect(TEXTURES.carriageway).toBe("asphalt_02");
expect(TEXTURES.lots).toBe("asphalt_02");
});
it("gibt der Markierung eine Farbe statt einer Textur", () => {
expect(SURFACE_COLORS.markings).toBeDefined();
expect(TEXTURES.markings).toBeUndefined();
});
it("gibt der Markierung eine Farbe statt einer Textur", () => {
expect(SURFACE_COLORS.markings).toBeDefined();
expect(TEXTURES.markings).toBeUndefined();
});
it("zieht die Markierung im Tiefenpuffer am weitesten nach vorn", () => {
expect(POLYGON_OFFSET_ORDER.at(-1)).toBe("markings");
// Fahrbahn und Bordstein liegen 15 cm tiefer und sind echte Geometrie — kein Versatz nötig.
expect(POLYGON_OFFSET_ORDER).not.toContain("carriageway");
expect(POLYGON_OFFSET_ORDER).not.toContain("curb");
it("gibt der Markierung einen eigenen kleinen Versatz statt der obersten Stufe der Beläge", () => {
// Ihr einziger Nachbar ist die Fahrbahn 1 cm darunter, und die hat keinen Versatz. Die oberste
// Stufe zöge sie beim streifenden Blick am weitesten nach vorn, ohne dass es etwas nützte.
expect(POLYGON_OFFSET_ORDER).not.toContain("markings");
expect(POLYGON_OFFSETS.markings).toEqual({ polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 });
// Fahrbahn und Bordstein liegen 15 cm tiefer und sind echte Geometrie — kein Versatz nötig.
expect(POLYGON_OFFSETS.carriageway).toBeUndefined();
expect(POLYGON_OFFSETS.curb).toBeUndefined();
});
});
```
In `tests/streaming.test.js`:
In `tests/streaming.test.js`, im selben `describe` direkt nach dem Test „legt Parkwege und die neuen Flächenarten als Auflagen ohne Kollision in die Szene":
```js
it("zählt Fahrbahn und Bordstein zur Kollision, den Rest zu den Auflagen", () => {
for (const key of ["sidewalk", "markings", "lots", "paths"]) expect(DECALS.has(key)).toBe(true);
for (const key of ["carriageway", "curb", "bridges", "buildings"]) expect(DECALS.has(key)).toBe(false);
});
// Teil 2: Fahrbahn und Bordstein sind Kollision — man fährt in der Mulde, und der Bordstein ist die
// Stufe zum Gehweg. Gehweg, Markierung und Restflächen bleiben Auflagen.
it("legt Fahrbahn und Bordstein in die Kollision, Gehweg, Markierung und Restfläche als Auflagen in die Szene", async () => {
const square = [[[0, 0], [20, 0], [20, 20], [0, 20]]];
const chunk = {
chunk: [4, 6], origin: [0, -1000], land: [square], surfaces: [], buildings: [],
carriageway: [{ poly: [[[0, 20], [20, 20], [20, 30], [0, 30]]], klasse: "wohnstrasse" }],
curb: [[[0, 20], [20, 20]]],
sidewalk: [{ poly: [[[0, 17], [20, 17], [20, 20], [0, 20]]] }],
markings: [{ poly: [[[2, 25], [5, 25], [5, 25.1], [2, 25.1]]], art: "gelb_gestrichelt" }],
lots: [{ poly: [[[0, 0], [5, 0], [5, 5], [0, 5]]] }],
};
const { streamer, player, scene } = makeStreamer(chunk);
streamer.update(emptyChunkCenter);
await flush();
expect(player.addCollider.mock.calls[0][0].shape.source.children).toHaveLength(3); // Landplatte, Fahrbahn, Bordstein
const group = scene.add.mock.calls[0][0];
expect(group.children.filter((child) => child.isMesh)).toHaveLength(3); // Gehweg, Markierung, Restfläche
});
```
In `tests/props.test.js`:
In `tests/props.test.js` den ganzen Block `describe("vergeLampPoints", …)` durch diesen ersetzen. `vergeLampPoints` entfällt in Schritt 5; was seine Tests zusicherten — die Laterne steht neben der Fahrbahn, ohne Platz entfällt sie, geschlossene Ringe gehen — trägt der neue Block in der neuen Form weiter:
```js
describe("Laternen auf dem Gehweg", () => {
const gehweg = [{ poly: [[[4, -1], [12, -1], [12, 1], [4, 1]]] }];
// Straße entlang der x-Achse, angle 0: Die Normale ist (0, 1). Der Gehweg liegt nur auf der Seite
// z > 0, 3,5 bis 6 m neben der Mittellinie — wie bei einer Wohnstraße mit 7 m Fahrbahn.
const gehweg = [{ poly: [[[-20, 3.5], [20, 3.5], [20, 6], [-20, 6]]] }];
it("stellt die Laterne auf den Gehweg statt neben die Straße", () => {
it("stellt die Laterne auf den Gehweg, so dicht an die Bordsteinkante wie möglich", () => {
const out = sidewalkLampPoints([{ x: 0, z: 0, angle: 0 }], gehweg);
expect(out).toHaveLength(1);
expect(out[0].x).toBeGreaterThanOrEqual(4);
expect(out[0].x).toBeLessThanOrEqual(12);
expect(out[0].x).toBeCloseTo(0);
expect(out[0].z).toBeCloseTo(4); // der erste Abstand aus LAMP_VERGE, der auf dem Gehweg liegt
});
it("sucht auch auf der anderen Straßenseite", () => {
const gegenueber = [{ poly: [[[-20, -6], [20, -6], [20, -3.5], [-20, -3.5]]] }];
const out = sidewalkLampPoints([{ x: 0, z: 0, angle: 0 }], gegenueber);
expect(out).toHaveLength(1);
expect(out[0].z).toBeCloseTo(-4);
});
it("lässt eine Laterne weg, für die es keinen Gehweg gibt", () => {
@@ -2616,28 +2770,55 @@ describe("Laternen auf dem Gehweg", () => {
});
it("hängt nicht an der Listenposition", () => {
const punkte = [{ x: 0, z: 0, angle: 0 }, { x: 0, z: 5, angle: 0 }];
// Die alte Fassung wählte die Seite nach dem Index: Der zweite Punkt hätte auf der Seite ohne
// Gehweg gesucht und wäre entfallen.
const punkte = [{ x: -10, z: 0, angle: 0 }, { x: 10, z: 0, angle: 0 }];
const vorwaerts = sidewalkLampPoints(punkte, gehweg);
const rueckwaerts = sidewalkLampPoints([...punkte].reverse(), gehweg);
expect(vorwaerts.map((p) => p.x).sort()).toEqual(rueckwaerts.map((p) => p.x).sort());
expect(vorwaerts).toHaveLength(2);
const xs = (out) => out.map((p) => p.x).sort((a, b) => a - b);
expect(xs(vorwaerts)).toEqual(xs(rueckwaerts));
});
it("findet den Gehweg auch, wenn sein Ring geschlossen ist (so liegen die Chunk-Dateien vor)", () => {
const ring = [[-20, 3.5], [20, 3.5], [20, 6], [-20, 6]];
expect(sidewalkLampPoints([{ x: 0, z: 0, angle: 0 }], [{ poly: [[...ring, ring[0]]] }])).toHaveLength(1);
});
it("setzt aus einem Chunk im Format von Teil 2 Laternen auf den Gehweg", () => {
// Wohnstraße entlang z = 50, Gehweg auf beiden Seiten; lampPoints() setzt auf 100 m drei Punkte.
const chunk = {
chunk: [0, 0], origin: [0, 0], land: [], surfaces: [], buildings: [], palms: [],
carriageway: [], curb: [], markings: [], lots: [], bridges: [],
edges: [{ line: [[0, 50], [100, 50]], klasse: "wohnstrasse", breite: 12, fahrbahn: 7 }],
sidewalk: [{ poly: [[[0, 53.5], [100, 53.5], [100, 56], [0, 56]]] },
{ poly: [[[0, 44], [100, 44], [100, 46.5], [0, 46.5]]] }],
};
const out = chunkLampPoints(chunk);
expect(out).toHaveLength(3);
for (const p of out) expect(chunk.sidewalk.some((s) => pointInPolygon(p.x, p.z, s.poly))).toBe(true);
});
});
```
Im Import oben in `tests/props.test.js` entfällt `vergeLampPoints`; `chunkLampPoints` und `sidewalkLampPoints` kommen dazu.
- [ ] **Schritt 2: Tests laufen lassen, Fehlschlag sehen**
Ausführen: `npm test`
Erwartet: die neuen Tests rot, `sidewalkLampPoints is not defined`.
Erwartet: die neuen Tests rot (etwa `sidewalkLampPoints is not a function`). Rot ist außerdem seit Task 13 „materials > deckt genau die Geometrie-Teile ab", weil `MATERIAL_KEYS` noch `roads` enthält — der wird mit Schritt 3 grün.
- [ ] **Schritt 3: `materials.js` umstellen**
`DoubleSide` zum Import aus `three` hinzufügen.
```js
export const MATERIAL_KEYS = ["ground", "sand", "park", "urban", "field", "court", "pool", "parking",
"paths", "carriageway", "curb", "sidewalk", "markings", "lots",
"bridges", "buildings"];
```
In `TEXTURES` `roads: "asphalt_02"` ersetzen durch:
In `TEXTURES` die Zeile `roads: "asphalt_02",` ersetzen durch:
```js
carriageway: "asphalt_02",
@@ -2646,21 +2827,65 @@ In `TEXTURES` `roads: "asphalt_02"` ersetzen durch:
sidewalk: "sidewalk",
```
`SURFACE_COLORS` bekommt `markings: 0xffffff` mit einem Satz Begründung: Die Farbe je Markierung steckt als Vertexfarbe in der Geometrie, das Material ist nur der Träger.
Die beiden Kommentare in `TEXTURES`, die noch `roads` nennen, nachziehen: „klont park und roads" wird „klont park und carriageway"; der Kommentar zu Ruling 56 über `bridges` sagt nur noch, dass eine Brücke dasselbe Straßenstück über Wasser ist und deshalb dieselbe Textur wie die Fahrbahn trägt — `roads.filter(r => r.bridge)` gibt es nicht mehr.
In `createMaterials`:
- `tints` auf `{ carriageway: ROAD_TINT, lots: ROAD_TINT, bridges: ROAD_TINT }` umstellen.
- `CLONES` auf `{ field: { from: "park", tint: FIELD_TINT }, parking: { from: "carriageway" } }`.
- Nach dem Erzeugen der einfarbigen Materialien: `materials.markings.vertexColors = true;` und `materials.curb.side = DoubleSide;` (`DoubleSide` aus three importieren).
```js
// Die Markierung trägt ihre Farbe je Art als Vertexfarbe in der Geometrie; das Material ist nur der Träger.
export const SURFACE_COLORS = { court: 0x6f8c79, pool: 0x3f9fd8, markings: 0xffffff };
```
`POLYGON_OFFSET_ORDER` neu:
In `FALLBACK_COLORS` die Zeile mit `roads` ersetzen durch:
```js
carriageway: 0x505050, lots: 0x505050, curb: 0xbdbdbd, sidewalk: 0xd8d2c4,
bridges: 0xb0b0b0, buildings: 0xe8e4dc,
```
```js
const CLONES = { field: { from: "park", tint: FIELD_TINT }, parking: { from: "carriageway" } };
```
`POLYGON_OFFSET_ORDER` und `POLYGON_OFFSETS` neu:
```js
export const POLYGON_OFFSET_ORDER = ["ground", "urban", "park", "sand", "field", "court", "pool",
"parking", "lots", "sidewalk", "paths", "markings"];
"parking", "lots", "sidewalk", "paths"];
export const POLYGON_OFFSETS = {
...Object.fromEntries(
POLYGON_OFFSET_ORDER.map((key, i) => [
key,
{ polygonOffset: true, polygonOffsetFactor: -i, polygonOffsetUnits: -i },
]),
),
markings: { polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 },
};
```
Den Kommentarblock darüber um einen Absatz ergänzen: Fahrbahn und Bordstein brauchen keinen Versatz, weil sie 15 cm tiefer als echte Geometrie liegen und mit nichts koplanar sind; die Markierung steht ganz vorn, weil sie nur 1 cm über der Fahrbahn liegt.
Den Kommentarblock über `POLYGON_OFFSET_ORDER` um diesen Absatz ergänzen:
```js
// Teil 2: Gehweg und Restflächen liegen als Beläge auf SURFACE_OFFSET und reihen sich unter den
// Wegen ein. Fahrbahn und Bordstein brauchen keinen Versatz, weil sie 15 cm tiefer als echte
// Geometrie liegen und mit nichts koplanar sind. Die Markierung steht nicht in der Rangfolge: Sie
// liegt 1 cm über der Fahrbahn und berührt keinen Belag, ihr einziger Nachbar hat Versatz 0. Die
// oberste Stufe zöge sie beim streifenden Blick am weitesten nach vorn, ohne dass es etwas nützte,
// deshalb ein eigener Versatz von -1. Die Rangfolge wächst damit nur um eine Stufe; die Wege
// stehen jetzt auf -10.
```
In `createMaterials`:
```js
const tints = { carriageway: ROAD_TINT, lots: ROAD_TINT, bridges: ROAD_TINT, park: PARK_TINT };
```
und direkt nach `materials.buildings.vertexColors = true;`:
```js
materials.markings.vertexColors = true; // Farbe je Markierungsart, s. geometry.js MARKING_COLORS
materials.curb.side = DoubleSide; // welche Seite der Wand außen liegt, weiß die Linie nicht
```
- [ ] **Schritt 4: `streaming.js` umstellen**
@@ -2669,25 +2894,32 @@ const DECALS = new Set(["sand", "park", "urban", "field", "court", "pool", "park
"paths", "sidewalk", "markings", "lots"]);
```
Den Kommentar darüber ergänzen: Fahrbahn und Bordstein sind seit Teil 2 Kollisionsgeometrie — die Fahrbahn, weil man in der Mulde fährt, der Bordstein, weil er die Stufe zum Gehweg ist. Gemessen wurde, dass die Kapsel 15 cm ohne Halt erklimmt und erst bei 40 cm blockiert.
Darüber ein Kommentar: Fahrbahn und Bordstein sind seit Teil 2 Kollisionsgeometrie — die Fahrbahn, weil man in der Mulde fährt, der Bordstein, weil er die Stufe zum Gehweg ist. Gemessen wurde, dass die Kapsel 15 und 25 cm ohne Halt erklimmt und erst bei 40 cm blockiert. Den Kommentar in `#load` („Straßen und Bodenflächen sind reine Auflagen; Kollision trägt die Landplatte.") entsprechend nachziehen: Auflagen sind Beläge, Wege, Gehweg, Markierung und Restflächen; Kollision tragen Landplatte, Fahrbahn, Bordstein, Brücken und Gebäude.
- [ ] **Schritt 5: `props.js` umstellen**
`vergeLampPoints` durch `sidewalkLampPoints` ersetzen:
`LAMP_VERGE` ersetzen:
```js
// Abstände zur Mittellinie, in denen ein Platz auf dem Gehweg gesucht wird. Von innen nach
// außen, damit die Laterne so dicht wie möglich an der Bordsteinkante steht.
export const LAMP_VERGE = [4, 5, 6, 7, 8, 9, 10, 11, 12];
```
/** Laternen auf den Gehweg.
`vergeLampPoints` samt Docstring durch diese beiden Funktionen ersetzen:
```js
/**
* Laternen auf den Gehweg.
*
* Vorher wählte vergeLampPoints() die Straßenseite über den Listenindex (gerade links,
* ungerade rechts) und verwarf, was auf der Fahrbahn landete. Das hing an der Reihenfolge der
* Mittellinien in der Datei — beim Sortieren der Ausgabe wechselten 1.848 von 3.921 Laternen
* die Seite, weshalb die Mittellinien als einzige Schicht unsortiert bleiben mussten. Jetzt
* entscheidet die Gehwegfläche: erst links, dann rechts, und wo kein Gehweg ist, steht keine
* Laterne. Das Ergebnis hängt an der Geometrie statt an der Listenposition. */
* Vorher wählte vergeLampPoints() die Straßenseite über den Listenindex (gerade links, ungerade
* rechts) und verwarf, was auf der Fahrbahn landete. Das hing an der Reihenfolge der Mittellinien in
* der Datei — beim Sortieren der Ausgabe wechselten 1.848 von 3.921 Laternen die Seite, weshalb die
* Mittellinien als einzige Schicht unsortiert bleiben mussten. Jetzt entscheidet die Gehwegfläche:
* Senkrecht zur Blickrichtung (Normale zu (cos angle, sin angle) ist (-sin angle, cos angle)) wird
* erst links, dann rechts von innen nach außen durch LAMP_VERGE probiert, und wo kein Gehweg ist,
* steht keine Laterne. Das Ergebnis hängt an der Geometrie statt an der Listenposition.
*/
export function sidewalkLampPoints(points, sidewalk) {
const out = [];
for (const p of points) {
@@ -2707,24 +2939,78 @@ export function sidewalkLampPoints(points, sidewalk) {
}
return out;
}
/**
* Die Laternen eines Chunks: im Abstand von lampPoints() entlang der Straßenkanten, jede auf den
* Gehweg daneben. Eine eigene Funktion, damit ein Test prüfen kann, dass aus einem Chunk im Format
* von Teil 2 wirklich Laternen entstehen — nach dem Formatwechsel las #buildLamps Schlüssel, die es
* nicht mehr gab, und setzte ohne jeden Fehler keine einzige Laterne.
*/
export function chunkLampPoints(chunk) {
return sidewalkLampPoints(lampPoints((chunk.edges ?? []).map((e) => e.line)), chunk.sidewalk ?? []);
}
```
In `#buildLamps`:
In `#buildLamps` die beiden ersten Zeilen durch `const points = chunkLampPoints(chunk);` ersetzen und im Docstring „Laternen entlang der Mittellinien (lampPoints), seitlich zur Fahrbahnkante versetzt (vergeLampPoints, …)" durch „Laternen auf dem Gehweg entlang der Straßenkanten (chunkLampPoints)" ersetzen. Im Docstring von `pointInPolygon` wird aus „wie chunk.roads[i].poly, Task 6" „wie chunk.sidewalk[i].poly".
- [ ] **Schritt 6: Bestehende Tests umstellen**
`tests/materials.test.js`:
- `expect(TEXTURES.parking).toBe(TEXTURES.roads)` → `TEXTURES.carriageway`
- `expect(Object.keys(SURFACE_COLORS).sort()).toEqual(["court", "pool"])` → `["court", "markings", "pool"]`
- `expect(TEXTURES.bridges).toBe(TEXTURES.roads)` → `TEXTURES.carriageway`, der Kommentar darüber ohne `roads.filter(r => r.bridge)`
- die erwartete `POLYGON_OFFSET_ORDER` → `["ground", "urban", "park", "sand", "field", "court", "pool", "parking", "lots", "sidewalk", "paths"]`, der Testname entsprechend („… < Parkplatz < Restfläche < Gehweg < Weg")
- im Test zu `applyPolygonOffsets` die Platzhalterschlüssel auf `["ground", "urban", "park", "sand", "field", "court", "pool", "parking", "lots", "sidewalk", "paths", "markings", "carriageway", "curb", "bridges", "buildings", "water", "seafloor"]` und die unberührten auf `["carriageway", "curb", "bridges", "buildings", "water", "seafloor"]`
`tests/placesSite.test.js`:
```js
const points = sidewalkLampPoints(lampPoints((chunk.edges ?? []).map((e) => e.line)),
chunk.sidewalk ?? []);
import { chunkLampPoints, pointInPolygon } from "../src/world/props.js";
```
- [ ] **Schritt 6: Alle Tests**
```js
// Straße ist seit Teil 2 alles, was früher der Korridor war: Fahrbahn, Gehweg und Restflächen.
const onRoad = [...(c.carriageway ?? []), ...(c.sidewalk ?? []), ...(c.lots ?? [])]
.some((road) => pointInPolygon(u, v, road.poly));
```
Ausführen: `npm test`
Erwartet: grün. Tests, die `vergeLampPoints` oder `chunk.roads` benutzen, auf die neuen Namen umstellen, ohne ihre Aussage zu ändern.
```js
const lamps = chunkLampPoints(c).map((p) => toWorld(p.x, p.z));
```
- [ ] **Schritt 7: Commit**
und im Kommentar über dem Laternentest „bis 16 m neben ihrer Mittellinie" → „bis 12 m neben ihrer Straßenkante (LAMP_VERGE)". Dieser Test liest die committeten Chunks, und die liegen bis Task 16 im alten Format vor: Straße und Laternen prüft er deshalb erst mit den neuen Daten wieder wirklich. Task 16 muss ihn mit den neuen Daten grün sehen.
- [ ] **Schritt 7: Docstring von `_line()` nachziehen**
In `tools/extract/leonida/emit.py`:
```python
"""Ein Linienzug in Chunk-Koordinaten, richtungsunabhängig festgelegt.
normalize() dreht eine Linie in eine feste Laufrichtung, damit dieselbe Linie unabhängig vom
Rechenweg gleich in der Datei steht. Zulässig ist das, weil kein Verbraucher aus Richtung oder
Listenposition etwas ableitet: Die Laternen (sidewalkLampPoints() in props.js) suchen ihre
Straßenseite an der Gehwegfläche, und die Oberkante des Bordsteins (kerbGeometry() in
geometry.js) findet ihre Seite an der Fahrbahnfläche. Bei den früheren centerlines war das
nicht so, deshalb blieben sie unsortiert und unnormalisiert.
"""
```
- [ ] **Schritt 8: Alle Tests**
```bash
git add src/world tests
npm test
cd tools/extract && PYTHONUTF8=1 uv run pytest -q
```
Erwartet: beide grün, pytest mit derselben Zahl wie vorher (nur ein Docstring geändert).
- [ ] **Schritt 9: Commit**
```bash
cd ../.. && git status --short
git add src/world/materials.js src/world/streaming.js src/world/props.js tools/extract/leonida/emit.py \
tests/materials.test.js tests/streaming.test.js tests/props.test.js tests/placesSite.test.js
git commit -m "feat(world): Materialien, Kollision und Laternen für die neuen Straßen"
```
@@ -2832,15 +3118,21 @@ git commit -m "feat(extract): Minimap zeichnet Fahrbahn, Gehweg und Restflächen
### Task 16: Welt neu erzeugen und abnehmen
> **Skip entfernen:** `test_committed_map_files_match_the_committed_chunks` in `tools/extract/tests/test_overview.py` ist seit der Minimap-Umstellung übersprungen, weil die committeten Chunks bis zu dieser Aufgabe noch im alten Format vorlagen. Sobald `public/data/vice-a` hier neu erzeugt ist, **muss der Skip fallen** und der Test grün sein. Das gehört zur Abnahme, nicht zu einer späteren Aufräumrunde.
> **Skip entfernen:** `test_committed_map_files_match_the_committed_chunks` in `tools/extract/tests/test_overview.py` ist seit der Minimap-Umstellung übersprungen, weil die committeten Chunks bis zu dieser Aufgabe noch im alten Format vorlagen. Sobald `public/data/vice-a` hier neu erzeugt ist, **muss der Skip fallen** und der Test grün sein (Schritt 4). Das gehört zur Abnahme, nicht zu einer späteren Aufräumrunde.
> **Vor dem Auftrag nachgesehen, drei Punkte sind hier schon berichtigt:**
> (1) Die Erwartungen der Geometrie-Abnahme stammten von vor dem Flamingo Park und vor drei Fixrunden. Jetzt stehen dort die Zahlen, die der Controller mit der Kette aus Task 12 gemessen hat, und dasselbe Messskript wie in Task 12.
> (2) Der Entwurf verlangt unter „Tests" einen Integrationslauf auf den echten Daten, der Kantenzahl je Klasse, Markierungszahl und Restflächenzahl festhält. Keine Aufgabe hatte ihn; er entsteht in Schritt 6, sobald die Daten feststehen. Die Kreuzungszahl steht in keinem Chunk, weil keine Knoten ausgeliefert werden — sie kommt aus der Zeile „Straßennetz" in die Messwerte des Entwurfs, nicht in den Test.
> (3) Zwei Tests lesen die committeten Chunks und prüfen die neuen Schichten erst mit den neuen Daten wirklich: der übersprungene Minimap-Abgleich und `tests/placesSite.test.js` (seit Task 14 gegen Fahrbahn, Gehweg, Restflächen und `chunkLampPoints`). Beide müssen nach dem Neuerzeugen grün sein.
**Dateien:**
- Erzeugt: `public/data/vice-a/*`
- Ändern: `docs/superpowers/specs/2026-09-16-strassen-design.md` (Messwerte)
- Neu: `tools/extract/tests/test_committed_roads.py`
- Ändern: `tools/extract/tests/test_overview.py` (Skip entfernen), `docs/superpowers/specs/2026-09-16-strassen-design.md` (Messwerte)
**Schnittstellen:**
- Verbraucht: alles.
- Liefert: die ausgelieferte Welt und die Zahlen für den Entwurf.
- Liefert: die ausgelieferte Welt, einen Test, der sie festhält, und die Zahlen für den Entwurf.
- [ ] **Schritt 1: Beide Suiten**
@@ -2848,66 +3140,175 @@ git commit -m "feat(extract): Minimap zeichnet Fahrbahn, Gehweg und Restflächen
cd tools/extract && PYTHONUTF8=1 uv run pytest -q
cd ../.. && npm test
```
Erwartet: beide grün. Ist etwas rot: nicht weitermachen.
Erwartet: beide grün (pytest mit einem übersprungenen Test). Ist etwas rot: nicht weitermachen.
- [ ] **Schritt 2: Welt erzeugen**
```bash
cd tools/extract && PYTHONUTF8=1 uv run python run.py
```
Erwartet: Exit 0, die Zeilen „Straßennetz: …", „Straßen: …", „Flamingo Park: …" und **genau eine** Warnung.
Erwartet: Exit 0 nach rund vier Minuten, die Zeilen „Straßennetz: 1537 Knoten, 1790 Kanten", „Straßen: 830 Bordsteinstücke, 171 Restflächen, 22394 Markierungen {…}, 56 Palmen an der Fahrbahn entfernt", „Flamingo Park: …" und **genau eine** Warnung (`Zufahrt schneidet die Sperrfläche tribuene`). Die ganze Ausgabe in den Bericht.
- [ ] **Schritt 3: Abnahme der Lauf-Stabilität**
```bash
cd /a/developer/GitHub-desktop/gta6 && git add public/data/vice-a && git commit -m "chore(data): Welt mit Bordstein, Gehweg und Markierung neu erzeugt"
cd /a/developer/GitHub-desktop/gta6 && git status --short
git add public/data/vice-a && git commit -m "chore(data): Welt mit Bordstein, Gehweg und Markierung neu erzeugt"
cd tools/extract && PYTHONUTF8=1 uv run python run.py
cd .. && git status --short
cd ../.. && git status --short
```
Erwartet: **keine einzige geänderte Datei**, nur die beiden Kit-Ordner als `??`. Ist das nicht so, ist die Ausgabe nicht lauf-stabil — melden, nicht schönreden.
Erwartet: vor dem Commit nur Dateien unter `public/data/vice-a` und die beiden Kit-Ordner; nach dem zweiten Lauf **keine einzige geänderte Datei**, nur die beiden Kit-Ordner als `??`. Ist das nicht so, ist die Ausgabe nicht lauf-stabil — melden, nicht schönreden.
- [ ] **Schritt 4: Abnahme der Geometrie**
- [ ] **Schritt 4: Skip entfernen, beide Suiten mit den neuen Daten**
In `tools/extract/tests/test_overview.py` den `@pytest.mark.skip(...)` vor `test_committed_map_files_match_the_committed_chunks` entfernen, dazu den Satz im Docstring, der das Überspringen erklärt.
```bash
cd tools/extract && PYTHONUTF8=1 uv run pytest -q
cd ../.. && npm test
```
Erwartet: beide grün, pytest ohne übersprungenen Test, `tests/placesSite.test.js` grün. Wird etwas rot, ist das ein Befund über die neuen Daten: melden, nicht den Test anpassen.
```bash
git add tools/extract/tests/test_overview.py
git commit -m "test(extract): Minimap-Abgleich mit den neuen Daten wieder scharf"
```
- [ ] **Schritt 5: Abnahme der Geometrie**
```bash
cd tools/extract && PYTHONUTF8=1 uv run python - <<'EOF'
import json
from collections import Counter
from pathlib import Path
from shapely.geometry import Polygon
from shapely.ops import unary_union
from shapely.geometry import LineString, Point, Polygon
ROOT = Path("../../public/data/vice-a")
gesamt = {}
for key in ("carriageway", "sidewalk", "lots", "markings"):
flaechen = []
for path in sorted(ROOT.glob("chunk_*.json")):
c = json.loads(path.read_text(encoding="utf-8"))
stuecke, flaeche, arten, klassen = Counter(), Counter(), Counter(), Counter()
bordstein, palmen_auf_fahrbahn = 0.0, 0
for path in sorted(ROOT.glob("chunk_*.json")):
c = json.loads(path.read_text(encoding="utf-8"))
for key in ("carriageway", "sidewalk", "lots", "markings", "curb", "edges"):
stuecke[key] += len(c.get(key, []))
for key in ("carriageway", "sidewalk", "lots"):
for item in c.get(key, []):
flaechen.append(Polygon(item["poly"][0], item["poly"][1:]).buffer(0))
gesamt[key] = (len(flaechen), unary_union(flaechen).area / 1e6 if flaechen else 0.0)
for key, (n, km2) in gesamt.items():
print(f"{key}: {n} Stücke, {km2:.3f} km²")
kerb = sum(len(json.loads(p.read_text(encoding='utf-8')).get("curb", []))
for p in sorted(ROOT.glob("chunk_*.json")))
print("Bordsteinstücke:", kerb)
flaeche[key] += Polygon(item["poly"][0], item["poly"][1:]).area
bordstein += sum(LineString(line).length for line in c.get("curb", []))
arten.update(m["art"] for m in c.get("markings", []))
klassen.update(e["klasse"] for e in c.get("edges", []))
fahrbahn = [Polygon(item["poly"][0], item["poly"][1:]) for item in c.get("carriageway", [])]
palmen_auf_fahrbahn += sum(1 for x, y in c.get("palms", [])
if any(f.contains(Point(x, y)) for f in fahrbahn))
print("Stücke, je Chunk gezählt:", dict(stuecke))
print("Flächen km²:", {k: round(v / 1e6, 4) for k, v in flaeche.items()})
print("Bordstein km:", round(bordstein / 1e3, 2))
print("Markierungsstücke je Art:", dict(sorted(arten.items())))
print("Kantenstücke je Klasse:", dict(klassen))
print("Palmen auf der Fahrbahn:", palmen_auf_fahrbahn)
EOF
```
Erwartet, gegen die Messung des Controllers nach Task 7 gehalten: Fahrbahn 0,914 km², Gehweg 0,295 km², Restflächen 0,093 km² (heute gilt die ganze Fläche von 1,316 km² als Straße). Alle Zahlen in den Bericht, Abweichungen über 5 % ausdrücklich benennen.
Erwartet, vom Controller mit derselben Kette gemessen (dieselben Zahlen wie in Task 12, Schritt 5):
- [ ] **Schritt 5: Messwerte in den Entwurf**
| Größe | Erwartet |
|---|---|
| Fahrbahn | **0,9084 km²** |
| Gehweg | **0,2983 km²** |
| Restflächen | **0,0973 km²** |
| Bordstein | **120,61 km** |
| Markierungsstücke je Art | je Art mindestens so viele wie in der Zeile „Straßen:" aus Schritt 2; `gelb_gestrichelt` und `weiss_gestrichelt` nicht 0 |
| Kantenstücke je Klasse | alle vier Klassen besetzt |
| Palmen auf der Fahrbahn | **0** |
Im Abschnitt „Fehlerfälle und Risiken" von `docs/superpowers/specs/2026-09-16-strassen-design.md` einen Punkt „Gemessen im Spiel" ergänzen: die Zahlen aus Schritt 4, die Zeilen des Laufs, das Ergebnis der beiden Abnahmen und die Zeit aus Task 1.
Zur Einordnung: Heute gilt die ganze Korridorfläche von 1,316 km² als Straße. Weicht eine Fläche oder die Bordsteinlänge um mehr als 1 % ab, ist das ein Befund — melden, nicht die Erwartung anpassen. Die ganze Ausgabe in den Bericht; die Stückzahlen braucht Schritt 6.
- [ ] **Schritt 6: Commit**
- [ ] **Schritt 6: Die ausgelieferten Straßen festhalten**
```bash
git add docs/superpowers/specs/2026-09-16-strassen-design.md
git commit -m "docs: Messwerte des Straßenumbaus im Entwurf festhalten"
`tools/extract/tests/test_committed_roads.py`:
```python
"""Die ausgelieferten Straßen, festgehalten (Entwurf, Abschnitt Tests: Integrationslauf).
Der Test liest die committeten Chunks, nicht den Lauf — die Kacheln liegen nicht im Repository.
Die Zahlen sind beim Neuerzeugen gemessen. Ändert sich die Extraktion absichtlich, gehören die neuen
Zahlen hierher, mit einem Satz im Commit, warum. Die Kreuzungszahl fehlt, weil die Chunks keine
Knoten tragen; sie steht in den Messwerten des Entwurfs.
"""
from __future__ import annotations
import json
from collections import Counter
from pathlib import Path
from shapely.geometry import LineString, Point, Polygon
ROOT = Path(__file__).resolve().parents[3] / "public" / "data" / "vice-a"
def _chunks() -> list[dict]:
return [json.loads(p.read_text(encoding="utf-8")) for p in sorted(ROOT.glob("chunk_*.json"))]
def _flaeche(chunks, key) -> float:
return sum(Polygon(i["poly"][0], i["poly"][1:]).area for c in chunks for i in c.get(key, []))
def test_kantenstuecke_je_klasse():
assert dict(Counter(e["klasse"] for c in _chunks() for e in c["edges"])) == {
# die vier Zahlen aus „Kantenstücke je Klasse", Schritt 5
}
def test_markierungsstuecke_je_art():
assert dict(Counter(m["art"] for c in _chunks() for m in c["markings"])) == {
# die sechs Zahlen aus „Markierungsstücke je Art", Schritt 5
}
def test_restflaechen_und_bordsteinstuecke():
chunks = _chunks()
assert sum(len(c["lots"]) for c in chunks) == ... # „lots" aus „Stücke, je Chunk gezählt"
assert sum(len(c["curb"]) for c in chunks) == ... # „curb" aus „Stücke, je Chunk gezählt"
def test_flaechen_und_bordsteinlaenge():
chunks = _chunks()
assert round(_flaeche(chunks, "carriageway") / 1e6, 4) == ... # aus „Flächen km²"
assert round(_flaeche(chunks, "sidewalk") / 1e6, 4) == ...
assert round(_flaeche(chunks, "lots") / 1e6, 4) == ...
laenge = sum(LineString(line).length for c in chunks for line in c["curb"])
assert round(laenge / 1e3, 2) == ... # aus „Bordstein km"
def test_keine_palme_auf_der_fahrbahn():
for c in _chunks():
fahrbahn = [Polygon(i["poly"][0], i["poly"][1:]) for i in c["carriageway"]]
auf = [p for p in c["palms"] if any(f.contains(Point(p)) for f in fahrbahn)]
assert auf == [], f"Chunk {c['chunk']}"
```
- [ ] **Schritt 7: Dem Controller übergeben**
Die Platzhalter (`{…}`-Kommentare und `...`) mit den **genauen** Zahlen aus Schritt 5 füllen — sie werden hier festgehalten, nicht geschätzt. Dann:
Die Prüfung im Browser macht der Controller selbst, weil dafür der Browser, eine Fahrt über den Bordstein und eine Abstimmung mit dem Nutzer nötig sind. In den Bericht gehören: die Zahlen aus Schritt 4, beide Abnahmen, die Testzahlen und alles, was offen blieb.
```bash
cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_committed_roads.py -q
```
Erwartet: 5 bestanden. Gegenprobe, damit der Test nicht nur sich selbst bestätigt: eine der Zahlen vorübergehend um 1 ändern, rot sehen, zurückändern — Ausgabe in den Bericht.
- [ ] **Schritt 7: Messwerte in den Entwurf**
Im Abschnitt „Fehlerfälle und Risiken" von `docs/superpowers/specs/2026-09-16-strassen-design.md` einen Punkt „Gemessen im Spiel" ergänzen: die Zahlen aus Schritt 5, die Zeilen des Laufs aus Schritt 2 (mit Knoten- und Kantenzahl), das Ergebnis der Abnahmen aus Schritt 3 und 4 und die Zeit aus Task 1.
- [ ] **Schritt 8: Commit**
```bash
cd ../.. && git status --short
git add docs/superpowers/specs/2026-09-16-strassen-design.md tools/extract/tests/test_committed_roads.py
git commit -m "test(extract): ausgelieferte Straßen festhalten, Messwerte in den Entwurf"
```
- [ ] **Schritt 9: Dem Controller übergeben**
Die Prüfung im Browser macht der Controller selbst, weil dafür der Browser, eine Fahrt über den Bordstein und eine Abstimmung mit dem Nutzer nötig sind. In den Bericht gehören: die Ausgaben aus Schritt 2 und 5, alle Abnahmen, die Testzahlen und alles, was offen blieb.
---
@@ -2937,6 +3338,7 @@ Die Prüfung im Browser macht der Controller selbst, weil dafür der Browser, ei
| Risiko „Auto in der Mulde" | Controller, Browserprüfung nach Task 16 |
| Risiko „alle Chunks ändern sich" | 16, Schritt 3 |
| Namensnennung | 12 (Manifest) |
| Tests: Integrationslauf auf den echten Daten | 16 (Schritt 6) |
**Offene Punkte, die der Controller entscheidet**