diff --git a/public/models/props/concrete_road_barrier_02.glb b/public/models/props/concrete_road_barrier_02.glb deleted file mode 100644 index 43175bf..0000000 Binary files a/public/models/props/concrete_road_barrier_02.glb and /dev/null differ diff --git a/public/models/props/fire_hydrant.glb b/public/models/props/fire_hydrant.glb deleted file mode 100644 index 070137a..0000000 Binary files a/public/models/props/fire_hydrant.glb and /dev/null differ diff --git a/public/models/props/metal_trash_can.glb b/public/models/props/metal_trash_can.glb deleted file mode 100644 index 7c67617..0000000 Binary files a/public/models/props/metal_trash_can.glb and /dev/null differ diff --git a/public/models/props/painted_wooden_bench.glb b/public/models/props/painted_wooden_bench.glb deleted file mode 100644 index 780d17c..0000000 Binary files a/public/models/props/painted_wooden_bench.glb and /dev/null differ diff --git a/src/world/props.js b/src/world/props.js index 8f0a13b..42101dc 100644 --- a/src/world/props.js +++ b/src/world/props.js @@ -6,6 +6,7 @@ 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) /** Kleiner, wiederholbarer Zufall aus einem Schlüssel. */ function hashUnit(seed, key) { @@ -39,6 +40,58 @@ export function lampPoints(lines, spacing = 35) { return out; } +/** Halbstrahl-Verfahren (Ray Casting): liegt (x, z) im Ring? Ring offen oder geschlossen. */ +function inRing(x, z, ring) { + let inside = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const [xi, zi] = ring[i], [xj, zj] = ring[j]; + if ((zi > z) !== (zj > z) && x < (xj - xi) * (z - zi) / (zj - zi) + xi) inside = !inside; + } + return inside; +} + +/** + * 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 + * keinem der Löcher. + */ +export function pointInPolygon(x, z, rings) { + return rings.length > 0 && inRing(x, z, rings[0]) && !rings.slice(1).some((hole) => inRing(x, z, hole)); +} + +/** + * 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. + * + * 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). + */ +export function vergeLampPoints(points, roads) { + 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; + } + } + }); + return out; +} + export function palmTransforms(points, seed = 1) { return points.map(([x, z]) => { const key = `${x.toFixed(1)},${z.toFixed(1)}`; @@ -126,27 +179,14 @@ export class PropLayer { } /** - * Laternen entlang der Mittellinien (lampPoints, oben). - * - * Ohne diese Methode wäre lampPoints() eine getestete, aber nirgends aufgerufene Funktion, - * und this.lamp ein geladenes, nie benutztes Modell — im gesamten Umsetzungsplan ruft keine - * andere Aufgabe lampPoints() auf. Die Verdrahtung hier verwendet ausschließlich bereits - * spezifizierte, getestete Bausteine (lampPoints exakt wie oben, this.lamp aus load()). - * - * Platzierung exakt auf der Mittellinie, ohne seitlichen Versatz zur Fahrbahnkante: anders - * als bei den Palmen (Python-seitige Suche vom Fahrbahnrand nach außen gegen Straßen- und - * Gebäudepolygone, Ruling 22) kennen die Chunk-Daten keine separate Gehwegspur — nur - * Fahrbahn-, Sand-, Park- und Stadtfläche — und lampPoints(lines, spacing) bekommt bewusst - * keine Straßen- oder Gebäudepolygone übergeben. Ein erfundener fester Versatz ließe sich - * an keiner echten Geometrie prüfen und wäre nach Ruling 22 vermutlich ebenso falsch wie der - * ursprüngliche 4-m-Versatz der Palmen (60 % auf der Fahrbahn). Ruling 18 nimmt "mitten auf - * der Fahrbahn" für Laternen bereits ausdrücklich hin ("an einer Kreuzung ... stehen vier - * Laternen im selben Punkt, mitten auf der Fahrbahn") und behebt dort nur das Stapeln an - * Kreuzungen — genau dafür ist lampPoints() da. Das Modell ist bei 1,7 m laut Poly-Haven- - * Angabe bereits maßstäblich, anders als die Palme also keine Zufallsskalierung. + * Laternen entlang der Mittellinien (lampPoints), seitlich zur Fahrbahnkante versetzt + * (vergeLampPoints, Ruling 60 — Fix-Runde 1; siehe dort für die Begründung). Das Modell + * ist bei 1,7 m laut Poly-Haven-Angabe bereits maßstäblich, anders als die Palme also + * keine Zufallsskalierung. */ #buildLamps(chunk, visual) { - const points = lampPoints(chunk.centerlines ?? []); + const centerlinePoints = lampPoints(chunk.centerlines ?? []); + const points = vergeLampPoints(centerlinePoints, chunk.roads ?? []); if (!points.length || !this.lampParts?.length) return; const dummy = new Object3D(); const local = new Matrix4(); diff --git a/tests/props.test.js b/tests/props.test.js index 5c31d1c..26499d1 100644 --- a/tests/props.test.js +++ b/tests/props.test.js @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { lampPoints, palmTransforms } from "../src/world/props.js"; +import { lampPoints, palmTransforms, pointInPolygon, vergeLampPoints } from "../src/world/props.js"; describe("props", () => { it("setzt Laternen im gewünschten Abstand entlang der Linie", () => { @@ -32,3 +32,53 @@ describe("props", () => { } }); }); + +// Fix-Runde 1, Befund A / Ruling 60: Laternen standen exakt auf der Mittellinie. +describe("pointInPolygon", () => { + // Quadrat 0..20 mit einem 4x4-Loch in der Mitte (8..12) — offene Ringe, wie im Rest + // dieser Datei; die Laufzeitdaten schließen ihre Ringe (letzter Punkt = erster), das + // Halbstrahl-Verfahren behandelt beide Formen gleich (siehe Kommentar in props.js). + const outer = [[0, 0], [20, 0], [20, 20], [0, 20]]; + const hole = [[8, 8], [12, 8], [12, 12], [8, 12]]; + + it("Punkt innen (im Außenring, außerhalb jedes Lochs)", () => { + expect(pointInPolygon(2, 2, [outer, hole])).toBe(true); + }); + + it("Punkt außen (außerhalb des Außenrings)", () => { + expect(pointInPolygon(25, 25, [outer, hole])).toBe(false); + }); + + it("Punkt im Loch (im Außenring, aber auch im Loch)", () => { + expect(pointInPolygon(10, 10, [outer, hole])).toBe(false); + }); +}); + +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); + 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); + }); + + 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); + }); +}); diff --git a/tools/assets/props.mjs b/tools/assets/props.mjs index 0a1f439..7dba63a 100644 --- a/tools/assets/props.mjs +++ b/tools/assets/props.mjs @@ -1,16 +1,23 @@ -// Lädt die fünf Poly-Haven-Requisiten in 1k (gltf + zugehörige Texturen/`.bin`) nach -// public/models/props/raw/ und vereinfacht sie danach mit @gltf-transform/cli zu je einer +// Lädt die Poly-Haven-Straßenlaterne in 1k (gltf + zugehörige Texturen/`.bin`) nach +// public/models/props/raw/ und vereinfacht sie danach mit @gltf-transform/cli zu einer // kompakten .glb unter public/models/props/. Gleiches Muster wie textures.mjs, aber Modelle // stehen unter dem Schlüssel "gltf" statt unter den Map-Namen ("Diffuse", "nor_gl", "arm"), // und die 1k-Auflösung liefert neben der .gltf-Datei selbst noch eine `include`-Liste mit den // referenzierten Texturen und der gemeinsamen `.bin`-Geometriedatei — ohne die kann // gltf-transform die .gltf-Datei nicht öffnen. +// +// Ruling 61 (Fix-Runde 1): ursprünglich fünf Requisiten (Laterne, Bank, Mülleimer, Hydrant, +// Absperrung), aber nur street_lamp_02 hat in den Chunk-Daten eine Platzierungsquelle +// (chunk.centerlines) — für die anderen vier gibt es weder ein Feld im Chunk-Schema noch +// einen Aufrufer irgendwo im Plan. Sie lagen ungenutzt mit rund 18 MB im Arbeitsverzeichnis +// (1,2 MB davon versioniert); erfundene Platzierungsdaten wären reines Raten gewesen (siehe +// Bericht). Wieder aufnehmen, sobald eine echte Platzierungsquelle für sie entsteht. import { execFileSync } from "node:child_process"; import { mkdir, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; const UA = { "User-Agent": "leonida-assets/0.1 (local hobby project)" }; -const SLUGS = ["street_lamp_02", "painted_wooden_bench", "metal_trash_can", "fire_hydrant", "concrete_road_barrier_02"]; +const SLUGS = ["street_lamp_02"]; const RAW_DIR = "public/models/props/raw"; const OUT_DIR = "public/models/props";