feat(world): Fahrbahnmulde, Bordsteinwand und Markierungen bauen

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-17 21:02:14 +02:00
1 parent 3d39a9bd66
commit ca4f7161af
2 files changed
+255 -16

No files matched your search

+143 -4
View File
@@ -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.
+112 -12
View File
@@ -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();
});
});