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:
2026-07-10 18:37:12 +02:00
parent 83abc1d87b
commit cdbef992f3
6 changed files with 508 additions and 9 deletions
+122
View File
@@ -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);
});
});
+180
View File
@@ -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 };
}
@@ -0,0 +1,112 @@
/**
* Unit-Tests für Decken-Aussparungen (Treppenauge/Schacht) im Grundriss
* (generatePlan → addCeilingPoche): der Brücken-Trick (mergeHoles) fügt das
* Loch als EINEN erweiterten Poché-Ring ein.
*/
import { describe, it, expect } from "vitest";
import { generatePlan } from "./generatePlan";
import type { Ceiling, Project } from "../model/types";
import type { Primitive } from "./generatePlan";
/** Minimalprojekt mit EINER Decke (6x4m), konfigurierbarem `openings`. */
function projectWithCeiling(openings?: Ceiling["openings"]): Project {
const ceiling: Ceiling = {
id: "C1",
type: "ceiling",
floorId: "eg",
categoryCode: "30",
outline: [
{ x: 0, y: 0 },
{ x: 6, y: 0 },
{ x: 6, y: 4 },
{ x: 0, y: 4 },
],
wallTypeId: "aw",
...(openings ? { openings } : {}),
};
return {
id: "t",
name: "T",
lineStyles: [{ id: "thin", name: "d", weight: 0.13, color: "#111", dash: null }],
hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }],
components: [{ id: "a", name: "A", color: "#d8d2c7", hatchId: "none", joinPriority: 10 }],
wallTypes: [{ id: "aw", name: "AW", layers: [{ componentId: "a", thickness: 0.3 }] }],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [{ code: "30", name: "Decken", color: "#0a0a0a", lw: 0.3, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [ceiling],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
const visible = new Set(["30"]);
const plan = (p: Project) => generatePlan(p, "eg", visible, undefined, "mittel");
/** Die Poché-Fläche der Decke (Fill-Polygon mit `ceilingId`). */
const pochePrim = (primitives: Primitive[]) =>
primitives.find((pr): pr is Extract<Primitive, { kind: "polygon" }> => pr.kind === "polygon" && pr.ceilingId === "C1");
const outlineLineCount = (primitives: Primitive[]) =>
primitives.filter((pr) => pr.kind === "line" && pr.cls === "ceiling-outline").length;
describe("generatePlan — Decken-Aussparungen (Brücken-Trick)", () => {
it("ohne openings: Poché-Polygon = unveränderte Outline, kein noStrokeEdges (Regression)", () => {
const p = projectWithCeiling(undefined);
const prims = plan(p).primitives;
const poche = pochePrim(prims);
expect(poche).toBeDefined();
expect(poche!.pts).toEqual(p.ceilings![0].outline);
expect(poche!.noStrokeEdges).toBeUndefined();
expect("noStrokeEdges" in poche!).toBe(false);
// 4 Aussenkanten, keine Wände im Testprojekt -> 4 volle Umrisslinien.
expect(outlineLineCount(prims)).toBe(4);
});
it("mit einem 1x1-Loch mittig: Poché-Polygon hat mehr Ecken, noStrokeEdges = Brückenkanten", () => {
const hole = [
{ x: 2.5, y: 1.5 },
{ x: 3.5, y: 1.5 },
{ x: 3.5, y: 2.5 },
{ x: 2.5, y: 2.5 },
];
const p = projectWithCeiling([hole]);
const prims = plan(p).primitives;
const poche = pochePrim(prims);
expect(poche).toBeDefined();
// 4 (Aussen) + 4 (Loch) + 2 (Brücken-Duplikate) = 10 Ecken.
expect(poche!.pts.length).toBe(10);
expect(poche!.pts.length).toBeGreaterThan(p.ceilings![0].outline.length);
expect(poche!.noStrokeEdges).toBeDefined();
expect(poche!.noStrokeEdges!.length).toBe(2);
// Die Brückenkanten selbst tragen keine Umrisslinie; der Rest (8 Kanten:
// 4 Aussen + 4 Lochrand) wird gestrichen — der Lochrand erscheint als
// eigenständige Umrisslinien, obwohl `ceiling.outline` unverändert blieb.
expect(outlineLineCount(prims)).toBe(poche!.pts.length - poche!.noStrokeEdges!.length);
expect(outlineLineCount(prims)).toBe(8);
});
it("Loch ausserhalb der Outline wird ignoriert (Poché bleibt unverändert)", () => {
const outsideHole = [
{ x: 20, y: 20 },
{ x: 21, y: 20 },
{ x: 21, y: 21 },
{ x: 20, y: 21 },
];
const p = projectWithCeiling([outsideHole]);
const prims = plan(p).primitives;
const poche = pochePrim(prims);
expect(poche!.pts).toEqual(p.ceilings![0].outline);
expect("noStrokeEdges" in poche!).toBe(false);
expect(outlineLineCount(prims)).toBe(4);
});
});
+22 -8
View File
@@ -45,6 +45,7 @@ import {
wallTypeThickness,
} from "../model/types";
import { columnFootprint } from "../geometry/column";
import { mergeHoles } from "../geometry/polygonHoles";
import { roofGeometry } from "../geometry/roof";
import { effectiveOverrides, hasOverrides } from "../overrides/engine";
import { evaluateRoom, siaLabel } from "../geometry/roomArea";
@@ -2466,6 +2467,13 @@ function addCeilingPoche(
): void {
const pts = ceiling.outline;
if (pts.length < 3) return;
// Aussparungen (Treppenauge/Schacht) über den Brücken-Trick in EINEN
// einfachen Poché-Ring einfügen: kein `openings` ⇒ `ring === pts` und
// `bridgeEdges` leer (exakt heutiger Codepfad, siehe {@link mergeHoles}).
const ceilingHoles = ceiling.openings ?? [];
const { ring, bridgeEdges } = ceilingHoles.length
? mergeHoles(pts, ceilingHoles)
: { ring: pts, bridgeEdges: [] as number[] };
const wt = getCeilingType(project, ceiling);
const comp = wt.layers.length > 0 ? getComponent(project, wt.layers[0].componentId) : null;
const stroke = ceiling.color ?? POCHE_STROKE;
@@ -2500,23 +2508,29 @@ function addCeilingPoche(
// Trägt weiterhin die `ceilingId` für die Links-Klick-Auswahl der Fläche.
out.push({
kind: "polygon",
pts,
pts: ring,
fill: comp ? pocheFill(hatch.pattern) : "none",
stroke: "none",
strokeWidthMm: 0,
hatch,
greyed,
ceilingId,
...(bridgeEdges.length ? { noStrokeEdges: bridgeEdges } : {}),
});
// Kräftige Umrisslinie NUR dort, wo keine Wand die Decke verdeckt: jede
// Umriss-Kante gegen die Wand-Footprints clippen und die freien Teilstücke als
// Linien ausgeben. Liegt die Decke deckungsgleich unter den Wänden (Regelfall:
// Deckenumriss = Gebäude-/Wand-Mittellinien), entfällt der Umriss ganz; nur
// Überstände (Vordach/Balkon jenseits der Wand) bleiben sichtbar.
const n = pts.length;
// Umriss-Kante (inkl. Lochränder, die als Teil des Rings automatisch
// mitgestrichen werden) gegen die Wand-Footprints clippen und die freien
// Teilstücke als Linien ausgeben. Liegt die Decke deckungsgleich unter den
// Wänden (Regelfall: Deckenumriss = Gebäude-/Wand-Mittellinien), entfällt
// der Umriss ganz; nur Überstände (Vordach/Balkon jenseits der Wand)
// bleiben sichtbar. Brückenkanten (künstliche Verbindung Aussenring↔Loch)
// werden NICHT gestrichen.
const n = ring.length;
const bridgeSet = new Set(bridgeEdges);
for (let i = 0; i < n; i++) {
const a = pts[i];
const b = pts[(i + 1) % n];
if (bridgeSet.has(i)) continue;
const a = ring[i];
const b = ring[(i + 1) % n];
const holes: Array<[number, number]> = [];
for (const fp of wallFootprints) {
const iv = segmentInsideWallBand(a, b, fp);
@@ -0,0 +1,62 @@
/**
* Unit-Tests für Decken-Aussparungen (Treppenauge/Schacht) im 3D-Modell
* (toWalls3d → emitSlabs, über {@link pickGeometry}): der Brücken-Trick
* (mergeHoles) liefert je Decken-Schicht denselben erweiterten Slab-Ring.
*/
import { describe, it, expect } from "vitest";
import type { RVec2 } from "./toWalls3d";
import { pickGeometry } from "./toWalls3d";
import type { Project } from "../model/types";
import { sampleProject } from "../model/sampleProject";
/** Shoelace-Fläche eines RVec2-Rings. */
function ringArea(ring: RVec2[]): number {
let s = 0;
for (let i = 0; i < ring.length; i++) {
const [ax, ay] = ring[i];
const [bx, by] = ring[(i + 1) % ring.length];
s += ax * by - bx * ay;
}
return Math.abs(s) / 2;
}
// C1 im sampleProject: Rechteck-Umriss (5x4m, Fläche 20), Deckentyp
// "dg-massiv" mit 3 Schichten (Estrich/Dämmung/Beton) -> 3 Slabs je Decke.
const hole: { x: number; y: number }[] = [
{ x: 2, y: 1.5 },
{ x: 3, y: 1.5 },
{ x: 3, y: 2.5 },
{ x: 2, y: 2.5 },
]; // 1x1, vollständig innerhalb des 5x4-Umrisses
function projectWithHole(): Project {
return {
...sampleProject,
ceilings: sampleProject.ceilings!.map((c) => (c.id === "C1" ? { ...c, openings: [hole] } : c)),
};
}
describe("emitSlabs (via pickGeometry) — Decken-Aussparungen", () => {
it("ohne openings: Slab-Outline unverändert (Regression)", () => {
const slabs = pickGeometry(sampleProject).slabs.filter((s) => s.ceilingId === "C1");
expect(slabs.length).toBe(3); // 3 Schichten
for (const s of slabs) {
expect(s.outline.length).toBe(4);
expect(ringArea(s.outline)).toBeCloseTo(20, 2);
}
});
it("mit einem 1x1-Loch: jede Schicht bekommt denselben erweiterten Ring (mehr Vertices, Fläche = Aussen - Loch)", () => {
const slabs = pickGeometry(projectWithHole()).slabs.filter((s) => s.ceilingId === "C1");
expect(slabs.length).toBe(3); // Anzahl Schichten unverändert
for (const s of slabs) {
// 4 (Aussen) + 4 (Loch) + 2 (Brücken-Duplikate) = 10 Vertices.
expect(s.outline.length).toBe(10);
expect(ringArea(s.outline)).toBeCloseTo(20 - 1, 2);
}
// Alle Schichten teilen denselben Ring (Brücken-Trick einmal pro Decke).
const [first, ...rest] = slabs;
for (const s of rest) expect(s.outline).toEqual(first.outline);
});
});
+10 -1
View File
@@ -50,6 +50,7 @@ import {
import { wallVerticalExtent, ceilingVerticalExtent, columnVerticalExtent, stairVerticalExtent } from "../model/wall";
import { columnFootprint } from "../geometry/column";
import { pointInOutline } from "../geometry/ceiling";
import { mergeHoles } from "../geometry/polygonHoles";
import { openingInterval, openingVerticalExtent, openingJambs } from "../geometry/opening";
import type { DetailLevel } from "../ui/TopBar";
import { stairGeometry, stairBBox } from "../geometry/stair";
@@ -1358,7 +1359,15 @@ function emitSlabs(project: Project): RSlab[] {
// bereits über ceilingThickness (respektiert Typ-Dicke + Übersteuerung).
const { zBottom, zTop } = ceilingVerticalExtent(project, c);
if (zTop - zBottom <= EPS) continue;
const outline = c.outline.map((p) => [p.x, p.y] as RVec2);
// Aussparungen (Treppenauge/Schacht) über den Brücken-Trick in EINEN
// einfachen Umriss-Ring einfügen — derselbe Ring für ALLE Schichten dieser
// Decke. Kein `openings` ⇒ unverändertes `c.outline` (exakt heutiges
// Verhalten). Der Ring ist paritätskorrekt fürs Ear-Clipping (render3d)
// UND fürs Pick (pickGeometry nutzt denselben emitSlabs-Pfad): ein Klick
// ins Loch trifft die Decke NICHT — das ist gewollt.
const openings = c.openings ?? [];
const mergedOutline = openings.length ? mergeHoles(c.outline, openings).ring : c.outline;
const outline = mergedOutline.map((p) => [p.x, p.y] as RVec2);
// Geschichteter Bodenaufbau: je Schicht des Deckentyps EIN eigener Slab,
// von oben (OK, erste Lage) nach unten (UK, letzte Lage) gestapelt und