Decken-Aussparungen: Treppenauge/Schacht in 2D und 3D (Brücken-Trick)

Ceiling.openings (e469a93) 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 1bba149f72
commit b290120195
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);
});
});