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