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.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
// Brücken-Trick: Aussparungen (Löcher) werden über schmale Brücken in den
|
||||
// Aussenring eingefügt, sodass EIN einfacher (nicht selbstüberschneidender)
|
||||
// Polygonring entsteht — kein Loch-Support in den nachgelagerten Primitiven
|
||||
// nötig. Ein solcher Ring ist paritätsbasiert korrekt für SVG-/Canvas-Fill,
|
||||
// Scanline-Hatch, Ear-Clipping-Triangulierung UND Punkt-in-Polygon-Pick.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { Vec2 } from "../model/types";
|
||||
import { add, cross, sub } from "../model/geometry";
|
||||
|
||||
const EPS = 1e-9;
|
||||
|
||||
const dist = (a: Vec2, b: Vec2): number => Math.hypot(a.x - b.x, a.y - b.y);
|
||||
|
||||
/** Vorzeichenbehaftete Polygonfläche (Shoelace); >0 = CCW. */
|
||||
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;
|
||||
}
|
||||
|
||||
/** Schwerpunkt (einfaches Punkt-Mittel, genügt für den Innen/Aussen-Test). */
|
||||
function centroid(pts: Vec2[]): Vec2 {
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
for (const p of pts) {
|
||||
x += p.x;
|
||||
y += p.y;
|
||||
}
|
||||
return { x: x / pts.length, y: y / pts.length };
|
||||
}
|
||||
|
||||
/** Punkt-in-Polygon (Ray-Casting). */
|
||||
function pointInPolygon(p: Vec2, poly: Vec2[]): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const a = poly[i];
|
||||
const b = poly[j];
|
||||
const hit =
|
||||
a.y > p.y !== b.y > p.y &&
|
||||
p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y || 1e-12) + a.x;
|
||||
if (hit) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/** Einheits-Normale nach LINKS (90° CCW) eines Vektors ({0,0} bei Nulllänge). */
|
||||
function leftNormalUnit(d: Vec2): Vec2 {
|
||||
const l = Math.hypot(d.x, d.y);
|
||||
if (l < EPS) return { x: 0, y: 0 };
|
||||
return { x: -d.y / l, y: d.x / l };
|
||||
}
|
||||
|
||||
/** Ob sich zwei Strecken ECHT (im Inneren BEIDER Strecken) schneiden. */
|
||||
function segmentsProperlyCross(a1: Vec2, a2: Vec2, b1: Vec2, b2: Vec2): boolean {
|
||||
const da = sub(a2, a1);
|
||||
const db = sub(b2, b1);
|
||||
const denom = cross(da, db);
|
||||
if (Math.abs(denom) < EPS) return false; // parallel/kollinear — blockiert nicht
|
||||
const t = cross(sub(b1, a1), db) / denom;
|
||||
const s = cross(sub(b1, a1), da) / denom;
|
||||
const e = 1e-9;
|
||||
return t > e && t < 1 - e && s > e && s < 1 - e;
|
||||
}
|
||||
|
||||
/** Kanten eines geschlossenen Rings als [from,to]-Paare. */
|
||||
function ringEdges(pts: Vec2[]): [Vec2, Vec2][] {
|
||||
const out: [Vec2, Vec2][] = [];
|
||||
for (let i = 0; i < pts.length; i++) out.push([pts[i], pts[(i + 1) % pts.length]]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Ergebnis von {@link mergeHoles}. */
|
||||
export interface MergedRing {
|
||||
/** Einfacher (nicht selbstüberschneidender) Polygonring mit eingefügten Löchern. */
|
||||
ring: Vec2[];
|
||||
/**
|
||||
* Kanten-Indizes der eingefügten Brückenkanten im Ergebnis-Ring (Kante i =
|
||||
* ring[i]→ring[(i+1)%n]) — für `noStrokeEdges` im 2D (Brückenkanten sind
|
||||
* künstliche Verbindungen, kein realer Bauteilrand, daher nicht stricheln).
|
||||
* Je Loch genau 2 Einträge.
|
||||
*/
|
||||
bridgeEdges: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt Aussparungen (`holes`) über schmale Brücken in `outline` ein, sodass
|
||||
* EIN einfacher Polygonring entsteht (der Brücken-Trick). Löcher werden
|
||||
* ITERATIV in den (wachsenden) Ring eingefügt: je Loch das nächstgelegene
|
||||
* SICHTBARE Ring-/Loch-Vertex-Paar verbinden (Sichtbarkeit = Verbindungs-
|
||||
* strecke schneidet keine andere Ring-/Loch-Kante; bei Schnitt wird das
|
||||
* nächstbeste Paar versucht). Der Loch-Ring wird dabei in GEGENLÄUFIGER
|
||||
* Orientierung zum Aussenring eingefügt (Shoelace-Fläche des Ergebnisses =
|
||||
* Aussenfläche − Lochfläche). Die Brücke besteht aus ZWEI knapp parallelen
|
||||
* Kanten (`gap`-Versatz quer zur Brückenrichtung statt identischer Punkte),
|
||||
* damit der Ring einfach (selbstüberschneidungsfrei) bleibt.
|
||||
*
|
||||
* Robustheit: degenerierte Löcher (< 3 Punkte oder ~Nullfläche) sowie Löcher,
|
||||
* deren Schwerpunkt ausserhalb des aktuellen Rings liegt (Loch ausserhalb der
|
||||
* Outline, oder Überlappung mit einem bereits eingefügten Loch), werden
|
||||
* übersprungen. Ohne Löcher (leere Liste) liefert die Funktion `outline`
|
||||
* unverändert (Kopie) mit leeren `bridgeEdges`.
|
||||
*/
|
||||
export function mergeHoles(outline: Vec2[], holes: Vec2[][], gap = 1e-4): MergedRing {
|
||||
if (outline.length < 3 || holes.length === 0) {
|
||||
return { ring: outline.slice(), bridgeEdges: [] };
|
||||
}
|
||||
const outerCCW = signedArea(outline) > 0;
|
||||
let ring = outline.slice();
|
||||
// Referenz-Paare der eingefügten Brückenkanten (Objekt-Identität) — damit
|
||||
// die Kanten-Indizes robust am fertigen Ring ermittelt werden können,
|
||||
// unabhängig von Index-Verschiebungen durch spätere Loch-Einfügungen.
|
||||
const bridgePairs: [Vec2, Vec2][] = [];
|
||||
|
||||
for (const holeRaw of holes) {
|
||||
if (holeRaw.length < 3) continue; // degeneriert
|
||||
if (Math.abs(signedArea(holeRaw)) < 1e-9) continue; // Nulllinie/Punkt
|
||||
const c = centroid(holeRaw);
|
||||
if (!pointInPolygon(c, ring)) continue; // ausserhalb (oder bereits verlocht)
|
||||
|
||||
// Loch in Gegenrichtung zum Aussenring einfügen.
|
||||
const holeCCW = signedArea(holeRaw) > 0;
|
||||
const hole = holeCCW === outerCCW ? holeRaw.slice().reverse() : holeRaw.slice();
|
||||
const m = hole.length;
|
||||
|
||||
// Nächstgelegenes SICHTBARES Paar (Ring-Vertex i, Loch-Vertex j): nach
|
||||
// Abstand sortiert, erstes Paar ohne echten Schnitt mit Ring- oder
|
||||
// Loch-Kanten nehmen (Fallback: nächstbestes Paar in der Reihenfolge,
|
||||
// notfalls das absolut nächste).
|
||||
const rEdges = ringEdges(ring);
|
||||
const hEdges = ringEdges(hole);
|
||||
const candidates: { i: number; j: number; d: number }[] = [];
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
for (let j = 0; j < m; j++) candidates.push({ i, j, d: dist(ring[i], hole[j]) });
|
||||
}
|
||||
candidates.sort((a, b) => a.d - b.d);
|
||||
let chosen = candidates[0];
|
||||
for (const cand of candidates) {
|
||||
const P = ring[cand.i];
|
||||
const Q = hole[cand.j];
|
||||
const blocked =
|
||||
rEdges.some(([e1, e2]) => segmentsProperlyCross(P, Q, e1, e2)) ||
|
||||
hEdges.some(([e1, e2]) => segmentsProperlyCross(P, Q, e1, e2));
|
||||
if (!blocked) {
|
||||
chosen = cand;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const { i, j } = chosen;
|
||||
const A = ring[i];
|
||||
const B = hole[j];
|
||||
const perp = leftNormalUnit(sub(B, A));
|
||||
const off: Vec2 = { x: perp.x * gap, y: perp.y * gap };
|
||||
const A1: Vec2 = add(A, off);
|
||||
const A2: Vec2 = sub(A, off);
|
||||
const B1: Vec2 = add(B, off);
|
||||
const B2: Vec2 = sub(B, off);
|
||||
|
||||
const holePart: Vec2[] = [];
|
||||
for (let k = 1; k < m; k++) holePart.push(hole[(j + k) % m]);
|
||||
|
||||
ring = [...ring.slice(0, i), A1, B1, ...holePart, B2, A2, ...ring.slice(i + 1)];
|
||||
bridgePairs.push([A1, B1], [B2, A2]);
|
||||
}
|
||||
|
||||
const bridgeEdges: number[] = [];
|
||||
for (let k = 0; k < ring.length; k++) {
|
||||
const a = ring[k];
|
||||
const b = ring[(k + 1) % ring.length];
|
||||
if (bridgePairs.some(([x, y]) => x === a && y === b)) bridgeEdges.push(k);
|
||||
}
|
||||
|
||||
return { ring, bridgeEdges };
|
||||
}
|
||||
Reference in New Issue
Block a user