diff --git a/src/world/materials.js b/src/world/materials.js index 28c8bb4..aa10bfd 100644 --- a/src/world/materials.js +++ b/src/world/materials.js @@ -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); diff --git a/src/world/props.js b/src/world/props.js index 4c16001..dfd3a14 100644 --- a/src/world/props.js +++ b/src/world/props.js @@ -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(); diff --git a/src/world/streaming.js b/src/world/streaming.js index 68fe675..9a5d591 100644 --- a/src/world/streaming.js +++ b/src/world/streaming.js @@ -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 diff --git a/tests/materials.test.js b/tests/materials.test.js index 9634b9f..e85c719 100644 --- a/tests/materials.test.js +++ b/tests/materials.test.js @@ -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(); + }); +}); diff --git a/tests/placesSite.test.js b/tests/placesSite.test.js index 517fd72..20a98b9 100644 --- a/tests/placesSite.test.js +++ b/tests/placesSite.test.js @@ -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([]); diff --git a/tests/props.test.js b/tests/props.test.js index 96cb7e5..ed95c49 100644 --- a/tests/props.test.js +++ b/tests/props.test.js @@ -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); }); }); diff --git a/tests/streaming.test.js b/tests/streaming.test.js index 55cdc81..b6a90c8 100644 --- a/tests/streaming.test.js +++ b/tests/streaming.test.js @@ -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 diff --git a/tools/extract/leonida/emit.py b/tools/extract/leonida/emit.py index a1838af..007e15b 100644 --- a/tools/extract/leonida/emit.py +++ b/tools/extract/leonida/emit.py @@ -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