diff --git a/src/geometry/polygonHoles.test.ts b/src/geometry/polygonHoles.test.ts new file mode 100644 index 0000000..567e63e --- /dev/null +++ b/src/geometry/polygonHoles.test.ts @@ -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); + }); +}); diff --git a/src/geometry/polygonHoles.ts b/src/geometry/polygonHoles.ts new file mode 100644 index 0000000..efd37f0 --- /dev/null +++ b/src/geometry/polygonHoles.ts @@ -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 }; +} diff --git a/src/plan/generatePlan.ceilingOpenings.test.ts b/src/plan/generatePlan.ceilingOpenings.test.ts new file mode 100644 index 0000000..ff41dea --- /dev/null +++ b/src/plan/generatePlan.ceilingOpenings.test.ts @@ -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 => 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); + }); +}); diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index 57826d1..8ebeb58 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -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); diff --git a/src/plan/toWalls3d.ceilingOpenings.test.ts b/src/plan/toWalls3d.ceilingOpenings.test.ts new file mode 100644 index 0000000..9a24d6f --- /dev/null +++ b/src/plan/toWalls3d.ceilingOpenings.test.ts @@ -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); + }); +}); diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index 98de36a..c2dad63 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -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