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
@@ -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