Files
DOSSIER-STANDALONE/src/geometry/polygonHoles.test.ts
T
karim cdbef992f3 Decken-Aussparungen: Treppenauge/Schacht in 2D und 3D (Brücken-Trick)
Ceiling.openings (37f4fe8) wird jetzt gerendert. Kern: mergeHoles() fügt
Löcher über schmale Brücken (1e-4) in den Aussenring ein → EIN einfacher
Polygonring, paritätskorrekt für SVG-Fill, Scanline-Hatch, Ear-Clipping
(render3d) und Punkt-in-Polygon-Pick — kein Loch-Support in den Primitiven
nötig. Loch-Orientierung gegenläufig (signedArea), Sichtbarkeitstest für die
Brücke, mehrere Löcher iterativ.

- 2D (addCeilingPoche): Poché-Ring mit Löchern; Brückenkanten via
  noStrokeEdges unsichtbar, Lochränder werden als Ringkanten gestrichen
  (wand-footprint-geclippt wie der Aussenumriss)
- 3D (emitSlabs): derselbe Ring für alle Schichten; Klick ins Loch trifft
  die Decke nicht (Pick paritätskorrekt)
- Ohne openings byte-identischer Codepfad (Regressionstests)
+11 Tests (Ring-Einfachheit, Shoelace-Flächen, 2-Loch-Fall, Regressionen).
684/684 grün. 3D visuell in Tauri zu prüfen.
2026-07-10 18:37:12 +02:00

123 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Unit-Tests für {@link mergeHoles} (Brücken-Trick: Aussparungen → EIN
* einfacher Polygonring). Geprüft werden die drei Kern-Eigenschaften:
* • der Ergebnis-Ring ist selbstüberschneidungsfrei (echte Kanten-Kreuzungen
* ausgeschlossen, benachbarte Kanten teilen sich naturgemäss einen Punkt),
* • die Shoelace-Fläche entspricht Aussenfläche Lochfläche(n),
* • `bridgeEdges` benennt genau die künstlichen Brückenkanten (2 je Loch).
*/
import { describe, it, expect } from "vitest";
import type { Vec2 } from "../model/types";
import { mergeHoles } from "./polygonHoles";
function signedArea(pts: Vec2[]): number {
let s = 0;
for (let i = 0; i < pts.length; i++) {
const a = pts[i];
const b = pts[(i + 1) % pts.length];
s += a.x * b.y - b.x * a.y;
}
return s / 2;
}
/** Ob sich zwei Strecken ECHT (im Inneren beider) schneiden — Test-Kopie der
* Produktionslogik (bewusst unabhängig, damit der Test die Implementierung
* nicht einfach "durchreicht"). */
function properlyCross(a1: Vec2, a2: Vec2, b1: Vec2, b2: Vec2): boolean {
const dax = a2.x - a1.x, day = a2.y - a1.y;
const dbx = b2.x - b1.x, dby = b2.y - b1.y;
const denom = dax * dby - day * dbx;
if (Math.abs(denom) < 1e-12) return false;
const ex = b1.x - a1.x, ey = b1.y - a1.y;
const t = (ex * dby - ey * dbx) / denom;
const s = (ex * day - ey * dax) / denom;
const eps = 1e-9;
return t > eps && t < 1 - eps && s > eps && s < 1 - eps;
}
/** Prüft, dass KEIN Paar nicht-benachbarter Kanten sich echt schneidet. */
function isSimpleRing(ring: Vec2[]): boolean {
const n = ring.length;
const edges: [Vec2, Vec2][] = [];
for (let i = 0; i < n; i++) edges.push([ring[i], ring[(i + 1) % n]]);
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
// Benachbarte Kanten (teilen einen Endpunkt) sind erlaubt.
if (j === i + 1 || (i === 0 && j === n - 1)) continue;
if (properlyCross(edges[i][0], edges[i][1], edges[j][0], edges[j][1])) return false;
}
}
return true;
}
const rect = (x0: number, y0: number, x1: number, y1: number): Vec2[] => [
{ x: x0, y: y0 },
{ x: x1, y: y0 },
{ x: x1, y: y1 },
{ x: x0, y: y1 },
];
describe("mergeHoles", () => {
it("Rechteck 6x4 mit 1x1-Loch mittig -> einfacher Ring, Fläche = Aussen - Loch, 2 Brückenkanten", () => {
const outline = rect(0, 0, 6, 4); // Fläche 24
const hole = rect(2.5, 1.5, 3.5, 2.5); // Fläche 1
const { ring, bridgeEdges } = mergeHoles(outline, [hole]);
expect(ring.length).toBe(outline.length + hole.length + 2);
expect(isSimpleRing(ring)).toBe(true);
expect(Math.abs(signedArea(ring))).toBeCloseTo(24 - 1, 2);
expect(bridgeEdges.length).toBe(2);
// Die Brückenkanten sind kurz (Aussenring↔Loch-Rand), nicht Teil des
// eigentlichen Loch- oder Aussenumrisses.
for (const e of bridgeEdges) {
const a = ring[e];
const b = ring[(e + 1) % ring.length];
const len = Math.hypot(a.x - b.x, a.y - b.y);
expect(len).toBeGreaterThan(0);
expect(len).toBeLessThan(3); // deutlich kürzer als Aussen-/Lochkanten (>=1)
}
});
it("ohne Löcher -> Ring identisch zur Outline, keine Brückenkanten (Regression)", () => {
const outline = rect(0, 0, 6, 4);
const { ring, bridgeEdges } = mergeHoles(outline, []);
expect(ring).toEqual(outline);
expect(bridgeEdges).toEqual([]);
});
it("Loch ausserhalb der Outline wird übersprungen", () => {
const outline = rect(0, 0, 6, 4);
const outsideHole = rect(10, 10, 11, 11);
const { ring, bridgeEdges } = mergeHoles(outline, [outsideHole]);
expect(ring).toEqual(outline);
expect(bridgeEdges).toEqual([]);
});
it("degeneriertes Loch (< 3 Punkte) wird übersprungen", () => {
const outline = rect(0, 0, 6, 4);
const { ring, bridgeEdges } = mergeHoles(outline, [[{ x: 1, y: 1 }, { x: 2, y: 2 }]]);
expect(ring).toEqual(outline);
expect(bridgeEdges).toEqual([]);
});
it("zwei Löcher -> beide eingefügt, Ring einfach, Fläche = Aussen - Loch1 - Loch2, 4 Brückenkanten", () => {
const outline = rect(0, 0, 10, 4);
const holeA = rect(1, 1, 2, 2); // Fläche 1
const holeB = rect(7, 1, 9, 3); // Fläche 4
const { ring, bridgeEdges } = mergeHoles(outline, [holeA, holeB]);
expect(isSimpleRing(ring)).toBe(true);
expect(Math.abs(signedArea(ring))).toBeCloseTo(40 - 1 - 4, 2);
expect(bridgeEdges.length).toBe(4);
});
it("Loch mit CCW-Orientierung (gleich wie Aussenring) wird korrekt gegenläufig eingefügt", () => {
// rect() liefert CCW; die Fläche muss trotzdem korrekt subtrahiert werden,
// unabhängig von der gespeicherten Loch-Wicklung.
const outline = rect(0, 0, 6, 4);
const hole = rect(2.5, 1.5, 3.5, 2.5).reverse(); // CW
const { ring } = mergeHoles(outline, [hole]);
expect(Math.abs(signedArea(ring))).toBeCloseTo(24 - 1, 2);
});
});