fix(world): openRing() stürzt bei leerem oder fehlendem Außenring nicht mehr ab

Ruling 47 (Fix-Runde 1, Befund A): Die Längenprüfung stand erst im return,
destrukturiert wurde davor. extrudePolygons/flatPolygons warfen dadurch bei
einem Polygon mit leerem Außenring ([[]]) oder ganz ohne Ringe ([]), statt wie
der Rest des Moduls zu null zu degradieren. Längenprüfung nach vorn gezogen
und zusätzlich raw[0] am Aufrufort auf [] defaultet, da ein Polygon ganz ohne
Ringe openRing() ein undefined statt eines leeren Rings übergibt — die
Prüfung allein in openRing() fängt diesen zweiten Fall nicht ab.

Dazu Ruling 49: Regressionstest für die Wickelrichtung (Außenwände nach
außen, Lochwände nach innen, Dach nach oben) anhand von COURTYARD. Bestand
beim ersten Lauf unverändert, keine Anpassung am Modul nötig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-12 19:43:46 +02:00
1 parent 4c82b14a57
commit 962bfef778
2 files changed
+58 -3

No files matched your search

+14 -3
View File
@@ -20,10 +20,17 @@ function buildingTint(poly) {
// ab dem ersten Loch um eins gegenüber dieser internen Nummerierung, und Dreiecke zeigen auf // ab dem ersten Loch um eins gegenüber dieser internen Nummerierung, und Dreiecke zeigen auf
// falsche Punkte. Deshalb hier einmal vorab entfernen, bevor irgendetwas anderes mit dem Ring // falsche Punkte. Deshalb hier einmal vorab entfernen, bevor irgendetwas anderes mit dem Ring
// passiert — Ringe ohne Schlusspunkt (wie in den Tests) bleiben dabei unverändert. // passiert — Ringe ohne Schlusspunkt (wie in den Tests) bleiben dabei unverändert.
//
// Ruling 47: Länge zuerst prüfen, dann destrukturieren. Ein Ring mit 0 oder 1 Punkten hat
// keinen von "erstem" verschiedenen "letzten" Punkt, den man entdoppeln könnte — vorher
// stand die Längenprüfung erst im return, sodass ring[0] bei einem leeren Ring bereits
// undefined war und die Destrukturierung warf, statt wie der Rest dieses Moduls zu einem
// harmlosen leeren Ergebnis zu degradieren.
const openRing = (ring) => { const openRing = (ring) => {
if (ring.length <= 1) return ring;
const [x0, z0] = ring[0]; const [x0, z0] = ring[0];
const [xn, zn] = ring[ring.length - 1]; const [xn, zn] = ring[ring.length - 1];
return ring.length > 1 && x0 === xn && z0 === zn ? ring.slice(0, -1) : ring; return x0 === xn && z0 === zn ? ring.slice(0, -1) : ring;
}; };
const signedArea = (ring) => ring.reduce((sum, [x0, z0], i) => { const signedArea = (ring) => ring.reduce((sum, [x0, z0], i) => {
@@ -47,7 +54,9 @@ function toGeometry(pos, idx, uv, color) {
export function flatPolygons(polygons, { y = 0, uvScale = 8 } = {}) { export function flatPolygons(polygons, { y = 0, uvScale = 8 } = {}) {
const pos = [], idx = [], uv = []; const pos = [], idx = [], uv = [];
for (const raw of polygons) { for (const raw of polygons) {
const outer = clockwise(openRing(raw[0])); // raw[0] fehlt, wenn die Fläche ganz ohne Ringe übergeben wird (raw = []) — dann
// wie einen leeren Außenring behandeln, statt in openRing() auf undefined zuzugreifen.
const outer = clockwise(openRing(raw[0] ?? []));
// clockwise() gibt denselben Array zurueck, wenn der Ring schon passt — deshalb // clockwise() gibt denselben Array zurueck, wenn der Ring schon passt — deshalb
// vor dem Umdrehen kopieren, sonst wird die Eingabe des Aufrufers veraendert. // vor dem Umdrehen kopieren, sonst wird die Eingabe des Aufrufers veraendert.
const holes = raw.slice(1).map((hole) => [...clockwise(openRing(hole))].reverse()); const holes = raw.slice(1).map((hole) => [...clockwise(openRing(hole))].reverse());
@@ -71,7 +80,9 @@ export function extrudePolygons(polygons, { base = 0, top = 10, uvScale = 4, tin
for (const raw of polygons) { for (const raw of polygons) {
// Jede Fläche ist eine Liste von Ringen: Außenring zuerst, danach die Löcher. // Jede Fläche ist eine Liste von Ringen: Außenring zuerst, danach die Löcher.
// triangulateShape erwartet die Löcher gegenläufig zum Außenring. // triangulateShape erwartet die Löcher gegenläufig zum Außenring.
const outer = clockwise(openRing(raw[0])); // raw[0] fehlt, wenn die Fläche ganz ohne Ringe übergeben wird (raw = []) — dann
// wie einen leeren Außenring behandeln, statt in openRing() auf undefined zuzugreifen.
const outer = clockwise(openRing(raw[0] ?? []));
// clockwise() gibt denselben Array zurueck, wenn der Ring schon passt — deshalb // clockwise() gibt denselben Array zurueck, wenn der Ring schon passt — deshalb
// vor dem Umdrehen kopieren, sonst wird die Eingabe des Aufrufers veraendert. // vor dem Umdrehen kopieren, sonst wird die Eingabe des Aufrufers veraendert.
const holes = raw.slice(1).map((hole) => [...clockwise(openRing(hole))].reverse()); const holes = raw.slice(1).map((hole) => [...clockwise(openRing(hole))].reverse());
+44
View File
@@ -108,4 +108,48 @@ describe("geometry", () => {
const geo = flatPolygons([COURTYARD_CLOSED], { y: 0, uvScale: 4 }); const geo = flatPolygons([COURTYARD_CLOSED], { y: 0, uvScale: 4 });
expect(footprintArea(geo)).toBeCloseTo(800, 6); expect(footprintArea(geo)).toBeCloseTo(800, 6);
}); });
// Fix-Runde 1, Befund A / Ruling 47: ein Polygon mit leerem Außenring oder ganz ohne
// Ringe muss wie eine leere Fläche zu null degradieren, nicht werfen.
it("gibt bei einem Polygon mit leerem Außenring oder ganz ohne Ringe null zurück, statt zu werfen", () => {
expect(extrudePolygons([[[]]], { base: 0, top: 10, uvScale: 4 })).toBeNull();
expect(extrudePolygons([[]], { base: 0, top: 10, uvScale: 4 })).toBeNull();
expect(flatPolygons([[[]]], { y: 0, uvScale: 4 })).toBeNull();
expect(flatPolygons([[]], { y: 0, uvScale: 4 })).toBeNull();
});
// Fix-Runde 1, Befund B / Ruling 49.
it("wendet Außenwände nach außen, Lochwände nach innen und Deckflächen nach oben", () => {
const geo = extrudePolygons([COURTYARD], { base: 0, top: 10, uvScale: 4 });
const pos = geo.getAttribute("position"), idx = geo.getIndex();
const MITTE = 15; // Außenring 30x30 und Loch 10x10 teilen den Mittelpunkt
let deckel = 0, aussen = 0, innen = 0;
for (let t = 0; t < idx.count; t += 3) {
const p = [0, 1, 2].map((k) => {
const i = idx.getX(t + k);
return [pos.getX(i), pos.getY(i), pos.getZ(i)];
});
const u = [0, 1, 2].map((a) => p[1][a] - p[0][a]);
const v = [0, 1, 2].map((a) => p[2][a] - p[0][a]);
const n = [u[1] * v[2] - u[2] * v[1],
u[2] * v[0] - u[0] * v[2],
u[0] * v[1] - u[1] * v[0]];
const mid = [0, 1, 2].map((a) => (p[0][a] + p[1][a] + p[2][a]) / 3);
if (Math.abs(n[0]) < 1e-6 && Math.abs(n[2]) < 1e-6) {
expect(n[1]).toBeGreaterThan(0); // Deckfläche nach oben
deckel++;
continue;
}
const radial = [mid[0] - MITTE, mid[2] - MITTE];
const dot = n[0] * radial[0] + n[2] * radial[1];
if (Math.max(Math.abs(radial[0]), Math.abs(radial[1])) > 10) {
expect(dot).toBeGreaterThan(0); // Außenwand zeigt vom Mittelpunkt weg
aussen++;
} else {
expect(dot).toBeLessThan(0); // Lochwand zeigt in den Hof hinein
innen++;
}
}
expect([deckel, aussen, innen]).toEqual([8, 8, 8]);
});
}); });