feat(world): Materialien, Kollision und Laternen für die neuen Straßen

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-17 21:26:18 +02:00
1 parent ca4f7161af
commit 25248b3929
8 files changed
+205 -110

No files matched your search

+38 -19
View File
@@ -1,30 +1,35 @@
import { DataTexture, MeshStandardMaterial, RepeatWrapping, RGBAFormat, SRGBColorSpace, TextureLoader,
import { DataTexture, DoubleSide, MeshStandardMaterial, RepeatWrapping, RGBAFormat, SRGBColorSpace, TextureLoader,
Vector2 } from "three";
export const MATERIAL_KEYS = ["ground", "sand", "park", "urban", "field", "court", "pool", "parking", "paths",
"roads", "bridges", "buildings"];
export const MATERIAL_KEYS = ["ground", "sand", "park", "urban", "field", "court", "pool", "parking",
"paths", "carriageway", "curb", "sidewalk", "markings", "lots",
"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
// Flamingo Park (docs/superpowers/specs/2026-09-15-flamingo-park-design.md): Felder im Gras des Parks, der
// Parkplatz im Asphalt der Straßen. Beide lädt createMaterials nicht neu, sondern klont park und roads.
// Parkplatz im Asphalt der Straßen. Beide lädt createMaterials nicht neu, sondern klont park und carriageway.
field: "leafy_grass",
parking: "asphalt_02",
paths: "concrete_pavement", // helle Betonplatten, auch für die Gehwege aus Teil 2 gedacht
roads: "asphalt_02",
// Ruling 56: eine Brücke ist in den Daten dasselbe Straßenstück, nur über Wasser
// (geometry.js trennt sie einzig über roads.filter(r => r.bridge)) — dieselbe
// Textur wie roads, sonst zeigt jeder Straße-Brücke-Übergang eine Farbnaht.
carriageway: "asphalt_02",
lots: "asphalt_02", // Parkplätze und Vorplätze: derselbe Asphalt wie die Fahrbahn
curb: "curb", // modular_sidewalk_curb_kit (Just8, CC BY 4.0), s. tools/assets/textures.mjs
sidewalk: "sidewalk",
// Ruling 56: eine Brücke ist in den Daten dasselbe Straßenstück, nur über Wasser — dieselbe
// Textur wie die Fahrbahn, sonst zeigt jeder Straße-Brücke-Übergang eine Farbnaht.
bridges: "asphalt_02",
buildings: "white_stucco",
};
// Plätze und Pool sind einfarbig: Sandplätze, Hartplätze und Poolwasser hat Poly Haven nicht.
export const SURFACE_COLORS = { court: 0x6f8c79, pool: 0x3f9fd8 };
// 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 };
const FALLBACK_COLORS = {
ground: 0xa8b878, sand: 0xe8d888, park: 0x8bc34a, urban: 0xd8d8d8, paths: 0xd8d2c4,
roads: 0x505050, bridges: 0xb0b0b0, buildings: 0xe8e4dc,
carriageway: 0x505050, lots: 0x505050, curb: 0xbdbdbd, sidewalk: 0xd8d2c4,
bridges: 0xb0b0b0, buildings: 0xe8e4dc,
};
// Ruling 55: asphalt_02 misst roh heller (Ø 89.5) als concrete_pavement_03 (Ø 65.9) —
// Stadtflächen wirkten dadurch wie dunkle Flecken auf der Straße, das Straßennetz
@@ -41,7 +46,7 @@ const PARK_TINT = 0xb4dc94;
// im Rasen. material.color kann nur abdunkeln, also Rot und Blau stärker als Grün.
const FIELD_TINT = 0xc8f0b0;
// Klone statt zweiter Ladevorgang: dieselben Texturen, eigene Tönung und eigener polygonOffset.
const CLONES = { field: { from: "park", tint: FIELD_TINT }, parking: { from: "roads" } };
const CLONES = { field: { from: "park", tint: FIELD_TINT }, parking: { from: "carriageway" } };
/** Kachelbare Wellen-Normalmap, weil Poly Haven keine Wassertextur hat. */
export function waterNormalData(size = 128) {
@@ -138,14 +143,26 @@ async function loadSet(loader, slug, fallback, { tint, maxAnisotropy } = {}) {
//
// Flamingo Park: Felder, Plätze, Pool und Parkplatz liegen über dem Rasen, die Wege über allen Flächen
// und unter den Straßen, die sie an der Parkkante berühren.
export const POLYGON_OFFSET_ORDER = ["ground", "urban", "park", "sand", "field", "court", "pool", "parking", "paths", "roads"];
//
// 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.
export const POLYGON_OFFSET_ORDER = ["ground", "urban", "park", "sand", "field", "court", "pool",
"parking", "lots", "sidewalk", "paths"];
export const POLYGON_OFFSETS = Object.fromEntries(
POLYGON_OFFSET_ORDER.map((key, i) => [
key,
{ polygonOffset: true, polygonOffsetFactor: -i, polygonOffsetUnits: -i },
]),
);
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 },
};
/** Trägt POLYGON_OFFSETS in die passenden Materialien ein. Nur die benannten Schichten —
* Brücken (eigene, geometrische Lösung, s. geometry.js BRIDGE-Kommentar), Gebäude, Wasser und
@@ -159,7 +176,7 @@ export function applyPolygonOffsets(materials) {
export async function createMaterials(maxAnisotropy) {
const loader = new TextureLoader();
const tints = { roads: ROAD_TINT, bridges: ROAD_TINT, park: PARK_TINT };
const tints = { carriageway: ROAD_TINT, lots: ROAD_TINT, bridges: ROAD_TINT, park: PARK_TINT };
const textured = MATERIAL_KEYS.filter((key) => !CLONES[key] && !SURFACE_COLORS[key]);
const entries = await Promise.all(
textured.map(async (key) =>
@@ -174,6 +191,8 @@ export async function createMaterials(maxAnisotropy) {
materials[key] = new MeshStandardMaterial({ color, roughness: key === "pool" ? 0.25 : 0.9, metalness: 0 });
}
materials.buildings.vertexColors = true; // pastellige Tönung je Gebäude
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
applyPolygonOffsets(materials);
const normal = new DataTexture(waterNormalData(128), 128, 128, RGBAFormat);
+38 -30
View File
@@ -6,7 +6,9 @@ import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
const PALM_MODEL_HEIGHT = 439.74; // Einheiten im OBJ, gemessen
const TRUNK_RADIUS = 0.22;
const LAMP_VERGE = [6, 8, 10, 12, 14, 16]; // Meter, von innen nach außen (Ruling 60)
// 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];
// Befund B (Fix-Runde 3): street_lamp_02.glb ist mit `npx @gltf-transform/cli@4.5.0 inspect`
// vermessen nur 1,65736 m hoch (bboxMax.y 1.26478 - bboxMin.y -0.39258) — Poly Haven selbst
@@ -77,7 +79,7 @@ function inRing(x, z, ring) {
/**
* Liegt (x, z) auf der Fläche, die dieses Ringpaket beschreibt — Außenring zuerst, danach
* Löcher (wie chunk.roads[i].poly, Task 6)? Auf der Fläche heißt: im Außenring und in
* Löcher (wie chunk.sidewalk[i].poly)? Auf der Fläche heißt: im Außenring und in
* keinem der Löcher.
*/
export function pointInPolygon(x, z, rings) {
@@ -85,38 +87,46 @@ export function pointInPolygon(x, z, rings) {
}
/**
* Versetzt lampPoints()-Punkte seitlich von der Fahrbahn (Ruling 60). lampPoints kennt nur
* die Mittellinie und setzt jeden Punkt genau darauf — ohne diesen Schritt stünde jede
* Laterne mitten auf der Fahrbahn. Für jeden Punkt wird senkrecht zur Blickrichtung
* (Normale zu (cos angle, sin angle) ist (-sin angle, cos angle)) von innen nach außen
* durch LAMP_VERGE probiert; die Seite wechselt von Punkt zu Punkt (gerader Index links,
* ungerader rechts), das halbiert die Zahl je Straßenseite und wirkt weniger wie eine
* durchgehende Reihe. Der erste Abstand, dessen Punkt außerhalb aller Straßenpolygone des
* Chunks liegt, gewinnt; findet keiner der sechs einen freien Punkt, entfällt die Laterne
* ganz — wie bei den Palmen (Ruling 22) kein Sonderfall, sondern die vorgesehene Reaktion
* auf eine Straße, die breiter ist als der Suchbereich.
* Laternen auf den Gehweg.
*
* Bewusst nicht in der Python-Pipeline (Ruling 60): chunk.roads kennt nur die Straßen
* dieses einen Chunks, nicht die des Nachbarn — nahe einer Chunk-Grenze kann eine Laterne
* deshalb auf der Fahrbahn stehen bleiben, obwohl eine Straße im Nachbarchunk sie deckt.
* Bekannter, hingenommener Rest (siehe Bericht für Zahlen an echten Daten).
* 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 vergeLampPoints(points, roads) {
export function sidewalkLampPoints(points, sidewalk) {
const out = [];
points.forEach((p, i) => {
const side = i % 2 === 0 ? 1 : -1;
const nx = -Math.sin(p.angle) * side, nz = Math.cos(p.angle) * side;
for (const verge of LAMP_VERGE) {
const x = p.x + nx * verge, z = p.z + nz * verge;
if (!roads.some((r) => pointInPolygon(x, z, r.poly))) {
out.push({ x, z, angle: p.angle });
return;
for (const p of points) {
let placed = null;
for (const side of [1, -1]) {
const nx = -Math.sin(p.angle) * side, nz = Math.cos(p.angle) * side;
for (const verge of LAMP_VERGE) {
const x = p.x + nx * verge, z = p.z + nz * verge;
if (sidewalk.some((s) => pointInPolygon(x, z, s.poly))) {
placed = { x, z, angle: p.angle };
break;
}
}
if (placed) break;
}
});
if (placed) out.push(placed);
}
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 ?? []);
}
export function palmTransforms(points, seed = 1) {
return points.map(([x, z]) => {
const key = `${x.toFixed(1)},${z.toFixed(1)}`;
@@ -220,16 +230,14 @@ export class PropLayer {
}
/**
* Laternen entlang der Mittellinien (lampPoints), seitlich zur Fahrbahnkante versetzt
* (vergeLampPoints, Ruling 60 — Fix-Runde 1; siehe dort für die Begründung). Skalierung
* Laternen auf dem Gehweg entlang der Straßenkanten (chunkLampPoints). Skalierung
* auf STREET_LAMP_HEIGHT und die Fuß-Anhebung stecken bereits in this.lampParts[*].matrix
* (s. load(), Befund B / Fix-Runde 3) — hier wird jede Laterne nur noch bei y = 0 auf den
* Boden gesetzt. Anders als bei der Palme keine Zufallsskalierung je Instanz: jede Laterne
* ist gleich hoch, wie ein echtes Serienprodukt.
*/
#buildLamps(chunk, visual) {
const centerlinePoints = lampPoints(chunk.centerlines ?? []);
const points = vergeLampPoints(centerlinePoints, chunk.roads ?? []);
const points = chunkLampPoints(chunk);
if (!points.length || !this.lampParts?.length) return;
const dummy = new Object3D();
const local = new Matrix4();
+7 -2
View File
@@ -4,7 +4,11 @@ import { buildChunkGeometry } from "./geometry.js";
import { pointInPolygon } from "./props.js";
const key = (i, j) => `${i}_${j}`;
const DECALS = new Set(["roads", "sand", "park", "urban", "field", "court", "pool", "parking", "paths"]);
// 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.
const DECALS = new Set(["sand", "park", "urban", "field", "court", "pool", "parking",
"paths", "sidewalk", "markings", "lots"]);
/** Mittelpunkt eines Chunks in three-Koordinaten. */
function chunkCenter(i, j) {
@@ -89,7 +93,8 @@ export class ChunkStreamer {
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.
// Auflagen sind Beläge, Wege, Gehweg, Markierung und Restflächen; Kollision tragen
// Landplatte, Fahrbahn, Bordstein, Brücken und Gebäude.
(DECALS.has(name) ? group : collide).add(mesh);
}
// this.props ist seit Task 8 ein optionales Konstruktorfeld (siehe dortiger
+41 -10
View File
@@ -15,9 +15,9 @@ describe("materials", () => {
it("gibt Feldern das Gras des Parks, dem Parkplatz den Asphalt der Straßen und Wegen Betonplatten", () => {
expect(TEXTURES.field).toBe(TEXTURES.park);
expect(TEXTURES.parking).toBe(TEXTURES.roads);
expect(TEXTURES.parking).toBe(TEXTURES.carriageway);
expect(TEXTURES.paths).toBe("concrete_pavement");
expect(Object.keys(SURFACE_COLORS).sort()).toEqual(["court", "pool"]);
expect(Object.keys(SURFACE_COLORS).sort()).toEqual(["court", "markings", "pool"]);
});
it("deckt Strand und Park mit eigenen Materialien ab", () => {
@@ -26,9 +26,8 @@ describe("materials", () => {
});
it("gibt Brücken dieselbe Textur wie Straßen, damit an der Übergangsnaht kein Farbsprung entsteht (Ruling 56)", () => {
// Brücken sind in den Daten dasselbe Straßenstück, nur über Wasser
// (geometry.js trennt sie einzig über roads.filter(r => r.bridge)).
expect(TEXTURES.bridges).toBe(TEXTURES.roads);
// Brücken sind in den Daten dasselbe Straßenstück, nur über Wasser.
expect(TEXTURES.bridges).toBe(TEXTURES.carriageway);
});
it("erzeugt eine Wellen-Normalmap mit neutraler Grundfarbe", () => {
@@ -47,8 +46,9 @@ describe("materials", () => {
// Datenstruktur, applyPolygonOffsets() eine reine Funktion auf Platzhalterobjekten — beide
// brauchen weder createMaterials() noch echtes Texturladen.
describe("Tiefenpuffer-Versatz je Schicht (gegen Flackern zwischen Boden/Stadt/Park/Strand/Straße)", () => {
it("ordnet die Schichten Boden < Stadtfläche < Park < Strand < Feld < Platz < Pool < Parkplatz < Weg < Straße", () => {
expect(POLYGON_OFFSET_ORDER).toEqual(["ground", "urban", "park", "sand", "field", "court", "pool", "parking", "paths", "roads"]);
it("ordnet die Schichten Boden < Stadtfläche < Park < Strand < Feld < Platz < Pool < Parkplatz < Restfläche < Gehweg < Weg", () => {
expect(POLYGON_OFFSET_ORDER).toEqual(["ground", "urban", "park", "sand", "field", "court", "pool",
"parking", "lots", "sidewalk", "paths"]);
});
it("aktiviert polygonOffset für jede der fünf Schichten", () => {
@@ -79,16 +79,47 @@ describe("Tiefenpuffer-Versatz je Schicht (gegen Flackern zwischen Boden/Stadt/P
it("trägt den Versatz nur in Boden/Stadtfläche/Park/Strand/Straße ein — Brücken, Gebäude, "
+ "Wasser und Meeresboden bleiben unberührt (eigene Materialobjekte, s. Auftrag)", () => {
const fake = Object.fromEntries(
["ground", "urban", "park", "sand", "field", "court", "pool", "parking", "paths", "roads",
"bridges", "buildings", "water", "seafloor"]
["ground", "urban", "park", "sand", "field", "court", "pool", "parking", "lots", "sidewalk",
"paths", "markings", "carriageway", "curb", "bridges", "buildings", "water", "seafloor"]
.map((k) => [k, {}]),
);
applyPolygonOffsets(fake);
for (const key of POLYGON_OFFSET_ORDER) {
expect(fake[key]).toMatchObject(POLYGON_OFFSETS[key]);
}
for (const key of ["bridges", "buildings", "water", "seafloor"]) {
for (const key of ["carriageway", "curb", "bridges", "buildings", "water", "seafloor"]) {
expect(fake[key]).toEqual({});
}
});
});
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("gibt der Markierung eine Farbe statt einer Textur", () => {
expect(SURFACE_COLORS.markings).toBeDefined();
expect(TEXTURES.markings).toBeUndefined();
});
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();
});
});
+6 -4
View File
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE, chunkForWorld } from "../src/world/coords.js";
import { PLACES, PLACE_HIDE_RADIUS, PLACE_SHOW_RADIUS, placeTransform } from "../src/world/places.js";
import { lampPoints, pointInPolygon, vergeLampPoints } from "../src/world/props.js";
import { chunkLampPoints, pointInPolygon } from "../src/world/props.js";
import { withoutReplacedBuildings } from "../src/world/streaming.js";
// Radien aus ChunkStreamer.update (src/world/streaming.js), gemessen zum Chunk-Mittelpunkt.
@@ -72,7 +72,9 @@ describe.each(PLACES.map((place) => [place.id, place]))("Ort %s auf der Karte",
const c = chunk(i, j);
const u = X - c.origin[0], v = -Y - c.origin[1];
const onLand = c.land.some((rings) => pointInPolygon(u, v, rings));
const onRoad = c.roads.some((road) => pointInPolygon(u, v, road.poly));
// 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));
const inBuilding = standingBuildings(i, j).some((building) => pointInPolygon(u, v, building.poly));
return !onLand || onRoad || inBuilding;
});
@@ -106,7 +108,7 @@ describe.each(PLACES.map((place) => [place.id, place]))("Ort %s auf der Karte",
});
// Bruch: eine Palme oder Laterne mitten im Ort. Beide entstehen je Chunk und wissen nichts von Orten;
// Laternen stehen bis 16 m neben ihrer Mittellinie, deshalb zählen auch die Nachbarchunks.
// Laternen stehen bis 12 m neben ihrer Straßenkante (LAMP_VERGE), deshalb zählen auch die Nachbarchunks.
it("enthält keine Palme und keine Laterne", () => {
const nearby = new Set();
for (const [i, j] of under) {
@@ -120,7 +122,7 @@ describe.each(PLACES.map((place) => [place.id, place]))("Ort %s auf der Karte",
const c = chunk(...key.split("_").map(Number));
const toWorld = (u, v) => ({ X: c.origin[0] + u, Y: -(c.origin[1] + v) });
const palms = (c.palms ?? []).map(([u, v]) => toWorld(u, v));
const lamps = vergeLampPoints(lampPoints(c.centerlines ?? []), c.roads ?? []).map((p) => toWorld(p.x, p.z));
const lamps = chunkLampPoints(c).map((p) => toWorld(p.x, p.z));
return [...palms, ...lamps];
});
expect(props.filter(({ X, Y }) => insideBounds(place, X, Y))).toEqual([]);
+48 -33
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { STREET_LAMP_HEIGHT, lampFit, lampPoints, palmTransforms, pointInPolygon, vergeLampPoints } from "../src/world/props.js";
import { STREET_LAMP_HEIGHT, chunkLampPoints, lampFit, lampPoints, palmTransforms, pointInPolygon,
sidewalkLampPoints } from "../src/world/props.js";
describe("props", () => {
it("setzt Laternen im gewünschten Abstand entlang der Linie", () => {
@@ -66,43 +67,57 @@ describe("pointInPolygon", () => {
});
});
describe("vergeLampPoints", () => {
it("versetzt eine Laterne von der Fahrbahn zum ersten freien Abstand, senkrecht zur Linie", () => {
// angle 0 -> Blickrichtung (1, 0), Normale (0, 1). Straße 10 m breit (Halbbreite 5 m):
// der erste Versatz aus LAMP_VERGE (6 m) reicht bereits über den Fahrbahnrand hinaus.
const points = [{ x: 0, z: 0, angle: 0 }];
const roads = [{ poly: [[[-50, -5], [50, -5], [50, 5], [-50, 5]]], bridge: false }];
const out = vergeLampPoints(points, roads);
describe("Laternen auf dem Gehweg", () => {
// 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, 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).toBeCloseTo(0);
expect(out[0].z).toBeCloseTo(6);
expect(pointInPolygon(out[0].x, out[0].z, roads[0].poly)).toBe(false);
expect(out[0].z).toBeCloseTo(4); // der erste Abstand aus LAMP_VERGE, der auf dem Gehweg liegt
});
it("lässt eine Laterne entfallen, wenn keiner der sechs Abstände die Fahrbahn verlässt", () => {
// Halbbreite 20 m > größter Versatz (16 m) -> jede Probe bleibt auf der Fahrbahn.
const points = [{ x: 0, z: 0, angle: 0 }];
const roads = [{ poly: [[[-50, -20], [50, -20], [50, 20], [-50, 20]]], bridge: false }];
expect(vergeLampPoints(points, roads)).toHaveLength(0);
});
it("wechselt die Versatzseite von Punkt zu Punkt ab", () => {
// Ohne Straßen gewinnt sofort der erste (kleinste) Versatz bei jedem Punkt.
const points = [{ x: 0, z: 0, angle: 0 }, { x: 10, z: 0, angle: 0 }];
const out = vergeLampPoints(points, []);
expect(out[0].z).toBeCloseTo(6);
expect(out[1].z).toBeCloseTo(-6);
});
// Fix-Runde 2, Befund B / Ruling 63: siehe Kommentar bei pointInPolygon — derselbe
// ungeprüfte Fall (offene statt geschlossene Ringe) steckte auch hier in jeder Vorrichtung.
it("versetzt eine Laterne auch dann, wenn der Straßenring geschlossen ist", () => {
const points = [{ x: 0, z: 0, angle: 0 }];
const ring = [[-50, -5], [50, -5], [50, 5], [-50, 5]];
const roads = [{ poly: [[...ring, ring[0]]], bridge: false }];
const out = vergeLampPoints(points, roads);
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(6);
expect(out[0].z).toBeCloseTo(-4);
});
it("lässt eine Laterne weg, für die es keinen Gehweg gibt", () => {
expect(sidewalkLampPoints([{ x: 0, z: 0, angle: 0 }], [])).toEqual([]);
});
it("hängt nicht an der Listenposition", () => {
// 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).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);
});
});
+21
View File
@@ -239,6 +239,27 @@ describe("ChunkStreamer", () => {
const group = scene.add.mock.calls[0][0];
expect(group.children.filter((child) => child.isMesh)).toHaveLength(5);
});
// 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
});
});
// Fix-Runde 2, Befund C / Ruling 64: der Zweig `if (this.props)` in #load hatte keine
+6 -12
View File
@@ -74,18 +74,12 @@ def _sorted_surfaces(entries: list[dict]) -> list[dict]:
def _line(line, i: int, j: int) -> list[list[float]]:
"""Ein Linienzug in Chunk-Koordinaten, richtungsunabhängig festgelegt.
normalize() dreht eine Linie in eine feste Laufrichtung. Normalisieren und Sortieren ist für
curb und edges nur zulässig, solange keine Laterne ihre Straßenseite aus der Listenposition
wählt, sondern aus der Geometrie - Kantenrichtung und eine Prüfung gegen die Gehwegfläche.
Bei den früheren centerlines war das nicht so, deshalb blieben sie unsortiert und
unnormalisiert.
Eingelöst wird die Voraussetzung erst mit einer geometriebasierten Nachfolgefunktion in
props.js (vorgesehen als sidewalkLampPoints(), als Ablösung von vergeLampPoints()). Heute
liest dort noch niemand curb oder edges: vergeLampPoints() wählt die Seite weiterhin über den
geraden/ungeraden Index (Zeile 107) und läuft auf chunk.centerlines/chunk.roads. Bis zur
Ablösung ist das hier deshalb folgenlos - nicht weil die Bedingung gälte, sondern weil noch
kein Verbraucher zuhört.
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.
"""
ox = SLICE["x0"] + i * CHUNK_SIZE
oy = SLICE["y1"] - j * CHUNK_SIZE