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,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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user