From ca4f7161af27f4e5b17bff345a750b8c29f8638c Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Thu, 17 Sep 2026 21:02:14 +0200 Subject: [PATCH] feat(world): Fahrbahnmulde, Bordsteinwand und Markierungen bauen Co-Authored-By: Claude Sonnet 5 --- src/world/geometry.js | 147 +++++++++++++++++++++++++++++++++++++++-- tests/geometry.test.js | 124 ++++++++++++++++++++++++++++++---- 2 files changed, 255 insertions(+), 16 deletions(-) diff --git a/src/world/geometry.js b/src/world/geometry.js index ed97c46..732acb0 100644 --- a/src/world/geometry.js +++ b/src/world/geometry.js @@ -14,6 +14,30 @@ const BRIDGE_THICKNESS = 0.6; const BRIDGE_OFFSET = SURFACE_OFFSET; export const SURFACE_KINDS = ["sand", "park", "urban", "field", "court", "pool", "parking"]; +// Die Fahrbahn liegt in einer Mulde, statt dass der Gehweg auf einem Sockel läge (Entwurf +// Teil 2, "Die Mulde"): Ein erhöhter Gehweg hätte überall dort eine Kante ins Nichts, wo kein +// Haus anschließt — an jedem Park, jeder Baulücke, jedem Strandzugang. So bleibt die Welt auf +// 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], + gelb_doppelt: [0.94, 0.75, 0.13], + weiss_gestrichelt: [0.94, 0.94, 0.94], + weiss_rand: [0.94, 0.94, 0.94], + haltelinie: [0.94, 0.94, 0.94], + zebra: [0.94, 0.94, 0.94], +}; + /** Pastellton je Gebäude, reproduzierbar aus seiner Lage. * * Der Ton hängt am ersten Punkt des Außenrings, nicht an der Listenposition. Welcher Punkt das @@ -129,13 +153,128 @@ export function extrudePolygons(polygons, { base = 0, top = 10, uvScale = 4, tin return pos.length ? toGeometry(pos, idx, uv, tint ? color : null) : null; } +/** 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. 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; + // 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(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(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. + * + * Ü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, y, z); + uv.push(x, z); + color.push(...tint); + } + const points = ring.map(([x, z]) => new Vector2(x, z)); + for (const tri of ShapeUtils.triangulateShape(points, [])) { + idx.push(base + tri[0], base + tri[2], base + tri[1]); + } + } + return pos.length ? toGeometry(pos, idx, uv, color) : null; +} + export function buildChunkGeometry(chunk) { - const roads = chunk.roads.filter((r) => !r.bridge).map((r) => r.poly); - const bridges = chunk.roads.filter((r) => r.bridge).map((r) => r.poly); const out = { ground: extrudePolygons(chunk.land, { base: -GROUND_THICKNESS, top: 0, uvScale: 8 }), - roads: flatPolygons(roads, { y: ROAD_OFFSET, uvScale: 8 }), - bridges: extrudePolygons(bridges, { base: -BRIDGE_THICKNESS, top: BRIDGE_OFFSET, 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 ?? [], (chunk.carriageway ?? []).map((c) => c.poly)), + sidewalk: flatPolygons((chunk.sidewalk ?? []).map((s) => s.poly), { y: SURFACE_OFFSET, uvScale: 4 }), + 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. diff --git a/tests/geometry.test.js b/tests/geometry.test.js index 4a96753..d80303b 100644 --- a/tests/geometry.test.js +++ b/tests/geometry.test.js @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { Box3, BufferGeometry } from "three"; -import { buildChunkGeometry, extrudePolygons, flatPolygons } from "../src/world/geometry.js"; +import { buildChunkGeometry, CARRIAGEWAY_DEPTH, extrudePolygons, flatPolygons, KERB_HEIGHT, KERB_INSET, + KERB_TOP, kerbGeometry, MARKING_COLORS, MARKING_OFFSET, markingGeometry } from "../src/world/geometry.js"; const SQUARE = [[0, 0], [10, 0], [10, 10], [0, 10]]; const PLOT = [SQUARE]; // Fläche ohne Loch: nur der Außenring @@ -71,8 +72,8 @@ describe("geometry", () => { const chunk = { chunk: [0, 0], origin: [0, 0], land: [PLOT], surfaces: [], - roads: [{ poly: PLOT, bridge: true }], - centerlines: [], palms: [], buildings: [], + bridges: [{ poly: PLOT }], + palms: [], buildings: [], }; const out = buildChunkGeometry(chunk); const groundTop = new Box3().setFromBufferAttribute(out.ground.getAttribute("position")).max.y; @@ -90,12 +91,13 @@ describe("geometry", () => { chunk: [0, 0], origin: [0, 0], land: [PLOT], surfaces: [{ poly: PLOT, kind: "sand" }, { poly: PLOT, kind: "park" }], - roads: [{ poly: PLOT, bridge: false }, { poly: PLOT, bridge: true }], - centerlines: [], palms: [], + carriageway: [{ poly: PLOT, klasse: "wohnstrasse" }], + bridges: [{ poly: PLOT }], + palms: [], buildings: [{ poly: PLOT, height: 12, source: "district", speculative: false }], }; const out = buildChunkGeometry(chunk); - for (const key of ["ground", "sand", "park", "roads", "bridges", "buildings"]) { + for (const key of ["ground", "sand", "park", "carriageway", "bridges", "buildings"]) { expect(out[key]).toBeInstanceOf(BufferGeometry); } expect(out.urban).toBeNull(); @@ -103,7 +105,7 @@ describe("geometry", () => { it("baut Parkwege flach auf Straßenhöhe und die neuen Flächenarten wie Beläge", () => { const chunk = { - chunk: [0, 0], origin: [0, 0], land: [], roads: [], centerlines: [], palms: [], buildings: [], + chunk: [0, 0], origin: [0, 0], land: [], palms: [], buildings: [], surfaces: ["field", "court", "pool", "parking"].map((kind) => ({ poly: PLOT, kind })), paths: [{ poly: COURTYARD }], }; @@ -118,18 +120,18 @@ describe("geometry", () => { }); it("liefert ohne Parkwege null statt einer leeren Geometrie", () => { - const chunk = { chunk: [0, 0], origin: [0, 0], land: [PLOT], surfaces: [], roads: [], centerlines: [], buildings: [], palms: [] }; + const chunk = { chunk: [0, 0], origin: [0, 0], land: [PLOT], surfaces: [], buildings: [], palms: [] }; expect(buildChunkGeometry(chunk).paths).toBeNull(); }); it("gibt Gebäuden eine Farbe je Haus", () => { - const chunk = { chunk: [0, 0], origin: [0, 0], land: [], surfaces: [], roads: [], centerlines: [], palms: [], + const chunk = { chunk: [0, 0], origin: [0, 0], land: [], surfaces: [], palms: [], buildings: [{ poly: PLOT, height: 9, source: "district", speculative: false }] }; expect(buildChunkGeometry(chunk).buildings.getAttribute("color")).toBeTruthy(); }); it("hält alle Koordinaten im Chunk", () => { - const chunk = { chunk: [0, 0], origin: [0, 0], land: [PLOT], surfaces: [], roads: [], centerlines: [], buildings: [], palms: [] }; + const chunk = { chunk: [0, 0], origin: [0, 0], land: [PLOT], surfaces: [], buildings: [], palms: [] }; const box = new Box3().setFromBufferAttribute(buildChunkGeometry(chunk).ground.getAttribute("position")); expect(box.min.x).toBeGreaterThanOrEqual(-0.01); expect(box.max.x).toBeLessThanOrEqual(250.01); @@ -214,7 +216,7 @@ describe("geometry", () => { it("baut eine Platte mit 100 Löchern in unter 50 ms", () => { const chunk = { chunk: [0, 0], origin: [0, 0], land: plateWithHoles(100), - roads: [], centerlines: [], buildings: [], palms: [] }; + buildings: [], palms: [] }; const start = performance.now(); const geo = buildChunkGeometry(chunk).ground; const ms = performance.now() - start; @@ -226,7 +228,7 @@ describe("geometry", () => { it("lässt kein Loch verschwinden", () => { const chunk = { chunk: [0, 0], origin: [0, 0], land: plateWithHoles(25), - roads: [], centerlines: [], buildings: [], palms: [] }; + buildings: [], palms: [] }; const geo = buildChunkGeometry(chunk).ground; // Ohne Löcher hätte die Platte 4 Ringpunkte für die Wände plus 4 für die Decke. expect(geo.getAttribute("position").count).toBeGreaterThan(25 * 16); @@ -237,3 +239,101 @@ describe("geometry", () => { }); }); }); + +describe("Straßen aus Teil 2", () => { + const chunk = (extra) => ({ + chunk: [0, 0], origin: [0, 0], land: [], surfaces: [], buildings: [], palms: [], + 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: FAHRBAHN, klasse: "wohnstrasse" }], + })).carriageway; + // 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 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 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); + } + } + }); + + 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)); + 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", () => { + const alt = { chunk: [0, 0], origin: [0, 0], land: [], surfaces: [], + buildings: [], palms: [] }; + expect(() => buildChunkGeometry(alt)).not.toThrow(); + expect(buildChunkGeometry(alt).carriageway).toBeNull(); + }); +});