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:
1 parent
ca4f7161af
commit
25248b3929
8 files changed
+205
-110
No files matched your search
+38
-19
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user