Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)

Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.

Enthalten (Details in PENDENZEN.md -Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
  Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
  Prioritäts-Verschneidung (section_boolean.rs), einstellbare
  Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
  Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
  Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
  Öffnungen/Treppen, Snap-Symbol-Restyle.
This commit is contained in:
2026-07-09 00:57:29 +02:00
parent 889cbb2c12
commit 35299307d6
131 changed files with 26501 additions and 852 deletions
+217 -13
View File
@@ -133,6 +133,12 @@ export interface PlanViewHandle {
pxPerMeter: () => number;
/** Mitte des aktuellen Ausschnitts in Modell-Metern (z. B. für Import-Platzierung). */
viewCenterModel: () => Vec2;
/**
* Auf eine beliebige Punktmenge (Modell-Meter) einpassen, z. B. die Bounding-
* Box neu importierter Kontext-Objekte (Auto-Zoom nach Geo-/DXF-Import).
* Leere Punktmenge → No-op (kein Fit, kein Crash).
*/
fitBounds: (pts: Vec2[]) => void;
}
/** Ergebnis eines Links-Klicks (Auswahl) an einer Modellposition. */
@@ -175,6 +181,17 @@ export interface PlanSelection {
* sonst `null`.
*/
roomId: string | null;
/**
* ID des getroffenen extrudierten Körpers (truck-Integration, Footprint-
* Umriss), wenn der Klick ihn traf und KEIN Element höherer Priorität
* (Wand/2D/Decke/Öffnung/Treppe); sonst `null`.
*/
extrudedSolidId: string | null;
/**
* ID der getroffenen Stütze (Column, Grundriss-Querschnitt), wenn der Klick sie
* traf und KEIN Element höherer Priorität (Öffnung/Wand); sonst `null`.
*/
columnId: string | null;
/**
* War beim Klick Shift gedrückt? Dann ERWEITERT der Aufrufer (App) die
* Auswahl um das getroffene Element (bzw. schaltet es ab/zu), statt sie zu
@@ -263,6 +280,17 @@ export interface PlanViewProps {
* Fläche JEDES dieser Räume mit einer Akzentkontur.
*/
selectedRoomIds?: string[];
/**
* Aktuell ausgewählte IDs extrudierter Körper (truck-Integration; kontrolliert
* von App). Die Ansicht markiert den Footprint-Umriss JEDES dieser Körper mit
* einer Akzentkontur.
*/
selectedExtrudedSolidIds?: string[];
/**
* Aktuell ausgewählte Stützen-IDs (Tragwerk; kontrolliert von App). Die Ansicht
* markiert den Querschnitt-Umriss JEDER dieser Stützen mit einer Akzentkontur.
*/
selectedColumnIds?: string[];
/**
* Der Stempel-Griff des (einzeln) gewählten Raums: sein Anker (Meter) + ID.
* PlanView zeichnet einen ziehbaren Griff und meldet Bewegung/Doppelklick.
@@ -273,6 +301,8 @@ export interface PlanViewProps {
onStampMove?: (roomId: string, delta: Vec2) => void;
/** Doppelklick auf einen Raum-Stempel: Editor öffnen. */
onStampEdit?: (roomId: string) => void;
/** Doppelklick auf ein Text-2D-Element: Inhalt bearbeiten (Editor öffnen). */
onEditDrawingText?: (drawingId: string) => void;
/**
* Links-Drag (Marquee): meldet die im Auswahl-Rechteck getroffenen Wände nach
* der CAD-Konvention (siehe {@link MarqueeSelection}). Optional.
@@ -348,7 +378,11 @@ export interface GripHandlers {
* wird und koinzidente Enden der übrigen gewählten Elemente mitwandern. Fehlt
* `target`, gilt das einzeln selektierte Element (Rückwärtskompatibilität).
*/
onMoveBody(delta: Vec2, target?: { wallId?: string; drawingId?: string }): void;
onMoveBody(
delta: Vec2,
target?: { wallId?: string; drawingId?: string },
mods?: ToolMods,
): void;
/**
* Eine SEITE (Kante) ziehen: verschiebt beide Vertices der Kante senkrecht
* (nur die Normal-Komponente von `raw` wirkt; siehe App). `raw` ist der rohe
@@ -374,9 +408,12 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
selectedOpeningIds,
selectedStairIds,
selectedRoomIds,
selectedExtrudedSolidIds,
selectedColumnIds,
roomStamp,
onStampMove,
onStampEdit,
onEditDrawingText,
activeTool,
toolInputActive,
toolHandlers,
@@ -638,6 +675,10 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
const v = viewRef.current;
return { x: (v.x + v.w / 2) / PX_PER_M, y: -(v.y + v.h / 2) / PX_PER_M };
},
fitBounds: (pts) => {
if (pts.length === 0) return; // leerer Import → kein Fit, kein Crash
setView(fitBoxFor(pts, toScreenRef.current));
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
@@ -841,7 +882,34 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
bestId = p.drawingId;
}
}
return bestId;
if (bestId) return bestId;
// Kein Linien-Treffer: Text-Elemente prüfen (eigene Bounding-Box-Logik).
return pickDrawingText(clientX, clientY);
};
// Trifft der Klick ein Text-2D-Element (kind "drawingText")? Grosszügige Modell-
// Bounding-Box um den Ankerpunkt (Text hat keine Linien-Geometrie zum Anfassen);
// Winkel wird vernachlässigt (meist waagrecht). Liefert die drawingId oder null.
const pickDrawingText = (clientX: number, clientY: number): string | null => {
const m = rawModelAt(clientX, clientY);
for (const p of plan.primitives) {
if (p.kind !== "drawingText" || !p.drawingId) continue;
const singleW = Math.max(1, p.text.length) * p.heightM * 0.6;
// Textspalte: Breite = wrapWidth, Höhe = geschätzte Zeilenzahl · Zeilenhöhe.
const w = p.wrapWidth && p.wrapWidth > 0 ? p.wrapWidth : singleW;
const lines =
p.wrapWidth && p.wrapWidth > 0 ? Math.max(1, Math.ceil(singleW / p.wrapWidth)) : 1;
const pad = p.heightM * 0.6;
const minX = p.at.x - pad;
const maxX = p.at.x + w + pad;
// Baseline am Anker; Text läuft nach unten (Zeilen) und etwas nach oben (Oberlängen).
const minY = p.at.y - lines * p.heightM * 1.25 - pad;
const maxY = p.at.y + p.heightM * 0.4 + pad;
if (m.x >= minX && m.x <= maxX && m.y >= minY && m.y <= maxY) {
return p.drawingId;
}
}
return null;
};
// Nächstgelegene Öffnung (Fenster/Tür) zum Cursor: Linien-/Bogen-Primitive mit
@@ -917,6 +985,35 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
return null;
};
// Getroffener extrudierter Körper (truck-Integration): der Footprint-Umriss
// ist ein reiner Haarlinien-Umriss (fill:"none"), aber per Punkt-in-Polygon
// trotzdem als FLÄCHE anklickbar (analog Raum, nicht nur auf der Linie).
const pickExtrudedSolid = (clientX: number, clientY: number): string | null => {
const v = clientToView(clientX, clientY, view);
const m = viewToModel(v.x, v.y);
for (let i = plan.primitives.length - 1; i >= 0; i--) {
const p = plan.primitives[i];
if (p.kind === "polygon" && p.extrudedSolidId && pointInPolygon(m, p.pts)) {
return p.extrudedSolidId;
}
}
return null;
};
// Getroffene Stütze (Tragwerk): ihr Querschnitt ist eine gefüllte Poché
// (Polygon mit columnId) — per Punkt-in-Polygon als FLÄCHE anklickbar.
const pickColumn = (clientX: number, clientY: number): string | null => {
const v = clientToView(clientX, clientY, view);
const m = viewToModel(v.x, v.y);
for (let i = plan.primitives.length - 1; i >= 0; i--) {
const p = plan.primitives[i];
if (p.kind === "polygon" && p.columnId && pointInPolygon(m, p.pts)) {
return p.columnId;
}
}
return null;
};
// Liegt der Cursor auf dem KÖRPER eines GEWÄHLTEN Elements (Wand-Poché bzw.
// 2D-Linie)? Liefert das gegriffene Element (Wand-ID ODER Drawing-ID) für das
// Parallel-Verschieben — oder null. Bei MEHRFACHAUSWAHL wird das Element UNTER
@@ -1188,7 +1285,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
if (md && md.pointerId === p.pointerId) {
const delta = { x: raw.x - md.last.x, y: raw.y - md.last.y };
if (delta.x !== 0 || delta.y !== 0) {
gripHandlers!.onMoveBody(delta, md.target);
gripHandlers!.onMoveBody(delta, md.target, mods);
md.last = raw;
}
return;
@@ -1402,28 +1499,46 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
const openingId = pickOpening(e.clientX, e.clientY);
const wallId =
openingId ? null : hit && hit.kind === "polygon" ? hit.wallId ?? null : null;
// Traf der Klick KEINE Wand/Öffnung: zuerst eine getroffene gefüllte 2D-Fläche
// (Polygon mit drawingId), sonst die 2D-Linien (Drawing2D) per Nähe.
// Stütze (Tragwerk): ihre Querschnitt-Poché liegt ÜBER der Wand-Poché —
// ein direkter Klick auf sie trifft zuerst die Stütze (hit.columnId), sonst
// die Fläche per Punkt-in-Polygon. Priorität direkt nach Öffnung/Wand.
const polyColumnId =
hit && hit.kind === "polygon" ? hit.columnId ?? null : null;
const columnId =
openingId || wallId
? null
: polyColumnId ?? pickColumn(e.clientX, e.clientY);
// Traf der Klick KEINE Wand/Öffnung/Stütze: zuerst eine getroffene gefüllte
// 2D-Fläche (Polygon mit drawingId), sonst die 2D-Linien (Drawing2D) per Nähe.
const polyDrawingId =
hit && hit.kind === "polygon" ? hit.drawingId ?? null : null;
const drawingId =
openingId || wallId
openingId || wallId || columnId
? null
: polyDrawingId ?? pickDrawing(e.clientX, e.clientY);
// Extrudierter Körper (truck-Integration): sein Footprint-Umriss ersetzt
// die frühere Drawing2D-Fläche, also gleiche Priorität wie drawingId.
const polyExtrudedSolidId =
hit && hit.kind === "polygon" ? hit.extrudedSolidId ?? null : null;
const extrudedSolidId =
openingId || wallId || columnId || drawingId
? null
: polyExtrudedSolidId ?? pickExtrudedSolid(e.clientX, e.clientY);
// Decke nur, wenn weder Öffnung/Wand noch 2D-Element getroffen wurde. Da die
// Decken-Polygone UNTER der Wand-Poché liegen, trifft ein direkter Klick
// auf die Wand zuerst die Wand; freie Deckenfläche trifft die Decke.
const hitCeilingId =
hit && hit.kind === "polygon" ? hit.ceilingId ?? null : null;
const ceilingId = openingId || wallId || drawingId ? null : hitCeilingId;
const ceilingId =
openingId || wallId || columnId || drawingId || extrudedSolidId ? null : hitCeilingId;
// Treppe nur, wenn nichts anderes getroffen wurde (Tritte liegen frei).
const stairId =
openingId || wallId || drawingId || ceilingId
openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId
? null
: pickStair(e.clientX, e.clientY);
// Raum als letzte Priorität: die Raum-Fläche liegt UNTER allem anderen.
const roomId =
openingId || wallId || drawingId || ceilingId || stairId
openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId
? null
: pickRoom(e.clientX, e.clientY);
onSelect({
@@ -1435,6 +1550,8 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
openingId,
stairId,
roomId,
extrudedSolidId,
columnId,
shift: e.shiftKey,
});
}
@@ -1490,6 +1607,14 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
toolHandlers!.onToolCommit();
return;
}
// Doppelklick auf ein Text-2D-Element → Inhalt bearbeiten (Vorrang vor Raum).
if (onEditDrawingText) {
const tid = pickDrawingText(e.clientX, e.clientY);
if (tid) {
onEditDrawingText(tid);
return;
}
}
// Doppelklick auf einen Raum (Stempel-Griff ODER Fläche) → Stempel-Editor.
if (onStampEdit) {
const rid =
@@ -1614,6 +1739,33 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
);
}, [plan, selectedRoomIds]);
// Hervorhebung der selektierten extrudierten Körper (truck-Integration): der
// Footprint-Umriss (Polygon mit extrudedSolidId) mit Akzentkontur überzeichnen
// — analog zur Raum-Auswahl.
const highlightExtrudedSolidPolys = useMemo(() => {
const ids =
selectedExtrudedSolidIds && selectedExtrudedSolidIds.length
? new Set(selectedExtrudedSolidIds)
: null;
if (!ids) return [];
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "polygon" }> =>
p.kind === "polygon" && p.extrudedSolidId != null && ids.has(p.extrudedSolidId),
);
}, [plan, selectedExtrudedSolidIds]);
// Hervorhebung der selektierten Stützen (Tragwerk): der Querschnitt-Umriss
// (Polygon mit columnId) mit Akzentkontur überzeichnen — analog Extrusion/Raum.
const highlightColumnPolys = useMemo(() => {
const ids =
selectedColumnIds && selectedColumnIds.length ? new Set(selectedColumnIds) : null;
if (!ids) return [];
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "polygon" }> =>
p.kind === "polygon" && p.columnId != null && ids.has(p.columnId),
);
}, [plan, selectedColumnIds]);
// Zusammenhängende 2D-Zeichen-Linien (gleiche drawingId + Stil) zu Zügen
// bündeln, damit sie als EIN <polyline>/<polygon> gezeichnet werden können —
// so gehren dicke Ecken sauber (kein Butt-Cap-Sprung). generatePlan schiebt die
@@ -1819,6 +1971,30 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
pointerEvents="none"
/>
))}
{/* Auswahl-Hervorhebung der gewählten extrudierten Körper (truck-Integration). */}
{highlightExtrudedSolidPolys.map((poly, i) => (
<polygon
key={`selex-${i}`}
className="plan-selected"
points={poly.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
))}
{/* Auswahl-Hervorhebung der gewählten Stützen (Tragwerk). */}
{highlightColumnPolys.map((poly, i) => (
<polygon
key={`selcol-${i}`}
className="plan-selected"
points={poly.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
))}
{/* Hervorhebung der selektierten Öffnungen (Fenster/Tür-Symbole). */}
{highlightOpeningPrims.map((p, i) =>
p.kind === "line" ? (
@@ -2024,7 +2200,7 @@ function DraftOverlay({
snapColor: string;
}) {
const vertSize = 4.5 * vbPerPx; // halbe Kantenlänge eines Stützpunkt-Quadrats (klein + einheitlich)
const markSize = 4.5 * vbPerPx; // halbe Kantenlänge des Snap-Markers (gleich groß wie Stützpunkt)
const markSize = 6.5 * vbPerPx; // halbe Kantenlänge des Snap-Markers (grösser = besser sichtbar)
return (
<g className="tool-overlay" pointerEvents="none">
{/* Vorschau-Formen. */}
@@ -3081,12 +3257,32 @@ function renderPrimitive(
);
}
case "drawingText": {
// Modellverankerter Einzeltext (DXF-Import): Höhe in Metern → viewBox-
// Einheiten (skaliert mit dem Zoom), Baseline am Ankerpunkt (links).
// Modellverankerter Einzeltext (DXF-Import / Text-Werkzeug): Höhe in Metern →
// viewBox-Einheiten (skaliert mit dem Zoom), Baseline am Ankerpunkt (links).
const c = toScreen(p.at);
const fontSize = p.heightM * PX_PER_M;
// Modell-Winkel CCW; Screen-Y ist gespiegelt und SVG-rotate CW-positiv → -deg.
const deg = -(p.angle * 180) / Math.PI;
// Spaltentext: bei gesetzter `wrapWidth` den Text wortweise auf die Breite
// umbrechen (Zeichenbreite ≈ 0.55·fontSize geschätzt), sonst eine Zeile.
const lineH = fontSize * 1.25;
const lines: string[] = [];
if (p.wrapWidth && p.wrapWidth > 0) {
const maxPx = p.wrapWidth * PX_PER_M;
const charPx = fontSize * 0.55;
const maxChars = Math.max(1, Math.floor(maxPx / charPx));
for (const paragraph of p.text.split("\n")) {
let line = "";
for (const word of paragraph.split(/\s+/)) {
if (line === "") line = word;
else if ((line.length + 1 + word.length) <= maxChars) line += " " + word;
else { lines.push(line); line = word; }
}
lines.push(line);
}
} else {
lines.push(p.text);
}
return (
<text
x={c.x}
@@ -3099,7 +3295,15 @@ function renderPrimitive(
transform={deg ? `rotate(${deg} ${c.x} ${c.y})` : undefined}
style={{ pointerEvents: "none", userSelect: "none" }}
>
{p.text}
{lines.length <= 1 ? (
p.text
) : (
lines.map((ln, li) => (
<tspan key={li} x={c.x} dy={li === 0 ? 0 : lineH}>
{ln === "" ? "" : ln}
</tspan>
))
)}
</text>
);
}
+162
View File
@@ -0,0 +1,162 @@
/**
* Unit-Tests für das Stützen-Bauteil (Column) — DOSSIER-Audit A4 Tragwerk:
* • 2D-Grundriss (generatePlan): eine Stütze erzeugt ein Poché-Polygon mit
* columnId an der richtigen Position; die Rotation dreht den Rechteck-
* Querschnitt.
* • 3D (projectToModel3d/emitColumns): eine Stütze wird zu einem Prisma-Mesh
* (Rechteck-Box bzw. Kreis-Prisma) mit der erwarteten vertikalen Ausdehnung.
* • Vertikale Lage (columnVerticalExtent): Geschoss-Default sowie UK/OK-Anker.
*/
import { describe, it, expect } from "vitest";
import { generatePlan } from "./generatePlan";
import { projectToModel3d } from "./toWalls3d";
import { columnVerticalExtent } from "../model/wall";
import { columnFootprint } from "../geometry/column";
import type { Column, Project } from "../model/types";
/** Minimalprojekt (zwei Geschosse gestapelt) mit einer konfigurierbaren Stütze. */
function projectWithColumn(column: Column): Project {
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: "#c8c8cc", 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: 3, cutHeight: 1.0, baseElevation: 0 },
{ id: "og", name: "OG", kind: "floor", visible: true, locked: false, floorHeight: 3, cutHeight: 1.0, baseElevation: 3 },
],
layers: [
{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false },
{ code: "50", name: "Tragwerk", color: "#0a0a0a", lw: 0.5, visible: true, locked: false },
],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
columns: [column],
drawings2d: [],
context: [],
};
}
const visible = new Set(["20", "50"]);
/** Basis-Stütze: Rechteck 0.4×0.2 an (2,3), Höhe 3, keine Drehung. */
const rectColumn: Column = {
id: "C1",
type: "column",
floorId: "eg",
categoryCode: "50",
position: { x: 2, y: 3 },
profile: { kind: "rect", width: 0.4, depth: 0.2 },
rotation: 0,
height: 3,
};
describe("Stütze — 2D-Grundriss (generatePlan)", () => {
it("erzeugt ein Poché-Polygon mit columnId am Rechteck-Querschnitt", () => {
const plan = generatePlan(projectWithColumn(rectColumn), "eg", visible);
const polys = plan.primitives.filter(
(p) => p.kind === "polygon" && p.columnId === "C1",
);
expect(polys.length).toBe(1);
const poly = polys[0];
if (poly.kind !== "polygon") throw new Error("kein Polygon");
// Rechteck 0.4×0.2 um (2,3): Ecken bei x∈{1.8,2.2}, y∈{2.9,3.1}.
const xs = poly.pts.map((p) => p.x).sort((a, b) => a - b);
const ys = poly.pts.map((p) => p.y).sort((a, b) => a - b);
expect(poly.pts.length).toBe(4);
expect(xs[0]).toBeCloseTo(1.8, 6);
expect(xs[3]).toBeCloseTo(2.2, 6);
expect(ys[0]).toBeCloseTo(2.9, 6);
expect(ys[3]).toBeCloseTo(3.1, 6);
});
it("dreht den Querschnitt um 90° (Breite/Tiefe vertauscht)", () => {
const rotated: Column = { ...rectColumn, rotation: Math.PI / 2 };
const plan = generatePlan(projectWithColumn(rotated), "eg", visible);
const poly = plan.primitives.find(
(p) => p.kind === "polygon" && p.columnId === "C1",
);
if (!poly || poly.kind !== "polygon") throw new Error("kein Polygon");
// Nach 90°-Drehung: X-Ausdehnung = ±depth/2 = ±0.1, Y = ±width/2 = ±0.2.
const xs = poly.pts.map((p) => p.x).sort((a, b) => a - b);
const ys = poly.pts.map((p) => p.y).sort((a, b) => a - b);
expect(xs[0]).toBeCloseTo(1.9, 6);
expect(xs[3]).toBeCloseTo(2.1, 6);
expect(ys[0]).toBeCloseTo(2.8, 6);
expect(ys[3]).toBeCloseTo(3.2, 6);
});
it("blendet die Stütze aus, wenn Kategorie 50 unsichtbar ist", () => {
const plan = generatePlan(projectWithColumn(rectColumn), "eg", new Set(["20"]));
expect(plan.primitives.some((p) => p.kind === "polygon" && p.columnId)).toBe(false);
});
});
describe("Stütze — 3D (projectToModel3d / emitColumns)", () => {
it("erzeugt eine Rechteck-Box (8 Ecken) über die volle Höhe", () => {
const model = projectToModel3d(projectWithColumn(rectColumn));
const meshes = model.meshes.filter((m) => m.kind === "extrusion");
expect(meshes.length).toBe(1);
const mesh = meshes[0];
// 4 Footprint-Punkte × (unten + oben) = 8 Vertices.
expect(mesh.positions.length).toBe(8 * 3);
// z-Werte (jedes 3. Element) liegen bei 0 (UK) und 3 (OK).
const zs = mesh.positions.filter((_, i) => i % 3 === 2);
expect(Math.min(...zs)).toBeCloseTo(0, 6);
expect(Math.max(...zs)).toBeCloseTo(3, 6);
});
it("erzeugt ein tesselliertes Kreis-Prisma (64 Ecken)", () => {
const round: Column = {
...rectColumn,
profile: { kind: "round", radius: 0.15 },
};
const model = projectToModel3d(projectWithColumn(round));
const mesh = model.meshes.find((m) => m.kind === "extrusion");
if (!mesh) throw new Error("kein Prisma-Mesh");
// 32-Eck × (unten + oben) = 64 Vertices.
expect(mesh.positions.length).toBe(64 * 3);
});
});
describe("Stütze — vertikale Lage (columnVerticalExtent)", () => {
it("nutzt Geschoss-UK + Höhe als Default", () => {
const { zBottom, zTop } = columnVerticalExtent(
projectWithColumn(rectColumn),
rectColumn,
);
expect(zBottom).toBeCloseTo(0, 6);
expect(zTop).toBeCloseTo(3, 6);
});
it("bindet UK/OK an Anker (custom + floor)", () => {
const anchored: Column = {
...rectColumn,
bottom: { mode: "custom", z: 0.5 },
top: { mode: "floor", floorId: "og" }, // baseElevation OG = 3
};
const { zBottom, zTop } = columnVerticalExtent(projectWithColumn(anchored), anchored);
expect(zBottom).toBeCloseTo(0.5, 6);
expect(zTop).toBeCloseTo(3, 6);
});
});
describe("Stütze — Footprint-Helfer (columnFootprint)", () => {
it("tesselliert ein rundes Profil als 32-Eck um die Position", () => {
const round: Column = { ...rectColumn, profile: { kind: "round", radius: 0.15 } };
const pts = columnFootprint(round);
expect(pts.length).toBe(32);
// Alle Punkte liegen im Radius 0.15 um (2,3).
for (const p of pts) {
const d = Math.hypot(p.x - 2, p.y - 3);
expect(d).toBeCloseTo(0.15, 6);
}
});
});
+181
View File
@@ -0,0 +1,181 @@
// Integrations-Tests: der Grundriss-Pfad (generatePlan) wendet die grafischen
// Override-Regeln (Project.overrideRules) als OBERSTE Schicht über der
// By-Layer/By-Object-Auflösung an — als reines Rendering-Overlay (die
// Elementdaten bleiben unverändert).
import { describe, it, expect } from "vitest";
import { generatePlan } from "./generatePlan";
import type { OverrideRule, Project, Wall } from "../model/types";
/** Minimalprojekt: eine Wand (Ebene „Wände"/20) + eine 2D-Linie (Ebene 60). */
function project(overrideRules?: OverrideRule[]): Project {
const wall: Wall = {
id: "W1",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 4, y: 0 },
wallTypeId: "aw",
height: 2.6,
};
return {
id: "t",
name: "T",
lineStyles: [
{ id: "dashed", name: "Gestrichelt", weight: 0.13, color: "#111", dash: [2, 1] },
],
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 Beton", layers: [{ componentId: "a", thickness: 0.2 }] }],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [
{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false },
{ code: "60", name: "Zeichnung", color: "#0a0a0a", lw: 0.25, visible: true, locked: false },
],
walls: [wall],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [
{
id: "D1",
type: "drawing2d",
levelId: "eg",
categoryCode: "60",
geom: { shape: "line", a: { x: 0, y: 1 }, b: { x: 2, y: 1 } },
},
],
context: [],
overrideRules,
};
}
const visible = new Set(["20", "60"]);
/** Alle Polygone der Wand W1 (grob = eine Sammelfläche mit Umriss). */
function wallPolys(p: ReturnType<typeof generatePlan>) {
return p.primitives.filter(
(pr): pr is Extract<typeof pr, { kind: "polygon" }> =>
pr.kind === "polygon" && pr.wallId === "W1",
);
}
/** Die Linien-Primitive des 2D-Elements D1. */
function drawingLines(p: ReturnType<typeof generatePlan>) {
return p.primitives.filter(
(pr): pr is Extract<typeof pr, { kind: "line" }> =>
pr.kind === "line" && pr.drawingId === "D1",
);
}
describe("generatePlan — grafische Overrides (Regel-Engine)", () => {
it("layer_name-Regel färbt die Wand-Umrisslinie um (Farbe-Aktion)", () => {
const rules: OverrideRule[] = [
{
id: "r1",
name: "Wände rot",
enabled: true,
condition: { type: "layer_name", operator: "equals", value: "Wände" },
actions: { color: "#ff0000" },
},
];
const plan = generatePlan(project(rules), "eg", visible, undefined, "grob");
const polys = wallPolys(plan);
expect(polys.length).toBeGreaterThan(0);
expect(polys.every((p) => p.stroke === "#ff0000")).toBe(true);
});
it("Lineweight-Regel wirkt identisch zum expliziten Attribut-Override", () => {
const rules: OverrideRule[] = [
{
id: "r1",
name: "Wände dick",
enabled: true,
condition: { type: "layer_name", operator: "equals", value: "20" },
actions: { lineweight: 0.7 },
},
];
const withRule = generatePlan(project(rules), "eg", visible, undefined, "grob");
// Referenz: dieselbe Strichstärke direkt als Element-Attribut gesetzt.
const ref = project();
ref.walls[0].strokeWeight = 0.7;
const withAttr = generatePlan(ref, "eg", visible, undefined, "grob");
expect(wallPolys(withRule).map((p) => p.strokeWidthMm)).toEqual(
wallPolys(withAttr).map((p) => p.strokeWidthMm),
);
// Und unterscheidet sich vom Plan ohne Regel (Regel wirkt tatsächlich).
const base = generatePlan(project(), "eg", visible, undefined, "grob");
expect(wallPolys(withRule)[0].strokeWidthMm).not.toBe(
wallPolys(base)[0].strokeWidthMm,
);
});
it("deaktivierte Regel wirkt nicht (Standard-Strich bleibt)", () => {
const rules: OverrideRule[] = [
{
id: "r1",
name: "Wände rot (aus)",
enabled: false,
condition: { type: "layer_name", operator: "equals", value: "Wände" },
actions: { color: "#ff0000" },
},
];
const plan = generatePlan(project(rules), "eg", visible, undefined, "grob");
expect(wallPolys(plan).some((p) => p.stroke === "#ff0000")).toBe(false);
});
it("Regel-Override gewinnt über den Per-Instanz-Farbwert (oberste Schicht)", () => {
const rules: OverrideRule[] = [
{
id: "r1",
name: "Wände rot",
enabled: true,
condition: { type: "layer_name", operator: "equals", value: "Wände" },
actions: { color: "#ff0000" },
},
];
const p = project(rules);
p.walls[0].color = "#00ff00"; // Instanz-Farbe — die Regel liegt darüber.
const plan = generatePlan(p, "eg", visible, undefined, "grob");
expect(wallPolys(plan).every((pr) => pr.stroke === "#ff0000")).toBe(true);
});
it("object_name-Regel + Linienstil-Aktion: 2D-Linie erhält das Strichmuster", () => {
const rules: OverrideRule[] = [
{
id: "r1",
name: "Linien gestrichelt",
enabled: true,
condition: { type: "object_name", operator: "equals", value: "line" },
actions: { linetypeId: "dashed", color: "#ff00ff" },
},
];
const plan = generatePlan(project(rules), "eg", visible, undefined, "mittel");
const lines = drawingLines(plan);
expect(lines.length).toBeGreaterThan(0);
expect(lines.every((l) => l.color === "#ff00ff")).toBe(true);
expect(lines.every((l) => Array.isArray(l.dash) && l.dash.length === 2)).toBe(true);
});
it("reines Rendering-Overlay: die Projektdaten bleiben unverändert", () => {
const rules: OverrideRule[] = [
{
id: "r1",
name: "Alles rot",
enabled: true,
condition: { type: "layer_name", operator: "contains", value: "" },
actions: { color: "#ff0000", lineweight: 1.0 },
},
];
const p = project(rules);
generatePlan(p, "eg", visible, undefined, "grob");
expect(p.walls[0].color).toBeUndefined();
expect(p.walls[0].strokeWeight).toBeUndefined();
expect(p.drawings2d[0].lineStyleId).toBeUndefined();
});
});
+261 -29
View File
@@ -8,11 +8,14 @@
import type {
AttributeSource,
Ceiling,
Column,
Component,
Drawing2D,
ExtrudedSolid,
HatchPattern,
LayerCategory,
Opening,
OverrideActions,
Project,
Room,
Stair,
@@ -20,6 +23,8 @@ import type {
Wall,
} from "../model/types";
import {
columnLabel,
columnsOfFloor,
flattenCategories,
getCeilingType,
getComponent,
@@ -27,11 +32,15 @@ import {
getLayerCategory,
getLineStyle,
getWallType,
openingLabel,
openingsOfWall,
roomsOfFloor,
stairLabel,
stairsOfFloor,
wallTypeThickness,
} from "../model/types";
import { columnFootprint } from "../geometry/column";
import { effectiveOverrides, hasOverrides } from "../overrides/engine";
import { evaluateRoom, siaLabel } from "../geometry/roomArea";
import type { RichTextDoc } from "../text/richText";
import { docFromText } from "../text/richText";
@@ -42,6 +51,7 @@ import { stairGeometry, stairCut, stairOutline } from "../geometry/stair";
import type { SpiralOutlineSegments } from "../geometry/stair";
import {
doorSymbol,
openingGapQuad,
openingInterval,
openingJambs,
wallAxisFrame,
@@ -224,6 +234,17 @@ export type Primitive =
stairId?: string;
/** ID des Raums (Room), falls dieses Polygon eine Raum-Fläche ist (Auswahl). */
roomId?: string;
/**
* ID des extrudierten Körpers (truck-Integration), falls dieses Polygon der
* Grundriss-Footprint einer Extrusion ist (für eine spätere Links-Klick-
* Auswahl — heute nur zur Nachvollziehbarkeit getaggt, noch nicht verdrahtet).
*/
extrudedSolidId?: string;
/**
* ID der Stütze (Column), falls dieses Polygon der Grundriss-Querschnitt
* (Poché) einer Stütze ist (für die Links-Klick-Auswahl der Fläche).
*/
columnId?: string;
}
| {
kind: "line";
@@ -312,6 +333,12 @@ export type Primitive =
/** Drehung in RADIANT (CCW im Modell). */
angle: number;
color: string;
/**
* Optionale Spaltenbreite in Modell-Metern (Textspalte): ist sie gesetzt,
* bricht der Renderer den Text wortweise auf diese Breite um. Fehlt sie,
* bleibt es einzeiliger Text.
*/
wrapWidth?: number;
/** ID des 2D-Zeichenelements (für Links-Klick-Auswahl). */
drawingId?: string;
greyed?: boolean;
@@ -597,15 +624,6 @@ export function generatePlan(
const primitives: Primitive[] = [];
// Aktives Geschoss (für die Grundriss-Schnitthöhe der Treppen).
const floor = project.drawingLevels.find((z) => z.id === floorId);
// Sichtbarkeit wie bisher; der Darstellungsmodus verfeinert sie zusätzlich:
// render=false blendet die Wand aus, greyed=true dimmt sie.
const walls = project.walls.filter(
(w) =>
w.floorId === floorId &&
visibleCodes.has(w.categoryCode) &&
categoryDisplay(w.categoryCode).render,
);
// Code → Kategorie-Strichstärke (mm), damit die Wand-Umrisslinie aus dem
// Modell (Ebene) stammt statt aus einer Magie-Konstante.
const lwByCode = categoryLwMap(project.layers);
@@ -614,26 +632,92 @@ export function generatePlan(
// "layer" von Vordergrund/Hintergrund/Strichstärke/Schraffur).
const catByCode = categoryMap(project.layers);
// ── Grafische Overrides (Regel-Engine, src/overrides/engine.ts) ───────────
// Effektive Aktionen je Element als OBERSTE Schicht ÜBER der regulären
// By-Layer/By-Object-Auflösung: die Elemente werden hier — rein zur
// Renderzeit — mit den Regel-Ergebnissen dekoriert (flache Kopien; das
// Projekt bleibt unverändert, jederzeit reversibel). `layer_name` matcht
// Ebenen-Name ODER -Code, `object_name` den Element-/Typ-Namen.
const overrideFor = (
categoryCode: string,
objectName?: string,
): OverrideActions =>
effectiveOverrides(project.overrideRules, {
layerName: catByCode.get(categoryCode)?.name,
layerCode: categoryCode,
objectName,
});
const wallTypeNameById = new Map(project.wallTypes.map((t) => [t.id, t.name]));
const ceilingTypeNameById = new Map(
(project.ceilingTypes ?? []).map((t) => [t.id, t.name]),
);
// Sichtbarkeit wie bisher; der Darstellungsmodus verfeinert sie zusätzlich:
// render=false blendet die Wand aus, greyed=true dimmt sie.
const walls = project.walls
.filter(
(w) =>
w.floorId === floorId &&
visibleCodes.has(w.categoryCode) &&
categoryDisplay(w.categoryCode).render,
)
.map((w) => {
// Regel-Override gewinnt über den Per-Instanz-Wert (oberste Schicht):
// Farbe → Umriss-/Fugenfarbe (Wall.color), Strichstärke → strokeWeight
// (der topmost-Wert der bestehenden resolveStrokeWeight-Kette).
const ov = overrideFor(w.categoryCode, wallTypeNameById.get(w.wallTypeId));
if (!hasOverrides(ov)) return w;
return {
...w,
color: ov.color ?? w.color,
strokeWeight: ov.lineweight ?? w.strokeWeight,
};
});
// Freie 2D-Zeichengeometrie dieses Geschosses (wie Wände nach sichtbaren
// Kategorien + Darstellungsmodus gefiltert). Wird über der Wand-Poché
// gezeichnet (am Ende angehängt).
const drawings = project.drawings2d.filter(
(d) =>
d.levelId === floorId &&
visibleCodes.has(d.categoryCode) &&
categoryDisplay(d.categoryCode).render,
);
const drawings = project.drawings2d
.filter(
(d) =>
d.levelId === floorId &&
visibleCodes.has(d.categoryCode) &&
categoryDisplay(d.categoryCode).render,
)
.map((d) => {
// `object_name` matcht bei freier 2D-Geometrie den Formnamen
// (line/polyline/rect/circle/arc/text). Linienstil-Override nur hier —
// Drawing2D ist das einzige Element mit LineStyle-Referenz.
const ov = overrideFor(d.categoryCode, d.geom.shape);
if (!hasOverrides(ov)) return d;
return {
...d,
color: ov.color ?? d.color,
weightMm: ov.lineweight ?? d.weightMm,
lineStyleId: ov.linetypeId ?? d.lineStyleId,
};
});
// Decken dieses Geschosses (wie Wände nach sichtbaren Kategorien +
// Darstellungsmodus gefiltert). Eine Decke liegt (als Slab) über der
// Schnittebene → im Grundriss als gefüllter/schraffierter Umriss dargestellt,
// UNTER der Wand-Poché (damit die Wände darüber lesbar bleiben).
const ceilings = (project.ceilings ?? []).filter(
(c) =>
c.floorId === floorId &&
visibleCodes.has(c.categoryCode) &&
categoryDisplay(c.categoryCode).render,
);
const ceilings = (project.ceilings ?? [])
.filter(
(c) =>
c.floorId === floorId &&
visibleCodes.has(c.categoryCode) &&
categoryDisplay(c.categoryCode).render,
)
.map((c) => {
// Decken: nur der Farb-Override wirkt (der Umriss ist eine feste
// Überkopf-Haarlinie, siehe addCeilingPoche — keine Strichstärke).
const typeName =
(c.ceilingTypeId ? ceilingTypeNameById.get(c.ceilingTypeId) : undefined) ??
wallTypeNameById.get(c.wallTypeId);
const ov = overrideFor(c.categoryCode, typeName);
return ov.color !== undefined ? { ...c, color: ov.color } : c;
});
// Wand-Footprints für das Verdecken der (darunterliegenden) Decken-Umrisse:
// die Decke liegt als Slab über der Schnittebene, ihre Umrisslinie soll dort,
// wo eine Wand darüber steht, NICHT durch die Wand-Poché schlagen. Nur nötig,
@@ -650,10 +734,17 @@ export function generatePlan(
// Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel
// (Name + Fläche + SIA-Tag). UNTER der Wand-Poché gezeichnet, damit die Wände
// lesbar bleiben. Der Stempel (Text) entfällt beim Detailgrad „grob".
const rooms = roomsOfFloor(project, floorId).filter(
(r) =>
visibleCodes.has(r.categoryCode) && categoryDisplay(r.categoryCode).render,
);
const rooms = roomsOfFloor(project, floorId)
.filter(
(r) =>
visibleCodes.has(r.categoryCode) && categoryDisplay(r.categoryCode).render,
)
.map((r) => {
// Räume: `object_name` matcht den Raum-Namen; der Farb-Override färbt
// Fläche + Stempel (Room.color) um.
const ov = overrideFor(r.categoryCode, r.name);
return ov.color !== undefined ? { ...r, color: ov.color } : r;
});
for (const room of rooms) {
const greyed = categoryDisplay(room.categoryCode).greyed;
addRoomArea(primitives, room, greyed, detail);
@@ -701,8 +792,13 @@ export function generatePlan(
for (const o of openings) {
const d = categoryDisplay(o.categoryCode);
if (visibleCodes.has(o.categoryCode) && d.render) {
const oLwMm = lwByCode.get(o.categoryCode) ?? LAYER_LINE_MM;
addOpeningSymbol(primitives, project, wall, o, d.greyed, detail, oLwMm);
// Override-Regeln: `object_name` matcht das Öffnungs-Label
// („Tür 90×210" / „Fenster 120×140").
const ov = overrideFor(o.categoryCode, openingLabel(o));
const oLwMm =
ov.lineweight ?? lwByCode.get(o.categoryCode) ?? LAYER_LINE_MM;
const effO = ov.color !== undefined ? { ...o, color: ov.color } : o;
addOpeningSymbol(primitives, project, wall, effO, d.greyed, detail, oLwMm);
}
}
}
@@ -717,8 +813,28 @@ export function generatePlan(
);
for (const stair of stairs) {
const greyed = categoryDisplay(stair.categoryCode).greyed;
const lwMm = lwByCode.get(stair.categoryCode) ?? WALL_FALLBACK_MM;
addStairSymbol(primitives, project, stair, greyed, lwMm, cutHeight);
// Override-Regeln: `object_name` matcht das Treppen-Label („Gerade 16 STG").
const ov = overrideFor(stair.categoryCode, stairLabel(stair));
const lwMm =
ov.lineweight ?? lwByCode.get(stair.categoryCode) ?? WALL_FALLBACK_MM;
const effStair = ov.color !== undefined ? { ...stair, color: ov.color } : stair;
addStairSymbol(primitives, project, effStair, greyed, lwMm, cutHeight);
}
// Stützen (Tragwerk) dieses Geschosses: Querschnitt-Poché (Rechteck/Kreis),
// um `rotation` gedreht, auf Kategorie „50". Über der Wand-Poché gezeichnet.
const columns = columnsOfFloor(project, floorId).filter(
(col) =>
visibleCodes.has(col.categoryCode) && categoryDisplay(col.categoryCode).render,
);
for (const column of columns) {
const greyed = categoryDisplay(column.categoryCode).greyed;
const category = catByCode.get(column.categoryCode);
const ov = overrideFor(column.categoryCode, columnLabel(column));
const lwMm =
ov.lineweight ?? resolveStrokeWeight(undefined, undefined, category, lwByCode.get(column.categoryCode) ?? WALL_FALLBACK_MM);
const effCol = ov.color !== undefined ? { ...column, color: ov.color } : column;
addColumnPoche(primitives, project, effCol, greyed, lwMm, category);
}
// 2D-Zeichengeometrie zuletzt (über der Poché).
@@ -727,6 +843,14 @@ export function generatePlan(
addDrawing2D(primitives, project, d, greyed, lwByCode, colorByCode, catByCode);
}
// Extrudierte Körper (truck-Integration): das Quell-Profil wurde beim
// Extrudieren aus `drawings2d` entfernt (s. `extrude.ts` appendExtrudedSolid)
// — im Grundriss erscheint stattdessen der Footprint-Umriss des Körpers
// selbst (Haarlinie, kein Poché/Schraffur; Farbe wie das 3D-Orange).
for (const solid of project.extrudedSolids ?? []) {
if (solid.levelId === floorId) addExtrudedSolidOutline(primitives, solid);
}
// Kontext-Konturen (Höhenlinien) als dezente Linien zeichnen — UNTER der
// Wand-Poché wäre ideal, aber da sie eine eigene gedämpfte Farbe tragen, ist
// die Reihenfolge unkritisch; wir hängen sie als Referenz an.
@@ -956,6 +1080,79 @@ function categoryMap(layers: LayerCategory[]): Map<string, LayerCategory> {
return map;
}
/** Footprint-Farbe der truck-Extrusionen im Grundriss (identisch zum 3D-Orange EXTRUSION_RGB in toWalls3d.ts). */
const EXTRUSION_STROKE = "#d98c40";
/**
* Footprint-Umriss eines extrudierten Körpers (truck-Integration) im Grundriss:
* reine Haarlinie ohne Füllung/Schraffur — analog zur Ansichtslinie einer Wand
* unter der Schnitthöhe ({@link addWallPoche} `viewOnly`), da der Körper (noch)
* keine Schnitt-Poché/Kategorie-Zuordnung hat.
*/
function addExtrudedSolidOutline(out: Primitive[], solid: ExtrudedSolid): void {
out.push({
kind: "polygon",
pts: solid.points,
fill: "none",
stroke: EXTRUSION_STROKE,
strokeWidthMm: SYMBOL_HAIRLINE_MM,
hatch: NO_HATCH,
extrudedSolidId: solid.id,
});
}
/** Fallback-Poché-Füllung einer Stütze ohne (auflösbares) Bauteil: neutrale Tinte. */
const COLUMN_FALLBACK_FILL = "#3a3f47";
/**
* Grundriss-Querschnitt einer Stütze (Column) als Schnitt-Poché: gefülltes
* Polygon (Rechteck bzw. tesselliertes Kreisprofil), an `position` platziert und
* um `rotation` gedreht. Trägt — sofern ein Bauteil (`componentId`) auflösbar ist
* — dessen Füllung + Schraffur (dieselbe Auflösung wie Wand/Decke, {@link
* resolveWallSectionStyle}); sonst eine neutrale Vollton-Poché. `columnId` macht
* die Fläche links-klick-selektierbar (analog Extrusion/Raum).
*/
function addColumnPoche(
out: Primitive[],
project: Project,
column: Column,
greyed: boolean,
lwMm: number,
category: LayerCategory | undefined,
): void {
const pts = columnFootprint(column);
const stroke = column.color ?? category?.color ?? MONO_INK;
let fill = column.color ?? category?.color ?? COLUMN_FALLBACK_FILL;
let hatch: HatchRender = NO_HATCH;
if (column.componentId) {
try {
const comp = getComponent(project, column.componentId);
const hatchId =
resolveHatchId(undefined, undefined, category, comp.hatchId) ?? comp.hatchId;
const resolved = resolveHatch(
project,
hatchId,
undefined,
resolveForeground(comp, undefined, category, undefined),
);
hatch = resolved;
fill = resolved.pattern === "solid" ? HATCH_INK : HATCH_PAPER;
} catch {
// Bauteil nicht auflösbar → neutrale Poché (Fallback bleibt bestehen).
}
}
out.push({
kind: "polygon",
pts,
fill,
stroke,
strokeWidthMm: lwMm,
hatch,
greyed,
columnId: column.id,
});
}
/**
* Leitet ein 2D-Zeichenelement in Plan-Primitive ab (docs/design/drawing-tools.md
* §7.2). Farbe/Strichstärke kommen aus dem optionalen LineStyle, sonst aus der
@@ -1091,6 +1288,7 @@ function addDrawing2D(
heightM: g.height,
angle: g.angle,
color,
...(g.width && g.width > 0 ? { wrapWidth: g.width } : {}),
greyed,
drawingId: d.id,
});
@@ -1691,6 +1889,24 @@ function addOpeningSymbol(
if (o.kind === "door") {
const sym = doorSymbol(wall, o);
if (!sym) return;
// Unsichtbares Pick-Polygon über der Öffnungslücke (Laibung × Wanddicke) —
// damit die Tür wie ein Fenster per Punkt-in-Polygon über die ganze
// Öffnungsfläche selektierbar ist (Türsymbol besteht sonst nur aus dünnem
// Blatt + Bogen, die man exakt treffen müsste). fill/stroke "none" ⇒ zeichnet
// nichts; `pickOpening` (PlanView) testet nur die `pts`.
const gap = openingGapQuad(project, wall, o);
if (gap) {
out.push({
kind: "polygon",
pts: gap,
fill: "none",
stroke: "none",
strokeWidthMm: 0,
hatch: NO_HATCH,
greyed,
openingId: o.id,
});
}
// Tür-Typ „wandoeffnung": reiner Wanddurchbruch — KEIN Türblatt, KEIN
// Schwenkbogen. Nur die Öffnung/Laibung bleibt (die Wandlücke kommt aus der
// ausgesparten Poché; Sturz-/Anschlaglinien werden weiter unten gezeichnet).
@@ -1795,6 +2011,22 @@ function addOpeningSymbol(
const glassCount = detail === "fein" ? 2 : 1;
const sym = windowSymbol(project, wall, o, glassCount);
if (!sym) return;
// Unsichtbares Pick-Polygon über der Öffnungslücke — macht das Fenster über
// die ganze Fläche selektierbar, auch bei „grob" (nur eine Glaslinie, sonst
// schwer zu treffen). Siehe Tür-Zweig oben.
const wgap = openingGapQuad(project, wall, o);
if (wgap) {
out.push({
kind: "polygon",
pts: wgap,
fill: "none",
stroke: "none",
strokeWidthMm: 0,
hatch: NO_HATCH,
greyed,
openingId: o.id,
});
}
if (detail !== "grob") {
// Rahmen-Rechteck als reine Umrisslinie (Poché-Stroke, keine Füllung).
out.push({
+14 -1
View File
@@ -12,7 +12,7 @@
import type { Plan } from "./generatePlan";
import type { Project } from "../model/types";
import { planToRenderScene } from "./toRenderScene";
import { projectToModel3d } from "./toWalls3d";
import { projectToModel3d, TRUCK_MESH_READY_EVENT } from "./toWalls3d";
/** Sammel-Fenster: Modelländerungen werden gebündelt gepusht (nicht je Frame). */
const DEBOUNCE_MS = 200;
@@ -56,8 +56,21 @@ export function pushNativeScene(plan: Plan): void {
*/
export function pushNativeWalls(project: Project): void {
if (!isTauri()) return;
lastProject = project;
clearTimeout(wallsTimer);
wallsTimer = setTimeout(() => {
void invokeNative("push_native_walls", { walls: projectToModel3d(project) });
}, DEBOUNCE_MS);
}
/** Letztes an {@link pushNativeWalls} übergebenes Projekt (für den Re-Push unten). */
let lastProject: Project | null = null;
// truck-Extrusionen (s. toWalls3d.ts emitExtrudedSolids) laden WASM asynchron;
// ohne diesen Re-Push nach Abschluss bliebe eine fertige Extrusion im nativen
// Fenster unsichtbar, weil `pushNativeWalls` nur bei Projektänderungen läuft.
if (typeof window !== "undefined") {
window.addEventListener(TRUCK_MESH_READY_EVENT, () => {
if (lastProject) pushNativeWalls(lastProject);
});
}
+561 -72
View File
@@ -9,6 +9,7 @@ import { describe, it, expect } from "vitest";
import type { Project } from "../model/types";
import { sampleProject } from "../model/sampleProject";
import { selectionHighlightLines, projectToWalls3d, projectToModel3d } from "./toWalls3d";
import { resolveHatch } from "./generatePlan";
const RGB: [number, number, number] = [1, 0.5, 0.1];
const FLOATS_PER_VERTEX = 6; // [px,py,pz, r,g,b]
@@ -25,14 +26,19 @@ describe("selectionHighlightLines", () => {
expect(lines.length).toBe(0);
});
it("eine Wand ohne Öffnungen -> je Materiallage ein Quader (4·12 Kanten)", () => {
it("eine Wand ohne Öffnungen -> je Materiallage ein Quader, Kern-Band unter der Decke gespalten (5·12 Kanten)", () => {
// W2 (EG, Ost) trägt keine Tür/kein Fenster im Sample-Projekt (nur W1/W3
// haben Öffnungen, s. sampleProject.ts) -> ein Achsen-Teilquader. Wandtyp
// "aw" hat 4 Schichten (render-ext/insulation/brick/render-int), die
// TS-seitig je als eigene Box gestapelt werden (Option B) -> 4 Quader =
// 4·12 Kanten. (Früher: 1 Vollkörper = 12 Kanten.)
// TS-seitig je als eigene Box gestapelt werden (Option B).
// NEU (per-Schicht-Decken-Dominanz): W2 steht unter der Geschossdecke C1
// (dg-massiv: screed 30 / insulation 20 / concrete 100). Der Backstein (50)
// wird NUR von der Beton-Schicht (100) im z-Band [2.3,2.5] gekappt und läuft
// darüber (bis Wandkopf 2.6) durch -> ZWEI Backstein-Boxen [0,2.3] + [2.5,2.6].
// Die anderen Bänder bleiben je 1 Box (Aussenputz/Dämmung aussen: ungeschnitten;
// Innenputz 10 < alle Deckenschichten: bis 2.3). Also 5 Quader = 5·12 Kanten.
const lines = selectionHighlightLines(sampleProject, ["W2"], [], RGB);
expect(lines.length).toBe(4 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
expect(lines.length).toBe(5 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
});
it("eine Wand mit Farbwerten in jedem Vertex", () => {
@@ -43,11 +49,14 @@ describe("selectionHighlightLines", () => {
expect(lines[5]).toBeCloseTo(RGB[2], 6);
});
it("eine Decke (Slab) -> 3·n Kanten (n = Umriss-Eckenzahl)", () => {
// C1 hat einen 4-eckigen Umriss (5x4m Rechteck) -> 3*4 = 12 Kanten.
it("eine Decke (Slab) -> je Schicht 3·n Kanten (geschichteter Bodenaufbau)", () => {
// C1 hat einen 4-eckigen Umriss (5x4m Rechteck) -> 3*4 = 12 Kanten JE SCHICHT.
// Deckentyp "dg-massiv" hat 3 Schichten (Estrich/Dämmung/Beton) -> je ein
// eigener Slab, damit der 3D-Schnitt den Aufbau geschichtet zeigt (wie 2D).
const lines = selectionHighlightLines(sampleProject, [], ["C1"], RGB);
const n = 4;
expect(lines.length).toBe(3 * n * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
const layers = 3; // dg-massiv: screed + insulation + concrete
expect(lines.length).toBe(layers * 3 * n * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
});
it("Mehrfachauswahl (Wand + Decke) -> Summe der Einzelgeometrien", () => {
@@ -63,16 +72,21 @@ describe("selectionHighlightLines", () => {
expect(two.length).toBe(one.length * 2);
});
it("eine Wand MIT Öffnungen -> EIN Körper je Schicht-Band mit Löchern (kein Segment-Vielfaches mehr)", () => {
// NEUES Verhalten (3D-Pfad, layered=true): W1 (Tür D1 + Fenster O1) wird
// NICHT mehr in Pfeiler/Brüstung/Sturz zerlegt, sondern als EIN Körper pro
// Schicht-Band mit echten Löchern emittiert. Das Highlight umrandet daher
// genau die Schicht-Bänder (Wandtyp "aw" = 4 Schichten) — exakt so viele
// Kanten wie eine öffnungslose Wand desselben Typs (W2), NICHT ein Vielfaches
// durch Segmente. (Die Löcher selbst fügen im Highlight keine Kanten hinzu.)
const w1 = selectionHighlightLines(sampleProject, ["W1"], [], RGB);
const w2 = selectionHighlightLines(sampleProject, ["W2"], [], RGB);
expect(w1.length).toBe(4 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
it("Öffnungen allein multiplizieren die Bänder NICHT (ein Körper je Schicht-Band mit Löchern)", () => {
// 3D-Pfad (layered=true): eine Wand mit Öffnungen wird NICHT in Pfeiler/
// Brüstung/Sturz zerlegt, sondern als EIN Körper pro Schicht-Band mit echten
// Löchern emittiert — die Löcher fügen im Highlight keine Kanten hinzu.
// Isoliert von Verschneidungen getestet: OHNE die Querwand W9 (deren T-Stoss
// sonst die innerste Putzschicht von W1 span-cutout-splittet, s. eigener
// Test unten) hat W1 (Tür D1 + Fenster O1) dieselbe Bänder-Zahl wie die
// öffnungslose Wand W2 desselben Typs "aw" — die Löcher fügen keine Körper
// hinzu. Beide stehen unter der Geschossdecke C1, deren Beton-Schicht (100)
// den Backstein (50) im z-Band [2.3,2.5] spaltet -> 5 Körper (per-Schicht-
// Decken-Dominanz, s. Backstein-Boxen [0,2.3] + [2.5,2.6]).
const noW9: Project = { ...sampleProject, walls: sampleProject.walls.filter((w) => w.id !== "W9") };
const w1 = selectionHighlightLines(noW9, ["W1"], [], RGB);
const w2 = selectionHighlightLines(noW9, ["W2"], [], RGB);
expect(w1.length).toBe(5 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
expect(w1.length).toBe(w2.length);
});
});
@@ -114,23 +128,37 @@ describe("Öffnungen als echte Löcher in EINEM Körper (3D-Pfad, layeredWalls:t
],
};
it("layered=true: 1 RWall je Schicht-Band mit BEIDEN Fenstern als holes", () => {
it("layered=true: BEIDE Fenster als holes je Band, Loch-Positionen bleiben nach Join-Rebase korrekt", () => {
const w1 = projectToWalls3d(twoOffsetWindows, true).filter((w) => w.wallId === "W1");
// Genau ein Körper je Schicht-Band (aw = 4 Schichten), KEINE Segment-Boxen.
expect(w1.length).toBe(4);
for (const body of w1) {
// Jeder Band-Körper trägt BEIDE Löcher (in EINEM Körper).
// W1 ist Durchgangswand für die Querwand W9 (T-Stoss): die innerste Putz-
// schicht wird durch den Span-Cutout entlang der Achse in ZWEI Teilstücke
// gebrochen. NEU (per-Schicht-Decken-Dominanz): W1 steht unter der Decke C1,
// deren Beton (100) den Backstein (50) im z-Band [2.3,2.5] spaltet -> der
// Backstein kommt als ZWEI Boxen [0,2.3] + [2.5,2.6]. Die obere Backstein-Box
// liegt ÜBER beiden Fenstern (z ≤ 2.1) und trägt daher KEINE Löcher.
// Körper gesamt: render-ext(1) + insulation(1) + brick[0,2.3](1) +
// brick[2.5,2.6](1, ohne Loch) + render-int-Span(2) = 6.
expect(w1.length).toBe(6);
// Nur die Körper, die die Fenster-z-Bänder schneiden, tragen Löcher (5 Stück);
// die obere Backstein-Box (über den Fenstern) hat keine.
const withHoles = w1.filter((b) => b.holes && b.holes.length > 0);
expect(withHoles.length).toBe(5);
// W1-Achse: (0,0)->(5,0), u=(1,0) -> Box-Achsenstart = box.start[0]. Die Loch-
// Achsenkoordinaten sind je Körper auf DESSEN Box-Start rebased; die ABSOLUTE
// Position (Box-Start + from) muss wieder exakt auf den Öffnungs-Intervallen liegen.
for (const body of withHoles) {
expect(body.holes).toBeDefined();
expect(body.holes!.length).toBe(2);
const axis0 = body.start[0];
const byFrom = [...body.holes!].sort((a, b) => a.from - b.from);
// Fenster A: u=[1,2], z=[0.9,2.1].
expect(byFrom[0].from).toBeCloseTo(1.0, 6);
expect(byFrom[0].to).toBeCloseTo(2.0, 6);
// Fenster A: u=[1,2], z=[0.9,2.1] (absolut rekonstruiert).
expect(axis0 + byFrom[0].from).toBeCloseTo(1.0, 6);
expect(axis0 + byFrom[0].to).toBeCloseTo(2.0, 6);
expect(byFrom[0].zBottom).toBeCloseTo(0.9, 6);
expect(byFrom[0].zTop).toBeCloseTo(2.1, 6);
// Fenster B: u=[1.5,2.5], z=[0.3,0.7] — überlappt A in u, liegt tiefer.
expect(byFrom[1].from).toBeCloseTo(1.5, 6);
expect(byFrom[1].to).toBeCloseTo(2.5, 6);
expect(axis0 + byFrom[1].from).toBeCloseTo(1.5, 6);
expect(axis0 + byFrom[1].to).toBeCloseTo(2.5, 6);
expect(byFrom[1].zBottom).toBeCloseTo(0.3, 6);
expect(byFrom[1].zTop).toBeCloseTo(0.7, 6);
}
@@ -149,7 +177,12 @@ describe("Öffnungen als echte Löcher in EINEM Körper (3D-Pfad, layeredWalls:t
// zBottom des Lochs == Wand-UK (0), NICHT ab einer Brüstung.
const p: Project = { ...sampleProject, openings: [] };
const w1 = projectToWalls3d(p, true).filter((w) => w.wallId === "W1");
expect(w1.length).toBe(4);
// W1 ist Durchgangswand für W9 (T-Stoss) -> innerste Putzschicht span-cutout-
// gebrochen. Zusätzlich spaltet die Decke C1 (Beton 100) den Backstein (50) im
// z-Band [2.3,2.5] -> obere Backstein-Box [2.5,2.6] ohne Loch. Körper: render-ext
// + insulation + brick[0,2.3] + brick[2.5,2.6] + render-int-Span(2) = 6.
expect(w1.length).toBe(6);
// Der erste Körper (Aussenputz-Band, volle Achse) trägt die Tür als Loch bis UK.
const door = w1[0].holes?.find((h) => h.zBottom < 1e-6);
expect(door).toBeDefined();
expect(door!.zBottom).toBeCloseTo(0, 6); // Loch bis zur Wand-UK.
@@ -161,10 +194,13 @@ describe("Bauteil-/Materialfarbe statt Einheits-Grau (Wände + Decken)", () => {
// Wandtyp "aw" (s. sampleProject) hat 4 Schichten mit eigenen Farben:
// render-ext 0.02 #d8d2c7, insulation 0.16 #ffffff,
// brick 0.15 #8c5544, render-int 0.015 #efece6 (Σ = 0.345 m).
// W2 ist öffnungslos -> genau ein Achsen-Teilquader, also 4 Schicht-Boxen.
// W2 ist öffnungslos, steht aber unter der Geschossdecke C1: die Beton-Schicht
// (100) spaltet den Backstein (50) im z-Band [2.3,2.5] -> der Backstein kommt
// als ZWEI Boxen [0,2.3] + [2.5,2.6] (per-Schicht-Decken-Dominanz), die drei
// übrigen Bänder je 1 Box = 5 Boxen. Die Farbzuordnung je Schicht bleibt.
const walls = projectToWalls3d(sampleProject);
const w2 = walls.filter((w) => w.wallId === "W2");
expect(w2.length).toBe(4);
expect(w2.length).toBe(5);
// Alle Schicht-Boxen tragen die gemeinsame wallId (Klick selektiert ganze Wand)
// und sind Vollkörper (layers leer -> render3d zeichnet in `color`).
@@ -173,14 +209,18 @@ describe("Bauteil-/Materialfarbe statt Einheits-Grau (Wände + Decken)", () => {
expect(seg.layers.length).toBe(0);
}
// Die vier Materialfarben kommen genau einmal vor.
// Die vier Materialfarben kommen vor; der Backstein (#8c5544) zweimal (gespalten).
const hex = (c: [number, number, number]) =>
c.map((v) => Math.round(v * 255).toString(16).padStart(2, "0")).join("");
const colors = w2.map((s) => hex(s.color)).sort();
expect(colors).toEqual(["8c5544", "d8d2c7", "efece6", "ffffff"]);
expect(colors).toEqual(["8c5544", "8c5544", "d8d2c7", "efece6", "ffffff"]);
expect([...new Set(colors)].sort()).toEqual(["8c5544", "d8d2c7", "efece6", "ffffff"]);
// Die Schichtdicken summieren zur Gesamt-Wanddicke (keine Lücken/Overlaps).
const totalT = w2.reduce((s, seg) => s + seg.thickness, 0);
// Die Schichtdicken (je EINDEUTIGEM Querversatz, damit die gespaltene Backstein-
// Box nur EINMAL zählt) summieren zur Gesamt-Wanddicke (keine Lücken/Overlaps).
const byOffset = new Map<number, number>();
for (const seg of w2) byOffset.set(Math.round((seg.start[0] - 5) * 1e6) / 1e6, seg.thickness);
const totalT = [...byOffset.values()].reduce((s, t) => s + t, 0);
expect(totalT).toBeCloseTo(0.345, 6);
});
@@ -189,9 +229,14 @@ describe("Bauteil-/Materialfarbe statt Einheits-Grau (Wände + Decken)", () => {
// wirkt rein auf x (Box-Start.x = 5 + offset). Sortiert man die Boxen nach
// Versatz, müssen sich benachbarte Flächen exakt berühren und der Stapel
// symmetrisch um die Achse liegen: min = -T/2, max = +T/2.
const w2 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2");
const bands = w2
.map((s) => ({ offset: s.start[0] - 5, t: s.thickness }))
// Nach EINDEUTIGEM Querversatz zusammenfassen: der unter der Decke gespaltene
// Backstein ([0,2.3] + [2.5,2.6]) liegt zweimal am SELBEN Versatz vor und darf
// die Dicken-Bilanz nicht doppelt zählen (per-Schicht-Decken-Dominanz).
const w2raw = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2");
const byOffset = new Map<number, number>();
for (const s of w2raw) byOffset.set(Math.round((s.start[0] - 5) * 1e6) / 1e6, s.thickness);
const bands = [...byOffset.entries()]
.map(([offset, t]) => ({ offset, t }))
.sort((a, b) => a.offset - b.offset);
const T = bands.reduce((s, b) => s + b.t, 0);
expect(bands[0].offset - bands[0].t / 2).toBeCloseTo(-T / 2, 6);
@@ -216,9 +261,14 @@ describe("Fenster-Glasscheiben (emitOpeningGlass)", () => {
context: [],
};
const { meshes } = projectToModel3d(p);
// `meshes` enthält jetzt auch die glatte Betontreppen-Laufplatte (ST1, Mesh);
// die Glasscheiben tragen die bläuliche Glasfarbe → danach filtern.
const glassMeshes = meshes.filter(
(m) => m.color[0] === 0.62 && m.color[1] === 0.76 && m.color[2] === 0.85,
);
// Genau EINE Scheibe: das Fenster O1; die Tür D1 bleibt offen (keine Scheibe).
expect(meshes.length).toBe(1);
const glass = meshes[0];
expect(glassMeshes.length).toBe(1);
const glass = glassMeshes[0];
// Quader: 8 Ecken (24 Floats) + 12 Dreiecke (36 Indizes), leicht bläuliches Glas.
expect(glass.positions.length).toBe(24);
expect(glass.indices.length).toBe(36);
@@ -234,32 +284,73 @@ describe("Fenster-Glasscheiben (emitOpeningGlass)", () => {
it("Türen bekommen keine Scheibe; je Fenster genau eine", () => {
// Volles Sample-Projekt: 2 Fenster (O1, O2) + 1 Tür (D1), Kontext leer.
// Neben den Glasscheiben liegt jetzt auch das Betontreppen-Mesh in `meshes`
// → nach Glasfarbe filtern: genau 2 Scheiben (O1, O2), Tür ohne Scheibe.
const { meshes } = projectToModel3d(sampleProject);
expect(meshes.length).toBe(2);
const glassMeshes = meshes.filter(
(m) => m.color[0] === 0.62 && m.color[1] === 0.76 && m.color[2] === 0.85,
);
expect(glassMeshes.length).toBe(2);
});
});
describe("Prioritäts-Trim Wand/Decke (Z-Fighting-Fix, s. trimWallTopForCeilings)", () => {
// Sample-Projekt: W1-W4 (EG, Wandtyp "aw", dominante Schicht "brick"
// joinPriority 50) tragen exakt Wandhöhe 2.6 m = Geschosshöhe -> zTop 2.6.
// Decke C1 (EG, Deckentyp "dg-massiv", dominante Schicht "concrete"
// joinPriority 100) hat OK bündig mit dem Wandkopf (zTop 2.6) und
// Gesamtdicke 0.3 m (screed .06 + insulation .04 + concrete .2) -> zBottom
// 2.3. Vor dem Trim überlappten sich Wand- und Deckenvolumen exakt über
// [2.3..2.6] im ganzen Wand-Footprint (Z-Fighting). Nach dem Trim endet die
// Wand an der Deckenunterkante, weil die Decke die höhere joinPriority hat.
it("Decke mit höherer joinPriority kappt die Wand an ihrer Unterkante", () => {
describe("Per-Schicht-Decken-Dominanz Wand/Decke (3D-Entsprechung der Schnitt-Boolean-Dominanz)", () => {
// Sample-Projekt: W1-W4 (EG, Wandtyp "aw": render-ext 10, insulation 20,
// brick 50, render-int 10) tragen Wandhöhe 2.6 m = Geschosshöhe. Decke C1
// (EG, Deckentyp "dg-massiv", dominante Schicht "concrete" joinPriority 100)
// hat OK bündig mit dem Wandkopf (zTop 2.6), Gesamtdicke 0.3 m -> zBottom 2.3.
//
// ENTSCHEIDEND (Nutzer-Bug „Kranz durch die Dämmung"): C1.outline ist der
// Raumgrundriss auf den WAND-ACHSEN ((0,0)-(5,0)-(5,4)-(0,4)) — die Decke liegt
// also nur auf dem INNEREN Halbquerschnitt jeder Aussenwand. Für W2 (Achse x=5)
// liegen die Bandmittellinien bei x = 5 offset:
// render-ext x=5.1625 (AUSSEN, x>5) -> NICHT überdeckt -> läuft durch
// insulation x=5.0725 (AUSSEN) -> NICHT überdeckt -> läuft durch
// brick x=4.9175 (INNEN, x<5) -> überdeckt -> Decken-z-Schnitt
// render-int x=4.8350 (INNEN) -> überdeckt -> Decken-z-Schnitt
// Die Decke schneidet ein Band also nur dort, wo ihre Outline die Footprint-
// Mittellinie DIESES Bandes überdeckt (geometrischer Überlapp wie
// toSection.ts::subtractDominantBands) — NICHT mehr per wandweitem BBox-Test.
// (Frühere Tests erwarteten hier „ALLE Schichten geschnitten"; das kodierte das
// falsche BBox-Verhalten und ist die Ursache des 3D-Kranzes. Korrigiert.)
it("Decke schneidet nur die von ihrer Outline überdeckten (inneren) Bänder; Dämmung/Aussenputz laufen durch; Beton spaltet den Backstein PER SCHICHT", () => {
const w2 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2");
expect(w2.length).toBeGreaterThan(0);
for (const seg of w2) {
expect(seg.baseElevation + seg.height).toBeCloseTo(2.3, 6);
}
// PER-SCHICHT-Decken-Dominanz (identisch zu toSection.ts::subtractDominantBands):
// jede Deckenschicht (screed 30 / insulation 20 / concrete 100) schneidet nur
// Wandbänder mit STRIKT niedrigerer Priorität in IHREM z-Band. Der Backstein (50)
// wird also NUR von der Beton-Schicht (100, z-Band [2.3,2.5]) gekappt und läuft
// darüber bis zum Wandkopf (2.6) durch -> ZWEI Boxen. So schneidet er im Schnitt
// die schwimmenden Deckenschichten Estrich/Dämmung über sich weg (Nutzer-Bug).
const zBoxes = (t: number) =>
w2.filter((s) => Math.abs(s.thickness - t) < 1e-6)
.map((s) => [s.baseElevation, s.baseElevation + s.height] as const)
.sort((a, b) => a[0] - b[0]);
// brick: [0,2.3] (unter Beton) + [2.5,2.6] (über Beton, bis Wandkopf).
const brick = zBoxes(0.15);
expect(brick.length).toBe(2);
expect(brick[0][1]).toBeCloseTo(2.3, 6); // endet an Beton-UK
expect(brick[1][0]).toBeCloseTo(2.5, 6); // beginnt an Beton-OK
expect(brick[1][1]).toBeCloseTo(2.6, 6); // läuft bis Wandkopf durch
// render-int (10 < allen Deckenschichten): über [2.3,2.6] komplett gekappt -> [0,2.3].
const renderInt = zBoxes(0.015);
expect(renderInt.length).toBe(1);
expect(renderInt[0][1]).toBeCloseTo(2.3, 6);
// AUSSEN liegende Schichten (Mittellinie ausserhalb der Decken-Outline): KEIN
// Schnitt -> volle Höhe (behobener „Kranz"-Bug, wie im 2D-Schnitt).
expect(zBoxes(0.16)).toEqual([[0, 2.6]]); // insulation läuft durch
expect(zBoxes(0.02)).toEqual([[0, 2.6]]); // render-ext (Aussenputz) läuft durch
expect(w2.length).toBe(5); // render-ext + insulation + brick(2) + render-int
});
it("kein Trim, wenn die Wand-Priorität >= Decken-Priorität ist", () => {
// Deckentyp-Priorität (concrete) auf 40 senken -> unter der Wand-Priorität
// (brick 50) -> die Decke gewinnt nicht mehr, die Wand bleibt unverändert
// auf voller Höhe (2.6 m, wie ohne Trim-Logik).
it("PER-SCHICHT: nur überdeckte Bänder mit < Decken-Priorität werden geschnitten (Kern >= Prio läuft durch)", () => {
// Deckentyp-Priorität (concrete) auf 40 senken. Von den ÜBERDECKTEN (inneren)
// Bändern läuft brick (50 > 40) jetzt bis zum Wandkopf (2.6) durch; render-int
// (10 < 40) wird im Decken-z-Band [2.3,2.6] geschnitten -> endet bei 2.3. Die
// AUSSEN liegenden Bänder (insulation, render-ext) sind ohnehin nicht überdeckt
// und laufen unabhängig von der Priorität durch (2.6). Priorität ALLEIN genügt
// also nicht — es braucht zusätzlich den geometrischen Überlapp (wie die
// „gestaffelte Kette" in toSection.boolean.test.ts, wo Beton die Dämmung trotz
// höherer Prio NICHT schneidet, weil sie sich nicht überlappen).
const p: Project = {
...sampleProject,
components: sampleProject.components.map((c) =>
@@ -267,13 +358,155 @@ describe("Prioritäts-Trim Wand/Decke (Z-Fighting-Fix, s. trimWallTopForCeilings
),
};
const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2");
expect(w2.length).toBeGreaterThan(0);
for (const seg of w2) {
expect(seg.baseElevation + seg.height).toBeCloseTo(2.6, 6);
}
expect(w2.length).toBe(4); // je 1 Box pro Band (Wand ragt nicht über die Decke)
const top = (t: number) => {
const b = w2.find((s) => Math.abs(s.thickness - t) < 1e-6)!;
return b.baseElevation + b.height;
};
expect(top(0.15)).toBeCloseTo(2.6, 6); // brick (überdeckt, 50 > 40) läuft durch
expect(top(0.015)).toBeCloseTo(2.3, 6); // render-int (überdeckt, 10 < 40) geschnitten
expect(top(0.16)).toBeCloseTo(2.6, 6); // insulation (nicht überdeckt) läuft durch
expect(top(0.02)).toBeCloseTo(2.6, 6); // render-ext (nicht überdeckt) läuft durch
});
it("kein Trim, wenn keine Decke im selben Geschoss überlappt (z.B. OG-Wände ohne Dach-Decke)", () => {
it("Wand ragt über die Decke: überdecktes dominiertes Band spaltet vertikal, Aussenschichten bleiben ungeteilt", () => {
// W2 auf 3.0 m erhöhen (über Decken-OK 2.6) und concrete-Priorität auf 25
// senken. Von den ÜBERDECKTEN Bändern spaltet render-int (10 < 25) vertikal in
// ZWEI Boxen [0,2.3] + [2.6,3.0] (Decken-z-Band [2.3,2.6] ausgespart); brick
// (50 > 25) läuft als EINE Box [0,3.0] durch. Die NICHT überdeckten Aussen-
// schichten (insulation, render-ext) bleiben je EINE volle Box [0,3.0] — sie
// werden NICHT vertikal aufgespalten (kein Kranz).
const p: Project = {
...sampleProject,
walls: sampleProject.walls.map((w) => (w.id === "W2" ? { ...w, height: 3.0 } : w)),
components: sampleProject.components.map((c) =>
c.id === "concrete" ? { ...c, joinPriority: 25 } : c,
),
};
const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2");
// render-int 2 Boxen + brick/insulation/render-ext je 1 Box = 5 Boxen.
expect(w2.length).toBe(5);
const zRanges = (t: number) =>
w2
.filter((s) => Math.abs(s.thickness - t) < 1e-6)
.map((s) => [s.baseElevation, s.baseElevation + s.height] as const)
.sort((a, b) => a[0] - b[0]);
// brick (überdeckt, 50 > 25): EINE durchgehende Box.
const brick = zRanges(0.15);
expect(brick.length).toBe(1);
expect(brick[0][0]).toBeCloseTo(0, 6);
expect(brick[0][1]).toBeCloseTo(3.0, 6);
// render-int (überdeckt, 10 < 25): ZWEI Boxen unter/über der Decke.
const renderInt = zRanges(0.015);
expect(renderInt.length).toBe(2);
expect(renderInt[0][0]).toBeCloseTo(0, 6);
expect(renderInt[0][1]).toBeCloseTo(2.3, 6);
expect(renderInt[1][0]).toBeCloseTo(2.6, 6);
expect(renderInt[1][1]).toBeCloseTo(3.0, 6);
// insulation (nicht überdeckt): EINE volle Box, KEIN vertikaler Split.
const insul = zRanges(0.16);
expect(insul.length).toBe(1);
expect(insul[0][0]).toBeCloseTo(0, 6);
expect(insul[0][1]).toBeCloseTo(3.0, 6);
// render-ext (nicht überdeckt): ebenfalls EINE volle Box.
const renderExt = zRanges(0.02);
expect(renderExt.length).toBe(1);
expect(renderExt[0][0]).toBeCloseTo(0, 6);
expect(renderExt[0][1]).toBeCloseTo(3.0, 6);
});
it("Decke überdeckt die VOLLE Wanddicke -> jede Schicht PER SCHICHT gegen die Deckenschichten verschnitten", () => {
// Gegenprobe: verbreitert man C1 so, dass die Outline die ganze W2 überdeckt
// (Aussenkante x=5.1725 < 5.5), liegen ALLE Bandmittellinien innerhalb der Decke.
// PER-SCHICHT (statt pauschal): Deckenschichten screed(30, z[2.54,2.6]),
// insulation(20, z[2.5,2.54]), concrete(100, z[2.3,2.5]).
// • render-ext/render-int (Prio 10 < allen): über [2.3,2.6] komplett gekappt -> [0,2.3].
// • brick (50): nur von concrete(100) gekappt -> [0,2.3] + [2.5,2.6].
// • insulation-Wand (20): von concrete(100) und screed(30) gekappt, aber NICHT
// von der gleichrangigen Decken-insulation(20) -> [0,2.3] + [2.5,2.54] (Sliver,
// wo gleichrangiges Material koexistiert — exakt wie „gleiche Prio koexistiert").
const p: Project = {
...sampleProject,
ceilings: (sampleProject.ceilings ?? []).map((c) =>
c.id === "C1"
? {
...c,
outline: [
{ x: 0, y: -0.5 },
{ x: 5.5, y: -0.5 },
{ x: 5.5, y: 4.5 },
{ x: 0, y: 4.5 },
],
}
: c,
),
};
const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2");
expect(w2.length).toBe(6);
const zBoxes = (t: number) =>
w2.filter((s) => Math.abs(s.thickness - t) < 1e-6)
.map((s) => [s.baseElevation, s.baseElevation + s.height] as const)
.sort((a, b) => a[0] - b[0]);
expect(zBoxes(0.02)).toEqual([[0, 2.3]]); // render-ext (10) voll gekappt
expect(zBoxes(0.015)).toEqual([[0, 2.3]]); // render-int (10) voll gekappt
const brick = zBoxes(0.15); // brick (50): nur Beton kappt
expect(brick.length).toBe(2);
expect(brick[0][1]).toBeCloseTo(2.3, 6);
expect(brick[1][0]).toBeCloseTo(2.5, 6);
expect(brick[1][1]).toBeCloseTo(2.6, 6);
const insul = zBoxes(0.16); // insulation-Wand (20): Sliver [2.5,2.54] bleibt
expect(insul.length).toBe(2);
expect(insul[0][1]).toBeCloseTo(2.3, 6);
expect(insul[1][0]).toBeCloseTo(2.5, 6);
expect(insul[1][1]).toBeCloseTo(2.54, 6);
});
it("Decke überdeckt nur EINEN TEIL der Wandlänge -> überdecktes Kern-Band spaltet ENTLANG DER ACHSE", () => {
// C1.outline auf die halbe Länge kürzen (y in [0,2] statt [0,4]): die Decke
// überdeckt die inneren W2-Bänder nur auf dem Achsenstück y in [0,2]. Das
// überdeckte Kern-Band (brick) wird dort z-geschnitten und läuft im Rest der
// Wandlänge (y > 2) über die volle Höhe durch — die Überdeckungsgrenze (y=2)
// wird aus der Bandmittellinien-Abtastung per Bisektion scharf ermittelt. Die
// Aussenschichten (nicht überdeckt) bleiben ganz. So wird auch eine Decke
// korrekt behandelt, die nur einen Teil der Wandlänge trägt.
const p: Project = {
...sampleProject,
ceilings: (sampleProject.ceilings ?? []).map((c) =>
c.id === "C1"
? { ...c, outline: [{ x: 0, y: 0 }, { x: 5, y: 0 }, { x: 5, y: 2 }, { x: 0, y: 2 }] }
: c,
),
};
const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2");
// Per-Schicht-Decken-Dominanz + Achsen-Überdeckung: der Backstein (50) wird nur
// von der Beton-Schicht (100) UND nur auf dem überdeckten Achsenstück y∈[~0.08,2]
// gekappt. Dort entstehen ZWEI z-Boxen [0,2.3] + [2.5,2.6]; das unbedeckte Stück
// y∈[2,~3.92] läuft als EINE volle Box [0,2.6] durch -> 3 Backstein-Boxen.
// render-int (10 < Beton) spaltet in [0,2.3] (überdeckt) + [0,2.6] (unbedeckt) = 2.
// Aussenschichten (insulation, render-ext) nicht überdeckt -> je 1 volle Box.
expect(w2.length).toBe(7);
const brick = w2
.filter((s) => Math.abs(s.thickness - 0.15) < 1e-6)
.map((s) => ({ sy: s.start[1], zb: s.baseElevation, zt: s.baseElevation + s.height }))
.sort((a, b) => a.zb - b.zb || a.sy - b.sy);
expect(brick.length).toBe(3);
// Überdecktes Stück y∈[~0.08,2]: untere Box [0,2.3] + obere Box [2.5,2.6].
const covered = brick.filter((b) => b.sy < 1.0);
expect(covered.length).toBe(2);
expect(covered[0].zt).toBeCloseTo(2.3, 6); // unter Beton
expect(covered[1].zb).toBeCloseTo(2.5, 6); // über Beton
expect(covered[1].zt).toBeCloseTo(2.6, 6);
// Unbedecktes Stück y∈[2,~3.92]: EINE volle Box [0,2.6].
const uncovered = brick.filter((b) => b.sy > 1.5);
expect(uncovered.length).toBe(1);
expect(uncovered[0].zt).toBeCloseTo(2.6, 6);
// insulation (aussen) bleibt EINE ungeteilte volle Box.
const insul = w2.filter((s) => Math.abs(s.thickness - 0.16) < 1e-6);
expect(insul.length).toBe(1);
expect(insul[0].baseElevation + insul[0].height).toBeCloseTo(2.6, 6);
});
it("keine Subtraktion, wenn keine Decke im selben Geschoss überlappt (z.B. OG-Wände ohne Dach-Decke)", () => {
// W6 (OG) hat keine überlappende Decke im Sample-Projekt (C1 ist EG) ->
// unverändertes Verhalten (volle wall.height).
const w6 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W6");
@@ -284,15 +517,271 @@ describe("Prioritäts-Trim Wand/Decke (Z-Fighting-Fix, s. trimWallTopForCeilings
});
});
describe("Bauteil-/Materialfarbe Decke", () => {
it("Decke -> Farbe der dicksten Schicht (dg-massiv: concrete 0.2 m, #9aa0a6)", () => {
// Deckentyp "dg-massiv": screed 0.06, insulation 0.04, concrete 0.2 ->
// dickste Schicht = concrete (#9aa0a6).
describe("Geschichteter Bodenaufbau Decke", () => {
it("Decke -> je Schicht ein Slab, oben->unten gestapelt mit Bauteilfarbe/-schraffur", () => {
// Deckentyp "dg-massiv": screed 0.06 (#c9c2b3), insulation 0.04 (#ffffff),
// concrete 0.2 (#9aa0a6) — Reihenfolge OK->UK. C1: zTop 2.6, Gesamt 0.3 ->
// zBottom 2.3. Proportional: Estrich 2.54..2.6, Dämmung 2.50..2.54,
// Beton 2.30..2.50. Der 3D-Schnitt zeigt so den Aufbau geschichtet (wie 2D).
const { slabs } = projectToModel3d(sampleProject);
const c1 = slabs.find((s) => s.ceilingId === "C1");
expect(c1).toBeDefined();
expect(c1!.color[0]).toBeCloseTo(0x9a / 255, 6);
expect(c1!.color[1]).toBeCloseTo(0xa0 / 255, 6);
expect(c1!.color[2]).toBeCloseTo(0xa6 / 255, 6);
const c1 = slabs
.filter((s) => s.ceilingId === "C1")
.sort((a, b) => b.zTop - a.zTop); // oben zuerst
expect(c1.length).toBe(3);
// Oberste Schicht = Estrich (#c9c2b3), bündig mit OK der Decke (2.6).
expect(c1[0].zTop).toBeCloseTo(2.6, 6);
expect(c1[0].color[0]).toBeCloseTo(0xc9 / 255, 6);
expect(c1[0].color[1]).toBeCloseTo(0xc2 / 255, 6);
expect(c1[0].color[2]).toBeCloseTo(0xb3 / 255, 6);
// Mittlere Schicht = Dämmung (#ffffff).
expect(c1[1].color[0]).toBeCloseTo(1, 6);
expect(c1[1].color[1]).toBeCloseTo(1, 6);
expect(c1[1].color[2]).toBeCloseTo(1, 6);
// Unterste Schicht = Betondecke (#9aa0a6), bündig mit UK der Decke (2.3).
expect(c1[2].zBottom).toBeCloseTo(2.3, 6);
expect(c1[2].color[0]).toBeCloseTo(0x9a / 255, 6);
expect(c1[2].color[1]).toBeCloseTo(0xa0 / 255, 6);
expect(c1[2].color[2]).toBeCloseTo(0xa6 / 255, 6);
// Schichten schließen lückenlos aneinander (UK Schicht k == OK Schicht k+1).
expect(c1[0].zBottom).toBeCloseTo(c1[1].zTop, 6);
expect(c1[1].zBottom).toBeCloseTo(c1[2].zTop, 6);
});
});
describe("sliceTermination: Wand endet an der Decke (Zuschnitt, nicht Priorität)", () => {
// W2 (EG, aw) steht unter der Decke C1 (dg-massiv). Ohne Terminierung ("both")
// spaltet der Beton (100) den Backstein (50) in [0,2.3] + [2.5,2.6] (heutiges,
// prioritäts-korrektes Verhalten). "below" clippt die Wand an der Decken-UK →
// nur die untere Backstein-Box [0,2.3] bleibt; "above" spiegelbildlich [2.5,2.6].
const withTermination = (mode: "both" | "below" | "above") => ({
...sampleProject,
walls: sampleProject.walls.map((w) =>
w.id === "W2" ? { ...w, sliceTermination: mode === "both" ? undefined : mode } : w,
),
});
const brickBoxes = (mode: "both" | "below" | "above") =>
projectToWalls3d(withTermination(mode))
.filter((w) => w.wallId === "W2" && Math.abs(w.thickness - 0.15) < 1e-6)
.map((w) => [w.baseElevation, w.baseElevation + w.height] as const)
.sort((a, b) => a[0] - b[0]);
it('Default/"both": Backstein bleibt als ZWEI Boxen [0,2.3] + [2.5,2.6] (heutiges Verhalten)', () => {
const brick = brickBoxes("both");
expect(brick.length).toBe(2);
expect(brick[0][0]).toBeCloseTo(0, 6);
expect(brick[0][1]).toBeCloseTo(2.3, 6);
expect(brick[1][0]).toBeCloseTo(2.5, 6);
expect(brick[1][1]).toBeCloseTo(2.6, 6);
});
it('"below": NUR die untere Backstein-Box [0,2.3] (Wand endet an der Decke, kein Rest oben)', () => {
const brick = brickBoxes("below");
expect(brick.length).toBe(1);
expect(brick[0][0]).toBeCloseTo(0, 6);
expect(brick[0][1]).toBeCloseTo(2.3, 6);
// Auch der Innenputz (render-int, überdeckt) endet einheitlich an der Decken-UK 2.3.
const renderInt = projectToWalls3d(withTermination("below"))
.filter((w) => w.wallId === "W2" && Math.abs(w.thickness - 0.015) < 1e-6);
expect(renderInt.length).toBe(1);
expect(renderInt[0].baseElevation + renderInt[0].height).toBeCloseTo(2.3, 6);
// Aussenschichten (nicht überdeckt) laufen weiter voll durch (2.6).
const insul = projectToWalls3d(withTermination("below"))
.filter((w) => w.wallId === "W2" && Math.abs(w.thickness - 0.16) < 1e-6);
expect(insul[0].baseElevation + insul[0].height).toBeCloseTo(2.6, 6);
});
it('"above": NUR die obere Backstein-Box [2.5,2.6] (Brüstung/Attika von oben)', () => {
const brick = brickBoxes("above");
expect(brick.length).toBe(1);
expect(brick[0][0]).toBeCloseTo(2.5, 6);
expect(brick[0][1]).toBeCloseTo(2.6, 6);
});
});
describe("Schnitt-Schraffur-Orientierung: relativeToWall dreht bei Wand, nicht bei Decke (2D-Parität)", () => {
// 2D-Referenz (verbindlich): splitWallLayers ruft resolveHatch(…, 90, …) (Wand
// steht vertikal, relativeToWall-Muster drehen mit), splitSlabLayers ruft
// resolveHatch(…, undefined, …) (Decke liegt, absoluter Winkel). Der 3D-Pfad muss
// EXAKT diesen Endwinkel je Kontext liefern (statt des rohen Hatch-Winkels) —
// sonst läuft z. B. die Boden-Dämmung im 3D-Schnitt horizontal statt vertikal.
const wallInsul = () =>
projectToModel3d(sampleProject).walls.find(
(w) => w.cut?.componentId === "insulation" && Math.abs(w.thickness - 0.16) < 1e-6,
)!;
const slabInsul = () =>
projectToModel3d(sampleProject).slabs.find((s) => s.cut?.componentId === "insulation")!;
it("WAND-Dämmung: 3D-Hatch-Winkel == resolveHatch(sia-insulation, 90)", () => {
const expected = resolveHatch(sampleProject, "sia-insulation", 90).angle;
expect(wallInsul().hatch?.angle).toBeCloseTo(expected, 6);
});
it("DECKEN-Dämmung: 3D-Hatch-Winkel == resolveHatch(sia-insulation, undefined) (absolut)", () => {
const expected = resolveHatch(sampleProject, "sia-insulation", undefined).angle;
expect(slabInsul().hatch?.angle).toBeCloseTo(expected, 6);
});
it("Wand- und Decken-Dämmung unterscheiden sich um den Wandachsenwinkel (relativeToWall dreht 90°)", () => {
// sia-insulation ist relativeToWall -> Wand-Winkel = roh 90, Decke = roh.
// Die Differenz ist exakt 90° (der Boden-Aufbau läuft dadurch quer zur Wand).
const diff = (slabInsul().hatch!.angle - wallInsul().hatch!.angle + 360) % 360;
expect(diff).toBeCloseTo(90, 6);
});
it("WAND-Backstein (relativeToWall): 3D-Hatch-Winkel == resolveHatch(sia-brick, 90)", () => {
const brick = projectToModel3d(sampleProject).walls.find(
(w) => w.cut?.componentId === "brick" && Math.abs(w.thickness - 0.15) < 1e-6,
)!;
expect(brick.hatch?.angle).toBeCloseTo(resolveHatch(sampleProject, "sia-brick", 90).angle, 6);
});
it("Musterlinien-Stärke kommt aus dem LineStyle des Hatch durch (2D-Parität)", () => {
// sia-insulation nutzt lineStyleId "hatch-hair" (0.02 mm). Der 3D-Hatch muss die
// resolveHatch-lineWeight (aus dem LineStyle) tragen — für Wand UND Decke gleich.
const expected = resolveHatch(sampleProject, "sia-insulation", 90).lineWeight;
expect(wallInsul().hatch?.lineWeight).toBeCloseTo(expected, 6);
expect(slabInsul().hatch?.lineWeight).toBeCloseTo(expected, 6);
expect(expected).toBeCloseTo(0.02, 6); // hatch-hair
});
});
describe("Wand-Joins im 3D (L-/T-Stösse, identisch zum 2D-Grundriss/Schnitt)", () => {
// Die per-Schicht-Cut-Distanzen aus computeJoins (model/joins.ts) werden im
// layered-3D-Pfad auf die Band-Achsenlängen angewandt — dieselben Zahlen, die
// generatePlan.ts über clippedBand auf die 2D-Polygone anwendet (Band-Mittellinie
// an der Gehrungs-/Anschluss-Linie geschnitten). Alle Erwartungswerte unten sind
// aus der Join-Geometrie des Sample-Projekts von Hand hergeleitet.
it("(a) L-Ecke gleicher Wandtyp: jede Schicht individuell gehrt (aussen länger, Kern kürzer)", () => {
// W2 (5,0)->(5,4), Typ "aw", KEINE Öffnung/Querwand -> genau 4 Schicht-Bänder.
// Beide Enden sind rechtwinklige L-Ecken (mit W1 bei (5,0), W3 bei (5,4)):
// 45°-Gehrung x+y=5 (Start) bzw. y=x-1 (Ende). Die Mittellinie einer Schicht
// bei Querversatz `offset` trifft die Gehrung bei Achsenmeter `offset` (Start)
// bzw. `4-offset` (Ende). Aussenschichten (negativer offset) laufen ÜBER die
// Ecke hinaus (wrappen), Innenschichten (positiver offset) enden kürzer.
// Achse u=(0,1) -> Box-Achsenanfang/-ende = start[1]/end[1].
// W2 steht unter der Decke C1: die Beton-Schicht (100) spaltet den Backstein (50)
// z-mässig in [0,2.3] + [2.5,2.6] (per-Schicht-Decken-Dominanz) -> 5 Boxen. Die
// Gehrung ist z-unabhängig, beide Backstein-Boxen tragen denselben Achsenschnitt.
const w2 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2");
expect(w2.length).toBe(5);
const band = (t: number) => w2.find((s) => Math.abs(s.thickness - t) < 1e-9)!;
// offset je Schicht (aw, center): -0.1625 / -0.0725 / 0.0825 / 0.165.
const cases: Array<[number, number]> = [
[0.02, -0.1625], // render-ext (aussen) -> from=-0.1625, to=4.1625
[0.16, -0.0725], // insulation
[0.15, 0.0825], // brick (Kern)
[0.015, 0.165], // render-int (innen) -> from=0.165, to=3.835 (am kürzesten)
];
for (const [t, offset] of cases) {
const b = band(t);
expect(b.start[1]).toBeCloseTo(offset, 6); // Achsenanfang = offset
expect(b.end[1]).toBeCloseTo(4 - offset, 6); // Achsenende = 4-offset
}
// Der Kern (brick) ist kürzer als die Aussenschicht (render-ext): die
// per-Schicht-Gehrung ist also wirklich individuell (keine einheitliche Länge).
const len = (t: number) => band(t).end[1] - band(t).start[1];
expect(len(0.15)).toBeLessThan(len(0.02));
expect(len(0.15)).toBeCloseTo(3.835, 6); // 3.9175 - 0.0825
expect(len(0.02)).toBeCloseTo(4.325, 6); // 4.1625 - (-0.1625)
});
it("(b) T-Stoss: Abzweig-Kern läuft bis zur Rückgrat-Nahfläche durch, Putz-Band stoppt an der Wandfläche", () => {
// Querwand W9 (2.4,0)->(2.4,4), Typ "iw" (render-int 0.015 / brick 0.12 /
// render-int 0.015), stösst mittig auf W1 (unten) und W3 (oben) -> zwei
// Mittelspannen-T-Stösse. W9-brick == W1/W3-Rückgrat (Komponente "brick",
// Prio 50) -> MERGE: der brick-Kern sticht durch den 0.015-Nah-Putz der
// Durchgangswand bis zu deren Rückgrat-Nahfläche, die render-int-Putzschichten
// stoppen an der Wandoberfläche. Achse u=(0,1) -> Achsenmeter = start[1]/end[1].
// W9 (iw) steht ebenfalls unter der Decke C1: die Beton-Schicht (100) spaltet den
// Kern-Backstein (50) z-mässig in [0,2.3] + [2.5,2.6] -> 2 Kern-Boxen + 2 Putz = 4.
// Die Gehrung/T-Stoss-Achse ist z-unabhängig, beide Kern-Boxen identisch.
const w9 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W9");
expect(w9.length).toBe(4);
const brick = w9.find((s) => Math.abs(s.thickness - 0.12) < 1e-9)!;
const renders = w9.filter((s) => Math.abs(s.thickness - 0.015) < 1e-9);
expect(renders.length).toBe(2);
// Putz stoppt an W1-Oberfläche (y=0.1725) bzw. W3-Oberfläche (y=3.8275).
for (const r of renders) {
expect(r.start[1]).toBeCloseTo(0.1725, 6);
expect(r.end[1]).toBeCloseTo(3.8275, 6);
}
// Kern läuft 0.015 tiefer bis zur Rückgrat-Nahfläche (y=0.1575 bzw. 3.8425).
expect(brick.start[1]).toBeCloseTo(0.1575, 6);
expect(brick.end[1]).toBeCloseTo(3.8425, 6);
expect(brick.start[1]).toBeLessThan(renders[0].start[1]); // Kern durchgesteckt
});
it("(b) T-Stoss Durchgangswand: Nah-Putz wird über die Kernbreite aufgebrochen (Span-Cutout splittet das Band)", () => {
// W1 (Durchgangswand) verliert den Nah-Putz (innerstes render-int-Band) über
// die Achsen-Breite des durchstechenden W9-Kerns: das Band zerfällt entlang
// der Achse in zwei Teilstücke [0.165..2.34] und [2.46..4.835] (Lücke = die
// 0.12 m Kernbreite um x=2.4). W1-Achse u=(1,0) -> Achsenmeter = start[0]/end[0].
const w1 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W1");
const innerPlaster = w1
.filter((s) => Math.abs(s.thickness - 0.015) < 1e-9)
.map((s) => [s.start[0], s.end[0]] as const)
.sort((a, b) => a[0] - b[0]);
expect(innerPlaster.length).toBe(2); // in zwei Teilstücke gebrochen
// Lücke = Kernbreite-Projektion [2.34, 2.46] um die Querwand-Achse x=2.4.
expect(innerPlaster[0][1]).toBeCloseTo(2.34, 6); // Ende Teilstück 1
expect(innerPlaster[1][0]).toBeCloseTo(2.46, 6); // Anfang Teilstück 2
});
it("(c) Join-verschobenes Band: Öffnungs-Löcher bleiben absolut korrekt positioniert (rebased)", () => {
// W1 trägt Fenster O1 (Achsenintervall [3.0,4.0]). Das Aussenputz-Band ist an
// den L-Ecken über die Achse hinaus verlängert (Achsenanfang < 0) — die Loch-
// `from`/`to` sind auf DIESEN Box-Start umgerechnet. Rekonstruiert man die
// absolute Achsenposition (Box-Achsenanfang + `from`), liegt das Fenster wieder
// exakt auf [3.0,4.0], unabhängig von der Join-Verschiebung. W1-Achse u=(1,0).
const w1 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W1");
const outer = w1.find((s) => Math.abs(s.thickness - 0.02) < 1e-9)!;
expect(outer.start[0]).toBeLessThan(0); // Band über die Ecke hinaus verlängert
const window = (outer.holes ?? [])
.map((h) => [outer.start[0] + h.from, outer.start[0] + h.to] as const)
.sort((a, b) => a[0] - b[0])
.find(([a]) => a > 2); // das Fenster O1 (das andere Loch ist die Tür D1)
expect(window).toBeDefined();
expect(window![0]).toBeCloseTo(3.0, 6);
expect(window![1]).toBeCloseTo(4.0, 6);
});
it("Joins wirken NUR im layered-3D-Pfad, nicht im Schnitt-Pfad (layered=false unverändert)", () => {
// Der Schnitt-Pfad (computeSection, layeredWalls:false) darf KEINE Join-Cuts
// bekommen: W2 bleibt eine Voll-Box über die ganze Dicke, ungehrt (Achse
// 0..4), damit toSection.ts::subtractDominantBands seine eigene Dominanz auf
// Rohgeometrie anwendet.
const w2 = projectToWalls3d(sampleProject, false).filter((w) => w.wallId === "W2");
expect(w2.length).toBe(1); // Voll-Box, keine Schicht-Bänder
expect(w2[0].start[1]).toBeCloseTo(0, 6); // ungehrt: Achse 0..4
expect(w2[0].end[1]).toBeCloseTo(4, 6);
});
});
describe("emitStairMeshes (Betontreppe: glatte Laufplatte als Mesh)", () => {
it("gerade Betontreppe -> Mesh mit 8 Ecken je Tritt, glatte schräge Untersicht", () => {
// Sample-Treppe ST1: Typ "stair-standard" (Tragart "beton"), gerade,
// stepCount 15 -> ein konvexes Prisma je Tritt = 8 Ecken. Das Mesh landet in
// model.meshes (kind "extrusion").
const model = projectToModel3d(sampleProject);
const stairMesh = model.meshes.find((m) => m.positions.length / 3 === 8 * 15);
expect(stairMesh).toBeTruthy();
const m = stairMesh!;
// Indizes bilden Dreiecke (6 Quads * 2 Tris * 3 = 36 Indizes je Tritt).
expect(m.indices.length).toBe(15 * 36);
expect(m.indices.length % 3).toBe(0);
// Keine NaN/Inf in der Geometrie.
expect(m.positions.every((v) => Number.isFinite(v))).toBe(true);
// Höhen (jede 3. Komponente) liegen zwischen Treppen-UK (~0) und -OK (~2.6).
const heights = m.positions.filter((_, i) => i % 3 === 2);
expect(Math.min(...heights)).toBeGreaterThanOrEqual(-1e-6);
expect(Math.max(...heights)).toBeLessThanOrEqual(2.6 + 1e-6);
// Alle Index-Werte referenzieren existierende Vertices.
const vcount = m.positions.length / 3;
expect(m.indices.every((idx) => idx >= 0 && idx < vcount)).toBe(true);
});
});
+1243 -124
View File
File diff suppressed because it is too large Load Diff
+169
View File
@@ -0,0 +1,169 @@
// Unit-Tests für den gemeinsamen Wand-Loch-Ausschnitt-Generator (wallMeshCut.ts,
// TS-Port von render3d/src/mesh.rs::extrude_layer_segment_with_holes). Prüft die
// geometrische Zerlegung (gleiche Rechteck-Anzahl wie die Rust-Referenz), nicht
// nur „läuft".
import { describe, it, expect } from "vitest";
import { isWatertight, solidSubrects, wallCutMesh } from "./wallMeshCut";
import type { CutMesh } from "./wallMeshCut";
import type { RWall } from "./toWalls3d";
/**
* Geschlossene-Fläche-Kennzahlen: Vektorfläche ∮ n dA (Σ (v1v0)×(v2v0)) und
* 6× signiertes Volumen (Σ v0·(v1×v2)). Dienen den Watertight-Tests direkt (statt
* eines naiven Kanten-Manifold-Zählers, der an den legitimen T-Stössen der vollen
* Deckel/Boden-Streifen scheitern würde — siehe isWatertight-Doc in wallMeshCut.ts).
*/
function surfaceIntegrals(m: CutMesh): { areaLen: number; vol6: number } {
const p = m.positions;
let ax = 0;
let ay = 0;
let az = 0;
let vol6 = 0;
for (let t = 0; t < m.indices.length; t += 3) {
const i0 = m.indices[t] * 3;
const i1 = m.indices[t + 1] * 3;
const i2 = m.indices[t + 2] * 3;
const e1 = [p[i1] - p[i0], p[i1 + 1] - p[i0 + 1], p[i1 + 2] - p[i0 + 2]];
const e2 = [p[i2] - p[i0], p[i2 + 1] - p[i0 + 1], p[i2 + 2] - p[i0 + 2]];
const cx = e1[1] * e2[2] - e1[2] * e2[1];
const cy = e1[2] * e2[0] - e1[0] * e2[2];
const cz = e1[0] * e2[1] - e1[1] * e2[0];
ax += cx;
ay += cy;
az += cz;
vol6 += p[i0] * cx + p[i0 + 1] * cy + p[i0 + 2] * cz;
}
return { areaLen: Math.hypot(ax, ay, az), vol6 };
}
/** Ein RWall-Band mit optionalen Löchern (frei stehend, Achse entlang +x). */
function band(holes?: RWall["holes"]): RWall {
return {
start: [0, 0],
end: [5, 0],
thickness: 0.4,
height: 2.6,
baseElevation: 0,
color: [0.8, 0.8, 0.8],
layers: [],
wallId: "W1",
...(holes ? { holes } : {}),
};
}
describe("solidSubrects — Koordinaten-Kompression (Rust-Parität)", () => {
it("ohne Loch: genau ein Vollrechteck", () => {
expect(solidSubrects(0, 5, 0, 2.6, [])).toEqual([[0, 5, 0, 2.6]]);
});
it("ein voll im Inneren liegendes Loch → 3×3-Gitter minus 1 = 8 solide Teilrechtecke", () => {
const rects = solidSubrects(0, 5, 0, 2.6, [[2, 3, 0.9, 2.4]]);
expect(rects).toHaveLength(8);
// Keiner der Teilrechteck-Mittelpunkte liegt im Loch.
for (const [ua, ub, za, zb] of rects) {
const cu = (ua + ub) / 2;
const cz = (za + zb) / 2;
expect(cu > 2 && cu < 3 && cz > 0.9 && cz < 2.4).toBe(false);
}
});
it("randständiges Loch (Tür am Boden) → 3×2-Gitter minus 1 = 5 Teilrechtecke", () => {
expect(solidSubrects(0, 5, 0, 2.6, [[2, 3, 0, 2.1]])).toHaveLength(5);
});
it("zwei versetzt übereinanderliegende Löcher: keine überlappenden/entarteten Teilrechtecke", () => {
const rects = solidSubrects(0, 5, 0, 2.8, [
[1, 2, 0.9, 1.4],
[3, 4, 1.6, 2.2],
]);
// Alle Teilrechtecke haben echte Ausdehnung und liegen ausserhalb beider Löcher.
for (const [ua, ub, za, zb] of rects) {
expect(ub - ua).toBeGreaterThan(0);
expect(zb - za).toBeGreaterThan(0);
}
expect(rects.length).toBeGreaterThan(0);
});
});
describe("wallCutMesh — Loch-Ausschnitt-Mesh", () => {
it("ohne Loch: 12 Dreiecke (volle Box-Äquivalenz)", () => {
const m = wallCutMesh(band());
expect(m.indices.length / 3).toBe(12);
});
it("Fenster im Inneren: 48 Dreiecke, 4 Laibungen, Loch-Rand-Vertices vorhanden", () => {
const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0.9, zTop: 2.4 }]));
expect(m.indices.length / 3).toBe(48);
// Vertices an allen vier Loch-Rändern (Brüstung/Sturz/links/rechts).
const has = (axis: number, val: number) => {
for (let i = 0; i < m.positions.length; i += 3) {
if (Math.abs(m.positions[i + axis] - val) < 1e-6) return true;
}
return false;
};
expect(has(1, 0.9)).toBe(true); // y = Brüstungs-OK
expect(has(1, 2.4)).toBe(true); // y = Sturz-UK
expect(has(0, 2)).toBe(true); // x = linke Kante
expect(has(0, 3)).toBe(true); // x = rechte Kante
// KEIN Vertex liegt strikt im offenen Loch-Rechteck (die Fläche ist wirklich weg).
for (let i = 0; i < m.positions.length; i += 3) {
const x = m.positions[i];
const y = m.positions[i + 1];
const strictlyInside = x > 2 + 1e-6 && x < 3 - 1e-6 && y > 0.9 + 1e-6 && y < 2.4 - 1e-6;
expect(strictlyInside).toBe(false);
}
});
it("Tür am Boden: 36 Dreiecke (untere Laibung entfällt, Boden bekommt die Lücke)", () => {
const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0, zTop: 2.1 }]));
expect(m.indices.length / 3).toBe(36);
});
it("alle Dreiecks-Indizes referenzieren existierende Vertices", () => {
const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0.9, zTop: 2.4 }]));
const vcount = m.positions.length / 3;
for (const idx of m.indices) {
expect(idx).toBeGreaterThanOrEqual(0);
expect(idx).toBeLessThan(vcount);
}
});
});
describe("wallCutMesh — Wasserdichtigkeit + Aussen-Orientierung (der hohl/offen-Bug)", () => {
it("volle Wand OHNE Loch: geschlossen (∮ n dA = 0), Normalen aussen (Volumen = L·T·H)", () => {
const m = wallCutMesh(band());
const { areaLen, vol6 } = surfaceIntegrals(m);
expect(areaLen).toBeLessThan(1e-6); // ∮ n dA = 0 ⇒ kein Loch, kein invertiertes Dreieck
expect(vol6 / 6).toBeCloseTo(5 * 0.4 * 2.6, 6); // > 0 ⇒ aussen orientiert
expect(isWatertight(m)).toBe(true);
});
it("Wand mit Fenster: trotz T-Stösse geschlossen + aussen orientiert (Volumen = Wand Fensterquader)", () => {
const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0.9, zTop: 2.4 }]));
const { areaLen, vol6 } = surfaceIntegrals(m);
expect(areaLen).toBeLessThan(1e-6);
expect(vol6 / 6).toBeCloseTo(5 * 0.4 * 2.6 - 1 * 0.4 * (2.4 - 0.9), 6);
expect(isWatertight(m)).toBe(true);
});
it("eine EINZELNE invertierte Kappe würde erkannt (Regressionswächter gegen falsche Wicklung)", () => {
// Bau die volle Wand, kehre die Wicklung EINES Dreiecks um → nicht mehr dicht.
const m = wallCutMesh(band());
const broken: CutMesh = { positions: m.positions.slice(), indices: m.indices.slice() };
[broken.indices[1], broken.indices[2]] = [broken.indices[2], broken.indices[1]];
expect(isWatertight(broken)).toBe(false);
});
it("Türwand ist ein GESCHLOSSENER Prisma-Körper (Querschnitt = П-Polygon, Türkerbe umlaufend)", () => {
// Die Türkerbe ist eine Einbuchtung der UNTEREN Umriss-Kante (kein Loch): der
// Querschnitt bleibt EIN einfaches, geschlossenes Polygon (П-Form), das über
// die Dicke extrudiert wieder einen geschlossenen Körper ergibt. Die fehlende
// untere Laibung und die Boden-Lücke heben sich in ∮ n dA exakt auf.
const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0, zTop: 2.1 }]));
const { areaLen, vol6 } = surfaceIntegrals(m);
expect(areaLen).toBeLessThan(1e-6);
expect(vol6 / 6).toBeCloseTo(5 * 0.4 * 2.6 - 1 * 0.4 * 2.1, 6);
expect(isWatertight(m)).toBe(true);
});
});
+370
View File
@@ -0,0 +1,370 @@
// Gemeinsamer Wand-Mesh-Generator MIT echtem Loch-Ausschnitt (Fenster/Türen).
//
// Portierung der Rust-Referenz `render3d/src/mesh.rs::extrude_layer_segment_
// with_holes` nach TS: aus EINEM RWall-Band (Achse `start→end`, `thickness`,
// `baseElevation`, `height` + rechteckige `holes`) wird das Dreiecks-Mesh
// erzeugt, das die Öffnungen ECHT ausschneidet (statt der vollen Box). Damit
// sehen die Datei-Exporte (STL/OBJ/IFC) genauso aus wie die 3D-Ansicht: die
// Löcher sind sichtbar ausgestanzt, inklusive der Laibungs-/Jamb-Flächen.
//
// WICHTIG — WARUM KEINE GEHRUNG HIER: Die RWall-Bänder aus
// `toWalls3d.ts::pickGeometry` sind bereits an ihren Achsenden auf die
// Gehrungs-/Anschluss-Schnittlinie GEKÜRZT (die Box endet aber achsparallel,
// keine gekippte Stirnfläche — siehe `bandCutDistance` in toWalls3d.ts). Die
// Ecken-Verschneidung (Joins) steckt also schon in `start`/`end` jedes Bandes.
// Deshalb entfällt hier die `start_cut`/`end_cut`-Gehrungslogik der Rust-Funktion
// (im Rust-Pfad wird sie nur gebraucht, weil dort EIN ungekürztes Band mit
// Gehrungslinien extrudiert wird). Das Ergebnis: dieselbe Rechteck-Zerlegung der
// Langseiten + dieselben 4 Laibungsquads je Loch wie in Rust, nur mit rechten
// (ungekippten) Stirnkappen — exakt die Geometrie, die der Viewer schon zeigt.
//
// ACHSEN: Modell (x, y) → Welt (x, Höhe, y), Y-UP — identisch zu exportMesh.ts /
// selectionHighlightLines / dem 3D-Viewer. Ein Weltvertex (vx,vy,vz) ist also
// (Modell-x, Meter-Höhe, Modell-y).
import type { RWall } from "./toWalls3d";
/** Rundungs-/Entartungsschwelle (1e-4 m), 1:1 wie `openings::MIN_SPAN` in Rust. */
const MIN_SPAN = 1e-4;
/** Ein Weltpunkt (x, Höhe, y). */
type V3 = [number, number, number];
/**
* Ein Dreiecks-Mesh in LOKALEN (0-basierten) Indizes: `positions` flach
* (x,y,z, …) in Weltmetern (Y-up), `indices` je 3 = ein Dreieck.
*/
export interface CutMesh {
positions: number[];
indices: number[];
}
/**
* Aussen-Normale eines planaren, CCW-von-aussen georderten Quads über die ersten
* drei Ecken: `normalize(cross(b-a, d-a))` — 1:1 wie `mesh.rs::quad_normal`.
* Nulllange Flächen liefern den Nullvektor (Aufrufer nutzt sie dann nicht).
*/
function quadNormal(a: V3, b: V3, d: V3): V3 {
const ab: V3 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
const ad: V3 = [d[0] - a[0], d[1] - a[1], d[2] - a[2]];
const c: V3 = [
ab[1] * ad[2] - ab[2] * ad[1],
ab[2] * ad[0] - ab[0] * ad[2],
ab[0] * ad[1] - ab[1] * ad[0],
];
const len = Math.max(Math.hypot(c[0], c[1], c[2]), 1e-9);
return [c[0] / len, c[1] / len, c[2] / len];
}
/**
* Hängt ein Dreieck an `mesh` an, dessen Winding an `wantNormal` ausgerichtet
* wird (robust gegen die Eck-Reihenfolge) — 1:1 wie `mesh.rs::push_tri_oriented`.
*/
function pushTriOriented(mesh: CutMesh, a: V3, b: V3, c: V3, wantNormal: V3): void {
const ab: V3 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
const ac: V3 = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
const gn: V3 = [
ab[1] * ac[2] - ab[2] * ac[1],
ab[2] * ac[0] - ab[0] * ac[2],
ab[0] * ac[1] - ab[1] * ac[0],
];
const dot = gn[0] * wantNormal[0] + gn[1] * wantNormal[1] + gn[2] * wantNormal[2];
const [v0, v1, v2] = dot < 0 ? [a, c, b] : [a, b, c];
const base = mesh.positions.length / 3;
mesh.positions.push(v0[0], v0[1], v0[2], v1[0], v1[1], v1[2], v2[0], v2[1], v2[2]);
mesh.indices.push(base, base + 1, base + 2);
}
/** Hängt ein Quad (vier Ecken als Ring) als zwei orientierte Dreiecke an. */
function pushQuadOriented(mesh: CutMesh, a: V3, b: V3, c: V3, d: V3, wantNormal: V3): void {
pushTriOriented(mesh, a, b, c, wantNormal);
pushTriOriented(mesh, a, c, d, wantNormal);
}
/**
* Zerlegt das Rechteck `[uLo,uHi]×[zLo,zHi]` MINUS `holes` (`[u0,u1,z0,z1]`) in
* achsparallele, disjunkte SOLIDE Teilrechtecke — Koordinaten-Kompression, 1:1
* wie `mesh.rs::solid_subrects` (jede Gitterzelle, deren Mittelpunkt in keinem
* Loch liegt, ist ein solides Teilrechteck). Exportiert für die Zerlegungs-Tests
* (gleiche Rechteck-Anzahl wie die Rust-Zerlegung).
*/
export function solidSubrects(
uLo: number,
uHi: number,
zLo: number,
zHi: number,
holes: Array<[number, number, number, number]>,
): Array<[number, number, number, number]> {
const sortDedup = (v: number[]): number[] => {
const s = [...v].sort((a, b) => a - b);
const out: number[] = [];
for (const x of s) {
if (out.length === 0 || Math.abs(x - out[out.length - 1]) > MIN_SPAN) out.push(x);
}
return out;
};
const usRaw = [uLo, uHi];
const zsRaw = [zLo, zHi];
for (const [a0, a1, hb, ht] of holes) {
usRaw.push(a0, a1);
zsRaw.push(hb, ht);
}
const us = sortDedup(usRaw);
const zs = sortDedup(zsRaw);
const out: Array<[number, number, number, number]> = [];
for (let i = 0; i < us.length - 1; i++) {
const ua = us[i];
const ub = us[i + 1];
if (ub - ua <= MIN_SPAN) continue;
for (let j = 0; j < zs.length - 1; j++) {
const za = zs[j];
const zb = zs[j + 1];
if (zb - za <= MIN_SPAN) continue;
const cu = (ua + ub) * 0.5;
const cz = (za + zb) * 0.5;
const inHole = holes.some(([a0, a1, hb, ht]) => cu > a0 && cu < a1 && cz > hb && cz < ht);
if (!inHole) out.push([ua, ub, za, zb]);
}
}
return out;
}
/**
* Zieht die (ggf. überlappenden) `gaps` vom Intervall `[lo,hi]` ab und liefert
* die verbleibenden, sortierten, disjunkten Teilintervalle — 1:1 wie
* `mesh.rs::subtract_intervals` (für Deckel-/Boden-/Kappen-Lücken randständiger
* Löcher).
*/
function subtractIntervals(lo: number, hi: number, gaps: Array<[number, number]>): Array<[number, number]> {
const gs = gaps
.map(([a, b]): [number, number] => [Math.max(a, lo), Math.min(b, hi)])
.filter(([a, b]) => b - a > MIN_SPAN)
.sort((p, q) => p[0] - q[0]);
const out: Array<[number, number]> = [];
let cursor = lo;
for (const [a, b] of gs) {
if (a > cursor + MIN_SPAN) out.push([cursor, a]);
cursor = Math.max(cursor, b);
}
if (hi > cursor + MIN_SPAN) out.push([cursor, hi]);
return out;
}
/**
* Prüft, ob ein Mesh eine GESCHLOSSENE, KONSISTENT NACH AUSSEN gewundene Fläche
* ist (bounds ein Volumen ohne Löcher) — über das Gauss-/Divergenz-Kriterium
* statt eines Kanten-Manifold-Zählers:
* • ∮ n dA = 0 ⟺ Σ (v1v0)×(v2v0) = 0 über alle Dreiecke (jede offene
* Lücke ODER jedes falsch gewundene/fehlende Dreieck verletzt die Summe).
* • signiertes Volumen Σ v0·(v1×v2)/6 > 0 ⟺ Normalen zeigen NACH AUSSEN.
*
* BEWUSST NICHT der naive „jede ungerichtete Kante genau 2×"-Manifold-Check:
* dieses Mesh nutzt (wie die Rust-Referenz) VOLLE Deckel/Boden-Streifen über die
* ganze Breite, während die Langseiten an den Loch-Spalten unterteilt sind — das
* erzeugt legitime T-Stösse (eine lange Kante trifft mehrere kurze), die KEINE
* echten Lücken sind. Der naive Kantenzähler würde diese korrekte, wasserdichte
* Geometrie fälschlich als „offen" melden; das Gauss-Kriterium ist T-Stoss-robust
* und misst genau die Eigenschaft, auf die es ankommt (dichtes Volumen + Aussen-
* Orientierung — die Ursache des „hohl/offen"-Bugs war eine invertierte Wicklung
* im IFC-Export, nicht eine fehlende Fläche).
*/
export function isWatertight(mesh: CutMesh, eps = 1e-6): boolean {
const p = mesh.positions;
let ax = 0;
let ay = 0;
let az = 0;
let vol6 = 0;
for (let t = 0; t < mesh.indices.length; t += 3) {
const i0 = mesh.indices[t] * 3;
const i1 = mesh.indices[t + 1] * 3;
const i2 = mesh.indices[t + 2] * 3;
const e1x = p[i1] - p[i0];
const e1y = p[i1 + 1] - p[i0 + 1];
const e1z = p[i1 + 2] - p[i0 + 2];
const e2x = p[i2] - p[i0];
const e2y = p[i2 + 1] - p[i0 + 1];
const e2z = p[i2 + 2] - p[i0 + 2];
const cx = e1y * e2z - e1z * e2y;
const cy = e1z * e2x - e1x * e2z;
const cz = e1x * e2y - e1y * e2x;
ax += cx;
ay += cy;
az += cz;
// v0 · (v1 × v2) = v0 · (e1 × e2) (die reinen v0-Terme heben sich auf).
vol6 += p[i0] * cx + p[i0 + 1] * cy + p[i0 + 2] * cz;
}
return Math.hypot(ax, ay, az) < eps && vol6 > eps;
}
/**
* Erzeugt das Wand-Mesh EINES RWall-Bandes mit ECHTEN Loch-Ausschnitten — der
* TS-Port von `mesh.rs::extrude_layer_segment_with_holes` (ohne Gehrung, siehe
* Moduldoc). Aufbau:
* • Langseiten (+n / n): Flächen-Rechteck MINUS der Löcher, per {@link
* solidSubrects} in Teilrechtecke zerlegt (je 2 Dreiecke).
* • Deckel (y1) / Boden (y0): voller Streifen minus der u-Intervalle rand-
* ständiger Löcher (z. B. eine Tür am Boden).
* • Stirnkappen (a=0 / a=length): volle Höhe minus z-Intervalle randständiger
* Löcher.
* • Laibungen je Loch: bis zu vier Quads (links/rechts/unten/oben), nur für die
* im Wand-Inneren liegenden Seiten.
*
* Ohne `holes` (undefined/leer) liefert das die volle Box (Regressionsfall);
* Aufrufer, die für lochlose Bänder die klassische 8-Ecken-Box wollen, prüfen
* das selbst (siehe exportMesh.ts).
*/
export function wallCutMesh(w: RWall): CutMesh {
const mesh: CutMesh = { positions: [], indices: [] };
const [sx, sy] = w.start;
const [ex, ey] = w.end;
const dx = ex - sx;
const dy = ey - sy;
const length = Math.hypot(dx, dy);
if (length < 1e-9 || w.height <= 1e-9 || w.thickness <= 1e-9) return mesh;
const ux = dx / length;
const uy = dy / length;
// Links-Normale n = (uy, ux) — dieselbe Konvention wie mesh.rs::left_normal.
const n: [number, number] = [-uy, ux];
const y0 = w.baseElevation;
const y1 = w.baseElevation + w.height;
const offA = w.thickness / 2;
const offB = -w.thickness / 2;
const off = (p: [number, number], s: number): [number, number] => [p[0] + n[0] * s, p[1] + n[1] * s];
const axisPoint = (a: number): [number, number] => [sx + ux * a, sy + uy * a];
// Plan-Punkt bei Achsen-Position `a`, Dicken-Versatz `offv` (keine Gehrung).
const gAt = (a: number, offv: number): [number, number] => off(axisPoint(a), offv);
const wpt = (g: [number, number], y: number): V3 => [g[0], y, g[1]];
// Löcher auf [0,length]×[y0,y1] klemmen, entartete verwerfen.
const hs: Array<[number, number, number, number]> = [];
for (const h of w.holes ?? []) {
const a0 = Math.max(h.from, 0);
const a1 = Math.min(h.to, length);
const zb = Math.max(h.zBottom, y0);
const zt = Math.min(h.zTop, y1);
if (a1 - a0 > MIN_SPAN && zt - zb > MIN_SPAN) hs.push([a0, a1, zb, zt]);
}
// ── Langseiten (+n / n): Rechteck-Gitter minus Löcher. ────────────────────
const np: V3 = [n[0], 0, n[1]];
const nn: V3 = [-n[0], 0, -n[1]];
for (const [ua, ub, za, zb] of solidSubrects(0, length, y0, y1, hs)) {
pushQuadOriented(
mesh,
wpt(gAt(ua, offA), za),
wpt(gAt(ub, offA), za),
wpt(gAt(ub, offA), zb),
wpt(gAt(ua, offA), zb),
np,
);
pushQuadOriented(
mesh,
wpt(gAt(ua, offB), za),
wpt(gAt(ub, offB), za),
wpt(gAt(ub, offB), zb),
wpt(gAt(ua, offB), zb),
nn,
);
}
// ── Deckel (y1) / Boden (y0): voller Streifen minus randständiger Löcher. ──
const topGaps = hs.filter(([, , , zt]) => zt >= y1 - MIN_SPAN).map(([a0, a1]): [number, number] => [a0, a1]);
for (const [ua, ub] of subtractIntervals(0, length, topGaps)) {
pushQuadOriented(
mesh,
wpt(gAt(ua, offA), y1),
wpt(gAt(ub, offA), y1),
wpt(gAt(ub, offB), y1),
wpt(gAt(ua, offB), y1),
[0, 1, 0],
);
}
const botGaps = hs.filter(([, , zb]) => zb <= y0 + MIN_SPAN).map(([a0, a1]): [number, number] => [a0, a1]);
for (const [ua, ub] of subtractIntervals(0, length, botGaps)) {
pushQuadOriented(
mesh,
wpt(gAt(ua, offA), y0),
wpt(gAt(ub, offA), y0),
wpt(gAt(ub, offB), y0),
wpt(gAt(ua, offB), y0),
[0, -1, 0],
);
}
// ── Stirnkappen (a=0 / a=length): volle Höhe minus randständiger Löcher. ───
const startNormal = quadNormal(wpt(gAt(0, offB), y0), wpt(gAt(0, offA), y0), wpt(gAt(0, offB), y1));
const startGaps = hs.filter(([a0]) => a0 <= MIN_SPAN).map(([, , zb, zt]): [number, number] => [zb, zt]);
for (const [za, zb] of subtractIntervals(y0, y1, startGaps)) {
pushQuadOriented(
mesh,
wpt(gAt(0, offB), za),
wpt(gAt(0, offA), za),
wpt(gAt(0, offA), zb),
wpt(gAt(0, offB), zb),
startNormal,
);
}
const endNormal = quadNormal(wpt(gAt(length, offA), y0), wpt(gAt(length, offB), y0), wpt(gAt(length, offA), y1));
const endGaps = hs.filter(([, a1]) => a1 >= length - MIN_SPAN).map(([, , zb, zt]): [number, number] => [zb, zt]);
for (const [za, zb] of subtractIntervals(y0, y1, endGaps)) {
pushQuadOriented(
mesh,
wpt(gAt(length, offA), za),
wpt(gAt(length, offB), za),
wpt(gAt(length, offB), zb),
wpt(gAt(length, offA), zb),
endNormal,
);
}
// ── Laibungen je Loch: nur die im Wand-Inneren liegenden Seiten. ──────────
for (const [a0, a1, zb, zt] of hs) {
// Linke Laibung (bei a0), Normale +u (in die Öffnung).
if (a0 > MIN_SPAN) {
pushQuadOriented(
mesh,
wpt(gAt(a0, offA), zb),
wpt(gAt(a0, offB), zb),
wpt(gAt(a0, offB), zt),
wpt(gAt(a0, offA), zt),
[ux, 0, uy],
);
}
// Rechte Laibung (bei a1), Normale u.
if (a1 < length - MIN_SPAN) {
pushQuadOriented(
mesh,
wpt(gAt(a1, offA), zb),
wpt(gAt(a1, offB), zb),
wpt(gAt(a1, offB), zt),
wpt(gAt(a1, offA), zt),
[-ux, 0, -uy],
);
}
// Untere Laibung (bei zb = Brüstungsoberkante), Normale +Y.
if (zb > y0 + MIN_SPAN) {
pushQuadOriented(
mesh,
wpt(gAt(a0, offA), zb),
wpt(gAt(a1, offA), zb),
wpt(gAt(a1, offB), zb),
wpt(gAt(a0, offB), zb),
[0, 1, 0],
);
}
// Obere Laibung (bei zt = Sturzunterkante), Normale Y.
if (zt < y1 - MIN_SPAN) {
pushQuadOriented(
mesh,
wpt(gAt(a0, offA), zt),
wpt(gAt(a1, offA), zt),
wpt(gAt(a1, offB), zt),
wpt(gAt(a0, offB), zt),
[0, -1, 0],
);
}
}
return mesh;
}