God-Files aufgeteilt (ResourceManager/generatePlan/toWalls3d/App.tsx-Handler), Totcode entfernt (geometry-Crate, OCCT/HLR-Spike), PDF/Bild-Import ergänzt.

ResourceManager.tsx, generatePlan.ts und toWalls3d.ts sind nach Bauteil-
Domäne in src/ui/resourceManager/, src/plan/generatePlan/ und
src/plan/toWalls3d/ aufgeteilt (reine Verschiebung, keine Verhaltens-
änderung). App.tsx verliert weitere Handler-Gruppen an eigene Hooks
(useExportHandlers, useImportHandling, useKeyboardShortcuts) sowie
Hit-Testing an src/viewport/planHitTest.ts.

Unbenutzte Rust-geometry-Crate (durch WASM-Joins abgelöst) sowie der alte
OCCT/HLR-Schnitt-Spike (hlr.ts/occt.ts, vite occt-Aliases) entfernt.

Neu: PDF-Import als Rasterbild (src/io/pdfImport.ts) + gemeinsamer
Bild-Asset-Helfer (src/io/imageAsset.ts) für Plan- und Layout-Bilder.

tsc -b sauber, vitest 891/891 grün.
This commit is contained in:
2026-08-19 22:45:41 +02:00
parent 06c0143d32
commit 1507c2bf00
85 changed files with 12461 additions and 11280 deletions
+113 -4
View File
@@ -14,7 +14,7 @@ import {
useState,
} from "react";
import type { HatchRender, Plan, Primitive } from "./generatePlan";
import { docToLines, type SvgLine } from "../text/renderHtml";
import { docToLines, wrapDocToLines, type SvgLine } from "../text/renderHtml";
import type { EdgeGrip, Vec2 } from "../model/types";
import type { SnapResult, ToolDraft, ToolHandlers, ToolId, ToolMods } from "../tools/types";
import { useGlPlanRenderer } from "./useGlPlanRenderer";
@@ -566,9 +566,14 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
// vollen SVG-Pfad nutzen — dort liegt das Bild als eigene Ebene ZWISCHEN
// Blatt-Hintergrund und Geometrie (sichtbar, ohne den GL-Renderer zu ändern).
const hasBgImages = (plan.backgroundImages?.length ?? 0) > 0;
// Eingebettete Bild-Zeichenelemente (Drawing2D `{shape:"image"}`, Foto/Logo/
// PDF-Seite) kann das GPU-Backend ebenfalls nicht texturieren (kein Bild-
// Primitiv in render2d, s. toRenderScene.ts `RImage`-Kommentar) — dieselbe
// SVG-Fallback-Regel wie bei georeferenzierten Luftbildern.
const hasDrawingImages = plan.primitives.some((p) => p.kind === "drawingImage");
// Engine WIRKLICH aktiv? Steuert das Umschalten SVG↔GPU: bei false rendert der
// volle SVG-Pfad (Fallback), bei true übernimmt die GPU-Ebene + Text-Overlay.
const useGpuRenderer = wantGpu && glReady && !hasBgImages;
const useGpuRenderer = wantGpu && glReady && !hasBgImages && !hasDrawingImages;
// Stabiler Papier-Massstab-Nenner N (1:N) als REFERENZ für die Print-Strich-
// breiten. BEWUSST NICHT der live aus dem Zoom abgeleitete N (der sich beim
@@ -959,8 +964,8 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
}
}
if (bestId) return bestId;
// Kein Linien-Treffer: Text-Elemente prüfen (eigene Bounding-Box-Logik).
return pickDrawingText(clientX, clientY);
// Kein Linien-Treffer: Text- dann Bild-Elemente prüfen (eigene Bbox-Logik).
return pickDrawingText(clientX, clientY) ?? pickDrawingImage(clientX, clientY);
};
// Trifft der Klick ein Text-2D-Element (kind "drawingText")? Grosszügige Modell-
@@ -988,6 +993,21 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
return null;
};
// Trifft der Klick ein Bild-2D-Element (kind "drawingImage")? Reine
// Bounding-Box-Prüfung (achsparallel, keine Rotation). Liefert die drawingId
// oder null.
const pickDrawingImage = (clientX: number, clientY: number): string | null => {
const m = rawModelAt(clientX, clientY);
for (let i = plan.primitives.length - 1; i >= 0; i--) {
const p = plan.primitives[i];
if (p.kind !== "drawingImage" || !p.drawingId) continue;
if (m.x >= p.min.x && m.x <= p.max.x && m.y >= p.min.y && m.y <= p.max.y) {
return p.drawingId;
}
}
return null;
};
// Nächstgelegene Öffnung (Fenster/Tür) zum Cursor: Linien-/Bogen-Primitive mit
// `openingId` per Bildschirm-Nähe, plus getroffene Fenster-Rahmen (Polygon mit
// openingId, per Punkt-in-Polygon). Liefert die openingId oder null.
@@ -3815,6 +3835,25 @@ function renderPrimitive(
</text>
);
}
case "drawingImage": {
// Eingebettetes Rasterbild (Foto/Logo/gerasterte PDF-Seite), achsparallel.
// Screen-Y ist gespiegelt (toScreen) → oben-links = (min.x, max.y).
const topLeft = toScreen(p.max);
const w = (p.max.x - p.min.x) * PX_PER_M;
const h = (p.max.y - p.min.y) * PX_PER_M;
if (w <= 0 || h <= 0) return null;
return (
<image
href={p.src}
x={toScreen(p.min).x}
y={topLeft.y}
width={w}
height={h}
opacity={(p.greyed ? 0.45 : 1) * p.opacity}
preserveAspectRatio="none"
/>
);
}
case "drawingText": {
// Modellverankerter Einzeltext (DXF-Import / Text-Werkzeug): Höhe in Metern →
// viewBox-Einheiten (skaliert mit dem Zoom), Baseline am Ankerpunkt (links).
@@ -3822,6 +3861,76 @@ function renderPrimitive(
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;
// Reiches Textfeld (InDesign-artig, mehrere Absätze/gemischte Formatierung):
// hat Vorrang vor dem Alt-Pfad (einfacher String + einheitliche `marks`)
// weiter unten. Bricht bei gesetzter Spaltenbreite wortweise um (Run-Grenzen
// bleiben erhalten, s. `wrapDocToLines`).
if (p.doc) {
const unitPerPt = (0.0254 / 72) * PX_PER_M;
const basePt = (p.heightM / 0.0254) * 72;
const lineHeight = fontSize * 1.25;
const wrapWidthUnits = p.wrapWidth && p.wrapWidth > 0 ? p.wrapWidth * PX_PER_M : undefined;
const docLines: SvgLine[] = wrapDocToLines(p.doc, {
x: 0,
y: 0,
lineHeight,
unitPerPt,
basePt,
color: p.color,
...(wrapWidthUnits ? { wrapWidthUnits } : {}),
});
if (docLines.length === 0) return null;
const lineYs: number[] = [];
{
let y = c.y;
docLines.forEach((line, k) => {
if (k > 0) y += line.lineHeight;
lineYs.push(y);
});
}
return (
<text
opacity={p.greyed ? 0.45 : undefined}
transform={deg ? `rotate(${deg} ${c.x} ${c.y})` : undefined}
style={{ pointerEvents: "none", userSelect: "none" }}
>
{docLines.map((line, k) => {
const anchor =
line.align === "center" ? "middle" : line.align === "right" ? "end" : "start";
const x = !wrapWidthUnits
? c.x
: line.align === "center"
? c.x + wrapWidthUnits / 2
: line.align === "right"
? c.x + wrapWidthUnits
: c.x;
return (
<tspan key={k} x={x} y={lineYs[k]} textAnchor={anchor}>
{line.tspans.length === 0
? "​"
: line.tspans.map((ts, j) => (
<tspan
key={j}
fontSize={ts.fontSize}
fontWeight={ts.fontWeight}
fontStyle={ts.fontStyle}
fontFamily={ts.fontFamily ?? "var(--font-ui, sans-serif)"}
fill={ts.fill}
textDecoration={ts.textDecoration}
>
{ts.text}
</tspan>
))}
</tspan>
);
})}
</text>
);
}
// Alt-Pfad: einfacher String + einheitliche `marks` (Text-Werkzeug/DXF-Import
// ohne Rich-Doc) — unverändert.
// 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;
+47 -2637
View File
File diff suppressed because it is too large Load Diff
+139
View File
@@ -0,0 +1,139 @@
// Decken-Domäne des Grundriss-Generators: die Decke liegt als Slab ÜBER der
// Grundriss-Schnittebene und wird daher als flächiger Aufsicht-Umriss
// gezeichnet (nicht als Schicht-Querschnitt wie eine Wand) — die Kante wird
// gegen die {@link WallFootprint}s der darunterliegenden Wände geclippt, damit
// sie dort nicht durch die Wand-Poché schlägt.
import type { Ceiling, LayerCategory, Project } from "../../model/types";
import { getCeilingType, getComponent } from "../../model/types";
import { mergeHoles } from "../../geometry/polygonHoles";
import { add, scale, sub } from "../../model/geometry";
import {
NO_HATCH,
OVERHEAD_DASH,
POCHE_STROKE,
LAYER_DETAIL_FACTOR,
LAYER_LINE_MM,
pocheFill,
resolveForeground,
resolveHatch,
resolveHatchId,
type DetailLevel,
type Primitive,
} from "./shared";
import { segmentInsideWallBand, subtractIntervals, type WallFootprint } from "./walls";
/**
* Decken-Poché im Grundriss: die Decke liegt als Slab ÜBER der Schnittebene und
* wird daher als geschlossener Umriss ihres Grundriss-Polygons gezeichnet — eine
* gefüllte + schraffierte Fläche plus eine kräftige Umrisslinie. Fill/Schraffur
* stammen aus dem BAUTEIL der ersten (obersten) Schicht des Aufbau-Typs (analog
* zur Wand-Schichtauflösung); die Umrissfarbe aus der optionalen Decken-Farbe
* bzw. dem Poché-Strich. Jedes Polygon trägt die `ceilingId` für die Auswahl.
*
* BEWUSST FLÄCHIG, auch bei mehrschichtigem Deckentyp: anders als die Wand
* (deren Schichten im Grundriss als Querschnitt sichtbar sind) liegt der
* Schichtaufbau einer Decke in Z (oben → unten) — von oben unsichtbar. Die
* Schichtbänderung erscheint daher NUR im Schnitt (siehe `toSection.ts`), nicht
* hier. Im Detailgrad „grob" entfällt die Schraffur (nur Fläche + Umriss);
* „mittel"/„fein" zeigen die Ansichts-Schraffur des Bauteils (`viewHatchId`,
* Default weiss) — die frontal gesehene Aufsicht ist kein Schnitt. `lwMm` ist
* die Kategorie-Strichstärke.
*/
export function addCeilingPoche(
out: Primitive[],
project: Project,
ceiling: Ceiling,
greyed: boolean,
detail: DetailLevel,
category: LayerCategory | undefined,
wallFootprints: WallFootprint[],
): void {
const pts = ceiling.outline;
if (pts.length < 3) return;
// Aussparungen (Treppenauge/Schacht) über den Brücken-Trick in EINEN
// einfachen Poché-Ring einfügen: kein `openings` ⇒ `ring === pts` und
// `bridgeEdges` leer (exakt heutiger Codepfad, siehe {@link mergeHoles}).
const ceilingHoles = ceiling.openings ?? [];
const { ring, bridgeEdges } = ceilingHoles.length
? mergeHoles(pts, ceilingHoles)
: { ring: pts, bridgeEdges: [] as number[] };
const wt = getCeilingType(project, ceiling);
const comp = wt.layers.length > 0 ? getComponent(project, wt.layers[0].componentId) : null;
const stroke = ceiling.color ?? POCHE_STROKE;
const ceilingId = ceiling.id;
// Gefüllte Fläche mit der Ansichts-Schraffur: die Decke wird im Grundriss
// frontal von unten gesehen, nicht aufgeschnitten — daher greift hier
// NICHT die Schnitt-Schraffur (die gehört auf echte Schnittflächen, siehe
// resolveCeilingSectionStyle/splitSlabLayers), sondern `viewHatchId`. Fehlt
// sie (Default), bleibt die Aufsicht weiss. Im Detailgrad „grob" entfällt
// die Schraffur ganz (nur Fläche + Umriss). Das Attribut-Override
// `ceiling.hatchId` (+ `hatchSource`) überschreibt diese Ansichts-Schraffur
// genauso wie die Schnitt-Schraffur in {@link resolveCeilingSectionStyle}.
const effectiveViewHatchId = resolveHatchId(
ceiling.hatchId,
ceiling.hatchSource,
category,
comp?.viewHatchId,
);
const hatch =
detail !== "grob" && comp && effectiveViewHatchId
? resolveHatch(
project,
effectiveViewHatchId,
undefined,
resolveForeground(comp, ceiling.foreground, category, ceiling.foregroundSource),
)
: NO_HATCH;
// Gefüllte Fläche OHNE eigenen Umriss (stroke:"none"): die Decke liegt unter
// der Wand-Poché; ihre Kante wird separat als verdeckungs-geclippte Linie
// gezeichnet (s. u.), damit sie nicht durch die darüberstehenden Wände schlägt.
// Trägt weiterhin die `ceilingId` für die Links-Klick-Auswahl der Fläche.
out.push({
kind: "polygon",
pts: ring,
fill: comp ? pocheFill(hatch.pattern) : "none",
stroke: "none",
strokeWidthMm: 0,
hatch,
greyed,
ceilingId,
...(bridgeEdges.length ? { noStrokeEdges: bridgeEdges } : {}),
});
// Kräftige Umrisslinie NUR dort, wo keine Wand die Decke verdeckt: jede
// Umriss-Kante (inkl. Lochränder, die als Teil des Rings automatisch
// mitgestrichen werden) gegen die Wand-Footprints clippen und die freien
// Teilstücke als Linien ausgeben. Liegt die Decke deckungsgleich unter den
// Wänden (Regelfall: Deckenumriss = Gebäude-/Wand-Mittellinien), entfällt
// der Umriss ganz; nur Überstände (Vordach/Balkon jenseits der Wand)
// bleiben sichtbar. Brückenkanten (künstliche Verbindung Aussenring↔Loch)
// werden NICHT gestrichen.
const n = ring.length;
const bridgeSet = new Set(bridgeEdges);
for (let i = 0; i < n; i++) {
if (bridgeSet.has(i)) continue;
const a = ring[i];
const b = ring[(i + 1) % n];
const holes: Array<[number, number]> = [];
for (const fp of wallFootprints) {
const iv = segmentInsideWallBand(a, b, fp);
if (iv) holes.push(iv);
}
const d = sub(b, a);
for (const [t0, t1] of subtractIntervals(0, 1, holes)) {
out.push({
kind: "line",
a: add(a, scale(d, t0)),
b: add(a, scale(d, t1)),
cls: "ceiling-outline",
// Überkopf-Ansichtslinie (Decke liegt als Slab über der Schnittebene) →
// gestrichelte Haarlinie statt kräftiger Volllinie (BIM-Konvention).
weightMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail],
dash: OVERHEAD_DASH,
color: stroke,
greyed,
});
}
}
}
+170
View File
@@ -0,0 +1,170 @@
// Kontext-Domäne des Grundriss-Generators: zeichnet `project.context`
// (Höhenlinien, importierte Gebäude/Terrain-Meshes) als dezente Referenz-
// Konturen — für Meshes der Grundriss-Fussabdruck nahe der Objekt-Unterkante,
// nicht das volle Drahtgitter.
import type { Project } from "../../model/types";
import { NO_HATCH, type Primitive } from "./shared";
/** Dezente Plan-Farbe der Kontext-Konturen (gedämpftes Grau). */
const CONTEXT_LINE_COLOR = "#9aa3ad";
/** Strichstärke der Kontext-Konturen (mm Papier, dünn). */
const CONTEXT_LINE_MM = 0.1;
/** Höhen-Toleranz (Meter): Dreieckskanten, deren beide Endpunkte höchstens so
* weit über der Objekt-Unterkante (minZ) liegen, gelten als „Sockelkante" und
* bilden den Grundriss-Fussabdruck eines importierten Gebäudes. */
const FOOTPRINT_Z_TOL = 0.6;
/**
* Zeichnet die Kontext-Schicht `project.context` in den Grundriss:
* • ContourSet (Höhenlinien) → dünne Referenz-Linien (Z ignoriert).
* • ImportedMesh (Gebäude) → GRUNDRISS-FUSSABDRUCK: die Dreieckskanten nahe
* der Objekt-Unterkante (Sockel), auf die XY-Ebene projiziert und über
* quantisierte Endpunkte entdoppelt — so erscheint das importierte Haus als
* erkennbarer Umriss im Plan (Nutzer-Wunsch: „ich sehe es sonst nicht"),
* ohne das gesamte Dach-Drahtgitter dicht darüberzulegen.
* • TerrainMesh → nur der 2D-Umriss (Bounding-Box), da ein volles TIN-Gitter
* im Plan unbrauchbar dicht wäre.
* Rein additiv — Wand-/Drawing-Darstellung bleibt unberührt.
*/
export function addContextContours(out: Primitive[], project: Project): void {
const context = project.context ?? [];
for (const obj of context) {
if (obj.type === "contourSet") {
for (const c of obj.contours) {
const pts = c.pts;
if (pts.length < 2) continue;
const segEnd = c.closed ? pts.length : pts.length - 1;
for (let i = 0; i < segEnd; i++) {
out.push({
kind: "line",
a: pts[i],
b: pts[(i + 1) % pts.length],
cls: "context-line",
weightMm: CONTEXT_LINE_MM,
color: CONTEXT_LINE_COLOR,
});
}
}
} else if (obj.type === "importedMesh") {
addMeshFootprint(out, obj.positions, obj.indices);
addContextPickRegion(out, obj.positions, obj.id);
} else if (obj.type === "terrainMesh") {
addMeshBboxOutline(out, obj.positions);
addContextPickRegion(out, obj.positions, obj.id);
}
}
}
/**
* Zeichnet den Grundriss-Fussabdruck eines Meshes: Dreieckskanten nahe der
* Unterkante (minZ + {@link FOOTPRINT_Z_TOL}), auf XY projiziert, über
* quantisierte Endpunkt-Schlüssel entdoppelt. `positions` = flaches [x,y,z…]-
* Array (Modell-Meter, z = Höhe), `indices` = Dreiecks-Indizes.
*/
function addMeshFootprint(out: Primitive[], positions: number[], indices: number[]): void {
const vcount = Math.floor(positions.length / 3);
if (vcount < 3 || indices.length < 3) return;
let minZ = Infinity;
for (let i = 2; i < positions.length; i += 3) if (positions[i] < minZ) minZ = positions[i];
if (!Number.isFinite(minZ)) return;
const zLimit = minZ + FOOTPRINT_Z_TOL;
const q = (v: number) => Math.round(v * 100); // 1-cm-Quantisierung (Entdopplung)
const seen = new Set<string>();
const edge = (ia: number, ib: number) => {
if (ia >= vcount || ib >= vcount) return;
const za = positions[ia * 3 + 2];
const zb = positions[ib * 3 + 2];
if (za > zLimit || zb > zLimit) return; // nur Sockelkanten
const ax = positions[ia * 3], ay = positions[ia * 3 + 1];
const bx = positions[ib * 3], by = positions[ib * 3 + 1];
// Kanten-Schlüssel richtungsunabhängig (kleinerer Endpunkt zuerst).
const ka = `${q(ax)},${q(ay)}`;
const kb = `${q(bx)},${q(by)}`;
if (ka === kb) return;
const key = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
if (seen.has(key)) return;
seen.add(key);
out.push({
kind: "line",
a: { x: ax, y: ay },
b: { x: bx, y: by },
cls: "context-line",
weightMm: CONTEXT_LINE_MM,
color: CONTEXT_LINE_COLOR,
});
};
for (let i = 0; i + 2 < indices.length; i += 3) {
const a = indices[i], b = indices[i + 1], c = indices[i + 2];
edge(a, b);
edge(b, c);
edge(c, a);
}
}
/** Zeichnet nur den achsenparallelen 2D-Umriss (Bounding-Box) eines Meshes —
* für Terrain, dessen volles TIN-Gitter im Plan zu dicht wäre. */
function addMeshBboxOutline(out: Primitive[], positions: number[]): void {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let i = 0; i + 2 < positions.length; i += 3) {
const x = positions[i], y = positions[i + 1];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
if (!Number.isFinite(minX)) return;
const corners = [
{ x: minX, y: minY },
{ x: maxX, y: minY },
{ x: maxX, y: maxY },
{ x: minX, y: maxY },
];
for (let i = 0; i < 4; i++) {
out.push({
kind: "line",
a: corners[i],
b: corners[(i + 1) % 4],
cls: "context-line",
weightMm: CONTEXT_LINE_MM,
color: CONTEXT_LINE_COLOR,
});
}
}
/**
* Unsichtbares Pick-Band (Grundriss-Bounding-Box) für ein Kontext-Objekt
* (importiertes Gebäude/Terrain) — klickbar per Punkt-in-Polygon in PlanView,
* trägt die `ContextObject.id` (analog `sectionLineId`, s. dort). Macht das
* Objekt IRGENDWO innerhalb seiner Grundfläche anklickbar (nicht nur exakt auf
* der dünnen Umriss-/Fussabdrucklinie) — wie bei Wänden, deren Poché-Fläche
* ebenfalls als Ganzes klickbar ist (Nutzer-Wunsch: „eigene Elemente wie Wände").
* `positions` = flaches [x,y,z…]-Array (Modell-Meter); die Bbox nutzt NUR x/y,
* unabhängig von der Höhe.
*/
function addContextPickRegion(out: Primitive[], positions: number[], id: string): void {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let i = 0; i + 2 < positions.length; i += 3) {
const x = positions[i], y = positions[i + 1];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
if (!Number.isFinite(minX)) return;
out.push({
kind: "polygon",
pts: [
{ x: minX, y: minY },
{ x: maxX, y: minY },
{ x: maxX, y: maxY },
{ x: minX, y: maxY },
],
fill: "none",
stroke: "none",
strokeWidthMm: 0,
hatch: NO_HATCH,
contextObjectId: id,
});
}
+168
View File
@@ -0,0 +1,168 @@
// Freie 2D-Zeichengeometrie-Domäne des Grundriss-Generators (docs/design/
// drawing-tools.md §7.2): leitet ein `Drawing2D`-Element (line/polyline/rect/
// circle/arc/text/image) in Plan-Primitive ab. Farbe/Strichstärke kommen aus
// dem optionalen LineStyle, sonst aus der Kategorie.
import type { Drawing2D, LayerCategory, Project, Vec2 } from "../../model/types";
import {
NO_HATCH,
WALL_FALLBACK_MM,
MONO_INK,
resolveHatch,
resolveHatchId,
resolveStrokeWeight,
type Primitive,
} from "./shared";
export function addDrawing2D(
out: Primitive[],
project: Project,
d: Drawing2D,
greyed: boolean,
lwByCode: Map<string, number>,
colorByCode: Map<string, string>,
catByCode: Map<string, LayerCategory>,
): void {
const category = catByCode.get(d.categoryCode);
const ls = d.lineStyleId
? project.lineStyles.find((l) => l.id === d.lineStyleId)
: undefined;
// Plan-Tinte fix dunkel: Fallback auf MONO_INK (statt hell „#d0d0d0"), damit
// ein kategorieloses 2D-Element auf dem hellen Papier sichtbar bleibt.
const color = d.color ?? ls?.color ?? colorByCode.get(d.categoryCode) ?? MONO_INK;
// Strichstärke: "layer" erzwingt die Ebenen-Strichstärke (umgeht das
// LineStyle-Gewicht), sonst (Default "object") die heutige Kette.
const weightMm = resolveStrokeWeight(
d.weightMm,
d.strokeWeightSource,
category,
ls?.weight ?? lwByCode.get(d.categoryCode) ?? WALL_FALLBACK_MM,
);
const dash = ls?.dash ?? null;
// Drawing2D trägt kein Bauteil → die By-Layer-Quelle ist hier die einzige
// Alternative zum expliziten Wert (kein Component-Fallback); Default
// "object"/undefined bleibt exakt das heutige Verhalten.
const foreground = d.foreground ?? (d.foregroundSource === "layer" ? category?.color : undefined);
const background = d.background ?? (d.backgroundSource === "layer" ? category?.color : undefined);
const hatchId = resolveHatchId(d.hatchId, d.hatchSource, category, undefined);
// Zickzack-Parameter aus dem LineStyle durchreichen (nur kind==="zigzag").
// Additiv: fehlt es, bleibt die Linie gerade/gestrichelt wie bisher.
const zigzag = ls?.kind === "zigzag" ? ls.zigzag : undefined;
// Custom-Motiv analog durchreichen (nur kind==="custom").
const motif = ls?.kind === "custom" ? ls.motif : undefined;
const seg = (a: Vec2, b: Vec2) =>
out.push({ kind: "line", a, b, cls: "draw2d", weightMm, dash, zigzag, motif, color, greyed, drawingId: d.id });
/**
* Gefüllte Fläche einer GESCHLOSSENEN Form: trägt die Vollton-Füllfarbe
* (`d.fillColor`, sonst transparent) UND die optionale Füllschraffur
* (`d.hatchId`, sonst NO_HATCH). Wird VOR den Strichen gepusht, damit die
* Umrisslinien oben liegen. Trägt `drawingId`, sodass ein Klick auf die Fläche
* das Element selektiert (analog zu den Linien).
*/
const fillPoly = (pts: Vec2[]) =>
out.push({
kind: "polygon",
pts,
// Hintergrund-Override (Wert ?? By-Layer) hat Vorrang vor dem Alt-Feld
// fillColor (alle leer = transparente Fläche, nur Schraffur) — Sample
// bleibt identisch (kein Element setzt background/backgroundSource).
fill: background ?? d.fillColor ?? "none",
// Umriss zeichnen die separaten Linien-Primitive (mit Strichmuster); die
// Füllfläche selbst bleibt randlos, um doppelte Striche zu vermeiden.
stroke: "none",
strokeWidthMm: 0,
// Drawing2D trägt kein Bauteil → Vordergrund/Schraffur-Override direkt aus
// d.foreground/d.hatchId bzw. der Ebene (Attribut „Nach System" ⇒
// undefined ⇒ HatchStyle.color-Fallback bzw. NO_HATCH).
hatch: hatchId ? resolveHatch(project, hatchId, undefined, foreground) : NO_HATCH,
greyed,
drawingId: d.id,
});
const g = d.geom;
switch (g.shape) {
case "line":
seg(g.a, g.b);
break;
case "polyline": {
// Geschlossene Polylinie: zuerst die gefüllte Fläche (Vollton/Schraffur).
if (g.closed && g.pts.length > 2) fillPoly(g.pts);
for (let i = 0; i < g.pts.length - 1; i++) seg(g.pts[i], g.pts[i + 1]);
if (g.closed && g.pts.length > 2) seg(g.pts[g.pts.length - 1], g.pts[0]);
break;
}
case "rect": {
const c1 = g.min;
const c2 = { x: g.max.x, y: g.min.y };
const c3 = g.max;
const c4 = { x: g.min.x, y: g.max.y };
// Rechteck ist immer geschlossen → zuerst die gefüllte Fläche.
fillPoly([c1, c2, c3, c4]);
seg(c1, c2);
seg(c2, c3);
seg(c3, c4);
seg(c4, c1);
break;
}
case "circle":
// Echtes Kreis-Primitiv → glattes `<circle>` (rund bei jedem Zoom) mit
// optionaler Vollton-Füllung. Ersetzt die frühere 64-Ecken-Approximation.
out.push({
kind: "drawingCircle",
center: g.center,
r: g.r,
fill: background ?? d.fillColor ?? "none",
stroke: color,
weightMm,
dash,
greyed,
drawingId: d.id,
});
break;
case "arc":
// Echtes Bogen-Primitiv → glatter SVG-Bogenpfad (Winkel in Radiant).
out.push({
kind: "drawingArc",
center: g.center,
r: g.r,
a0: g.a0,
a1: g.a1,
stroke: color,
weightMm,
dash,
greyed,
drawingId: d.id,
});
break;
case "text":
out.push({
kind: "drawingText",
at: g.at,
text: g.text,
heightM: g.height,
angle: g.angle,
// marks.color (falls gesetzt) übersteuert die Kategorie-/Elementfarbe.
color: g.marks?.color ?? color,
...(g.width && g.width > 0 ? { wrapWidth: g.width } : {}),
...(g.marks ? { marks: g.marks } : {}),
...(g.doc ? { doc: g.doc } : {}),
greyed,
drawingId: d.id,
});
break;
case "image":
out.push({
kind: "drawingImage",
min: g.min,
max: g.max,
src: g.src,
opacity: g.opacity ?? 1,
greyed,
drawingId: d.id,
});
break;
default:
break;
}
}
+662
View File
@@ -0,0 +1,662 @@
// Öffnungs-Domäne des Grundriss-Generators: Tür-/Fenstersymbole je Detailgrad
// (Blatt/Schwenkbogen/Anschlag bzw. Rahmen/Sprossen/Glas), das optionale
// Rahmenband (Zarge/Blockrahmen) samt Schichteinzug aus der Typ-Bibliothek,
// sowie das Legacy-Türsymbol (`Door`, vor der Öffnungs-Typ-Bibliothek).
import type { Opening, Project, Vec2, Wall } from "../../model/types";
import {
getDoorType,
getWindowType,
getWallType,
sashesOfWindowType,
wallTypeThickness,
} from "../../model/types";
import {
doorLeaves,
doorSymbol,
openingGapQuad,
openingJambs,
wallAxisFrame,
windowSymbol,
} from "../../geometry/opening";
import { wallReferenceOffset } from "../../model/wall";
import { add, along, dot, leftNormal, normalize, scale, sub } from "../../model/geometry";
import {
NO_HATCH,
OVERHEAD_DASH,
POCHE_STROKE,
SYMBOL_HAIRLINE_MM,
type DetailLevel,
type Primitive,
} from "./shared";
/**
* Tür-Symbol je Detailgrad:
* • grob — nur die gerade Türblatt-Linie (die Öffnungs-Lücke kommt aus der
* ausgesparten Poché).
* • mittel — + Schwenkbogen (geschlossen → offen).
* • fein — + Rahmen/Anschlag-Striche an beiden Pfosten (kurze Striche quer
* zur Wand, die den Türrahmen/-anschlag andeuten).
*/
export function addDoorSymbol(
out: Primitive[],
wall: Wall,
door: Project["doors"][number],
greyed: boolean,
detail: DetailLevel,
_doorLwMm: number,
): void {
const u = normalize(sub(wall.end, wall.start));
const n = leftNormal(u); // linke Seite der Wandachse
const swingDir = door.swing === "left" ? n : scale(n, -1);
const jambStart = along(wall.start, wall.end, door.position);
const jambEnd = along(wall.start, wall.end, door.position + door.width);
const hinge = door.hinge === "start" ? jambStart : jambEnd;
const closedDir = door.hinge === "start" ? u : scale(u, -1);
const openEnd = add(hinge, scale(swingDir, door.width));
const closedEnd = add(hinge, scale(closedDir, door.width));
// Türblatt in geöffneter Stellung (alle Stufen).
out.push({
kind: "line",
a: hinge,
b: openEnd,
cls: "door-leaf",
weightMm: SYMBOL_HAIRLINE_MM,
greyed,
});
// mittel + fein: Schwenkbogen von geschlossen → offen.
if (detail !== "grob") {
out.push({
kind: "arc",
center: hinge,
from: closedEnd,
to: openEnd,
r: door.width,
cls: "door-swing",
weightMm: SYMBOL_HAIRLINE_MM,
greyed,
});
}
// fein: Rahmen/Anschlag-Striche an beiden Pfosten (kurz quer zur Wand,
// symmetrisch um die Achse über eine halbe Wanddicke-Andeutung).
if (detail === "fein") {
const reveal = 0.06; // 6 cm Anschlag-Andeutung je Seite
for (const jamb of [jambStart, jambEnd]) {
out.push({
kind: "line",
a: add(jamb, scale(n, reveal)),
b: add(jamb, scale(n, -reveal)),
cls: "door-frame",
weightMm: SYMBOL_HAIRLINE_MM,
greyed,
});
}
}
}
/**
* Aufgelöste Rahmen-Parameter (Typ-Bibliothek) einer Tür-/Fenster-Öffnung —
* nur vorhanden, wenn die Öffnung einen Bauteil-Typ referenziert (`typeId`).
* Fehlt der Typ, bleibt das heutige Inline-Verhalten unverändert (kein
* Rahmenband, kein Oberlicht) — siehe `resolveOpeningFrameSpec`.
*/
interface OpeningFrameSpec {
frameWidth: number;
/** Nur bei Türen relevant; Fenster zeichnen stets die schmale Zargen-Optik. */
frameKind: "zarge" | "blockrahmen";
insetFromFace: number;
insetFace: "aussen" | "innen";
transomHeight: number;
}
/**
* Löst die Rahmen-Parameter aus dem referenzierten Tür-/Fenstertyp auf (samt
* Defaults); `undefined`, wenn kein Typ referenziert/auflösbar ist — die
* Öffnung bleibt dann beim heutigen Inline-Verhalten (rückwärtskompatibel).
*/
function resolveOpeningFrameSpec(project: Project, o: Opening): OpeningFrameSpec | undefined {
if (o.kind === "door") {
const t = getDoorType(project, o);
if (!t) return undefined;
return {
frameWidth: t.frameWidth ?? 0.06,
frameKind: t.frameKind ?? "zarge",
insetFromFace: t.insetFromFace ?? 0,
insetFace: t.insetFace ?? "aussen",
transomHeight: t.transomHeight ?? 0,
};
}
const t = getWindowType(project, o);
if (!t) return undefined;
return {
frameWidth: t.frameWidth ?? 0.06,
frameKind: "zarge",
insetFromFace: t.insetFromFace ?? 0,
insetFace: t.insetFace ?? "aussen",
transomHeight: t.transomHeight ?? 0,
};
}
/**
* Rahmenband + Oberlicht-Andeutung einer Tür-/Fenstertyp-Öffnung im
* Grundriss-Schnitt. Nur aufgerufen, wenn `resolveOpeningFrameSpec` einen Typ
* liefert — sonst bleibt die Darstellung exakt wie zuvor (kein neues Primitiv).
*
* • Zarge — schmales Umlauf-Rahmenband, ringsum um `frameWidth`
* von der vollen Öffnung (Pfosten × Wanddicke) nach innen eingezogen.
* • Blockrahmen (Tür) — kräftiges Rechteckprofil, sitzt PROUD an der
* gewählten Fläche (kein Einzug an den Pfosten, nur eine Tiefenscheibe von
* `frameWidth` ab der Nah-Fläche), kräftigere Linie.
* • Schichteinzug — verschiebt die Nah-Fläche des Rahmens um
* `insetFromFace` von der gewählten Wandfläche nach innen; die Gegenfläche
* bleibt unverändert (der Rahmen reicht weiterhin bis dorthin).
* • Oberlicht — Überkopf-Andeutung (gestrichelte Linie quer über die
* Öffnung), nur wenn `transomHeight > 0`.
*/
function addOpeningFrameBand(
out: Primitive[],
project: Project,
wall: Wall,
o: Opening,
spec: OpeningFrameSpec,
greyed: boolean,
): void {
const jambs = openingJambs(wall, o);
if (!jambs) return;
const { n } = wallAxisFrame(wall);
const total = wallTypeThickness(getWallType(project, wall));
const refOff = wallReferenceOffset(wall, total);
// "aussen" = die Fläche der ERSTEN Wandtyp-Schicht (kleinster Achs-Offset,
// s. addWallPoche/`off` startet bei `refOff-total/2`) — mit `n = leftNormal(u)`
// ist das die NEGATIVE Seite. Bug bis 2026-07-12: hier stand `+total/2` für
// "aussen" (positive Seite, tatsächlich "innen") — Türrahmenbänder mit
// insetFace:"aussen" landeten dadurch an der falschen Wandfläche.
const outerFace = refOff - total / 2; // "aussen"-Fläche (−n, erste Schicht)
const innerFace = refOff + total / 2; // "innen"-Fläche (+n, letzte Schicht)
// Schichteinzug: die GEWÄHLTE Fläche rückt um `insetFromFace` nach innen; die
// gegenüberliegende Fläche bleibt unverändert (der Rahmen reicht weiterhin
// bis dorthin) — vgl. DoorType.insetFromFace/WindowType.insetFromFace.
const clampedInset = Math.max(0, Math.min(spec.insetFromFace, total));
const nearIsAussen = spec.insetFace !== "innen";
// "Nach innen" heisst: von der gewählten Fläche RICHTUNG WANDMITTE (refOff) —
// von aussen (negative Seite) aus AUFWÄRTS (+clampedInset), von innen
// (positive Seite) aus ABWÄRTS (−clampedInset).
const faceNear = nearIsAussen ? outerFace + clampedInset : innerFace - clampedInset;
const faceFar = nearIsAussen ? innerFace : outerFace;
const lo = Math.min(faceNear, faceFar);
const hi = Math.max(faceNear, faceFar);
const { jambStart, jambEnd } = jambs;
const color = o.color ?? POCHE_STROKE;
// Rahmen-Rechteck NUR bei Türen zeichnen. Beim Fenster stellt bereits
// `windowSymbol` den Rahmen dar (voller Öffnungsumriss + Glaslinien); ein
// zusätzliches Band ergäbe ein doppeltes, verschachteltes Rechteck — genau
// das wirkte im 2D „nicht korrekt". Die Tür hat dort nur Blatt+Bogen, daher
// ist ihr Rahmenband die einzige Rahmenkontur und bleibt.
if (o.kind === "door") {
const frameWidth = Math.max(0, spec.frameWidth);
const isBlock = spec.frameKind === "blockrahmen";
let bandLo: number;
let bandHi: number;
let jStart: Vec2;
let jEnd: Vec2;
let weightMm: number;
if (isBlock) {
// Blockrahmen: volle Öffnungsbreite (kein Pfosten-Einzug), nur eine
// Tiefenscheibe von frameWidth ab der Nah-Fläche — sitzt proud VOR/auf der
// Laibung, statt schmal darin zu verschwinden (Zargen-Optik). "aussen"
// liegt bei `lo` (s. Kommentar zu outerFace/innerFace oben), die Scheibe
// beginnt dort und wächst RICHTUNG WANDMITTE (Richtung `hi`).
const depth = Math.min(frameWidth, hi - lo);
bandLo = nearIsAussen ? lo : hi - depth;
bandHi = nearIsAussen ? lo + depth : hi;
jStart = jambStart;
jEnd = jambEnd;
weightMm = SYMBOL_HAIRLINE_MM * 2.2;
} else {
// Zarge: ringsum um frameWidth von der vollen Öffnung eingezogenes,
// schmales Rahmenband (Laibungs-Anschlaglinie).
const jambSpan = Math.hypot(jambEnd.x - jambStart.x, jambEnd.y - jambStart.y);
const jambInset = Math.min(frameWidth, jambSpan / 2);
jStart = along(jambStart, jambEnd, jambInset);
jEnd = along(jambEnd, jambStart, jambInset);
const depthInset = Math.min(frameWidth, (hi - lo) / 2);
bandLo = lo + depthInset;
bandHi = hi - depthInset;
weightMm = SYMBOL_HAIRLINE_MM;
}
// Geschlossenes Rechteck aus 4 Kanten (Primitive "line", nicht "polygon" —
// so bleibt das Band per `cls` selektierbar/testbar wie die übrigen Symbole).
const p1 = add(jStart, scale(n, bandLo));
const p2 = add(jEnd, scale(n, bandLo));
const p3 = add(jEnd, scale(n, bandHi));
const p4 = add(jStart, scale(n, bandHi));
const edges: [Vec2, Vec2][] = [
[p1, p2],
[p2, p3],
[p3, p4],
[p4, p1],
];
for (const [a, b] of edges) {
out.push({
kind: "line",
a,
b,
cls: "opening-frame",
weightMm,
color,
greyed,
openingId: o.id,
});
}
}
// Die frühere Oberlicht-Andeutung (gestrichelte Linie quer über die ganze
// Öffnung bei transomHeight>0) ist entfernt (Nutzer-Report: erscheint immer
// auf der Wandachse, unabhängig davon, ob die Schnittebene den Kämpfer
// überhaupt trifft — im horizontalen Grundriss-Schnitt architektonisch nicht
// aussagekräftig, ein Kämpfer ist ein Höhen-, kein Grundriss-Merkmal).
}
/**
* Öffnungs-Symbol (Fenster/Tür) je Detailgrad. Anders als das Legacy-`addDoorSymbol`
* arbeitet diese Funktion über die reinen Geometrie-Helfer (opening.ts) und taggt
* ihre Primitive mit `openingId`, sodass die Öffnung im Plan selektierbar ist.
*
* Tür:
* • grob — gerade Türblatt-Linie (die Lücke kommt aus der ausgesparten Poché).
* • mittel — + Schwenkbogen (Öffnungswinkel).
* • fein — + Anschlag-Striche an beiden Pfosten.
* Fenster:
* • grob — eine einzelne Glaslinie über die Lücke.
* • mittel — Rahmen-Rechteck (Umriss) + eine Glaslinie.
* • fein — Rahmen + zwei Glaslinien (Doppelverglasung angedeutet).
*/
export function addOpeningSymbol(
out: Primitive[],
project: Project,
wall: Wall,
o: Opening,
greyed: boolean,
detail: DetailLevel,
lwMm: number,
): void {
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).
// Entspricht `oeff_tuer_typ == "wandoeffnung"` im Rhino-Plugin.
const isWandoeffnung = (o.doorType ?? "normal") === "wandoeffnung";
if (!isWandoeffnung) {
// Türblätter (1 oder 2 Flügel, je DoorType.leafCount). Zweiflüglig ⇒ zwei
// halbbreite Blätter an gegenüberliegenden Pfosten (symmetrische Doppeltür).
const leafCount = Math.max(1, Math.round(getDoorType(project, o)?.leafCount ?? 1));
for (const leaf of doorLeaves(wall, o, leafCount)) {
out.push({
kind: "line",
a: leaf.hinge,
b: leaf.openEnd,
cls: "door-leaf",
weightMm: SYMBOL_HAIRLINE_MM,
greyed,
openingId: o.id,
});
// mittel + fein: Schwenkbogen von geschlossen → offen je Blatt.
if (detail !== "grob") {
out.push({
kind: "arc",
center: leaf.hinge,
from: leaf.closedEnd,
to: leaf.openEnd,
r: leaf.radius,
cls: "door-swing",
weightMm: SYMBOL_HAIRLINE_MM,
greyed,
openingId: o.id,
});
}
}
}
// fein: Anschlag-Striche an beiden Pfosten (quer zur Wand).
if (detail === "fein") {
const reveal = 0.06;
for (const jamb of [sym.jambStart, sym.jambEnd]) {
out.push({
kind: "line",
a: add(jamb, scale(sym.normal, reveal)),
b: add(jamb, scale(sym.normal, -reveal)),
cls: "door-frame",
weightMm: SYMBOL_HAIRLINE_MM,
greyed,
openingId: o.id,
});
}
}
// Rahmenband (Zarge/Blockrahmen) + Oberlicht-Andeutung — nur wenn die
// Öffnung einen Türtyp referenziert (siehe `resolveOpeningFrameSpec`).
const doorFrameSpec = resolveOpeningFrameSpec(project, o);
if (doorFrameSpec) addOpeningFrameBand(out, project, wall, o, doorFrameSpec, greyed);
// Sturzlinien (SIA, gestrichelt) — Überkopf-Projektion des Sturzes quer über
// die Öffnung an der Wand-Innen-/Aussenkante. Entspricht `_make_tuer_sturz_curves`
// im Rhino-Plugin. Default „beide" wenn nicht explizit gesetzt.
const lintelMode = o.lintelLines ?? "beide";
if (lintelMode !== "keine") {
const jambs = openingJambs(wall, o);
if (jambs) {
const { n } = wallAxisFrame(wall);
const total = wallTypeThickness(getWallType(project, wall));
const refOff = wallReferenceOffset(wall, total);
const halfD = total / 2;
// "aussen" = negative Seite (erste Wandtyp-Schicht), s. Kommentar in
// addOpeningFrameBand — Bug bis 2026-07-12: stand hier vertauscht.
const outerOff = refOff - halfD;
const innerOff = refOff + halfD;
// Strichmuster: gestrichelt (Überkopf-Projektion, SIA-Konvention).
const dash: number[] = [0.18, 0.09];
const strokeColor = o.color ?? POCHE_STROKE;
/** Linie quer über die Öffnung am gegebenen Perp-Offset. */
const lintelLine = (perpOff: number) => ({
a: add(jambs.jambStart, scale(n, perpOff)),
b: add(jambs.jambEnd, scale(n, perpOff)),
});
if (lintelMode === "aussen" || lintelMode === "beide") {
const seg = lintelLine(outerOff);
out.push({
kind: "line" as const,
a: seg.a,
b: seg.b,
cls: "door-lintel",
weightMm: SYMBOL_HAIRLINE_MM,
dash,
color: strokeColor,
greyed,
openingId: o.id,
});
}
if (lintelMode === "innen" || lintelMode === "beide") {
const seg = lintelLine(innerOff);
out.push({
kind: "line" as const,
a: seg.a,
b: seg.b,
cls: "door-lintel",
weightMm: SYMBOL_HAIRLINE_MM,
dash,
color: strokeColor,
greyed,
openingId: o.id,
});
}
}
}
return;
}
// Fenster: Rahmen (mittel/fein) + Glaslinie(n) je Detailgrad + Fenstertyp.
const wt = getWindowType(project, o);
// Flügeltabelle NUR übergeben, wenn ein Fenstertyp auflösbar ist — fehlt er
// (kein `typeId`/unbekannt), bleibt `windowSymbol` beim reinen
// `o.wingCount`-Alt-Verhalten (siehe dortiger Kommentar, Regressionssicherheit).
const spec = wt
? {
sashes: sashesOfWindowType(wt),
frameWidth: wt.frameWidth ?? 0.06,
frameDepth: wt.frameDepth,
insetFromFace: wt.insetFromFace,
insetFace: wt.insetFace,
}
: undefined;
// Glaslinienzahl NUR für den Alt-Pfad (kein `spec`) relevant — der typisierte
// Pfad entscheidet die Anzahl selbst nach SIA-Detailgrad (s. windowSymbol).
const glassCount = detail === "fein" ? 2 : 1;
const sym = windowSymbol(project, wall, o, glassCount, spec, detail);
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,
});
}
// ── SIA-Fensterdarstellung, nach Detailgrad gestaffelt ────────────────────
// grob (1:100): weisse Öffnung + EINE dünne Glaslinie, sonst nichts.
// mittel (1:50): Blendrahmen-Rechteck + Pfosten-Trennlinien + Stulp-Mittel-
// quadrat je Flügel-Junction + Glaslinie(n).
// fein (1:20): zusätzlich Flügelrahmen, Stulp als ZWEI Rechtecke, Fensterbank
// (aussen) und Anschlag-/Laibungskerben — deutlich reicher als mittel.
const strokeColor = o.color ?? POCHE_STROKE;
const { n: wn } = wallAxisFrame(wall);
if (detail === "grob") {
// SIA 1:100 — nur eine dünne Glaslinie mittig über die weisse Öffnung.
// Zählt zur "Fenster"-Kategorie (sashLine) — dieselbe Glaslinie, die bei
// mittel/fein früher sichtbar war (jetzt dort entfernt, s. Fenster-Session).
const g = sym.glassLines[Math.floor(sym.glassLines.length / 2)] ?? sym.glassLines[0];
if (g) {
out.push({
kind: "line",
a: g[0],
b: g[1],
cls: "window-glass",
weightMm: o.sashLine?.weight ?? SYMBOL_HAIRLINE_MM,
color: o.sashLine?.color ?? strokeColor,
greyed,
openingId: o.id,
});
}
} else {
// Linien-Kategorien einzeln übersteuerbar (Nutzer-Wunsch): Rahmen-Schnitt
// (Laibungs-/Stulp-Blöcke, geschnittenes Profil), Rahmen-Aufsicht (die
// durchlaufende Kontur DAZWISCHEN, projiziert statt geschnitten), Fenster
// (Flügelrahmen/Sprossen), Sims (Auf-/Untersicht-Andeutung, s. u.).
// `undefined` ⇒ bisheriges Verhalten (strokeColor/SYMBOL_HAIRLINE_MM);
// `frameViewLine` fällt zusätzlich auf `frameLine` zurück (eine Farbe für
// Schnitt+Aufsicht, bis der Nutzer sie explizit trennt).
const frameColor = o.frameLine?.color ?? strokeColor;
const frameWeight = o.frameLine?.weight ?? lwMm;
const frameHairline = o.frameLine?.weight ?? SYMBOL_HAIRLINE_MM;
const frameViewColor = o.frameViewLine?.color ?? frameColor;
const frameViewWeight = o.frameViewLine?.weight ?? frameWeight;
const sashColor = o.sashLine?.color ?? strokeColor;
const sashWeight = o.sashLine?.weight ?? SYMBOL_HAIRLINE_MM;
// Blendrahmen-Rechteck in Aufsicht (mittel + fein) — die durchlaufende
// Kontur zwischen den geschnittenen Laibungs-/Stulp-Blöcken.
out.push({
kind: "polygon",
pts: sym.frame,
fill: "none",
stroke: frameViewColor,
strokeWidthMm: frameViewWeight,
hatch: NO_HATCH,
greyed,
openingId: o.id,
});
// Pfosten-/Flügel-Trennlinien (Alt-Pfad ohne Typ — zählt als "Fenster").
for (const [a, b] of sym.mullionLines) {
out.push({ kind: "line", a, b, cls: "window-mullion", weightMm: sashWeight, color: sashColor, greyed, openingId: o.id });
}
// fein: Flügelrahmen (Flügelkontur) je öffenbarem Flügel — Schachtelung
// Blendrahmen → Flügelrahmen → Glas (Feindetail, hebt fein klar von mittel ab).
if (detail === "fein") {
for (const pts of sym.sashFrames) {
for (let i = 0; i < pts.length; i++) {
out.push({ kind: "line", a: pts[i], b: pts[(i + 1) % pts.length], cls: "window-sash", weightMm: sashWeight, color: sashColor, greyed, openingId: o.id });
}
}
}
// Laibungs-Blöcke an den Öffnungsenden (Blendrahmen-Querschnitt) — Teil des
// Blendrahmens, zählt zu "Rahmen".
for (const mark of sym.jambMarks) {
for (let i = 0; i < mark.length; i++) {
out.push({ kind: "line", a: mark[i], b: mark[(i + 1) % mark.length], cls: "window-stulp", weightMm: frameHairline, color: frameColor, greyed, openingId: o.id });
}
}
// Stulp-Mittelquadrat je Flügel-Junction (SIA-Signatur): mittel = EIN Quadrat,
// fein = ZWEI anliegende Rechtecke (die beiden Stulp-Stäbe der Flügel) —
// bereits tiefenkorrekt (inset-/insetFace-bewusst) in windowSymbol berechnet.
// Sitzt zwischen den Flügeln, zählt zu "Fenster" (separat vom Blendrahmen
// steuerbar — Nutzer-Feedback).
for (const mark of sym.meetingMarks) {
for (let i = 0; i < mark.length; i++) {
out.push({ kind: "line", a: mark[i], b: mark[(i + 1) % mark.length], cls: "window-stulp", weightMm: sashWeight, color: sashColor, greyed, openingId: o.id });
}
}
// Glaslinien (Anzahl je Detailgrad/Verglasung, s. glassCount).
for (const [a, b] of sym.glassLines) {
out.push({ kind: "line", a, b, cls: "window-glass", weightMm: SYMBOL_HAIRLINE_MM, greyed, openingId: o.id });
}
// Oberlicht-Andeutung (transom, gestrichelt) — mittel/fein.
const windowFrameSpec = resolveOpeningFrameSpec(project, o);
if (windowFrameSpec) addOpeningFrameBand(out, project, wall, o, windowFrameSpec, greyed);
// fein: Fensterbank (aussen) + Anschlag-/Laibungskerben.
if (detail === "fein") {
const jb = openingJambs(wall, o);
if (jb) {
const { jambStart, jambEnd } = jb;
// Tatsächliche Rahmen-Aussenkante (NICHT pauschal die volle Wandfläche —
// ein eingezogener Rahmen, s. {@link WindowType.insetFromFace}, hat seine
// Aussenkante weiter innen). "Aussen" ist der ALGEBRAISCH KLEINERE der
// beiden Tiefen-Werte in sym.frame (s. windowSymbol: frontRel für
// insetFace:"aussen" ist negativ) — welcher der beiden Eckpunkt-Paare das
// ist, hängt von insetFace ab, daher robust über Math.min bestimmt statt
// fest über sym.frame[0].
const outerN = Math.min(
dot(sub(sym.frame[0], jambStart), wn),
dot(sub(sym.frame[2], jambStart), wn),
);
// Fensterbank: schmales Rechteck vor der Aussenfläche, seitlich ~3 cm
// überstehend, ~4 cm auskragend (SIA-Sims-Andeutung im Grundriss).
// Auskragung geht IMMER weiter nach aussen (algebraisch kleiner, s. o.).
const ov = 0.03;
const proj = 0.04;
const s0 = along(jambStart, jambEnd, -ov);
const s1 = along(jambEnd, jambStart, -ov);
const b0 = add(s0, scale(wn, outerN));
const b1 = add(s1, scale(wn, outerN));
const b2 = add(s1, scale(wn, outerN - proj));
const b3 = add(s0, scale(wn, outerN - proj));
for (const [a, b] of [[b0, b1], [b1, b2], [b2, b3], [b3, b0]] as [Vec2, Vec2][]) {
out.push({ kind: "line", a, b, cls: "window-sill", weightMm: SYMBOL_HAIRLINE_MM, color: strokeColor, greyed, openingId: o.id });
}
// Anschlag-Kerben an beiden Pfosten (kurz quer zur Wand).
const reveal = 0.06;
for (const jamb of [jambStart, jambEnd]) {
out.push({ kind: "line", a: add(jamb, scale(wn, reveal)), b: add(jamb, scale(wn, -reveal)), cls: "window-frame", weightMm: SYMBOL_HAIRLINE_MM, color: strokeColor, greyed, openingId: o.id });
}
}
}
}
// Auf-/Untersicht-Andeutung ({@link WindowType.sillLine}): eine Referenzlinie
// an der Rahmen-Aussen-/Innenkante (NICHT der Wandachse — ersetzt die frühere,
// falsch positionierte Brüstungslinie), nur wenn der Typ es explizit wählt.
// "auf" (Aufsicht von oben, dünn gepunktet) vs. "unter" (Untersicht von unten,
// gestrichelt wie die übrigen Überkopf-Projektionen).
if (detail !== "grob" && wt?.sillLine) {
const { faces, view } = wt.sillLine;
const dash = view === "unter" ? OVERHEAD_DASH : [0.04, 0.08];
const sillColor = o.sillLineStyle?.color ?? strokeColor;
const sillWeight = o.sillLineStyle?.weight ?? SYMBOL_HAIRLINE_MM;
// Welches der beiden sym.frame-Eckpunkt-Paare physisch "aussen" ist, hängt
// von insetFace ab (s. windowSymbol) — robust über den Tiefen-Vergleich
// entlang wn bestimmt statt fest über den Index.
const pair01IsOuter = dot(sym.frame[0], wn) <= dot(sym.frame[3], wn);
const outerPair: [Vec2, Vec2] = pair01IsOuter ? [sym.frame[0], sym.frame[1]] : [sym.frame[3], sym.frame[2]];
const innerPair: [Vec2, Vec2] = pair01IsOuter ? [sym.frame[3], sym.frame[2]] : [sym.frame[0], sym.frame[1]];
if (faces === "aussen" || faces === "beide") {
out.push({ kind: "line", a: outerPair[0], b: outerPair[1], cls: "window-sill", weightMm: sillWeight, dash, color: sillColor, greyed, openingId: o.id });
}
if (faces === "innen" || faces === "beide") {
out.push({ kind: "line", a: innerPair[0], b: innerPair[1], cls: "window-sill", weightMm: sillWeight, dash, color: sillColor, greyed, openingId: o.id });
}
}
// Rollladen-/Sonnenschutzkasten (P0-Minimalfassung, reine 2D-Kontur): ein
// gestricheltes Rechteck ÜBER der Öffnung, ausserhalb der Wand-Aussenfläche —
// Breite = `shading.box.width` (0/fehlt ⇒ Öffnungsbreite), die zweite
// Rechteckseite = `shading.box.height`. Die 3D-Tiefe (`box.depth`) bleibt für
// eine spätere 3D-Box reserviert (Fokus hier bewusst 2D, s. Aufgabenstellung).
// Gestrichelt wie die übrigen Überkopf-Projektionen (Sturz/Oberlicht) — der
// Kasten liegt oberhalb der Schnittebene und wird nur angedeutet.
if (wt?.shading?.create) {
const jambs = openingJambs(wall, o);
if (jambs) {
const { u, n: nrm } = wallAxisFrame(wall);
const total = wallTypeThickness(getWallType(project, wall));
const refOff = wallReferenceOffset(wall, total);
// "aussen" = negative Seite (erste Wandtyp-Schicht), s. Kommentar in
// addOpeningFrameBand — Bug bis 2026-07-12: stand hier vertauscht (der
// Rollladenkasten landete an der Innenfläche statt aussen am Sturz).
const outerFace = refOff - total / 2;
const axisWidth = Math.hypot(
jambs.jambEnd.x - jambs.jambStart.x,
jambs.jambEnd.y - jambs.jambStart.y,
);
const boxWidth = wt.shading.box.width > 0 ? wt.shading.box.width : axisWidth;
const boxHeight = Math.max(0, wt.shading.box.height);
const center = add(jambs.jambStart, scale(u, axisWidth / 2));
const half = boxWidth / 2;
const c1 = add(center, scale(u, -half));
const c2 = add(center, scale(u, half));
const p1 = add(c1, scale(nrm, outerFace));
const p2 = add(c2, scale(nrm, outerFace));
const p3 = add(c2, scale(nrm, outerFace + boxHeight));
const p4 = add(c1, scale(nrm, outerFace + boxHeight));
const edges: [Vec2, Vec2][] = [
[p1, p2],
[p2, p3],
[p3, p4],
[p4, p1],
];
for (const [a, b] of edges) {
out.push({
kind: "line",
a,
b,
cls: "window-shading",
weightMm: SYMBOL_HAIRLINE_MM,
dash: OVERHEAD_DASH,
color: o.color ?? POCHE_STROKE,
greyed,
openingId: o.id,
});
}
}
}
}
+103
View File
@@ -0,0 +1,103 @@
// Dach-Domäne des Grundriss-Generators (Dachaufsicht): reine Linien-Darstellung
// ohne Poché — Traufe/First/Grat/Knick aus `roofGeometry`.
import type { LayerCategory, Project, Roof } from "../../model/types";
import { getComponent, roofLayers } from "../../model/types";
import { roofGeometry } from "../../geometry/roof";
import {
NO_HATCH,
POCHE_STROKE,
ROOF_BREAK_DASH,
ROOF_BREAK_MM,
ROOF_EAVES_MM,
ROOF_HIP_MM,
ROOF_RIDGE_MM,
resolveHatch,
type Primitive,
} from "./shared";
/**
* Dach im Grundriss (Dachaufsicht): reine Linien-Darstellung ohne Poché — die
* 2D-Geometrie aus `roofGeometry` (First/Grat/Knick hängen NICHT von der
* Traufhöhe ab, daher `eavesZ=0`):
* • Traufe (`eaves`): geschlossener Umriss-Ring als mittlere Haarlinie.
* • First (`ridges`): kräftige Volllinie, etwas stärker als die Traufe.
* • Grat (`hips`, Walm/Zelt): Volllinie zwischen Traufe- und First-Stärke.
* • Knick (`breaks`, Mansarde): dünne gestrichelte Linie.
* Farbe: `roof.color` übersteuert sonst die Kategorie-Farbe (analog Stützen-
* Poché), Default die neutrale Poché-Tinte.
*/
export function addRoof(
out: Primitive[],
project: Project,
roof: Roof,
greyed: boolean,
category: LayerCategory | undefined,
): void {
const stroke = roof.color ?? category?.color ?? POCHE_STROKE;
const geo = roofGeometry(roof, 0);
// Ansichts-Poché der Dachaufsicht: das Dach liegt ÜBER der Schnittebene und
// wird von oben gesehen (nicht geschnitten) → Ansichts-Schraffur
// (viewHatchId) der ÄUSSERSTEN Schicht (Eindeckung) des Dachtyps, analog dem
// Decken-Poché (Aufsicht = erste Lage). Ohne Dachtyp/viewHatchId keine
// Schraffur (weiss) — die Fläche bleibt trotzdem das Pick-Polygon.
let viewHatch = NO_HATCH;
const firstLayer = roofLayers(project, roof)[0];
if (firstLayer?.componentId) {
try {
const comp = getComponent(project, firstLayer.componentId);
if (comp.viewHatchId) viewHatch = resolveHatch(project, comp.viewHatchId);
} catch {
viewHatch = NO_HATCH;
}
}
// Pick-Polygon über der Traufe-Fläche (macht das Dach über die ganze Fläche
// per Links-Klick selektierbar) — trägt jetzt zusätzlich die Ansichts-
// Schraffur der Eindeckung (fill bleibt "none": keine Volltonfüllung, nur
// Musterlinien). PlanView testet die `pts` (Punkt-in-Polygon).
if (geo.eaves.length >= 3) {
out.push({
kind: "polygon",
pts: geo.eaves,
fill: "none",
stroke: "none",
strokeWidthMm: 0,
hatch: viewHatch,
greyed,
roofId: roof.id,
});
}
const n = geo.eaves.length;
for (let i = 0; i < n; i++) {
out.push({
kind: "line",
a: geo.eaves[i],
b: geo.eaves[(i + 1) % n],
cls: "roof-eaves",
weightMm: ROOF_EAVES_MM,
color: stroke,
greyed,
});
}
for (const [a, b] of geo.ridges) {
out.push({ kind: "line", a, b, cls: "roof-ridge", weightMm: ROOF_RIDGE_MM, color: stroke, greyed });
}
for (const [a, b] of geo.hips) {
out.push({ kind: "line", a, b, cls: "roof-hip", weightMm: ROOF_HIP_MM, color: stroke, greyed });
}
for (const [a, b] of geo.breaks) {
out.push({
kind: "line",
a,
b,
cls: "roof-break",
weightMm: ROOF_BREAK_MM,
dash: ROOF_BREAK_DASH,
color: stroke,
greyed,
});
}
}
+96
View File
@@ -0,0 +1,96 @@
// Raum-Domäne des Grundriss-Generators (SIA-416-Flächen): eine (unsichtbare,
// nur klickbare) Füllfläche + dünne neutrale Umrisslinie + ein Raum-Stempel
// (Name, Fläche, SIA-Kürzel) am Anker.
import type { Room } from "../../model/types";
import { evaluateRoom, siaLabel } from "../../geometry/roomArea";
import { docFromText } from "../../text/richText";
import { roomStampExtraLines, roomStampToDoc } from "../../model/roomStamp";
import { LAYER_DETAIL_FACTOR, LAYER_LINE_MM, NO_HATCH, type DetailLevel, type Primitive } from "./shared";
/**
* Deckkraft (0..255) der Raum-Füllung → 2-stelliges Hex-Suffix. Transluzente
* Farbfüllung entfällt — Plandarstellung schwarz/grau; Farbe bleibt späteren
* Schema-Ebenen vorbehalten. Alpha 0 = unsichtbar; das Polygon bleibt als
* `roomId`-Primitiv erhalten (Auswahl per Punkt-in-Polygon in PlanView, NICHT
* über die native Pointer-Trefferfläche der Füllung — die volle Fläche bleibt
* damit trotz Alpha 0 klickbar).
*/
const ROOM_FILL_ALPHA = "00";
/** Basis-Schriftgrösse des Raum-Stempels in Punkt (pt). */
const ROOM_STAMP_PT = 10;
/** Farbe mit Alpha-Suffix versehen (nur, wenn `color` ein 6-stelliges Hex ist). */
function withAlpha(color: string, alphaHex: string): string {
return /^#[0-9a-fA-F]{6}$/.test(color) ? `${color}${alphaHex}` : color;
}
/**
* Raum (SIA-416-Fläche) im Grundriss: eine (unsichtbare, nur klickbare) Füll-
* fläche über dem lichten Umriss + eine dünne neutrale Umrisslinie (beide
* `roomId`-getaggt für die Auswahl) + ein Raum-Stempel (Text) am Anker
* (Centroid oder `stampAnchor`) mit Name, Fläche „24.30 m²" und SIA-Kürzel.
* Keine Farbwäsche — Plandarstellung schwarz/grau, Farbe bleibt späteren
* Schema-Ebenen vorbehalten. Der Stempel entfällt beim Detailgrad „grob";
* Fläche/Umfang/Schwerpunkt werden aus `boundary` abgeleitet (nie gespeichert).
* Wird UNTER der Wand-Poché gezeichnet.
*/
export function addRoomArea(
out: Primitive[],
room: Room,
greyed: boolean,
detail: DetailLevel,
): void {
const pts = room.boundary;
if (pts.length < 3) return;
const res = evaluateRoom(pts, room.siaCategory);
const stroke = room.color;
// Unsichtbare Füllfläche (Alpha 0): keine Farbwäsche im Plan, das Polygon
// bleibt aber als `roomId`-Primitiv für die Punkt-in-Polygon-Auswahl erhalten.
out.push({
kind: "polygon",
pts,
fill: withAlpha(room.color, ROOM_FILL_ALPHA),
stroke,
strokeWidthMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail],
hatch: NO_HATCH,
greyed,
roomId: room.id,
});
// Raum-Stempel: frei editierbarer Rich-Text (Name/Notizen) + LIVE-Flächenzeile
// + SIA-Kürzel. Anker fix (stampAnchor, sonst Centroid). Nur ab Detailgrad
// „mittel". Die Flächenzeile aktualisiert sich unabhängig vom editierten Text.
if (detail !== "grob") {
const at = room.stampAnchor ?? res.centroid;
// Strukturierter Stempel (Feldmodell) hat Vorrang; sonst Alt-Pfad (Rich-Text
// bzw. Raum-Name als einfacher Text).
const doc = room.stamp
? roomStampToDoc(room.stamp)
: room.stampDoc ?? docFromText(room.name);
const extraLines = room.stamp
? roomStampExtraLines(room.stamp, {
netArea: res.netArea,
siaCategory: room.siaCategory,
siaLabel: siaLabel(room.siaCategory),
})
: [
{ text: `${res.netArea.toFixed(2)} m²`, align: "center" as const },
{
text: `${room.siaCategory} · ${siaLabel(room.siaCategory)}`,
align: "center" as const,
},
];
out.push({
kind: "text",
at,
doc,
extraLines,
basePt: ROOM_STAMP_PT,
color: stroke,
roomId: room.id,
greyed,
});
}
}
+145
View File
@@ -0,0 +1,145 @@
// Schnittführungs-Symbol-Domäne des Grundriss-Generators (klassische
// Strichpunkt-Schnittlinie mit Endmarken + Label) — NICHT zu verwechseln mit
// `generateSectionPlan()` (der Schnitt-ALS-Plan-Übersetzung, bleibt Orchestrator
// in `../generatePlan.ts`): diese Domäne zeichnet nur das Führungssymbol EINER
// Schnitt-/Ansichtsebene IM Grundriss.
import { leftNormal, normalize, sub } from "../../model/geometry";
import type { Project } from "../../model/types";
import { NO_HATCH, type Primitive } from "./shared";
/** Tinte der Schnittführungs-Linie/Marken (kräftiges Grau). */
const SECTION_LINE_INK = "#1f242c";
/** Haarlinie der Strichpunkt-Schnittlinie (mm Papier). */
const SECTION_LINE_MM = 0.18;
/** Kräftige Endmarken (Querstrich + Pfeil) (mm Papier). */
const SECTION_LINE_MARK_MM = 0.5;
/** Strichpunkt-Muster der Schnittlinie (lange Striche + Punkt, mm Papier). */
const SECTION_LINE_DASH: number[] = [6, 1.5, 0.4, 1.5];
/** Länge der Endmarken (Querstrich/Pfeil) in Modell-Metern. */
const SECTION_MARK_LEN = 0.6;
/** Halbe Breite des unsichtbaren Pick-Bands über der Linie (Modell-Meter). */
const SECTION_PICK_HALF = 0.3;
/** Schrifthöhe des Schnitt-Labels (Modell-Meter). */
const SECTION_LABEL_H = 0.5;
/**
* Kurz-Tag eines Schnitt-Labels: der letzte „Wort"-Teil des Ebenennamens (z. B.
* „Schnitt A" → „A"); enthält der Name kein Leerzeichen, der ganze Name. Klassisch
* wird der Schnitt an beiden Enden mit diesem Tag beschriftet (A–A).
*/
function sectionLabelTag(name: string): string {
const parts = name.trim().split(/\s+/);
return parts.length > 1 ? parts[parts.length - 1] : name.trim();
}
/**
* Zeichnet für jede Schnitt-/Ansichtsebene MIT gesetzter `linePoints` das
* klassische Schnittführungs-Symbol in den Grundriss: eine Strichpunkt-Haarlinie
* über die volle Länge, an beiden Enden einen kräftigen Querstrich + Richtungspfeil
* auf der Blickseite (`directionSign`) und das Schnitt-Label (Kurz-Tag) daneben.
* Zusätzlich ein unsichtbares Pick-Band (Polygon mit `sectionLineId`), damit die
* Linie im Grundriss anklickbar ist (analog dem Dach-Traufe-Pick-Polygon).
*/
export function addSectionLines(out: Primitive[], project: Project): void {
for (const level of project.drawingLevels) {
if (level.kind !== "section" && level.kind !== "elevation") continue;
// Schnittebene ist ein-/ausblendbar wie andere Ebenen: eine unsichtbare
// Schnitt-/Ansichtsebene zeigt ihre Linie im Grundriss nicht (Nutzer-Vision
// „eigene Ebene Schnittebenen"). `visible` fehlt = sichtbar (Alt-Projekte).
if (level.visible === false) continue;
const line = level.linePoints;
if (!line) continue;
const [p0, p1] = line;
const axis = sub(p1, p0);
const len = Math.hypot(axis.x, axis.y);
if (len < 1e-6) continue;
const u = normalize(axis);
const n = leftNormal(u); // (-uy, ux)
const sign = level.directionSign ?? 1;
const bn = { x: n.x * sign, y: n.y * sign }; // Blicknormale
// Hauptlinie (Strichpunkt-Haarlinie).
out.push({
kind: "line",
a: p0,
b: p1,
cls: "section-line",
weightMm: SECTION_LINE_MM,
dash: SECTION_LINE_DASH,
color: SECTION_LINE_INK,
});
// Endmarken (Querstrich + Richtungspfeil + Label) an beiden Enden.
const half = SECTION_MARK_LEN / 2;
for (const end of [p0, p1]) {
// Querstrich senkrecht zur Linie, mittig am Endpunkt.
const t0 = { x: end.x - n.x * half, y: end.y - n.y * half };
const t1 = { x: end.x + n.x * half, y: end.y + n.y * half };
out.push({
kind: "line",
a: t0,
b: t1,
cls: "section-line",
weightMm: SECTION_LINE_MARK_MM,
color: SECTION_LINE_INK,
});
// Richtungspfeil: vom Querstrich-Ende auf der Blickseite entlang bn.
const base = { x: end.x + bn.x * half, y: end.y + bn.y * half };
const tip = { x: base.x + bn.x * SECTION_MARK_LEN, y: base.y + bn.y * SECTION_MARK_LEN };
const wingBack = {
x: tip.x - bn.x * SECTION_MARK_LEN * 0.4,
y: tip.y - bn.y * SECTION_MARK_LEN * 0.4,
};
const w = SECTION_MARK_LEN * 0.25;
out.push({ kind: "line", a: base, b: tip, cls: "section-line", weightMm: SECTION_LINE_MARK_MM, color: SECTION_LINE_INK });
out.push({
kind: "line",
a: tip,
b: { x: wingBack.x + u.x * w, y: wingBack.y + u.y * w },
cls: "section-line",
weightMm: SECTION_LINE_MARK_MM,
color: SECTION_LINE_INK,
});
out.push({
kind: "line",
a: tip,
b: { x: wingBack.x - u.x * w, y: wingBack.y - u.y * w },
cls: "section-line",
weightMm: SECTION_LINE_MARK_MM,
color: SECTION_LINE_INK,
});
// Label (Kurz-Tag) etwas hinter dem Pfeil, horizontal.
const labelAt = {
x: tip.x + bn.x * SECTION_MARK_LEN * 0.4 - SECTION_LABEL_H * 0.3,
y: tip.y + bn.y * SECTION_MARK_LEN * 0.4 + SECTION_LABEL_H * 0.5,
};
out.push({
kind: "drawingText",
at: labelAt,
text: sectionLabelTag(level.name),
heightM: SECTION_LABEL_H,
angle: 0,
color: SECTION_LINE_INK,
});
}
// Unsichtbares Pick-Band (Rechteck entlang der Linie) — klickbar per
// Punkt-in-Polygon in PlanView, trägt die DrawingLevel-Id.
const off = { x: n.x * SECTION_PICK_HALF, y: n.y * SECTION_PICK_HALF };
out.push({
kind: "polygon",
pts: [
{ x: p0.x + off.x, y: p0.y + off.y },
{ x: p1.x + off.x, y: p1.y + off.y },
{ x: p1.x - off.x, y: p1.y - off.y },
{ x: p0.x - off.x, y: p0.y - off.y },
],
fill: "none",
stroke: "none",
strokeWidthMm: 0,
hatch: NO_HATCH,
sectionLineId: level.id,
});
}
}
+521
View File
@@ -0,0 +1,521 @@
// Gemeinsame Grundlage des Grundriss-Generators: das `Primitive`-Vokabular
// (das EINZIGE Austauschformat zwischen `generatePlan()`/`generateSectionPlan()`
// und allen Bauteil-Domänen unter `generatePlan/`), die By-Layer/By-Object-
// Attribut-Auflösung (`resolve*`) sowie die Papier-mm-/Tinten-Konstanten, die
// über mehrere Domänen hinweg identisch gelten müssen (SIA-Poché, Haarlinien).
//
// Jede Datei unter `generatePlan/` importiert von hier, NIE von `../generatePlan`
// selbst — das hielte die Orchestrator-Datei (`generatePlan.ts`) andernfalls in
// einem Zirkelimport mit jeder einzelnen Domäne.
import type {
AttributeSource,
Component,
HatchPattern,
LayerCategory,
Project,
Vec2,
} from "../../model/types";
import { getHatch, getLineStyle } from "../../model/types";
import type { Marks, RichTextDoc } from "../../text/richText";
import type { RoomStampLine } from "../../model/roomStamp";
/**
* Detailgrad der Plan-Darstellung (Oberleiste „grob/mittel/fein"). Steuert hier
* konkret zwei Dinge im Grundriss:
* • Tür-/Fenstersymbole: grob = nur Öffnungs-Lücke + gerade Türblatt-Linie;
* mittel = + Schwenkbogen; fein = + Rahmen/Anschlag-Striche.
* • Schraffuren + Linienfeinheit: grob = vereinfachte Poché (eine Sammelfläche
* ohne Schraffur, dickere Umrisslinie); mittel = aktueller mehrschichtiger
* Aufbau mit Schraffuren; fein = volle Schraffuren + dünnere Linien.
*
* NOCH OFFEN (folgt, sobald die Features existieren — hier bewusst KEIN
* Schein-Effekt): Bemaßung/Beschriftung werden mit dem Detailgrad auf-/abblenden
* (Maßketten, Raumstempel), und die 3D-Geometrie-Auflösung (z. B. Profil-
* Rundungen, Tritt-/Setzstufen) wird ebenfalls dem Detailgrad folgen, sobald
* Bemaßung bzw. eine feinere 3D-Tessellierung implementiert sind.
*/
export type DetailLevel = "grob" | "mittel" | "fein";
/** Stroke-Farbe der Schicht-Polygone (neutrales Schwarz, geteilte Tinte mit der Schraffur). */
export const POCHE_STROKE = "#1a1a1a";
/** Tinte für den Schwarz-Weiss-Modus: einheitliches Dunkel für alle Striche. */
export const MONO_INK = "#111111";
// ── Linienstärken in PAPIER-Millimeter (vgl. docs/design/plans-output.md §3.2) ──
// Alle Stricharten sind in mm Papier definiert; die PlanView rechnet sie über
// dpi = 96 in konstante Bildschirm-Pixel um (non-scaling-stroke), damit Linien
// beim Zoomen ihre Papiergröße behalten und der Massstab stimmt.
/** Default-Schichtfuge zwischen den Materialschichten in mm — Haarlinie (0.02),
* gleiche Stärke wie die Schraffur-Tinte. Pro Fuge im Wandstil überschreibbar
* (z. B. 0.13, wenn zwei massive Materialien aufeinandertreffen). */
export const LAYER_LINE_MM = 0.02;
/** Haarlinie für Tür-/Fenstersymbole (Flügel, Bogen, Rahmen, Glas) in mm. */
export const SYMBOL_HAIRLINE_MM = 0.02;
/** Strichmuster (mm Papier) für ÜBERKOPF-Ansichtslinien: Bauteile über der
* Grundriss-Schnittebene (Decken-/Slab-Überstände, Unterzüge) werden nach
* BIM-Konvention gestrichelt gezeichnet (Aufsicht auf ein Bauteil über einem). */
export const OVERHEAD_DASH: number[] = [0.4, 0.25];
/** Strichstärken der Dach-Grundrisslinien (mm Papier): Traufe = mittlere
* Haarlinie, First etwas kräftiger als die Traufe, Grat dazwischen, Knick dünn
* + gestrichelt (vgl. addRoof). */
export const ROOF_EAVES_MM = 0.13;
export const ROOF_RIDGE_MM = 0.25;
export const ROOF_HIP_MM = 0.18;
export const ROOF_BREAK_MM = 0.09;
/** Strichmuster (mm Papier) der Dach-Knicklinie (Mansarde). */
export const ROOF_BREAK_DASH: number[] = [0.12, 0.08];
/** Stärke der Wand-Umrisslinie je Detailgrad, als Faktor auf die Ebenen-lw. */
export const OUTLINE_DETAIL_FACTOR: Record<DetailLevel, number> = {
grob: 1.6, // dickere Sammellinie
mittel: 1.0, // wie die Kategorie-Strichstärke
fein: 0.6, // feiner
};
/** Stärke der Schichtfugen je Detailgrad (mittel/fein; grob hat keine Fugen). */
export const LAYER_DETAIL_FACTOR: Record<DetailLevel, number> = {
grob: 1.0,
mittel: 1.0,
fein: 0.7,
};
/** Default-Umrandungs-Strichstärke (mm), falls die Kategorie keine lw trägt
(Grundeinstellung für „normale Elemente"). */
export const WALL_FALLBACK_MM = 0.18;
/**
* Aufgelöste Schraffur eines Polygons — alle Werte stammen aus einer
* HatchStyle-Ressource (Muster/Maßstab/Winkel/Farbe) plus dem optional
* verknüpften LineStyle (Linienstärke/Strichmuster der Musterlinien). Die
* PlanView ist damit rein darstellend und kennt das Projekt nicht.
*/
export interface HatchRender {
pattern: HatchPattern;
scale: number;
angle: number;
/**
* AUFGELÖSTE Farbe der Musterlinien bzw. der Vollfüllung (pattern==="solid").
* Ergebnis der Farb-Resolve-Kette (siehe {@link resolveHatch}): Vordergrund =
* (Attribut-Override) ?? (Component.foreground) ?? (HatchStyle.color, Backward-
* Compat) ?? Default. Der heutige SVG-/GL-/DXF-Renderer liest weiterhin nur
* dieses Feld — die Kette ist zur Renderzeit bereits kollabiert.
*/
color: string;
/** Linienstärke der Musterlinien in mm. */
lineWeight: number;
/** Strichmuster der Musterlinien in mm; null = durchgezogen. */
dash: number[] | null;
/**
* Schraffur-Typ (additiv). `undefined`/"vector" ⇒ Muster nach `pattern` (heutiges
* Verhalten); "image" ⇒ Bild-Muster (`image`). Bestehende Renderer ignorieren
* `kind` und zeichnen nach `pattern` → unbekannte kinds fallen auf heute zurück.
*/
kind?: "vector" | "image";
/** Vektor-Untermodus (additiv). `undefined`/"parallel" ⇒ regelmäßig; "random" ⇒ zufällig. */
lines?: "parallel" | "random";
/** Bild-Muster-Parameter (nur `kind==="image"`); durchgereicht aus dem HatchStyle. */
image?: { src: string; scaleX: number; scaleY: number; rotation: number };
/**
* Steuer-Parameter der Random-Vektor-Streuung (nur `lines==="random"`), 1:1 aus
* dem HatchStyle durchgereicht. Alle optional — die eigentliche Erzeugung/
* Default-Auflösung liegt zentral in `glPlan/glPlanHatch.ts` (single render truth).
*/
seed?: number;
density?: number;
lengthMin?: number;
lengthMax?: number;
}
/**
* Render-Entscheidung je Kategorie-Code für den Grundriss (vgl. ItemDisplay aus
* dem Panel-Darstellungsmodus). `render`=false blendet die Kategorie ganz aus,
* `greyed`=true zeichnet sie gedimmt, `locked`=true (nur Modus „grey_locked")
* verbietet die Anwahl (siehe App.tsx onPlanSelect). Wird kein Resolver
* übergeben, gilt für jede sichtbare Kategorie {render:true, greyed:false,
* locked:false} (bisheriges Verhalten).
*/
export interface CategoryDisplay {
render: boolean;
greyed: boolean;
locked: boolean;
}
/** Funktion, die je Kategorie-Code die Render-Entscheidung liefert. */
export type CategoryDisplayResolver = (code: string) => CategoryDisplay;
/** Standard-Resolver: jede (bereits gefilterte) Kategorie normal zeichnen. */
export const SHOW_ALL: CategoryDisplayResolver = () => ({ render: true, greyed: false, locked: false });
export type Primitive =
| {
kind: "polygon";
pts: Vec2[];
/** Füllfarbe (Component-Poché) bzw. "none" für reine Umrisslinien. */
fill: string;
stroke: string;
/** Umriss-Strichstärke in PAPIER-Millimeter (siehe oben). */
strokeWidthMm: number;
hatch: HatchRender;
/** Gedimmt zeichnen (Darstellungsmodus „andere grau"). */
greyed?: boolean;
/**
* Kanten-Indizes, die NICHT umrissen (gestrichen) werden dürfen — die Füllung
* bleibt das volle Polygon. Kante `i` läuft von `pts[i]` nach `pts[(i+1)%n]`.
* Damit werden die inneren Gehrungs-/Stoß-Stirnflächen an einer Wandecke
* unterdrückt: die Diagonale, an der GLEICHFARBIGE Schichten zweier Wände
* aneinanderstoßen, würde sonst als sichtbare 45°-Naht erscheinen und der
* Umriss würde als Barbe über den Eck-Apex hinausschießen (Gehrungs-Spitze).
* Längskanten (Schichtfugen zwischen VERSCHIEDENEN Materialien) bleiben
* gestrichen — nur die Stoß-Stirnkanten am Knoten entfallen.
*/
noStrokeEdges?: number[];
/**
* ID der Wand, zu der dieses Schicht-/Umriss-Polygon gehört (für die
* Links-Klick-Auswahl: die PlanView markiert ALLE Polygone derselben
* Wand und meldet die wallId nach oben). Reine Umriss-/Symbol-Primitive
* ohne Wandbezug lassen das Feld weg.
*/
wallId?: string;
/**
* ID des 2D-Zeichenelements (Drawing2D), falls dieses gefüllte Polygon eine
* geschlossene 2D-Form ist (für die Links-Klick-Auswahl der Fläche).
*/
drawingId?: string;
/**
* ID der Decke (Ceiling), falls dieses Polygon eine Decken-Fläche/-Umriss
* ist (für die Links-Klick-Auswahl der Decke).
*/
ceilingId?: string;
/** ID der Öffnung (Fenster/Tür), z. B. der Fensterrahmen (Auswahl). */
openingId?: string;
/** ID der Treppe (Stair), falls dieses Polygon ein Tritt/Podest ist (Auswahl). */
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 des Kontext-Objekts (importiertes Gebäude/Terrain, `project.context`),
* falls dieses (unsichtbare) Pick-Band dessen Grundriss-Bounding-Box
* umhüllt (Links-Klick-Auswahl — analog `sectionLineId`, s. u.).
*/
contextObjectId?: 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;
/** ID des Dachs (Roof), falls dieses (unsichtbare) Polygon der Traufe-Umriss ist (Auswahl). */
roofId?: string;
/**
* ID der Schnitt-/Ansichtslinie (DrawingLevel), falls dieses (unsichtbare)
* Pick-Band das Schnittführungs-Symbol umhüllt (für die Links-Klick-Auswahl
* der Schnittlinie im Grundriss).
*/
sectionLineId?: string;
}
| {
kind: "line";
a: Vec2;
b: Vec2;
cls: string;
/** Strichstärke in mm Papier (non-scaling). */
weightMm: number;
/** Strichmuster in mm Papier; null/undefined = durchgezogen. */
dash?: number[] | null;
/**
* Zickzack-/Wellen-Parameter (aus einem LineStyle mit `kind==="zigzag"`),
* beide in mm Papier. Ist es gesetzt, zeichnen die Renderer die Linie als
* Zickzack-Pfad statt als gerade (ggf. gestrichelte) Linie — ADDITIV, ohne
* die bestehenden Strich/Dash-Linien zu verändern (Feld fehlt ⇒ heute).
*/
zigzag?: { amplitude: number; wavelength: number };
/**
* Frei gezeichnetes Wiederhol-Motiv (aus einem LineStyle mit
* `kind==="custom"`): eine offene Polylinie in einer Einheitszelle, `points`
* in mm Papier, `length` = Wiederhollänge in mm Papier. Ist es gesetzt,
* kacheln die Renderer das Motiv entlang der Linie (analog `zigzag`) —
* ADDITIV; fehlt es, bleibt die Linie gerade/gestrichelt.
*/
motif?: { points: Vec2[]; length: number };
/** Optionale explizite Strichfarbe (überschreibt die CSS-Klasse). */
color?: string;
/** ID des 2D-Zeichenelements (für Links-Klick-Auswahl von Drawing2D). */
drawingId?: string;
/** ID der Öffnung (für Links-Klick-Auswahl von Fenster/Tür-Symbolen). */
openingId?: string;
/** ID der Treppe (für Links-Klick-Auswahl von Treppen-Symbollinien). */
stairId?: string;
greyed?: boolean;
}
| {
kind: "arc";
center: Vec2;
from: Vec2;
to: Vec2;
r: number;
cls: string;
/** Strichstärke in mm Papier (non-scaling). */
weightMm: number;
/** Strichmuster in mm Papier; null/undefined = durchgezogen. */
dash?: number[] | null;
/** ID der Öffnung (für Links-Klick-Auswahl von Tür-Schwenkbögen). */
openingId?: string;
/** ID der Treppe (für Links-Klick-Auswahl von Wendel-Outline-Bögen). */
stairId?: string;
greyed?: boolean;
}
| {
kind: "text";
/** Ankerpunkt im Modell (Meter). */
at: Vec2;
/**
* Frei editierbarer Stempel-Text (Rich-Text). Wird zuerst gezeichnet;
* `align` je Absatz steuert die Ausrichtung.
*/
doc: RichTextDoc;
/**
* LIVE-Zusatzzeilen (z. B. Fläche „24.30 m²" + SIA-Tag), UNTER dem Doc
* gezeichnet — unabhängig vom editierten Text, stets aktuell. Jede Zeile
* trägt ihre eigene Ausrichtung (`align`).
*/
extraLines: RoomStampLine[];
/** Basis-Schriftgrösse in PUNKT (für Runs ohne eigene Grösse + Zusatzzeilen). */
basePt: number;
/** Textfarbe (hex) als Default. */
color: string;
/** ID des Raums (Room), zu dem dieser Stempel gehört (Auswahl). */
roomId?: string;
greyed?: boolean;
}
| {
// Schlichter, modellverankerter Einzeltext (aus Drawing2D `{shape:"text"}`,
// z. B. DXF-Import). Anders als `kind:"text"` (Raum-Stempel, RichText) trägt
// er nur einen String mit Höhe in Metern + Drehung — rein SVG-gerendert.
kind: "drawingText";
/** Ankerpunkt im Modell (Meter). */
at: Vec2;
text: string;
/** Schrifthöhe in Modell-Metern. */
heightM: number;
/** Drehung in RADIANT (CCW im Modell). */
angle: number;
color: string;
/** Optionale einheitliche Formatierung (Schriftfamilie/fett/kursiv/…). */
marks?: Marks;
/**
* 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;
/**
* Reiches Textfeld (mehrere Absätze, gemischte Formatierung) — Vorrang vor
* `text`/`marks` beim Rendern, s. `Drawing2DGeom["text"].doc`. Bricht bei
* gesetztem `wrapWidth` wortweise um (Run-Grenzen bleiben erhalten).
*/
doc?: RichTextDoc;
/** ID des 2D-Zeichenelements (für Links-Klick-Auswahl). */
drawingId?: string;
greyed?: boolean;
}
| {
// Eingebettetes Rasterbild (aus Drawing2D `{shape:"image"}`, Foto/Logo/
// gerasterte PDF-Seite) — achsparalleles Rechteck, Data-URL als Quelle.
kind: "drawingImage";
min: Vec2;
max: Vec2;
src: string;
opacity: number;
drawingId?: string;
greyed?: boolean;
}
| {
// Glatter Kreis (aus Drawing2D `{shape:"circle"}`, z. B. DXF-CIRCLE) — als
// echtes `<circle>` gerendert (rund bei jedem Zoom), nicht als Vieleck.
kind: "drawingCircle";
center: Vec2;
/** Radius in Modell-Metern. */
r: number;
/** Vollton-Füllfarbe oder "none". */
fill: string;
stroke: string;
weightMm: number;
dash?: number[] | null;
drawingId?: string;
greyed?: boolean;
}
| {
// Glatter Bogen (aus Drawing2D `{shape:"arc"}`, z. B. DXF-ARC) — als
// SVG-Bogenpfad gerendert; Winkel in RADIANT (CCW im Modell).
kind: "drawingArc";
center: Vec2;
r: number;
a0: number;
a1: number;
stroke: string;
weightMm: number;
dash?: number[] | null;
drawingId?: string;
greyed?: boolean;
};
/**
* Löst eine HatchStyle-Ressource (per id) zu einer darstellungsfertigen
* {@link HatchRender} auf. Der Linienstil der Musterlinien wird, falls gesetzt,
* aus dem Line Manager nachgeschlagen; sonst gilt ein dünner Default.
*
* VORDERGRUND (Muster-/Schraffurlinienfarbe bzw. Vollfüllung): AKTUELL fest auf
* {@link HATCH_INK} gezwungen (Nutzer-Vorgabe 2026-07-04) — Schnitt- WIE
* Oberflächen-/Ansichtsschraffur zeigen dieselbe dunkle Tinte, unabhängig von
* Material-/Bauteilfarbe, HatchStyle.color oder einem By-Layer/By-Object-
* Vordergrund-Override. `_foreground` bleibt Teil der Signatur — alle Aufrufer
* reichen weiterhin den über {@link resolveForeground} aufgelösten Bauteil-
* Vordergrund durch (z. B. für eine spätere Wiederherstellung der materialtreuen
* Schraffur) —, fließt aber bewusst nicht mehr in `color` ein.
*/
export function resolveHatch(
project: Project,
hatchId: string,
wallAngleDeg?: number,
_foreground?: string,
): HatchRender {
const h = getHatch(project, hatchId);
const ls = h.lineStyleId ? getLineStyle(project, h.lineStyleId) : null;
// Wandbezogener Winkel: `HatchRender.angle` wird von allen Renderern in der
// SVG-Konvention (im Uhrzeigersinn, Bildschirm-Y nach unten) verstanden — der
// Modell-Renderer negiert ihn in `toModelAngleRad`. Die Wandachse `wallAngleDeg`
// ist dagegen ein MODELL-Winkel (atan2 der Richtung, gegen den Uhrzeigersinn,
// Y nach oben). Wegen der Y-Spiegelung entspricht die Wandrichtung am Bildschirm
// dem Winkel −wallAngleDeg; das Muster folgt der Wand also, indem der
// Wandwinkel SUBTRAHIERT wird. Ohne Wandkontext (Decke) bleibt `angle` absolut.
const angle =
h.relativeToWall && wallAngleDeg != null ? h.angle - wallAngleDeg : h.angle;
return {
pattern: h.pattern,
scale: h.scale,
angle,
// Fest auf die Monochrom-Tinte gezwungen (siehe JSDoc oben) — weder
// `_foreground` noch `HatchStyle.color` (Material-/Bauteilfarbe) fließen ein.
color: HATCH_INK,
lineWeight: ls?.weight ?? 0.13,
dash: ls?.dash ?? null,
kind: h.kind,
lines: h.lines,
image: h.image,
// Random-Streu-Parameter unverändert durchreichen (Erzeugung in glPlanHatch).
seed: h.seed,
density: h.density,
lengthMin: h.lengthMin,
lengthMax: h.lengthMax,
};
}
/**
* Kollabiert die Vordergrund-Farbkette (Muster-/Schraffurlinienfarbe) eines
* Bauteils zu einem einzelnen Wert für {@link resolveHatch}. AUFLÖSUNGSREIHENFOLGE
* (By-Layer/By-Object, siehe `docs`/Attribut-Panel):
* (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.color`) ??
* (`Component.foreground`) ?? `undefined`.
* `source` fehlt/`"object"` ⇒ „Nach Bauteil" (heutiges Verhalten, unverändert).
* `undefined` als Endergebnis heißt „Nach System" — die Kette läuft dann in
* resolveHatch auf den HatchStyle.color-Backward-Compat-Fallback weiter
* (Sample-Verhalten: kein Element setzt `override`/`source`, also identisch).
*/
export function resolveForeground(
comp: Component,
override?: string,
category?: LayerCategory,
source?: AttributeSource,
): string | undefined {
if (override != null) return override;
if (source === "layer") return category?.color ?? comp.foreground;
return comp.foreground;
}
/**
* Kollabiert die Hintergrund-/Füllfarbkette (Poché) eines Bauteils, analog zu
* {@link resolveForeground}:
* (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.color`) ??
* (`Component.background`) ?? `Component.color`.
* Immer definiert (endet spätestens bei `Component.color` = heutiges Verhalten).
*/
export function resolveBackground(
comp: Component,
override?: string,
category?: LayerCategory,
source?: AttributeSource,
): string {
if (override != null) return override;
if (source === "layer") return category?.color ?? comp.background ?? comp.color;
return comp.background ?? comp.color;
}
/**
* Kollabiert die Schraffur-Referenz (Hatch Manager) eines Elements zu einer
* einzelnen `hatchId` für {@link resolveHatch} — By-Layer/By-Object, analog zu
* {@link resolveForeground}:
* (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.hatch`) ??
* `fallback` (Bauteil-Schraffur, z. B. `Component.hatchId`/`viewHatchId`, oder
* `undefined` bei Drawing2D ohne Bauteil-Bezug).
* `source` fehlt/`"object"` ⇒ „Nach Bauteil" (heutiges Verhalten, unverändert).
* Ergebnis kann `undefined` sein — der Aufrufer fällt dann auf `NO_HATCH` zurück.
*/
export function resolveHatchId(
override: string | undefined,
source: AttributeSource | undefined,
category: LayerCategory | undefined,
fallback?: string,
): string | undefined {
if (override != null) return override;
if (source === "layer") return category?.hatch ?? fallback;
return fallback;
}
/**
* Kollabiert die Strichstärke-Kette (mm Papier) eines Elements, By-Layer/By-
* Object, analog zu {@link resolveHatchId}:
* (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.lw`) ??
* `fallback` (bisherige Bauteil-/LineStyle-/Kategorie-Default-Kette des
* Aufrufers — heutiges Verhalten).
* `source` fehlt/`"object"` ⇒ „Nach Bauteil" (heutiges Verhalten, unverändert).
*/
export function resolveStrokeWeight(
override: number | undefined,
source: AttributeSource | undefined,
category: LayerCategory | undefined,
fallback: number,
): number {
if (override != null) return override;
if (source === "layer") return category?.lw ?? fallback;
return fallback;
}
// ── SIA-Poché-Füllung + monochrome Schraffur-Tinte ──────────────────────────
// Die 2D-Poché einer (auch mehrschichtigen) Wand trägt NICHT die rohe Bauteil-
// Albedo (3D-Materialfarbe), sondern einen neutralen Hintergrund, auf dem die
// Schraffur-Musterlinien liegen: Vollmuster ("solid") = dunkle Tinten-Poché
// (kein Linienmuster), jedes andere Muster (inkl. "none") = HELLER Hintergrund.
// Dieselben zwei Werte sind AKTUELL auch die feste Schraffur-Vordergrundfarbe
// (siehe {@link resolveHatch}) — Schnitt- wie Oberflächen-Schraffur zeigen also
// einheitlich Tinte auf hellem Papier, unabhängig von Material-/Bauteilfarbe.
export const HATCH_PAPER = "#f0f0f0";
export const HATCH_INK = "#0f0f0f";
/** Neutrale Poché-Füllfarbe für ein Muster: "solid" → Tinte, sonst Papier. */
export function pocheFill(pattern: HatchPattern): string {
return pattern === "solid" ? HATCH_INK : HATCH_PAPER;
}
/** Schraffur-Platzhalter „ohne" (für reine Umriss-/Sammelflächen). */
export const NO_HATCH: HatchRender = {
pattern: "none",
scale: 1,
angle: 0,
color: POCHE_STROKE,
lineWeight: LAYER_LINE_MM,
dash: null,
};
+185
View File
@@ -0,0 +1,185 @@
// Treppen-Domäne des Grundriss-Generators (DOSSIER/SIA): Tritte, Lauflinie mit
// Auf-/Abpfeil, Zwischenpodest und Aussenlinie — mit dem Grundriss-Schnitt
// (Tritte unter der Schnitthöhe durchgezogen, darüber gestrichelt/verdeckt +
// SIA-Bruchlinie).
import type { Project, Stair, Vec2 } from "../../model/types";
import { stairVerticalExtent } from "../../model/wall";
import { stairCut, stairGeometry, stairOutline } from "../../geometry/stair";
import type { SpiralOutlineSegments } from "../../geometry/stair";
import { NO_HATCH, POCHE_STROKE, type Primitive } from "./shared";
/**
* Zeichnet die Wendel-Aussenlinie: Innen-/Aussenbogen als Arc-Primitive plus zwei
* radiale Schliesslinien. Wird von `addStairSymbol` aufgerufen wenn `shape === "spiral"`.
*/
function _addSpiralOutline(
out: Primitive[],
seg: SpiralOutlineSegments,
stroke: string,
lwMm: number,
greyed: boolean,
stairId: string,
): void {
// Radiale Schliesslinien (Startwinkel + Endwinkel).
for (const [a, b] of seg.lines) {
out.push({ kind: "line", a, b, cls: "stair-outline", weightMm: lwMm, color: stroke, greyed, stairId });
}
// Innen- und Aussenbogen.
for (const arc of seg.arcs) {
const from: Vec2 = { x: arc.cx + arc.r * Math.cos(arc.a0), y: arc.cy + arc.r * Math.sin(arc.a0) };
const to: Vec2 = { x: arc.cx + arc.r * Math.cos(arc.a1), y: arc.cy + arc.r * Math.sin(arc.a1) };
out.push({
kind: "arc",
center: { x: arc.cx, y: arc.cy },
from,
to,
r: arc.r,
cls: "stair-outline",
weightMm: lwMm,
greyed,
stairId,
});
}
}
/**
* Treppen-Symbol im Grundriss (DOSSIER/SIA):
* • Tritte als Rechtecke: die Tritt-Trennlinien werden gezeichnet — unter der
* Schnittebene DURCHGEZOGEN (sichtbar), darüber GESTRICHELT (verdeckt).
* • Der erste Tritt oberhalb der Schnittebene trägt die diagonale SIA-Bruchlinie
* (Doppelstrich quer über den Lauf).
* • Zwischenpodest (L/Wendel) als Umriss-Polygon.
* • Lauflinie (Lauflinie) mit Auf-/Abpfeil (Auf-/Abpfeil) und Stufenanzahl-Label.
* Alle Primitive tragen `stairId` für die Auswahl.
*/
export function addStairSymbol(
out: Primitive[],
project: Project,
stair: Stair,
greyed: boolean,
lwMm: number,
cutHeight: number,
): void {
const { zBottom, zTop } = stairVerticalExtent(project, stair);
const totalRise = zTop - zBottom;
const geo = stairGeometry(stair, totalRise);
const cut = stairCut(geo, cutHeight);
const below = new Set(cut.belowIndices);
const stroke = stair.color ?? POCHE_STROKE;
const stairId = stair.id;
// Umriss + Tritt-Trennlinien je Stufe. Der letzte Punkt jedes Tritt-Rechtecks
// liefert die vordere Kante (Auftritt); wir zeichnen jede Tritt-Kante.
for (const tr of geo.treads) {
const isBelow = below.has(tr.index);
// Tritt-Rechteck als reine Umrisslinie (keine Füllung), durchgezogen wenn
// unter der Schnittebene, sonst gestrichelt (verdeckt).
out.push({
kind: "polygon",
pts: tr.pts,
fill: "none",
stroke,
strokeWidthMm: lwMm * 0.7,
hatch: NO_HATCH,
greyed,
stairId,
});
// Zusätzliche Auftrittskante (vordere Kante der Stufe) etwas kräftiger.
const front: [Vec2, Vec2] = [tr.pts[1], tr.pts[2]];
out.push({
kind: "line",
a: front[0],
b: front[1],
cls: "stair-tread",
weightMm: lwMm * (isBelow ? 1.0 : 0.6),
dash: isBelow ? null : [0.12, 0.08],
color: stroke,
greyed,
stairId,
});
}
// Zwischenpodest (L/Wendel-Auge): Umriss-Polygon.
if (geo.landing && geo.landing.length >= 3) {
out.push({
kind: "polygon",
pts: geo.landing,
fill: "none",
stroke,
strokeWidthMm: lwMm * 0.7,
hatch: NO_HATCH,
greyed,
stairId,
});
}
// SIA-Bruchlinie (Doppelstrich diagonal über den Schnitt-Tritt).
if (cut.breakLine) {
for (const [a, b] of cut.breakLine) {
out.push({
kind: "line",
a,
b,
cls: "stair-break",
weightMm: lwMm,
color: stroke,
greyed,
stairId,
});
}
}
// Lauflinie (Lauflinie) — kräftig, durchgezogen.
for (let i = 0; i < geo.runLine.length - 1; i++) {
out.push({
kind: "line",
a: geo.runLine[i],
b: geo.runLine[i + 1],
cls: "stair-run",
weightMm: lwMm,
color: stroke,
greyed,
stairId,
});
}
// Auf-/Abpfeil an der Spitze der Lauflinie. "filled" = gefülltes Dreieck
// (SIA-Vollpfeil), sonst der offene Winkelpfeil (zwei Linien, Default).
const [h1, tip, h2] = geo.arrow.head;
if (stair.arrowStyle === "filled") {
out.push({
kind: "polygon",
pts: [h1, tip, h2],
fill: stroke,
stroke,
strokeWidthMm: lwMm,
hatch: NO_HATCH,
greyed,
stairId,
});
} else {
out.push({ kind: "line", a: h1, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
out.push({ kind: "line", a: h2, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
}
// Aussenlinie (Outline) der Treppe — schliessendes Umriss-Polygon (gerade/L)
// bzw. Innen-/Aussenbogen + radiale Schliesslinien (Wendel).
// Entspricht `_aussen_gerade` / `_aussen_l_polygon` / `_aussen_wendel` im
// Rhino-Plugin. Stets durchgezogen, volle Strichstärke.
const outline = stairOutline(stair, totalRise);
if (outline.polygon && outline.polygon.length >= 3) {
out.push({
kind: "polygon",
pts: outline.polygon,
fill: "none",
stroke,
strokeWidthMm: lwMm,
hatch: NO_HATCH,
greyed,
stairId,
});
}
if (outline.spiral) {
_addSpiralOutline(out, outline.spiral, stroke, lwMm, greyed, stairId);
}
}
+93
View File
@@ -0,0 +1,93 @@
// Sonstige Tragwerks-/Fremdkörper-Domäne des Grundriss-Generators: Stützen
// (Poché-Querschnitt, analog zur Wand-/Deckenschicht-Auflösung) und der
// Footprint-Umriss einer truck-Extrusion (Haarlinie ohne Poché/Schraffur, da
// Extrusionen noch keine Kategorie-/Bauteil-Zuordnung tragen).
import type { Column, ExtrudedSolid, LayerCategory, Project } from "../../model/types";
import { getComponent } from "../../model/types";
import { columnFootprint } from "../../geometry/column";
import {
HATCH_INK,
HATCH_PAPER,
MONO_INK,
NO_HATCH,
SYMBOL_HAIRLINE_MM,
resolveForeground,
resolveHatch,
resolveHatchId,
type HatchRender,
type Primitive,
} from "./shared";
/** 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 (`addWallPoche` `viewOnly`), da der Körper (noch)
* keine Schnitt-Poché/Kategorie-Zuordnung hat.
*/
export 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
* ./shared.resolveHatch}); sonst eine neutrale Vollton-Poché. `columnId` macht
* die Fläche links-klick-selektierbar (analog Extrusion/Raum).
*/
export 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,
});
}
+531
View File
@@ -0,0 +1,531 @@
// Wand-Domäne des Grundriss-Generators: Öffnungs-Lücken entlang der Wandachse,
// die Wand-Footprints (für das Verdecken darunterliegender Decken-Umrisse, s.
// {@link ../ceilings}) und die eigentliche Wand-Poché (Schichten + Umriss +
// Öffnungsaussparungen + T-Stoss-/Gehrungs-Behandlung).
import type { LayerCategory, Opening, Project, Vec2, Wall } from "../../model/types";
import { getComponent, getWallType, wallTypeThickness } from "../../model/types";
import { wallReferenceOffset } from "../../model/wall";
import { openingInterval } from "../../geometry/opening";
import {
add,
along,
clippedBand,
dot,
leftNormal,
lineIntersect,
normalize,
scale,
sub,
} from "../../model/geometry";
import type { Line } from "../../model/geometry";
import type { WallCuts } from "../../model/joins";
import {
HATCH_INK,
HATCH_PAPER,
LAYER_DETAIL_FACTOR,
LAYER_LINE_MM,
NO_HATCH,
OUTLINE_DETAIL_FACTOR,
POCHE_STROKE,
resolveForeground,
resolveHatch,
resolveHatchId,
type DetailLevel,
type Primitive,
} from "./shared";
/** Ein Öffnungs-Intervall entlang der Wandachse (from..to in Metern). `headHeight`
* = Sturzhöhe (OK der Öffnung über der Wand-UK) für die 3D-Aussparung; im Plan
* ungenutzt, aber Teil des gemeinsamen Vertrags mit dem 3D-Pfad. */
export interface WallGap {
from: number;
to: number;
}
/**
* Vereint Legacy-Türen (`Door`) und Öffnungen (`Opening`) einer Wand zu einer
* sortierten Lücken-Intervall-Liste entlang der Wandachse. Entartete Intervalle
* (Breite ≤ 0 oder außerhalb der Achse) werden verworfen.
*/
export function wallGaps(
wall: Wall,
doors: Project["doors"],
openings: Opening[],
): WallGap[] {
const gaps: WallGap[] = [];
for (const d of doors) gaps.push({ from: d.position, to: d.position + d.width });
for (const o of openings) {
const iv = openingInterval(wall, o);
if (iv) gaps.push(iv);
}
return gaps.sort((a, b) => a.from - b.from);
}
/** Toleranz für die Achsen-Intervall-Arithmetik der Durchgangswand-Aussparungen. */
export const CUTOUT_EPS = 1e-9;
/**
* Zieht die Loch-Intervalle `holes` vom Segment `[s,e]` ab und liefert die
* verbleibenden (gezeichneten) Teil-Intervalle in Achsen-Metern. Löcher werden
* auf `[s,e]` geklemmt, sortiert und vereinigt; leeres Ergebnis heisst „ganz
* ausgespart". Ist `holes` leer, kommt exakt `[[s,e]]` zurück (unverändertes
* Verhalten für Wände ohne Aussparung).
*/
export function subtractIntervals(
s: number,
e: number,
holes: Array<[number, number]>,
): Array<[number, number]> {
const clipped = holes
.map(([a, b]): [number, number] => [
Math.max(s, Math.min(a, b)),
Math.min(e, Math.max(a, b)),
])
.filter(([a, b]) => b > a + CUTOUT_EPS)
.sort((p, q) => p[0] - q[0]);
const parts: Array<[number, number]> = [];
let cursor = s;
for (const [a, b] of clipped) {
if (a > cursor + CUTOUT_EPS) parts.push([cursor, a]);
if (b > cursor) cursor = b;
}
if (cursor < e - CUTOUT_EPS) parts.push([cursor, e]);
return parts;
}
/**
* Wand-Footprint als orientiertes Rechteck (OBB) für das Verdecken der
* darunterliegenden Decke im Grundriss: Achs-Startpunkt + Einheits-Richtung `u`
* und -Normale `m`, Halbdicke, Referenz-Querversatz (Wandachse↔Bandmitte) und
* Achslänge. Die u-Ausdehnung wird beim Test an beiden Enden um die Halbdicke
* verlängert, damit auch die Eck-Überlappe (Gehrungen) sicher abgedeckt sind.
*/
export interface WallFootprint {
a: Vec2;
u: Vec2;
m: Vec2;
len: number;
half: number;
refOff: number;
}
/** Baut die Wand-Footprints (OBBs) aus den gerenderten Wänden. Nicht auflösbarer
* Wandtyp ⇒ Default-Dicke 0.2 m (wie der Vollkörper-Fallback im 3D-Emitter). */
export function buildWallFootprints(project: Project, walls: Wall[]): WallFootprint[] {
const out: WallFootprint[] = [];
for (const w of walls) {
let t = 0.2;
try {
t = wallTypeThickness(getWallType(project, w));
} catch {
// Default-Dicke behalten.
}
const u = normalize(sub(w.end, w.start));
out.push({
a: w.start,
u,
m: leftNormal(u),
len: Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y),
half: t / 2,
refOff: wallReferenceOffset(w, t),
});
}
return out;
}
/**
* Parametrisches Intervall [t0,t1] ⊆ [0,1], über das die Strecke `pa→pb`
* INNERHALB des Wand-Footprints liegt (also von der Wand verdeckt). `null`, wenn
* die Strecke den Footprint nicht schneidet. OBB-Test über zwei Slabs: Projektion
* auf die Achse `u` (Bereich [−half, len+half]) und auf die Normale `m` (Bereich
* [refOff−half, refOff+half]); das Schnitt-Intervall beider Slabs ∩ [0,1] ist die
* verdeckte Strecke.
*/
export function segmentInsideWallBand(
pa: Vec2,
pb: Vec2,
fp: WallFootprint,
): [number, number] | null {
const d = sub(pb, pa);
const rel = sub(pa, fp.a);
const clipSlab = (
f0: number,
fd: number,
lo: number,
hi: number,
): [number, number] | null => {
if (Math.abs(fd) < 1e-12)
return f0 >= lo - CUTOUT_EPS && f0 <= hi + CUTOUT_EPS ? [0, 1] : null;
let ta = (lo - f0) / fd;
let tb = (hi - f0) / fd;
if (ta > tb) [ta, tb] = [tb, ta];
return [ta, tb];
};
const su = clipSlab(dot(rel, fp.u), dot(d, fp.u), -fp.half, fp.len + fp.half);
if (!su) return null;
const sm = clipSlab(
dot(rel, fp.m),
dot(d, fp.m),
fp.refOff - fp.half,
fp.refOff + fp.half,
);
if (!sm) return null;
const t0 = Math.max(0, su[0], sm[0]);
const t1 = Math.min(1, su[1], sm[1]);
return t1 > t0 + CUTOUT_EPS ? [t0, t1] : null;
}
const length = (w: Wall): number => Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y);
/**
* Wand als gefüllte Schnittfläche (Poché), um Öffnungen ausgespart.
*
* Detailgrad:
* • mittel/fein — jede Schicht des Wandtyps als eigenes Band (Füllfarbe +
* Schraffur des Bauteils), dazu eine kräftige Wand-Umrisslinie. fein zeichnet
* Schichtfugen und Umriss dünner als mittel.
* • grob — KEINE Schichten, KEINE Schraffur: eine einzige Sammelfläche über die
* volle Wanddicke (Füllung = Farbe des „Backbone"-Bauteils mit höchster
* joinPriority) mit dicker Umrisslinie. Bewusst vereinfacht (≙ DOSSIER
* „Einfach").
*/
export function addWallPoche(
out: Primitive[],
project: Project,
wall: Wall,
gaps: WallGap[],
cuts: WallCuts,
greyed: boolean,
detail: DetailLevel,
wallLwMm: number,
category: LayerCategory | undefined,
// Wand liegt GANZ unter der Grundriss-Schnittebene (z. B. 30-cm-Brüstung bei
// 1 m Schnitthöhe) → „von oben gesehen": nur Ansichts-Umriss (Haarlinie), KEINE
// Schnitt-Poché/Schraffur/Schichten. Der reguläre Schnittfall bleibt `false`.
viewOnly: boolean,
): void {
const wt = getWallType(project, wall);
const total = wallTypeThickness(wt);
// Versatz der Schichtstapelung über die Dicke gemäß Referenzlinie (center=0,
// left=+T/2, right=−T/2). Alle Band-Offsets unten werden hierum verschoben,
// sodass die Achse bei „außen"/„innen" auf der jeweiligen Wandfläche liegt.
const refOff = wallReferenceOffset(wall, total);
// Jede Schicht-/Umrissfläche trägt die Wand-ID, damit ein Links-Klick die
// GANZE Wand (alle Bänder) markiert und die PlanView die wallId nach oben
// melden kann.
const wallId = wall.id;
// Achswinkel der Wand (Modell, Grad) — treibt wandbezogene Schraffuren
// ({@link resolveHatch}), damit z. B. eine Diagonale oder die Dämmungsstriche
// mit der Wandorientierung mitdrehen.
const wallAngleDeg =
(Math.atan2(wall.end.y - wall.start.y, wall.end.x - wall.start.x) * 180) / Math.PI;
// Strich-/Umrandungsfarbe der Wand: optionale Übersteuerung (wall.color),
// sonst der Default-Poché-Strich. Betrifft NUR die Linienfarbe (Umriss +
// Schichtfugen), nicht die Schicht-Füllfarben/Schraffuren.
const stroke = wall.color ?? POCHE_STROKE;
// Öffnungs-Intervalle entlang der Wandachse, sortiert (bereits vereint).
const openings = gaps;
// Wand in Segmente zwischen den Öffnungen zerlegen.
let cursor = 0;
const segments: Array<[number, number]> = [];
for (const op of openings) {
if (op.from > cursor) segments.push([cursor, op.from]);
cursor = Math.max(cursor, op.to);
}
const axisLen = length(wall);
if (cursor < axisLen) segments.push([cursor, axisLen]);
const outlineMm = wallLwMm * OUTLINE_DETAIL_FACTOR[detail];
const layerLineMm = LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail];
// Vereinfachte Füllung (grob): immer schwarze Vollfüllung, keine Schraffur
// Durchgangswand-Aussparungen (Phase 1c): über diese Achsen-Intervalle × Quer-
// Offsetbereiche wird die Nah-Putz-Zone der Durchgangswand echt ausgeschnitten
// (der Abzweig-Kern sticht durch). `offA` ist stets die Nahfläche, `offB` die
// Rückgrat-Nahfläche (siehe SpanCutout in model/joins.ts). Leer ⇒ heutiges
// Verhalten (Wand ohne T-Durchstoss).
const spanCutouts = cuts.spanCutouts ?? [];
// Kanten-Indizes der inneren Gehrungs-Stirnflächen (die Diagonalen am Knoten).
// clippedBand liefert [A.start, A.end, B.end, B.start] → Kante 1 (A.end→B.end)
// ist die endCut-Stirnfläche, Kante 3 (B.start→A.start) die startCut-Stirnfläche.
// Diese werden NICHT umrissen: sonst 45°-Naht zwischen gleichfarbigen Schichten
// + überschießende Gehrungs-Barbe am Eck-Apex. Freie Enden (kein Cut) bleiben
// rechtwinklig gestrichen. Die Längskanten (Schichtfugen) bleiben stets sichtbar.
const joinEdges = (startCut: Line | null, endCut: Line | null): number[] => {
const e: number[] = [];
if (endCut) e.push(1);
if (startCut) e.push(3);
return e;
};
for (const [s, e] of segments) {
const p1 = along(wall.start, wall.end, s);
const p2 = along(wall.start, wall.end, e);
// Nur die echten Wandenden bekommen die Gehrung. Türnahe Segmentenden
// bleiben rechtwinklig (kein Schnitt).
const startCut = s <= 1e-6 ? cuts.startCut : null;
const endCut = e >= axisLen - 1e-6 ? cuts.endCut : null;
if (viewOnly) {
// Ansichts-Wand (unter der Schnittebene): ein Umriss über die volle Dicke
// als Haarlinie, ohne Füllung/Schraffur. Segment-/Gehrungs-/Öffnungs-Logik
// bleibt dieselbe wie im Schnittfall (nur eben ohne Poché).
out.push({
kind: "polygon",
pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut),
fill: "none",
stroke,
strokeWidthMm: layerLineMm,
hatch: NO_HATCH,
greyed,
noStrokeEdges: joinEdges(startCut, endCut),
wallId,
});
continue;
}
if (detail === "grob") {
// Eine Sammelfläche über die volle Dicke, ohne Schraffur, dicker Umriss.
// Bei grob: immer Vollschwarz-Füllung gemäß SIA 400 Anhang B.9, Figur 36
out.push({
kind: "polygon",
pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut),
fill: HATCH_INK,
stroke,
strokeWidthMm: outlineMm,
hatch: NO_HATCH,
greyed,
noStrokeEdges: joinEdges(startCut, endCut),
wallId,
});
continue;
}
// mittel/fein: Schichten von außen (-T/2) nach innen (+T/2) stapeln. Jede
// Schicht löst Bauteil (Füllfarbe) + Schraffur (Muster/Maßstab/Winkel/Farbe)
// auf. Die Bänder tragen NUR Füllung + Schraffur (kein eigener Umriss); jede
// innere Schichtfuge wird als EIGENSTÄNDIGE, stilisierbare Linie gezeichnet.
const bandJoinEdges = joinEdges(startCut, endCut);
// Nur die Aussparungen, die dieses Segment [s,e] entlang der Achse berühren.
const segCutouts = spanCutouts.filter(
(c) =>
Math.min(c.from, c.to) < e - CUTOUT_EPS &&
Math.max(c.from, c.to) > s + CUTOUT_EPS,
);
// Punkt entlang der Wandachse bei Achsen-Meter `d` (p1/p2 an den Rändern).
const axisPt = (d: number): Vec2 =>
d === s ? p1 : d === e ? p2 : along(wall.start, wall.end, d);
// Materialbewusster T-Stoss (siehe resolveJoinPriority in model/joins.ts):
// liegt eine Pro-Schicht-Cut-Überschreibung vor, ersetzt sie PRO SCHICHT den
// einheitlichen startCut/endCut — nur an den echten Wandenden (dieselbe
// Maskierung wie oben für startCut/endCut, s <= 1e-6 / e >= axisLen-1e-6).
// Fehlt sie, bleibt alles wie zuvor (startCut/endCut für jede Schicht).
const layerCuts = cuts.layerCuts;
let off = refOff - total / 2;
for (let li = 0; li < wt.layers.length; li++) {
const layer = wt.layers[li];
const comp = getComponent(project, layer.componentId);
const layerHatchId =
resolveHatchId(wall.hatchId, wall.hatchSource, category, comp.hatchId) ?? comp.hatchId;
const layerHatch = resolveHatch(
project,
layerHatchId,
wallAngleDeg,
resolveForeground(comp, wall.foreground, category, wall.foregroundSource),
);
// SIA-Poché: neutraler Hintergrund (Papier) statt Bauteil-Albedo, die
// Schichtschraffur (fest in HATCH_INK, siehe resolveHatch) liegt darüber;
// Vollmuster ("solid") = Tinten-Poché.
const layerSolid = layerHatch.pattern === "solid";
const layerStartCut = s <= 1e-6 ? (layerCuts ? layerCuts.start[li] : startCut) : null;
const layerEndCut = e >= axisLen - 1e-6 ? (layerCuts ? layerCuts.end[li] : endCut) : null;
// Durchgangswand-Aussparung: liegt der Quer-Offsetbereich dieses Bandes
// [off, off+thickness] im Nah-Putz-Bereich einer Aussparung, wird das Band
// entlang der Achse in Teil-Bänder VOR/NACH dem Intervall zerlegt (das
// Durchstoss-Stück entfällt). Ohne Überlappung bleibt genau ein Band
// [s,e] (unverändertes Verhalten).
const bandLo = off;
const bandHi = off + layer.thickness;
const bandHoles: Array<[number, number]> = [];
for (const c of segCutouts) {
const cLo = Math.min(c.offA, c.offB);
const cHi = Math.max(c.offA, c.offB);
if (bandLo < cHi - CUTOUT_EPS && bandHi > cLo + CUTOUT_EPS)
bandHoles.push([c.from, c.to]);
}
for (const [a, b] of subtractIntervals(s, e, bandHoles)) {
const sc = a <= s + CUTOUT_EPS ? layerStartCut : null;
const ec = b >= e - CUTOUT_EPS ? layerEndCut : null;
out.push({
kind: "polygon",
pts: clippedBand(axisPt(a), axisPt(b), off, off + layer.thickness, sc, ec),
fill: layerSolid ? HATCH_INK : HATCH_PAPER,
// Band ohne Umriss: keine Schichtfuge über die Bandkante, keine 45°-Naht
// am Knoten. Die Fugen kommen als separate `line`-Primitive (siehe unten).
stroke: "none",
strokeWidthMm: 0,
hatch: layerHatch,
greyed,
// Für die Ecktests/Konsistenz beibehalten (bei stroke:"none" wirkungslos).
noStrokeEdges: bandJoinEdges,
wallId,
});
}
// L-Anschluss: eine getrimmte Schicht (z. B. Putz), die an eine
// verschmelzende Nachbarschicht (durchlaufender Kern) grenzt, bekommt
// zusätzlich eine seitliche Cut-Linie entlang der Durchgangsachse — sie
// begrenzt die Schichtbreite dort, wo der Kern sie unterbricht (L-Form).
const sideStart = s <= 1e-6 ? layerCuts?.startSide[li] : null;
const sideEnd = e >= axisLen - 1e-6 ? layerCuts?.endSide[li] : null;
if (sideStart || sideEnd) {
const uSeg = normalize(sub(p2, p1));
const nSeg = leftNormal(uSeg);
const sidePoint = (base: Vec2, layerOff: number, cut: Line): Vec2 => {
const pt = add(base, scale(nSeg, layerOff));
return lineIntersect(pt, uSeg, cut.point, cut.dir) ?? pt;
};
if (sideStart) {
out.push({
kind: "line",
a: sidePoint(p1, off, sideStart),
b: sidePoint(p1, off + layer.thickness, sideStart),
cls: "layer-joint-l",
weightMm: layerLineMm,
color: stroke,
greyed,
});
}
if (sideEnd) {
out.push({
kind: "line",
a: sidePoint(p2, off, sideEnd),
b: sidePoint(p2, off + layer.thickness, sideEnd),
cls: "layer-joint-l",
weightMm: layerLineMm,
color: stroke,
greyed,
});
}
}
off += layer.thickness;
// Innere Schichtfuge (zwischen dieser und der nächst-inneren Schicht) als
// eigene Linie am kumulierten Offset `off`, mit dem Cut der Bänder geclippt.
// Stil aus layer.jointLineStyleId (Gewicht/Farbe/Strich); Rückfall = Haar-
// linie (LAYER_LINE_MM) in Wandfarbe. Das Gewicht folgt demselben Detail-
// Faktor wie die frühere Schichtfuge (`layerLineMm`), damit die Detailgrade
// sich unverändert verhalten. Die innerste Schicht hat keine innere Fuge.
// Nutzt bewusst weiterhin den einheitlichen startCut/endCut (nicht die
// Pro-Schicht-Cuts): die Fuge markiert den Materialwechsel INNERHALB
// dieser Wand und bleibt daher an der bisherigen Nahfläche.
if (li < wt.layers.length - 1) {
const jls = layer.jointLineStyleId
? project.lineStyles.find((l) => l.id === layer.jointLineStyleId)
: undefined;
// Durchgangswand-Aussparung: liegt die Fuge im (geschlossenen) Nah-Putz-
// Bereich einer Aussparung — inkl. der Rückgrat-Nahfläche (Merge-Kontakt)
// —, wird sie über das Durchstoss-Intervall unterbrochen. So bleibt an der
// Verschmelzungsfläche keine Trennlinie stehen.
const fugeHoles: Array<[number, number]> = [];
for (const c of segCutouts) {
const cLo = Math.min(c.offA, c.offB);
const cHi = Math.max(c.offA, c.offB);
if (off >= cLo - CUTOUT_EPS && off <= cHi + CUTOUT_EPS)
fugeHoles.push([c.from, c.to]);
}
for (const [a, b] of subtractIntervals(s, e, fugeHoles)) {
const sc = a <= s + CUTOUT_EPS ? startCut : null;
const ec = b >= e - CUTOUT_EPS ? endCut : null;
// clippedBand(pa, pb, off, off, …) liefert [edgeStart, edgeEnd, edgeEnd,
// edgeStart] → die zwei distinkten Endpunkte sind Index 0 (Start) und 1
// (Ende), beide am Cut abgeschnitten wie die Bandlängskante.
const jq = clippedBand(axisPt(a), axisPt(b), off, off, sc, ec);
out.push({
kind: "line",
a: jq[0],
b: jq[1],
cls: "layer-joint",
weightMm: jls ? jls.weight * LAYER_DETAIL_FACTOR[detail] : layerLineMm,
color: jls ? jls.color : stroke,
dash: jls ? jls.dash : null,
// Zickzack-Fuge (LineStyle kind==="zigzag") ebenfalls durchreichen.
zigzag: jls?.kind === "zigzag" ? jls.zigzag : undefined,
// Custom-Motiv-Fuge (LineStyle kind==="custom") ebenfalls durchreichen.
motif: jls?.kind === "custom" ? jls.motif : undefined,
greyed,
});
}
}
}
// Kräftige Wand-Umrisslinie über die volle Dicke (nur Kontur, keine Füllung),
// damit die Wand sich klar von den dünnen Schichtfugen abhebt. Die Gehrungs-
// Stirnkanten am Knoten entfallen → sauberer Eck-Apex ohne überschießende Barbe.
// Bei einer Durchgangswand-Aussparung wird zusätzlich die NAHFLÄCHEN-Kante
// (die Längskante auf der Durchstoss-Seite) über [from,to] strokelos gemacht
// und als separate Segmente vor/nach dem Durchstoss gezeichnet — sonst liefe
// die Umrisslinie über die Durchstoss-Breite.
const offLo = refOff - total / 2;
const offHi = refOff + total / 2;
const holesHi: Array<[number, number]> = [];
const holesLo: Array<[number, number]> = [];
for (const c of segCutouts) {
// `offA` ist stets die Nahfläche (Wandoberfläche); ordne die Aussparung der
// passenden Längskante (offHi bzw. offLo) zu.
if (Math.abs(c.offA - offHi) < 1e-6) holesHi.push([c.from, c.to]);
else if (Math.abs(c.offA - offLo) < 1e-6) holesLo.push([c.from, c.to]);
}
const outlineNoStroke = [...bandJoinEdges];
// clippedBand-Kantenreihenfolge [A.start(offLo), A.end(offLo), B.end(offHi),
// B.start(offHi)]: Kante 0 ist die offLo-Längskante, Kante 2 die offHi-Längskante.
if (holesHi.length) outlineNoStroke.push(2);
if (holesLo.length) outlineNoStroke.push(0);
out.push({
kind: "polygon",
pts: clippedBand(p1, p2, offLo, offHi, startCut, endCut),
fill: "none",
stroke,
strokeWidthMm: outlineMm,
hatch: NO_HATCH,
greyed,
noStrokeEdges: outlineNoStroke,
wallId,
});
const drawNearEdge = (edgeOff: number, holes: Array<[number, number]>): void => {
for (const [a, b] of subtractIntervals(s, e, holes)) {
const sc = a <= s + CUTOUT_EPS ? startCut : null;
const ec = b >= e - CUTOUT_EPS ? endCut : null;
const jq = clippedBand(axisPt(a), axisPt(b), edgeOff, edgeOff, sc, ec);
out.push({
kind: "line",
a: jq[0],
b: jq[1],
cls: "wall-outline",
weightMm: outlineMm,
color: stroke,
greyed,
});
}
};
if (holesHi.length) drawNearEdge(offHi, holesHi);
if (holesLo.length) drawNearEdge(offLo, holesLo);
}
}
/**
* Referenzlinie einer Wand: dünne, gestrichelte Achslinie entlang der Wandachse
* (start → end). Von der Oberleiste umschaltbar; rein darstellend.
*/
export function addReferenceLine(out: Primitive[], wall: Wall, greyed: boolean): void {
out.push({
kind: "line",
a: wall.start,
b: wall.end,
cls: "wall-axis",
weightMm: 0.13,
dash: [0.18, 0.06, 0.04, 0.06], // Strich-Punkt-Achse in mm Papier
greyed,
});
}
+72 -2
View File
@@ -21,7 +21,8 @@
import type { Plan, Primitive } from "./generatePlan";
import { applyDashRuns, buildHatchRuns, zigzagPoints, motifPoints, DASH_MM_TO_M } from "./glPlan/glPlanHatch";
import { docToLines } from "../text/renderHtml";
import { docToLines, wrapDocToLines } from "../text/renderHtml";
import { docFromText } from "../text/richText";
type LinePrim = Extract<Primitive, { kind: "line" }>;
@@ -100,6 +101,20 @@ export interface RBounds {
maxX: number;
maxY: number;
}
/**
* EIN eingebettetes Rasterbild (aus Drawing2D `{shape:"image"}`), achsparallel.
* Wie `bounds` additiv/rückwirkungsfrei fürs GPU-Backend (s. dort) — der native
* Renderer kennt keine Textur/kein Bild-Primitiv (GPU-Pfad wird bei Bildern im
* Plan ohnehin deaktiviert, s. PlanView.tsx `useGpuRenderer`/`hasBgImages`);
* `sceneToPrintSvg.ts` liest dieses Feld für den PDF-Export.
*/
export interface RImage {
min: RPoint;
max: RPoint;
src: string;
opacity: number;
}
export interface RScene {
fills: RFill[];
outlines: ROutline[];
@@ -107,6 +122,7 @@ export interface RScene {
arcs: RArc[];
lines: RLine[];
texts: RText[];
images: RImage[];
/**
* Plan-Bounds (Modell-Meter) — additiv aus `Plan.bounds` übernommen, damit
* Verbraucher (z. B. der Vektor-PDF-Serializer `sceneToPrintSvg.ts`) den
@@ -323,6 +339,7 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL
const arcs: RArc[] = [];
const lines: RLine[] = [];
const texts: RText[] = [];
const images: RImage[] = [];
// Sichtbare Umriss-Läufe werden erst GESAMMELT (nach Stil gruppiert) und am
// Ende über Polygon-Grenzen hinweg zusammengenäht — erst dadurch bekommen
@@ -562,6 +579,59 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL
});
});
}
} else if (p.kind === "drawingText") {
// Modellverankerter Einzeltext (Text-Werkzeug/DXF-Import) — wie "text"
// oben zeilenweise geflacht, aber TOP-DOWN ab `at` (kein vertikal
// zentrierter Block) und `heightM` (Modell-Meter) als Basisgrösse statt
// `basePt` (Papier-Punkt) — der Text bleibt so, wie bisher, massstabsfrei
// an der Modellgeometrie verankert (kein Papier-Punkt-Bezug nötig).
flushRun();
const doc = p.doc ?? docFromText(p.text, p.marks ?? {});
const defCol = withOpacity(toRgba(p.color, 1) ?? [0.1, 0.1, 0.1, 1], undefined, p.greyed);
const unitPerPt = PT_TO_MM * mmToM; // pt (Run-Override) -> Papier-mm -> Modell-Meter.
const basePt = unitPerPt > 0 ? p.heightM / unitPerPt : 0;
const lineHeight = p.heightM * 1.25;
const wrapWidthUnits = p.wrapWidth && p.wrapWidth > 0 ? p.wrapWidth : undefined;
const docLines = wrapDocToLines(doc, {
x: 0,
y: 0,
lineHeight,
unitPerPt,
basePt,
color: p.color,
...(wrapWidthUnits ? { wrapWidthUnits } : {}),
});
let dy = 0;
docLines.forEach((line, k) => {
if (k > 0) dy += line.lineHeight;
const content = line.tspans.map((t) => t.text).join("");
if (!content.trim()) return;
const sizeM = line.tspans.length
? Math.max(...line.tspans.map((t) => t.fontSize))
: p.heightM;
texts.push({
pos: [p.at.x, p.at.y - dy],
content,
sizeMm: unitPerPt > 0 ? sizeM / mmToM : sizeM,
color: withOpacity(
toRgba(line.tspans[0]?.fill ?? p.color, 1) ?? defCol,
undefined,
p.greyed,
),
align: line.align,
});
});
} else if (p.kind === "drawingImage") {
// Eingebettetes Rasterbild — keine Rust-Textur (s. `RImage`); wird vom
// GPU-Backend ignoriert (`useGpuRenderer` schaltet bei Bildern im Plan
// ohnehin auf den SVG-Pfad um), `sceneToPrintSvg.ts` liest es für den PDF-Export.
flushRun();
images.push({
min: [p.min.x, p.min.y],
max: [p.max.x, p.max.y],
src: p.src,
opacity: p.opacity,
});
}
}
flushRun();
@@ -579,5 +649,5 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL
}
}
return { fills, outlines, polylines, arcs, lines, texts, bounds: { ...plan.bounds } };
return { fills, outlines, polylines, arcs, lines, texts, images, bounds: { ...plan.bounds } };
}
+48 -2752
View File
File diff suppressed because it is too large Load Diff
+923
View File
@@ -0,0 +1,923 @@
// Wand-Domäne der 3D-Ableitung (SCHICHTEN → Bänder → Boxen, siehe Moduldoc in
// `../../toWalls3d.ts`): löst den Schichtaufbau EINER Wand in quer zur Dicke
// gestapelte Materialbänder auf und emittiert daraus Wandkörper — inklusive
// aller materialbewussten Verschneidungen (Gehrungs-/Anschluss-Cuts an L-/
// T-Stössen, Durchgangswand-Span-Cutouts, Decken-Dominanz pro Schicht, Dach-
// Unterkanten-Kappung). Das 3D-Pendant zu `generatePlan/walls.ts::addWallPoche`.
import type {
Ceiling,
Layer,
Project,
SliceTermination,
Vec2,
Wall,
} from "../../model/types";
import {
getCeilingType,
getComponent,
getWallType,
getWindowType,
openingsOfWall,
roofLayers,
wallTypeThickness,
} from "../../model/types";
import { ceilingVerticalExtent, wallVerticalExtent } from "../../model/wall";
import { pointInOutline } from "../../geometry/ceiling";
import { openingInterval, openingVerticalExtent } from "../../geometry/opening";
import { roofBaseElevation, roofGeometry, roofUndersideAt } from "../../geometry/roof";
import type { RoofGeometry } from "../../geometry/roof";
import type { WallCuts } from "../../model/joins";
import { add, dot, leftNormal, lineIntersect, scale, sub } from "../../model/geometry";
import type { Line } from "../../model/geometry";
import {
WALL_RGB,
dominantLayerColor,
dominantLayerHatch,
hexToRgb,
resolveComponentHatch,
resolveComponentMaterialLayer,
resolveCutLineStyle,
EPS,
type RCutMeta,
type RHatch,
type RHole,
type RRgb,
type RWall,
} from "./shared";
/**
* Achswinkel einer GESCHNITTENEN Wand im Schnitt-Koordinatensystem (Modell-
* Konvention, identisch zu `SECTION_WALL_AXIS_ANGLE_DEG` in toSection.ts): das
* Wand-Band steht vertikal (Achse entlang v, atan2(1,0) = 90°). Damit drehen
* `relativeToWall`-Muster (Dämmung, Backstein) im 3D-Schnitt genauso mit der Wand
* wie im 2D-Schnitt (`splitWallLayers` → `resolveHatch(…, 90, …)`). Für DECKEN gibt
* es keinen Wandachsen-Bezug — dort bleibt der Winkel absolut (`emitSlabs` ruft
* `resolveComponentHatch` ohne Achswinkel, wie `splitSlabLayers` → `resolveHatch(…,
* undefined, …)`), sodass die liegende Boden-Dämmung vertikal statt horizontal läuft.
*/
const SECTION_WALL_AXIS_ANGLE_DEG = 90;
/**
* Eine quer zur Wanddicke gestapelte Materiallage als 3D-Band: eigene Dicke,
* Materialfarbe und Mitten-Versatz `offset` (Meter, entlang der Wand-Normalen,
* relativ zur Wandachse). Jede solche Lage wird pro Achsen-Teilquader zu einer
* eigenen Voll-Box (siehe {@link emitWall}/{@link pushSegment}).
*/
interface WallBand {
thickness: number;
color: RRgb;
offset: number;
/** Schnitt-Schraffur des Band-Bauteils (siehe {@link RHatch}). */
hatch?: RHatch;
/** Material-Textur-Ebene des Band-Bauteils (1-basiert, siehe `wallMaterialColorMaps`). */
materialIndex?: number;
/**
* Verschneidungs-Rang dieser Materiallage (`Component.joinPriority`) — treibt
* die per-Schicht-Dominanz gegen Decken im layered-3D-Pfad (s.
* {@link collectCeilingCutters}/{@link emitWall}): eine Decke mit STRIKT höherer
* `joinPriority` schneidet nur die Bänder mit niedrigerem Rang aus ihrem
* z-Intervall (der Kern höherer Priorität läuft durch). `undefined` (kein
* auflösbarer WallType / synthetisches Fallback-Band) ⇒ keine Decken-
* Subtraktion (konservativ, wie das frühere Trim-Verhalten bei unbekanntem Typ).
*/
priority?: number;
/**
* Bauteil-Identität dieser Materiallage (`Component.id`) — treibt die MERGE-Regel
* der Schnitt-Boolean-Dominanz (gleiche Komponente + Priorität verschmelzen, keine
* innere Trennlinie). Reist als `RCutMeta.componentId` mit.
*/
componentId?: string;
/** Farbe der Schnitt-Umrisslinie dieser Schicht (aus `jointLineStyleId`, sonst Default). */
cutLineColor?: RRgb;
/** Strichstärke der Schnitt-Umrisslinie in mm-Papier (aus `jointLineStyleId`, sonst Default). */
cutLineWeight?: number;
}
/**
* Baut die {@link RCutMeta}-Nutzlast eines Wand-Bandes (Priorität/Identität/
* Schnittkanten-Stil), oder `undefined`, wenn das Band keinerlei Schnitt-Metadaten
* trägt (z. B. das Fallback-Vollkörper-Band ohne auflösbaren WallType). Nur gesetzte
* Felder werden übernommen, damit `#[serde(default)]` auf der Rust-Seite greift.
*/
function bandCutMeta(b: WallBand): RCutMeta | undefined {
const meta: RCutMeta = {};
if (b.priority !== undefined) meta.joinPriority = b.priority;
if (b.componentId !== undefined) meta.componentId = b.componentId;
if (b.cutLineColor !== undefined) meta.lineColor = b.cutLineColor;
if (b.cutLineWeight !== undefined) meta.lineWeight = b.cutLineWeight;
return Object.keys(meta).length > 0 ? meta : undefined;
}
/**
* Löst den Schichtstapel EINER Wand auf: je Materiallage ein {@link WallBand}
* mit ihrer Dicke, Component-Farbe und dem Mitten-Versatz entlang der Normalen.
* Die Versätze zentrieren den Stapel um die Wandachse; als Gesamtdicke T dient
* die SUMME der tatsächlichen Lagen-Dicken (nicht `wallTypeThickness`, falls es
* abweicht) — so schließen die Bänder lückenlos aneinander an. Kein auflösbarer
* WallType / keine Lagen ⇒ EIN Vollkörper-Band über die volle Dicke im
* neutralen Grundton (heutiges Fallback-Verhalten).
*/
function resolveWallBands(project: Project, wall: Wall, layered = true): WallBand[] {
try {
const wt = getWallType(project, wall);
if (wt.layers.length > 0) {
// Gesamtdicke = Summe der Lagen (konsistent mit den Offsets); wenn diese
// von wallTypeThickness abwiche, gewinnt die Lagen-Summe (keine Lücken).
const total = wt.layers.reduce((s, l) => s + l.thickness, 0);
// Schnitt-Eingabe (layered=false): EINE Voll-Box über die ganze Dicke statt
// je Lage eine Box. So bleibt die Emission 1:1 Wand→Rechteck — passend zu
// `wallSegmentOwners`/`splitWallLayers` in toSection.ts, die die Schicht-
// Zerlegung TS-seitig aus diesem einen Rechteck machen. (Der 3D-Viewer ruft
// mit Default layered=true auf und bekommt unverändert die Schicht-Bänder.)
if (!layered) {
const dominant = wt.layers.reduce((best, l) => (l.thickness > best.thickness ? l : best));
return [
{
thickness: total,
color: dominantLayerColor(wt.layers, project, WALL_RGB),
offset: 0,
hatch: dominantLayerHatch(wt.layers, project, SECTION_WALL_AXIS_ANGLE_DEG),
materialIndex: resolveComponentMaterialLayer(project, dominant.componentId),
},
];
}
const bands: WallBand[] = [];
let prefix = 0;
for (const l of wt.layers) {
const comp = getComponent(project, l.componentId);
const color = hexToRgb(comp.color);
const lineStyle = resolveCutLineStyle(project, l.jointLineStyleId);
bands.push({
thickness: l.thickness,
color,
offset: -total / 2 + prefix + l.thickness / 2,
priority: comp.joinPriority,
componentId: comp.id,
cutLineColor: lineStyle?.color,
cutLineWeight: lineStyle?.weight,
hatch: resolveComponentHatch(project, l.componentId, SECTION_WALL_AXIS_ANGLE_DEG),
materialIndex: resolveComponentMaterialLayer(project, l.componentId),
});
prefix += l.thickness;
}
return bands;
}
// WallType ohne Schichten: EIN Vollkörper über die Typ-Gesamtdicke.
return [{ thickness: wallTypeThickness(wt), color: WALL_RGB, offset: 0 }];
} catch {
// Kein auflösbarer WallType: EIN Vollkörper mit Default-Dicke/Grundton.
return [{ thickness: 0.2, color: WALL_RGB, offset: 0 }];
}
}
/**
* Hängt eine Wand-Schicht-Box an: das Achsenstück [from..to] (Meter ab
* Wand-Startpunkt) über den Höhenbereich [zBottom..zTop], quer um `normalOffset`
* Meter entlang der Wand-Normalen versetzt (Schichtmitte relativ zur Achse) und
* mit Schicht-`thickness`/`color`. Entartete Stücke (Länge ≤ 0 oder Höhe ≤ 0)
* werden übersprungen — so überleben Öffnungen dicht an den Wandenden ohne
* Nullquader.
*
* Optionale `holes`: echte rechteckige Aussparungen in DIESER Box (siehe
* {@link RHole}). Ihre `from`/`to` sind Achsen-Meter ab Wand-Start (dieselbe
* Achse wie `from`/`to` dieser Box), `zBottom`/`zTop` absolute Weltmeter — beides
* unverändert an render3d durchgereicht (der Querversatz `normalOffset` wirkt nur
* senkrecht zur Achse und lässt die Loch-Parametrisierung unberührt). Werden nur
* im 3D-Pfad (voller Achsen-Quader) gesetzt.
*/
function pushSegment(
out: RWall[],
wall: Wall,
from: number,
to: number,
zBottom: number,
zTop: number,
thickness: number,
normalOffset: number,
color: RRgb,
wallId: string,
hatch?: RHatch,
materialIndex?: number,
holes?: RHole[],
cut?: RCutMeta,
): void {
if (to - from <= EPS) return;
if (zTop - zBottom <= EPS) return;
const dx = wall.end.x - wall.start.x;
const dy = wall.end.y - wall.start.y;
const len = Math.hypot(dx, dy);
if (len < 1e-9) return;
const ux = dx / len;
const uy = dy / len;
// LINKE Grundriss-Normale (identisch zu `leftNormal(u)` = (−uy, ux) in
// generatePlan.ts/clippedBand — dieselbe Konvention, die addWallPoche für
// den Schichtstapel im 2D-Grundriss benutzt). Der Schicht-Versatz verschiebt
// Start/Ende der Box quer zur Wand, sodass render3d die Box um ihre eigene
// (versetzte) Achse zentriert aufbaut — mit dieser Normale liegt Schicht 0
// (Aussenseite, kleinster `offset`) auf derselben Weltseite wie im Grundriss.
// Bewusst NICHT identisch zur Normale, die raycast3d/selectionHighlightLines
// für den Box-Rebuild nutzen (n = (uy, −ux), rechte Normale) — das ist
// unschädlich, weil jene Boxen symmetrisch um die (hier bereits versetzte)
// Achse jedes einzelnen Layer-Records aufgebaut werden und daher unabhängig
// von der Vorzeichen-Konvention des Versatzes sind.
const ox = -uy * normalOffset;
const oy = ux * normalOffset;
const p1: [number, number] = [wall.start.x + ux * from + ox, wall.start.y + uy * from + oy];
const p2: [number, number] = [wall.start.x + ux * to + ox, wall.start.y + uy * to + oy];
out.push({
start: p1,
end: p2,
thickness,
height: zTop - zBottom,
baseElevation: zBottom,
color,
layers: [],
wallId,
...(hatch ? { hatch } : {}),
...(materialIndex != null ? { materialIndex } : {}),
...(holes && holes.length > 0 ? { holes } : {}),
...(cut ? { cut } : {}),
});
}
/**
* Achsenparalleles Grundriss-Rechteck (min/max je Achse), Meter, Modell-XY.
* Dient hier NUR der groben Footprint-Überlapp-Prüfung Wand↔Decke (s.
* {@link collectCeilingCutters}) — kein exakter Polygon-Test, siehe dort.
*/
interface Bounds2 {
minX: number;
maxX: number;
minY: number;
maxY: number;
}
/** Bounding-Box der Wandachse, um `halfThickness` in beide Richtungen geweitet
* (sichere Über-Approximation des tatsächlichen Wandkörpers, unabhängig von
* dessen Orientierung). */
function wallFootprintBounds(wall: Wall, halfThickness: number): Bounds2 {
return {
minX: Math.min(wall.start.x, wall.end.x) - halfThickness,
maxX: Math.max(wall.start.x, wall.end.x) + halfThickness,
minY: Math.min(wall.start.y, wall.end.y) - halfThickness,
maxY: Math.max(wall.start.y, wall.end.y) + halfThickness,
};
}
/** Bounding-Box eines geschlossenen Umrisses (Decke). */
function outlineBounds(outline: Array<{ x: number; y: number }>): Bounds2 {
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const p of outline) {
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y;
if (p.y > maxY) maxY = p.y;
}
return { minX, maxX, minY, maxY };
}
/** Überlappen sich zwei Bounding-Boxen (mit EPS-Toleranz an den Rändern)? */
function boundsOverlap(a: Bounds2, b: Bounds2): boolean {
return (
a.minX <= b.maxX + EPS &&
a.maxX >= b.minX - EPS &&
a.minY <= b.maxY + EPS &&
a.maxY >= b.minY - EPS
);
}
/**
* Dicke einer Wand für die Footprint-Näherung (s. {@link wallFootprintBounds}):
* Summe der WallType-Schichtdicken, sonst der Fallback-Grundton-Dicke aus
* {@link resolveWallBands} (0.2 m), falls kein WallType auflösbar ist. Auch von
* `../openings.ts::resolveOpeningFrame` genutzt (Rahmen-Tiefe auf die Wanddicke
* klemmen).
*/
export function wallThicknessSafe(project: Project, wall: Wall): number {
try {
return wallTypeThickness(getWallType(project, wall));
} catch {
return 0.2;
}
}
/**
* Repräsentative `joinPriority` einer Decke (höchste ihrer Schichten) — bestimmt,
* ob die Decke am Wand/Decke-Stoss "durchläuft" (gewinnt) und in die Wandschichten
* niedrigerer Priorität einschneidet (s. {@link collectCeilingCutters}).
*/
function ceilingDominantPriority(project: Project, ceiling: Ceiling): number | undefined {
try {
const ct = getCeilingType(project, ceiling);
let best: number | undefined;
for (const layer of ct.layers) {
const p = getComponent(project, layer.componentId).joinPriority;
if (best === undefined || p > best) best = p;
}
return best;
} catch {
return undefined;
}
}
/**
* Ein Decken-„Cutter": vertikales z-Intervall + Dominanz-Rang + Grundriss-Umriss
* (s. {@link collectCeilingCutters}). Der `outline` erlaubt die PER-BAND-genaue
* Überdeckungsprüfung in {@link emitWall} (statt eines wandweiten BBox-Tests): eine
* Decke schneidet ein Schicht-Band nur dort, wo ihre Outline die Footprint-
* Mittellinie des Bandes im Grundriss überdeckt.
*/
interface CeilingCutter {
zBottom: number;
zTop: number;
priority: number;
outline: Vec2[];
}
/**
* Sammelt die Decken, die eine Wand im layered-3D-Pfad PRO SCHICHT einschneiden —
* die 3D-Entsprechung der Schnitt-Boolean-Dominanz (`toSection.ts::
* subtractDominantBands`): statt die GANZE Wand am Deckenanschluss zu kappen
* (früheres `trimWallTopForCeilings`), behält die Wand ihre volle Höhe und JEDES
* Schicht-Band verliert nur das z-Intervall der Decken mit STRIKT höherer
* `joinPriority` als das Band (der Kern höherer Priorität läuft durch die Decke).
* Ein Band kann dadurch vertikal in zwei Boxen zerfallen (unter Deckenunterkante
* + über Deckenoberkante, falls die Wand die Decke überragt).
*
* Kriterien je Decke (alle müssen zutreffen):
* • dasselbe Geschoss (`ceiling.floorId === wall.floorId`),
* • vertikale Überlappung mit der (vollen) Wand-Ausdehnung,
* • grober Footprint-Überlapp (Bounding-Box, s. {@link boundsOverlap}) — nur ein
* billiger Vor-Filter (keine Wand berührt, wenn die Wand-BBox die Decken-BBox
* nicht schneidet); die EXAKTE, per-Band geometrische Überdeckung entscheidet
* dann in {@link emitWall} (s. {@link bandCoverageIntervals}).
* • auflösbare Decken-`joinPriority` (`ceilingDominantPriority`).
*
* Das z-Intervall/die Priorität der Cutter ist PER DECKENSCHICHT (nicht die ganze
* Decke als EIN Block der dominanten Priorität): jede Deckenschicht wird zu einem
* eigenen Cutter mit IHREM z-Band (proportional auf [zBottom,zTop] verteilt, exakt
* wie `../ceilings.ts::emitSlabs`) und IHRER `Component.joinPriority`. Das ist
* zwingend, damit der 3D-Cut EXAKT wie der 2D-Schnitt (`toSection.ts::
* subtractDominantBands`) verschneidet: dort trägt jede Schicht ihre eigene
* Priorität. Mit dem früheren EINEN Block (dominante Prio 100 über die volle
* Decke) verlor z. B. der Backstein (50) das GANZE Decken-z-Band [UK,OK] —
* obwohl er die schwimmenden Deckenschichten Estrich (30)/Dämmung (20)
* ÜBERRAGEN und schneiden soll; nur die Betonschicht (100) darf ihn kappen. So
* blieb im 3D (Solid UND Schnitt) über dem Backstein eine Lücke, durch die
* Estrich/Dämmung „rausliefen" (Nutzer-Bug). Per Schicht läuft der Backstein
* zwischen Beton-OK und Wandkopf durch und schneidet dort Estrich/Dämmung weg —
* identisch zum 2D-Schnitt. Der `outline` reist mit, damit {@link emitWall} PRO
* BAND prüft, ob die Decke die Footprint-Mittellinie dieses Bandes überdeckt
* (geometrischer Überlapp, kein wandweiter BBox-Test). Der Prioritätsvergleich pro
* Band passiert in {@link emitWall} (`cutter.priority > band.priority`) — eine
* Wand-Schicht mit ≥ Rang der jeweiligen Deckenschicht läuft durch deren z-Band durch.
* Ein-Schicht-/nicht auflösbare Deckentypen fallen auf EINEN Cutter über die volle
* Ausdehnung mit der dominanten Priorität zurück (Alt-Verhalten).
*/
function collectCeilingCutters(
project: Project,
wall: Wall,
zBottom: number,
zTop: number,
): CeilingCutter[] {
const halfThickness = wallThicknessSafe(project, wall) / 2;
const wallBounds = wallFootprintBounds(wall, halfThickness);
const cutters: CeilingCutter[] = [];
for (const c of project.ceilings ?? []) {
if (c.floorId !== wall.floorId) continue;
if (!c.outline || c.outline.length < 3) continue;
const cExtent = ceilingVerticalExtent(project, c);
if (cExtent.zBottom >= zTop - EPS || cExtent.zTop <= zBottom + EPS) continue;
if (!boundsOverlap(wallBounds, outlineBounds(c.outline))) continue;
// Per-Schicht-Cutter: je Deckenschicht ein eigenes z-Band + eigene Priorität
// (proportional auf die tatsächliche Decken-z-Spanne skaliert, oben→unten wie
// emitSlabs). Nur so schneidet Beton (100) den Backstein (50), Backstein aber
// Estrich (30)/Dämmung (20) — identisch zu subtractDominantBands.
let layers: Layer[] | undefined;
try {
layers = getCeilingType(project, c).layers;
} catch {
layers = undefined;
}
const total = layers?.reduce((s, l) => s + l.thickness, 0) ?? 0;
if (layers && layers.length > 0 && total > EPS) {
const span = cExtent.zTop - cExtent.zBottom;
let prefixTop = 0; // von OK (zTop) nach unten aufsummiert (Meter)
for (const l of layers) {
const t = (l.thickness / total) * span;
const layerTop = cExtent.zTop - prefixTop;
const layerBottom = layerTop - t;
prefixTop += t;
let prio: number | undefined;
try {
prio = getComponent(project, l.componentId).joinPriority;
} catch {
prio = undefined;
}
if (prio === undefined) continue;
cutters.push({ zBottom: layerBottom, zTop: layerTop, priority: prio, outline: c.outline });
}
} else {
const ceilingPriority = ceilingDominantPriority(project, c);
if (ceilingPriority === undefined) continue;
cutters.push({ zBottom: cExtent.zBottom, zTop: cExtent.zTop, priority: ceilingPriority, outline: c.outline });
}
}
return cutters;
}
/**
* Achsen-Teilintervalle (Meter ab `wall.start` entlang `u`, geklemmt auf
* `[lo,hi]`), auf denen die MITTELLINIE eines Schicht-Bands (Querversatz
* `bandOffset` entlang `n = leftNormal(u)`) im Decken-Umriss `outline` liegt —
* die per-Band-Entsprechung des geometrischen Rechteck-Überlapps in
* `toSection.ts::subtractDominantBands`. Nur diese Teilintervalle bekommen später
* den Decken-z-Schnitt; unbedeckte Achsenabschnitte behalten die volle Höhe (so
* laufen Dämmung/Aussenputz, deren Mittellinie AUSSERHALB des Decken-Umrisses
* liegt, ununterbrochen durch — der Nutzer-Bug „Kranz durch die Dämmung").
*
* Die Bandmittellinie wird entlang der Achse gerastert (~5-cm-Schritte, min. 32)
* und gegen {@link pointInOutline} geprüft; Innen/Aussen-Übergänge werden per
* Bisektion auf ~EPS verfeinert, sodass die Überdeckungsgrenzen scharf sind (auch
* eine Decke, die nur einen Teil der Wandlänge überdeckt, wird korrekt getrimmt).
*/
function bandCoverageIntervals(
wall: Wall,
u: Vec2,
n: Vec2,
bandOffset: number,
outline: Vec2[],
lo: number,
hi: number,
): Array<[number, number]> {
if (hi - lo <= EPS) return [];
const base = add(wall.start, scale(n, bandOffset));
const inside = (t: number): boolean => pointInOutline(add(base, scale(u, t)), outline);
const steps = Math.max(32, Math.ceil((hi - lo) / 0.05));
const dt = (hi - lo) / steps;
const intervals: Array<[number, number]> = [];
let prevInside = inside(lo);
let start: number | null = prevInside ? lo : null;
let prevT = lo;
for (let i = 1; i <= steps; i++) {
const t = i === steps ? hi : lo + i * dt;
const now = inside(t);
if (now !== prevInside) {
const cross = bisectBoundary(inside, prevT, t);
if (now) start = cross;
else if (start !== null) {
intervals.push([start, cross]);
start = null;
}
}
prevInside = now;
prevT = t;
}
if (start !== null) intervals.push([start, hi]);
return intervals;
}
/**
* Bisektion des Innen/Aussen-Übergangs der Bandmittellinie zwischen `a` und `b`
* (verschiedene {@link pointInOutline}-Seiten) auf ~EPS-Genauigkeit — liefert die
* scharfe Achsen-Position der Überdeckungsgrenze.
*/
function bisectBoundary(inside: (t: number) => boolean, a: number, b: number): number {
const aInside = inside(a);
let lo = a;
let hi = b;
for (let i = 0; i < 40 && hi - lo > EPS * 0.01; i++) {
const mid = (lo + hi) / 2;
if (inside(mid) === aInside) lo = mid;
else hi = mid;
}
return (lo + hi) / 2;
}
/**
* Zieht die Cutter-Intervalle `holes` vom Intervall `[lo,hi]` ab und liefert die
* verbleibenden Teil-Intervalle (Meter). Löcher werden auf `[lo,hi]` geklemmt,
* sortiert und vereinigt; leeres `holes` ⇒ exakt `[[lo,hi]]` (unveränderter Fall).
* Dient sowohl der Achsen-Zerlegung durch Durchgangswand-Span-Cutouts als auch
* der vertikalen Decken-Subtraktion (identische 1D-Semantik wie
* `generatePlan/walls.ts::subtractIntervals`, hier lokal gehalten).
*/
function subtractSpans(lo: number, hi: number, holes: Array<[number, number]>): Array<[number, number]> {
const clipped = holes
.map(([a, b]): [number, number] => [Math.max(lo, Math.min(a, b)), Math.min(hi, Math.max(a, b))])
.filter(([a, b]) => b > a + EPS)
.sort((p, q) => p[0] - q[0]);
const parts: Array<[number, number]> = [];
let cursor = lo;
for (const [a, b] of clipped) {
if (a > cursor + EPS) parts.push([cursor, a]);
if (b > cursor) cursor = b;
}
if (cursor < hi - EPS) parts.push([cursor, hi]);
return parts;
}
/**
* Wendet die Decken-z-Cutter `cutters` (dominante horizontale Cut-Bänder [zBottom,
* zTop]) auf das Wandband-Intervall `[lo,hi]` an — je nach Terminierungs-Regel der
* Wand (`Wall.sliceTermination`). Bewusst als EIGENER, klar benannter Schritt
* neben der Priorität gehalten (Terminierung = Zuschnitt, KEINE Prioritätsregel):
*
* • "both"/undefined ⇒ heutiges Verhalten: `subtractSpans` stanzt jedes Cutter-
* z-Band aus (oben+unten Reste bleiben erhalten). Bit-identisch zu vorher.
* • "below" ⇒ die Wand ENDET an der Decke: alles AB der Decken-UNTERKANTE wird
* verworfen (kein über der Decke wieder auftauchender Rest). Clip-Grenze = das
* KLEINSTE `zBottom` aller dominanten Cutter dieses Bandes. Da die dominanteste
* (tragende) Deckenschicht — z. B. Beton (Prio 100) — jedes schwächere Band
* kappt UND zugleich die unterste Deckenlage ist, terminieren so ALLE Bänder
* einheitlich an der Deckenunterkante (kein Durchstoss/Staffel, keine
* Interpenetration mit Beton/Dämmung darüber). Für den Backstein, den nur der
* Beton kappt, ist das exakt „unter dem dominanten Cut" (dessen Unterkante).
* • "above" ⇒ spiegelbildlich: alles BIS zur Decken-OBERKANTE wird verworfen.
* Clip-Grenze = das GRÖSSTE `zTop` aller dominanten Cutter dieses Bandes.
*
* Ohne Cutter (`cutters` leer) bleibt `[lo,hi]` in allen Modi unverändert — ein
* Band ausserhalb des Decken-Umrisses (Aussenputz/Dämmung) läuft also weiter voll
* durch, unabhängig von der Terminierung.
*/
function terminateOrSubtractSpans(
lo: number,
hi: number,
cutters: Array<[number, number]>,
termination: SliceTermination | undefined,
): Array<[number, number]> {
if (cutters.length === 0) return [[lo, hi]];
if (termination === "below") {
// Deckenunterkante = kleinstes zBottom der Cutter; die Wand endet dort.
let clipTop = Infinity;
for (const [cb] of cutters) clipTop = Math.min(clipTop, cb);
const top = Math.min(clipTop, hi);
return top > lo + EPS ? [[lo, top]] : [];
}
if (termination === "above") {
// Deckenoberkante = grösstes zTop der Cutter; die Wand beginnt dort.
let clipBottom = -Infinity;
for (const [, ct] of cutters) clipBottom = Math.max(clipBottom, ct);
const bottom = Math.max(clipBottom, lo);
return bottom < hi - EPS ? [[bottom, hi]] : [];
}
return subtractSpans(lo, hi, cutters);
}
/**
* Achsen-Distanz (Meter ab `wall.start` entlang der Wandrichtung `u`), an der die
* MITTELLINIE eines Schicht-Bands (Querversatz `bandOffset` entlang `leftNormal(u)`)
* die Gehrungs-/Anschluss-Schnittlinie `cut` trifft — die 3D-Achsenbox-Entsprechung
* des 2D-`clippedBand`. Da die Box achsparallel endet (keine gekippte Stirnfläche),
* wird die Band-Länge auf den Schnittpunkt der MITTELLINIE gekürzt/verlängert; die
* beiden 2D-Bandkanten (bei ±thickness/2) treffen die Schnittlinie an leicht
* versetzten Achsenpunkten — der Box-Wert ist ihr Mittelwert. `null` bei
* (nahezu) parallelem Schnitt (kollineare Achsen) ⇒ Aufrufer behält das Bandende.
*/
function bandCutDistance(wall: Wall, u: Vec2, n: Vec2, bandOffset: number, cut: Line): number | null {
const base = add(wall.start, scale(n, bandOffset));
const hit = lineIntersect(base, u, cut.point, cut.dir);
if (!hit) return null;
return dot(sub(hit, wall.start), u);
}
/** Schrittweite (Meter) der Dach-Unterkanten-Annäherung, s. {@link clipPieceToRoofs}. */
const ROOF_CLIP_STEP = 0.15;
/**
* Kappt ein Wand-Achsenstück [a,bb] (z-Bereich [zb,zt]) an der Dach-UNTERKANTE
* (falls ein Dach darüber liegt) und emittiert es via `emit` — in FEINE Achsen-
* Schritte (~{@link ROOF_CLIP_STEP}) zerlegt, je Schritt auf die dort gemessene
* Dach-Unterkante geklemmt. Das ist eine TREPPENSTUFEN-Annäherung an eine echte
* geneigte Giebelwand-Stirnfläche (render3d-Wandkörper (`RWall`) haben nur einen
* FLACHEN Top — echte Schrägflächen bräuchten einen neuen Mesh-Pfad); bei der
* gewählten Schrittweite optisch glatt genug UND behebt das eigentliche Problem
* (Wand durchdringt die Dachfläche → Z-Fighting, s. PENDENZEN.md „Dach-Wand-
* Verschneidung"), ohne render3d/Rust anzufassen.
*
* Ohne Dächer (oder keinem, das dieses Wandstück überdeckt) läuft das Stück
* UNVERÄNDERT als EIN Body durch (kein Overhead im Normalfall).
*/
function clipPieceToRoofs(
project: Project,
wall: Wall,
u: Vec2,
n: Vec2,
bandOffset: number,
a: number,
bb: number,
zb: number,
zt: number,
emit: (a: number, bb: number, zb: number, zt: number) => void,
): void {
if (zt - zb <= EPS || bb - a <= EPS) return;
const wallBounds = wallFootprintBounds(wall, Math.abs(bandOffset) + 0.01);
const roofs = (project.roofs ?? []).filter((r) => {
if (r.floorId !== wall.floorId) return false;
const eavesZ = roofBaseElevation(project, r);
// Grober Höhen-Ausschluss: Dach-Traufe weit unter dem Wandstück ⇒ kann nicht
// schneiden (übliche Dächer haben keine nach unten ausufernden Übermasse).
return eavesZ + 50 >= zb; // grosszügige Schranke statt exakter First-Höhe
});
if (roofs.length === 0) {
emit(a, bb, zb, zt);
return;
}
const roofData = roofs
.map((r) => {
const eavesZ = roofBaseElevation(project, r);
const geo = roofGeometry(r, eavesZ);
if (!boundsOverlap(wallBounds, outlineBounds(geo.eaves))) return null;
const total = roofLayers(project, r).reduce((s, l) => s + Math.max(0, l.thickness), 0);
return { geo, total };
})
.filter((d): d is { geo: RoofGeometry; total: number } => d !== null);
if (roofData.length === 0) {
emit(a, bb, zb, zt);
return;
}
const span = bb - a;
const steps = Math.max(1, Math.ceil(span / ROOF_CLIP_STEP));
const stepLen = span / steps;
// Je Stufe die geklemmte Oberkante bestimmen …
const tops: number[] = [];
for (let i = 0; i < steps; i++) {
const mid = a + (i + 0.5) * stepLen;
const p = add(add(wall.start, scale(u, mid)), scale(n, bandOffset));
let clip = zt;
for (const { geo, total } of roofData) {
const under = roofUndersideAt(geo, total, p.x, p.y);
if (under !== null && under < clip) clip = under;
}
tops.push(Math.max(zb, clip));
}
// … und AUFEINANDERFOLGENDE Stufen gleicher Oberkante zu EINEM Body verschmelzen.
// Wo das Dach das Stück gar nicht klippt (Oberkante == zt über die ganze Länge,
// z. B. nahe dem First oder wenn kein Dach wirklich überdeckt), entsteht so wieder
// EIN durchgehender Body statt vieler identischer 0.15-m-Fragmente — nur der echt
// geneigte Bereich bleibt gestuft. Das entlastet auch den Live-Schnitt, der genau
// diese Wandkörper cuttet (weniger, saubere Schnitt-Kappen).
let runStart = 0;
for (let i = 1; i <= steps; i++) {
if (i < steps && Math.abs(tops[i] - tops[runStart]) <= EPS) continue;
const sa = a + runStart * stepLen;
const sb = a + i * stepLen;
const zte = tops[runStart];
if (zte - zb > EPS) emit(sa, sb, zb, zte);
runStart = i;
}
}
/**
* Emittiert die Wandkörper EINER Wand (mit oder ohne Öffnungen).
*
* ZWEI PFADE je `layered`:
* • layered=true (3D-Viewer, Default): EIN Körper pro Schicht-Band über die
* VOLLE Achse mit allen Öffnungen als echte `holes` (render3d stanzt sie in
* die Langseiten). Ermöglicht auch versetzt übereinanderliegende Fenster in
* EINEM Körper (keine Segment-Nähte).
* • layered=false (Schnitt-Pfad, `computeSection`): UNVERÄNDERTE Pfeiler/
* Brüstung/Sturz-Segmentierung (EIN Voll-Band je Segment), damit die
* `ComponentRef.index`-Reihenfolge exakt zu `toSection.ts::wallSegmentOwners`
* passt (Owner-Kompatibilität; toSection.ts bleibt unangetastet). Löcher
* werden hier NICHT emittiert.
*/
export function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cuts?: WallCuts): void {
const bands = resolveWallBands(project, wall, layered);
const { zBottom, zTop } = wallVerticalExtent(project, wall);
// Anders als früher (`trimWallTopForCeilings` kappte im layered-Pfad die GANZE
// Wand an der Deckenunterkante) behält die Wand jetzt IN BEIDEN PFADEN ihre
// volle Höhe; die per-Schicht-Decken-Subtraktion (unten, nur layered) schneidet
// Bänder niedrigerer Priorität im Decken-z-Band aus — die 3D-Entsprechung der
// Schnitt-Boolean-Dominanz. Der Schnitt-Pfad (layered=false) bleibt unverändert
// (volle Rohgeometrie; seine Dominanz macht toSection.ts::subtractDominantBands).
const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
if (axisLen < 1e-9 || zTop - zBottom <= EPS) return;
// Ein Achsen-Teilquader × jede Materiallage = eine Box (Schichtstapel quer
// zur Wanddicke). Alle tragen die Wand-Id, sodass ein Klick auf irgendeine
// Schicht die GANZE Wand selektiert und das Highlight alle Bänder umrandet.
const emitSeg = (from: number, to: number, zb: number, zt: number): void => {
for (const b of bands) {
pushSegment(out, wall, from, to, zb, zt, b.thickness, b.offset, b.color, wall.id, b.hatch, b.materialIndex, undefined, bandCutMeta(b));
}
};
// Aussparungen der Wand als vereinheitlichte Cutouts sammeln:
// • Öffnungen (project.openings, Fenster mit Brüstung/Sturz),
// • Legacy-Türen (project.doors, sitzen am Boden, nur Sturz).
// Jeweils [from..to] auf die Achse geklemmt + vertikale Öffnungs-Ausdehnung.
const cutouts: Array<{
from: number;
to: number;
oBottom: number;
oTop: number;
/** Schicht-Rücksprung der innersten Schicht an DIESER Öffnung (nur Fenster
* mit `WindowType.innerLayerRecess`), s. Moduldoc unten. */
recessInnerLayer: boolean;
}> = [];
for (const op of openingsOfWall(project, wall.id)) {
const iv = openingInterval(wall, op);
if (!iv) continue;
const v = openingVerticalExtent(project, wall, op);
const recessInnerLayer =
op.kind === "window" && getWindowType(project, op)?.innerLayerRecess === true;
cutouts.push({ from: iv.from, to: iv.to, oBottom: v.zBottom, oTop: v.zTop, recessInnerLayer });
}
for (const d of project.doors ?? []) {
if (d.hostWallId !== wall.id) continue;
const from = Math.max(0, Math.min(d.position, axisLen));
const to = Math.max(from, Math.min(d.position + d.width, axisLen));
if (to - from < EPS) continue;
// Tür sitzt am Boden (UK der Wand), reicht bis zur lichten Türhöhe.
const oTop = Math.min(zTop, zBottom + d.height);
cutouts.push({ from, to, oBottom: zBottom, oTop, recessInnerLayer: false });
}
cutouts.sort((a, b) => a.from - b.from);
// ── 3D-Viewer-Pfad (layered=true): EIN Körper je Schicht-Band über die volle
// Achse mit allen Öffnungen als echte Löcher (render3d stanzt sie), PLUS die
// materialbewussten Verschneidungen (identisch zum 2D-Grundriss/Schnitt):
// 1. End-Cuts/Merge an L-/T-Stössen: jede Schicht wird an ihrem Achsenende an
// der Gehrungs-/Anschluss-Schnittlinie individuell gekürzt/verlängert
// (`WallCuts.startCut`/`endCut`, bzw. pro Schicht `layerCuts.start/end` —
// merge: Kern läuft bis zur Rückgrat-Nahfläche durch; trim: Putz stoppt an
// der Wandoberfläche). Wie im 2D wird die Band-MITTELLINIE geschnitten.
// 2. Durchgangswand-Span-Cutouts: der Nah-Putz einer Durchgangswand wird über
// die Achsen-Breite des durchstechenden Abzweig-Kerns ausgeschnitten — ein
// Band zerfällt dabei entlang der Achse in mehrere Teilstücke.
// 3. Decken-Dominanz pro Schicht: Bänder niedrigerer `joinPriority` verlieren
// das z-Intervall überlappender Decken (vertikaler Split), der Kern läuft
// durch (s. {@link collectCeilingCutters}).
// Die Öffnungs-Löcher (`holes`, Achsen-Meter ab Wand-Start) werden je Teilstück
// auf dessen Achsen-Start umgerechnet (rebased), damit render3d sie relativ zum
// jeweiligen Box-Start korrekt stanzt.
if (layered) {
const holes: (RHole & { recessInnerLayer: boolean })[] = cutouts.map((c) => ({
from: c.from,
to: c.to,
zBottom: c.oBottom,
zTop: c.oTop,
recessInnerLayer: c.recessInnerLayer,
}));
const startCut = cuts?.startCut ?? null;
const endCut = cuts?.endCut ?? null;
const layerCuts = cuts?.layerCuts;
const spanCutouts = cuts?.spanCutouts ?? [];
const ux = (wall.end.x - wall.start.x) / axisLen;
const uy = (wall.end.y - wall.start.y) / axisLen;
const u: Vec2 = { x: ux, y: uy };
const n = leftNormal(u); // (-uy, ux) — dieselbe Konvention wie pushSegment/2D.
const ceilingCutters = collectCeilingCutters(project, wall, zBottom, zTop);
for (let li = 0; li < bands.length; li++) {
const b = bands[li];
// (1) Per-Schicht-Cuts an den Achsenden. `layerCuts` (materialbewusster
// T-Stoss) überschreibt — wo gesetzt — den einheitlichen start/endCut PRO
// Schicht; `null` an einem Index heisst „keine Kürzung an diesem Ende".
// Exakt die 2D-Wahl aus generatePlan/walls.ts::addWallPoche.
const sCut = layerCuts ? layerCuts.start[li] : startCut;
const eCut = layerCuts ? layerCuts.end[li] : endCut;
let from0 = 0;
let to0 = axisLen;
if (sCut) {
const d = bandCutDistance(wall, u, n, b.offset, sCut);
if (d !== null) from0 = d;
}
if (eCut) {
const d = bandCutDistance(wall, u, n, b.offset, eCut);
if (d !== null) to0 = d;
}
// (2) Span-Cutouts, deren Quer-Offsetbereich dieses Band schneidet, brechen
// die Achse [from0,to0] auf (Nah-Putz-Durchstoss der Durchgangswand).
const bandLo = b.offset - b.thickness / 2;
const bandHi = b.offset + b.thickness / 2;
const axisHoles: Array<[number, number]> = [];
for (const c of spanCutouts) {
const cLo = Math.min(c.offA, c.offB);
const cHi = Math.max(c.offA, c.offB);
if (bandLo < cHi - EPS && bandHi > cLo + EPS) axisHoles.push([c.from, c.to]);
}
const axisPieces = subtractSpans(from0, to0, axisHoles);
// (3) Decken-Subtraktion GEOMETRISCH pro Band (Replik der Schnitt-Boolean-
// Dominanz `toSection.ts::subtractDominantBands`): eine Decke mit STRIKT
// höherer Priorität schneidet dieses Band nur dort aus, wo ihr Grundriss-
// Umriss die Footprint-MITTELLINIE dieses Bandes überdeckt (nicht mehr der
// wandweite BBox-Test). Aussenschichten (Dämmung/Aussenputz), deren
// Mittellinie ausserhalb des Decken-Umrisses liegt, laufen ununterbrochen
// durch — nur der überdeckte Kern verliert das Decken-z-Intervall. Ohne
// auflösbare Band-Priorität bleibt die volle Höhe (konservativ).
const bandZCuts: Array<{ from: number; to: number; zBottom: number; zTop: number }> = [];
if (b.priority !== undefined) {
for (const cc of ceilingCutters) {
if (cc.priority <= b.priority) continue;
for (const [ca, cb] of bandCoverageIntervals(wall, u, n, b.offset, cc.outline, from0, to0)) {
bandZCuts.push({ from: ca, to: cb, zBottom: cc.zBottom, zTop: cc.zTop });
}
}
}
// Öffnungs-Löcher, die ein z-Teilstück vertikal berühren, auf den Box-Start
// `a` umrechnen (render3d klemmt u/z selbst auf die Box). An der INNERSTEN
// Schicht (li = letztes Band) weicht das Loch bei Fenstern mit
// `WindowType.innerLayerRecess` rundum um die eigene Schichtdicke zurück
// (Schicht-Rücksprung an der Laibung) — die anderen Schichten bleiben bündig.
const isInnermostBand = li === bands.length - 1;
const recessDepth = b.thickness;
const emitPiece = (a: number, bb: number, zb: number, zt: number): void => {
const segHoles = holes
.filter((h) => h.zTop > zb + EPS && h.zBottom < zt - EPS)
.map((h) => {
const widen = isInnermostBand && h.recessInnerLayer;
return widen
? {
from: h.from - a - recessDepth,
to: h.to - a + recessDepth,
zBottom: h.zBottom - recessDepth,
zTop: h.zTop + recessDepth,
}
: { from: h.from - a, to: h.to - a, zBottom: h.zBottom, zTop: h.zTop };
});
pushSegment(out, wall, a, bb, zb, zt, b.thickness, b.offset, b.color, wall.id, b.hatch, b.materialIndex, segHoles, bandCutMeta(b));
};
for (const [a, bb] of axisPieces) {
// Nur die Decken-z-Schnitte, die dieses Achsen-Teilstück wirklich treffen.
const local = bandZCuts
.map((c) => ({ from: Math.max(a, c.from), to: Math.min(bb, c.to), zBottom: c.zBottom, zTop: c.zTop }))
.filter((c) => c.to > c.from + EPS);
if (local.length === 0) {
// Kein Decken-Überlapp auf diesem Achsenstück ⇒ volle Höhe (Band läuft
// durch) — ausser ein Dach kappt den Top (s. {@link clipPieceToRoofs}).
clipPieceToRoofs(project, wall, u, n, b.offset, a, bb, zBottom, zTop, emitPiece);
continue;
}
// Achse [a,bb] an allen Überdeckungs-Grenzen in elementare Teilstücke
// zerlegen; je Teilstück nur die dort greifenden Decken-z-Intervalle
// subtrahieren (unbedeckte Teilstücke behalten die volle Höhe).
const bounds = new Set<number>([a, bb]);
for (const c of local) {
bounds.add(c.from);
bounds.add(c.to);
}
const xs = [...bounds].sort((p, q) => p - q);
for (let i = 0; i < xs.length - 1; i++) {
const xa = xs[i];
const xb = xs[i + 1];
if (xb - xa <= EPS) continue;
const mid = (xa + xb) / 2;
const zCutters = local
.filter((c) => c.from <= mid && c.to >= mid)
.map((c): [number, number] => [c.zBottom, c.zTop]);
// Terminierung (Zuschnitt) statt reiner z-Subtraktion: bei "below"/"above"
// endet die Wand an der Decke, statt oben wieder aufzutauchen (s.
// {@link terminateOrSubtractSpans}). "both"/undefined = heutiges Verhalten.
for (const [zb, zt] of terminateOrSubtractSpans(zBottom, zTop, zCutters, wall.sliceTermination)) {
clipPieceToRoofs(project, wall, u, n, b.offset, xa, xb, zb, zt, emitPiece);
}
}
}
}
return;
}
// ── Schnitt-Pfad (layered=false): unveränderte Pfeiler/Brüstung/Sturz-
// Segmentierung (Owner-Kompatibilität mit toSection.ts::wallSegmentOwners).
// Ohne Aussparungen: ein durchgehender Quader je Schicht.
if (cutouts.length === 0) {
emitSeg(0, axisLen, zBottom, zTop);
return;
}
// Wand entlang der Achse durchlaufen: volle Pfeiler zwischen den Aussparungen,
// Brüstung/Sturz im Öffnungsbereich. Überlappende Cutouts werden über `cursor`
// zusammengefasst.
let cursor = 0;
for (const { from, to, oBottom, oTop } of cutouts) {
const segFrom = Math.max(cursor, from);
if (from > cursor) {
// Voller Wandpfeiler bis zur Aussparung.
emitSeg(cursor, from, zBottom, zTop);
}
if (to > segFrom) {
// Brüstung unter der Öffnung (bei Türen entfällt sie, da oBottom == zBottom).
if (oBottom > zBottom + EPS) {
emitSeg(segFrom, to, zBottom, oBottom);
}
// Sturz über der Öffnung (bis zum Wandkopf).
if (oTop < zTop - EPS) {
emitSeg(segFrom, to, oTop, zTop);
}
}
cursor = Math.max(cursor, to);
}
// Restlicher Wandpfeiler bis zum Achsenende.
if (cursor < axisLen) {
emitSeg(cursor, axisLen, zBottom, zTop);
}
}
+97
View File
@@ -0,0 +1,97 @@
// Decken-Domäne der 3D-Ableitung: extrudiert `project.ceilings` zu geschichteten
// Deckenplatten (eine RSlab je Materiallage, oben→unten gestapelt) — das 3D-
// Pendant zu `generatePlan/ceilings.ts::addCeilingPoche`.
import type { Layer, Project } from "../../model/types";
import { getCeilingType, getComponent } from "../../model/types";
import { ceilingVerticalExtent } from "../../model/wall";
import { mergeHoles } from "../../geometry/polygonHoles";
import {
EPS,
SLAB_RGB,
hexToRgb,
resolveComponentHatch,
resolveCutLineStyle,
type RCutMeta,
type RRgb,
type RSlab,
type RVec2,
} from "./shared";
/** Emittiert die Deckenplatten eines Projekts (falls vorhanden). */
export function emitSlabs(project: Project): RSlab[] {
const out: RSlab[] = [];
for (const c of project.ceilings ?? []) {
if (!c.outline || c.outline.length < 3) continue;
// zBottom/zTop (und damit die Deckendicke) berechnet ceilingVerticalExtent
// bereits über ceilingThickness (respektiert Typ-Dicke + Übersteuerung).
const { zBottom, zTop } = ceilingVerticalExtent(project, c);
if (zTop - zBottom <= EPS) continue;
// Aussparungen (Treppenauge/Schacht) über den Brücken-Trick in EINEN
// einfachen Umriss-Ring einfügen — derselbe Ring für ALLE Schichten dieser
// Decke. Kein `openings` ⇒ unverändertes `c.outline` (exakt heutiges
// Verhalten). Der Ring ist paritätskorrekt fürs Ear-Clipping (render3d)
// UND fürs Pick (pickGeometry nutzt denselben emitSlabs-Pfad): ein Klick
// ins Loch trifft die Decke NICHT — das ist gewollt.
const openings = c.openings ?? [];
const mergedOutline = openings.length ? mergeHoles(c.outline, openings).ring : c.outline;
const outline = mergedOutline.map((p) => [p.x, p.y] as RVec2);
// Geschichteter Bodenaufbau: je Schicht des Deckentyps EIN eigener Slab,
// von oben (OK, erste Lage) nach unten (UK, letzte Lage) gestapelt und
// proportional auf die tatsächliche z-Spanne [zBottom, zTop] verteilt (wie
// die Lagen-Summe bei den Wänden). Jede Lage trägt ihre Bauteil-Schraffur +
// -Farbe — so zeigt der 3D-Schnitt den Bodenaufbau GESCHICHTET (Estrich/
// Dämmung/Beton …), identisch zum 2D-Schnitt, statt eines Einkörpers in der
// dominanten Schicht. Kein auflösbarer Deckentyp / keine Lagen ⇒ EIN
// Vollkörper-Slab in SLAB_RGB (bisheriges Fallback-Verhalten).
let layers: Layer[] | undefined;
try {
layers = getCeilingType(project, c).layers;
} catch {
layers = undefined;
}
const total = layers?.reduce((s, l) => s + l.thickness, 0) ?? 0;
if (layers && layers.length > 0 && total > EPS) {
const span = zTop - zBottom;
let prefixTop = 0; // von OK (zTop) nach unten aufsummiert (Meter)
for (const l of layers) {
const t = (l.thickness / total) * span;
const layerTop = zTop - prefixTop;
const layerBottom = layerTop - t;
prefixTop += t;
let color: RRgb = SLAB_RGB;
let cut: RCutMeta | undefined;
try {
const comp = getComponent(project, l.componentId);
color = hexToRgb(comp.color);
// Schnitt-Metadaten der Schicht: joinPriority + Identität (für die
// Boolean-Dominanz/MERGE) + Schnittkanten-Stil aus jointLineStyleId.
const lineStyle = resolveCutLineStyle(project, l.jointLineStyleId);
cut = {
joinPriority: comp.joinPriority,
componentId: comp.id,
...(lineStyle?.color ? { lineColor: lineStyle.color } : {}),
...(lineStyle?.weight != null ? { lineWeight: lineStyle.weight } : {}),
};
} catch {
color = SLAB_RGB;
cut = undefined;
}
const hatch = resolveComponentHatch(project, l.componentId);
out.push({
outline,
zBottom: layerBottom,
zTop: layerTop,
color,
ceilingId: c.id,
...(hatch ? { hatch } : {}),
...(cut ? { cut } : {}),
});
}
} else {
out.push({ outline, zBottom, zTop, color: SLAB_RGB, ceilingId: c.id });
}
}
return out;
}
+78
View File
@@ -0,0 +1,78 @@
// Stützen-Domäne der 3D-Ableitung (Tragwerk): extrudiert `project.columns` zu
// parametrischen Prisma-Meshes (Rechteck-Box bzw. tesselliertes Kreis-Prisma).
import type { Column, Project, Vec2 } from "../../model/types";
import { getComponent } from "../../model/types";
import { columnFootprint } from "../../geometry/column";
import { columnVerticalExtent } from "../../model/wall";
import { EPS, hexToRgb, type RMesh, type RRgb } from "./shared";
/** Neutraler Beton-/Tragwerks-Grauton für Stützen (Fallback ohne Bauteil-Farbe). */
const COLUMN_RGB: RRgb = [0.7, 0.7, 0.72];
/**
* 3D-Farbe einer Stütze: die Bauteil-Albedo (`Component.color`), falls ein
* `componentId` auflösbar ist, sonst der neutrale Tragwerks-Grauton {@link
* COLUMN_RGB}. Eine explizite `column.color` (Grundriss-Umrissfarbe) wirkt
* bewusst NICHT auf die 3D-Fläche (nur der 2D-Umriss), damit die 3D-Darstellung
* material-/neutral bleibt — analog Wand (Umrissfarbe ≠ 3D-Albedo).
*/
function columnColor3d(project: Project, col: Column): RRgb {
if (col.componentId) {
try {
return hexToRgb(getComponent(project, col.componentId).color, COLUMN_RGB);
} catch {
return COLUMN_RGB;
}
}
return COLUMN_RGB;
}
/**
* Baut ein geschlossenes Prisma-Mesh aus einem konvexen Grundriss-Polygon
* `foot` (≥3 Punkte, CCW) zwischen `zBottom` und `zTop`. `positions` in MODELL-
* Metern (x, y, z=Höhe); render3d rendert Kontext-Meshes doppelseitig, daher
* ist die Winding-Reihenfolge unerheblich. Die Deckel werden als Dreiecks-
* Fächer ab dem ersten Vertex tesselliert (konvex → korrekt).
*/
function prismMesh(foot: Vec2[], zBottom: number, zTop: number, color: RRgb): RMesh {
const n = foot.length;
const positions: number[] = [];
const indices: number[] = [];
// 0..n-1 = untere Ring-Vertices, n..2n-1 = obere Ring-Vertices.
for (const p of foot) positions.push(p.x, p.y, zBottom);
for (const p of foot) positions.push(p.x, p.y, zTop);
// Mantelflächen: je Kante ein Quad (2 Dreiecke).
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
const b0 = i, b1 = j, t0 = i + n, t1 = j + n;
indices.push(b0, b1, t1, b0, t1, t0);
}
// Deckel (Fächer ab Vertex 0): unten + oben.
for (let i = 1; i < n - 1; i++) {
indices.push(0, i, i + 1); // unten
indices.push(n, n + i, n + i + 1); // oben
}
return { positions, indices, kind: "extrusion", color };
}
/**
* Extrudiert `project.columns` (Tragwerk) zu parametrischen Prisma-Meshes —
* Rechteck-Box bzw. tesselliertes Kreis-Prisma, je Profil. SYNCHRON (anders als
* die truck-Extrusion der `ExtrudedSolid`s, s. `../extrusions.ts`): die
* Kontur kommt aus `columnFootprint`, die vertikale Lage aus
* `columnVerticalExtent` (UK/OK-Anker bzw. Geschoss-UK + Höhe). Rust/render3d
* bleibt unverändert (Kontext-Mesh-Pfad).
*/
export function emitColumns(project: Project): RMesh[] {
const out: RMesh[] = [];
for (const col of project.columns ?? []) {
if (col.height <= 0) continue;
const foot = columnFootprint(col);
if (foot.length < 3) continue;
const { zBottom, zTop } = columnVerticalExtent(project, col);
if (zTop - zBottom <= EPS) continue;
out.push(prismMesh(foot, zBottom, zTop, columnColor3d(project, col)));
}
return out;
}
+88
View File
@@ -0,0 +1,88 @@
// Freie-2D-Geometrie-Domäne der 3D-Ableitung: zeigt jede 2D-Zeichnung
// (line/polyline/rect) als dünnes Boden-Ribbon-Mesh knapp über ihrer
// Geschossebene — die "2D-Referenz im 3D", die der drei.js-Fallback
// (`addDrawing2DLines` in Viewport3D.tsx) zeigt.
import type { Drawing2D, Project, Vec2 } from "../../model/types";
import { flattenCategories, getFloor } from "../../model/types";
import { DRAWING_LINE_FALLBACK_RGB, hexToRgb, type RMesh, type RRgb } from "./shared";
/** Halbe Linienbreite der 2D-Boden-Spuren im 3D (Meter) — liest sich als dünne Linie. */
const DRAWING_LINE_HALF_WIDTH = 0.008;
/** Höhen-Offset über der Geschossebene gegen Z-Fighting (wie addDrawing2DLines in Viewport3D.tsx). */
const DRAWING_LINE_ELEVATION_EPS = 0.01;
/**
* Strecken eines 2D-Zeichenelements für die Boden-Spur im 3D — exakt dieselbe
* Teilmenge wie `drawing2DSegments` im drei.js-Fallback (`Viewport3D.tsx`):
* line/polyline/rect. Kreis/Bogen/Text bleiben aussen vor (keine neue
* Fähigkeit gegenüber dem Fallback, nur Parität).
*/
function drawingSegments3d(d: Drawing2D): [Vec2, Vec2][] {
const g = d.geom;
const out: [Vec2, Vec2][] = [];
if (g.shape === "line") out.push([g.a, g.b]);
else if (g.shape === "polyline") {
for (let i = 0; i < g.pts.length - 1; i++) out.push([g.pts[i], g.pts[i + 1]]);
if (g.closed && g.pts.length > 2) out.push([g.pts[g.pts.length - 1], g.pts[0]]);
} else if (g.shape === "rect") {
const c1 = g.min, c3 = g.max;
const c2 = { x: g.max.x, y: g.min.y }, c4 = { x: g.min.x, y: g.max.y };
out.push([c1, c2], [c2, c3], [c3, c4], [c4, c1]);
}
return out;
}
/** Modellfarbe einer 2D-Zeichnung (color → LineStyle → Kategorie), analog `drawing2DColor`. */
function drawingColor3d(project: Project, d: Drawing2D): RRgb {
if (d.color) return hexToRgb(d.color, DRAWING_LINE_FALLBACK_RGB);
const ls = d.lineStyleId ? project.lineStyles.find((l) => l.id === d.lineStyleId) : undefined;
if (ls?.color) return hexToRgb(ls.color, DRAWING_LINE_FALLBACK_RGB);
const cat = flattenCategories(project.layers).find((c) => c.code === d.categoryCode);
return cat?.color ? hexToRgb(cat.color, DRAWING_LINE_FALLBACK_RGB) : DRAWING_LINE_FALLBACK_RGB;
}
/**
* Emittiert je 2D-Zeichnung ein dünnes Ribbon-Mesh knapp über ihrer
* Geschossebene — die "2D-Referenz im 3D" (z. B. ein Boden-Raster), die der
* drei.js-Fallback (`addDrawing2DLines`) zeigt, bisher aber im nativen
* render3d/wgpu-Pfad fehlte (`RModel3d` kannte nur walls/slabs/meshes).
* `positions` in MODELL-Metern (x, y, z=Höhe) wie `emitOpeningGlass`; render3d
* rendert Kontext-Meshes ohnehin doppelseitig (`append_context_mesh`), daher
* genügt ein flaches Ribbon (kein Quader) pro Strecke.
*/
export function emitDrawingLines(project: Project): RMesh[] {
const out: RMesh[] = [];
for (const d of project.drawings2d) {
const segs = drawingSegments3d(d);
if (segs.length === 0) continue;
let baseZ = 0;
try {
baseZ = getFloor(project, d.levelId).baseElevation ?? 0;
} catch {
// Geschoss nicht (mehr) auflösbar: bei UK 0 zeichnen (Fallback).
}
const elev = baseZ + DRAWING_LINE_ELEVATION_EPS;
const positions: number[] = [];
const indices: number[] = [];
for (const [a, b] of segs) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy);
if (len < 1e-9) continue;
const nx = (-dy / len) * DRAWING_LINE_HALF_WIDTH;
const ny = (dx / len) * DRAWING_LINE_HALF_WIDTH;
const base = positions.length / 3;
positions.push(
a.x + nx, a.y + ny, elev,
a.x - nx, a.y - ny, elev,
b.x - nx, b.y - ny, elev,
b.x + nx, b.y + ny, elev,
);
indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
}
if (positions.length === 0) continue;
out.push({ positions, indices, kind: "imported", color: drawingColor3d(project, d) });
}
return out;
}
+91
View File
@@ -0,0 +1,91 @@
// Extrusions-Domäne der 3D-Ableitung (truck-Integration, `docs/design/
// truck-plan.md`): extrudiert `project.extrudedSolids` asynchron zu Kontext-
// Meshes, über einen modulweiten Cache je Solid-Id (siehe Moduldoc unten).
import type { ExtrudedSolid, Project } from "../../model/types";
import { getFloor } from "../../model/types";
import { extrudeCircle, extrudePolygon } from "../../engine/truckSolid";
import type { RMesh, RRgb } from "./shared";
/** Warmes Orange für truck-Profil-Extrusionen (hebt sie klar von Wand/Decke/Kontext ab). */
const EXTRUSION_RGB: RRgb = [0.85, 0.55, 0.25];
/** Event-Name für den "Extrusion(en) geladen"-Stoss (s. Wasm3DViewport.tsx/nativeSync.ts). */
export const TRUCK_MESH_READY_EVENT = "dossier:truck-mesh-ready";
/**
* Extrudiert `project.extrudedSolids` (truck-Integration, `docs/design/
* truck-plan.md`) zu Kontext-Meshes. `extrudePolygon` lädt WASM asynchron;
* `emitMeshes`/`emitExtrudedSolids` selbst bleiben synchron (Aufrufer wie
* `updateModel`/`pushNativeWalls` erwarten das) — daher ein modulweiter Cache
* je Solid-Id, der bei fehlendem/veraltetem Eintrag die Extrusion im
* Hintergrund anstösst und `TRUCK_MESH_READY_EVENT` feuert, sobald sie fertig
* ist (Wasm3DViewport/nativeSync hören darauf und re-pushen das Modell — ohne
* das Signal käme eine fertige Extrusion trotz Erfolgs NIE ins Bild, weil
* `updateModel` nur bei PROJEKT-Änderungen erneut läuft). Nur im Browser (kein
* `window` unter vitest/node) und mit stillem Fallback bei Fehlern.
*/
interface TruckCacheEntry {
/** Signatur (Punkte+Höhe) des Solids beim Laden — invalidiert den Eintrag bei Änderung. */
signature: string;
/** null, solange die Extrusion noch lädt. */
mesh: RMesh | null;
}
const truckMeshCache = new Map<string, TruckCacheEntry>();
function extrudedSolidSignature(solid: ExtrudedSolid): string {
return `${solid.height}|${solid.taper ?? 0}|${solid.points.map((p) => `${p.x},${p.y}`).join(";")}`;
}
export function emitExtrudedSolids(project: Project): RMesh[] {
if (typeof window === "undefined") return [];
const out: RMesh[] = [];
// Cache-Einträge gelöschter/nicht mehr existierender Körper verwerfen (sonst
// wächst der Cache unbegrenzt über eine Session, z. B. nach Extrudieren →
// Kopieren → Löschen in Serie).
const liveIds = new Set((project.extrudedSolids ?? []).map((s) => s.id));
for (const id of truckMeshCache.keys()) {
if (!liveIds.has(id)) truckMeshCache.delete(id);
}
for (const solid of project.extrudedSolids ?? []) {
if (solid.points.length < 3 || solid.height <= 0) continue;
const signature = extrudedSolidSignature(solid);
let entry = truckMeshCache.get(solid.id);
if (!entry || entry.signature !== signature) {
// Die bisherige Mesh (falls vorhanden) WEITER anzeigen, bis die neue
// Extrusion fertig geladen ist — sonst verschwindet der Körper kurz,
// während der Nutzer Höhe/Verjüngung im Attribute-Panel tippt (jeder
// Tastendruck feuert onChange → neue Signatur).
const staleMesh = entry?.mesh ?? null;
entry = { signature, mesh: staleMesh };
truckMeshCache.set(solid.id, entry);
let baseZ = 0;
try {
baseZ = getFloor(project, solid.levelId).baseElevation ?? 0;
} catch {
// Geschoss nicht (mehr) auflösbar: bei UK 0 extrudieren (Fallback).
}
// Kreis-Profil: echte runde truck-Extrusion (extrudeCircle) statt der
// 48-Eck-Tessellierung in `points` — glattes Zylinder-Mesh.
const taper = solid.taper ?? 0;
const extrusion = solid.circle
? extrudeCircle(solid.circle.center.x, solid.circle.center.y, solid.circle.r, solid.height, taper)
: extrudePolygon(solid.points.flatMap((p) => [p.x, p.y]), solid.height, taper);
extrusion
.then((mesh) => {
// truck extrudiert immer ab z=0 — auf die Geschoss-UK anheben.
const positions =
baseZ === 0
? mesh.positions
: mesh.positions.map((v, i) => (i % 3 === 2 ? v + baseZ : v));
const cur = truckMeshCache.get(solid.id);
if (!cur || cur.signature !== signature) return; // inzwischen veraltet
cur.mesh = { positions, indices: mesh.indices, kind: "extrusion", color: EXTRUSION_RGB };
window.dispatchEvent(new Event(TRUCK_MESH_READY_EVENT));
})
.catch((e) => console.warn("truck-Extrusion fehlgeschlagen:", e));
}
if (entry.mesh) out.push(entry.mesh);
}
return out;
}
+899
View File
@@ -0,0 +1,899 @@
// Öffnungs-Domäne der 3D-Ableitung: Glasscheibe(n)/Türblatt, Rahmen-/Sprossen-
// Riegel (Zarge/Blendrahmen/Kämpfer/Mittelpfosten/Flügelrahmen + SIA-Symbole)
// und der Rollladen-/Sonnenschutzkasten typisierter Tür-/Fensteröffnungen — das
// 3D-Pendant zu `generatePlan/openings.ts`.
import type { Opening, Project, SashDef, Wall } from "../../model/types";
import { getDoorType, getWindowType, glazingPanesOf, openingsOfWall } from "../../model/types";
import { openingInterval, openingVerticalExtent } from "../../geometry/opening";
import { wallVerticalExtent } from "../../model/wall";
import type { DetailLevel } from "../../ui/TopBar";
import { EPS, type RMesh, type RRgb } from "./shared";
import { wallThicknessSafe } from "./bands";
/** Helles, leicht bläuliches Fenster-Glas (Scheibe in der Öffnung). */
const GLASS_RGB: RRgb = [0.62, 0.76, 0.85];
/**
* Halbe Dicke EINER Fensterscheibe (Meter, Mehrfachverglasungs-Pfad). Kompakt
* gehalten (8 mm Gesamtdicke je Scheibe, wie ein reales Einzelglas), damit
* z. B. eine Dreifachverglasung noch in den realistisch schlanken Default-
* Rahmen ({@link DEFAULT_FRAME_DEPTH}, ~70 mm) passt — bei der früheren
* "volle Wanddicke"-Rahmentiefe gab es dafür reichlich Platz, im schlanken
* Rahmen nicht mehr.
*/
const GLASS_HALF_THICK = 0.004;
/**
* Helles, warmes Rahmen-Grau (Zarge/Blendrahmen/Kämpfer/Sprossen) — bewusst
* deutlich heller als Wand und klar von der bläulichen Scheibe ({@link GLASS_RGB})
* unterscheidbar, damit der Rahmen im 3D als eigenes Bauteil lesbar ist
* (s. {@link emitOpeningFrames}).
*/
const FRAME_RGB: RRgb = [0.85, 0.85, 0.83];
/** Heller Grauton für den Rollladen-/Sonnenschutzkasten (s. {@link emitOpeningShading}). */
const SHADING_RGB: RRgb = [0.88, 0.88, 0.9];
/**
* Heller Stein-/Beton-Grauton der Fensterbank (Sims/Tropfkante) — bewusst etwas
* dunkler/kühler als der Rahmen ({@link FRAME_RGB}), damit die Bank im 3D als
* eigenes Bauteil (nicht als Rahmenfortsatz) liest (s. Sims-Zweig in
* {@link frameMeshesForOpening}).
*/
const SILL_RGB: RRgb = [0.8, 0.8, 0.78];
/**
* Dunkles Grau der SIA-Öffnungssymbole (dünne Diagonalen AUF dem Glas, wie in der
* 2D-Ansicht) — feine dunkle Linien auf der Scheibe (s. Symbol-Zweig in
* {@link frameMeshesForOpening}). Als sehr dünne, in der Öffnungsebene gedrehte
* Prismen ({@link openingPlaneBox}) knapp vor der Glasebene.
*/
const SYMBOL_RGB: RRgb = [0.25, 0.27, 0.3];
/** Halbe Breite der Symbol-Diagonalen (Meter) — ~5 mm Gesamtbreite, feine Linie. */
const SYMBOL_HALF_WIDTH = 0.0025;
/**
* Warmer, heller Türblatt-Ton — bewusst wärmer/heller als der Rahmen
* ({@link FRAME_RGB}), damit das Blatt im 3D als eigene Füllung liest
* (s. {@link doorLeafPanels}).
*/
const DOOR_LEAF_RGB: RRgb = [0.93, 0.9, 0.85];
/** Türblatt-Dicke quer zur Wand (Meter). */
const DOOR_LEAF_THICK = 0.04;
/** Default-Ansichtsbreite des Rahmenprofils (Meter), wenn `TYPE.frameWidth` fehlt. */
const DEFAULT_FRAME_WIDTH = 0.06;
/**
* Default-Rahmentiefe (Meter), wenn ein Typ keine `frameDepth` nennt: reale
* Fensterrahmen sind ~70–90 mm tief (Holz/PVC/Alu), UNABHÄNGIG von der
* Wanddicke — ein schlanker Rahmen mit sichtbarer Laibung ringsum, nicht ein
* massiver Block über die volle Wandtiefe. Deckt sich mit
* `geometry/opening.ts::DEFAULT_FRAME_DEPTH` (2D=3D).
*/
const DEFAULT_FRAME_DEPTH = 0.07;
// Faces eines Boxen-Quaders als Dreiecks-Indizes (Ecken-Index-Bits: bit0 =
// Achse from/to, bit1 = Höhe UK/OK, bit2 = Quer-Versatz nMin/nMax). Doppelseitig
// gerendert (append_context_mesh) ⇒ Winding egal; je Fläche 2 Dreiecke, 6
// Flächen = 12 Dreiecke. Gemeinsame Topologie für Glasscheiben UND Rahmen-/
// Sprossen-Riegel (s. {@link openingAxisBox}).
const OPENING_BOX_TRIS: number[] = [
0, 1, 3, 0, 3, 2, // Quer-Versatz nMin
4, 5, 7, 4, 7, 6, // Quer-Versatz nMax
0, 1, 5, 0, 5, 4, // UK
2, 3, 7, 2, 7, 6, // OK
0, 2, 6, 0, 6, 4, // from
1, 3, 7, 1, 7, 5, // to
];
/**
* Baut EINEN geschlossenen Achsen-Quader (8 Ecken, 12 Dreiecke) für eine
* Öffnung — die gemeinsame Basis von {@link emitOpeningGlass} (Scheiben) und
* {@link emitOpeningFrames} (Rahmen-/Sprossen-Riegel). Achsen-Intervall
* `[from,to]` (Meter ab `wall.start`, entlang der Wandrichtung u), Höhen-
* Intervall `[zBottom,zTop]` (absolute Weltmeter) und Quer-Versatz-Intervall
* `[nMin,nMax]` (Meter entlang der Grundriss-Normalen n=(uy,−ux), dieselbe
* Konvention wie die bisherige Glas-Box — `nMin`/`nMax` MÜSSEN nicht sortiert
* übergeben werden, die Box spannt zwischen beiden Werten). `positions` in
* MODELL-Metern (x, y, z=Höhe). Liefert `null` bei entarteten Massen (Achse/
* Höhe/Dicke ≤ EPS) statt eines Nullquaders.
*/
function openingAxisBox(
wall: Wall,
from: number,
to: number,
zBottom: number,
zTop: number,
nMin: number,
nMax: number,
color: RRgb,
): RMesh | null {
if (to - from <= EPS) return null;
if (zTop - zBottom <= EPS) return null;
if (Math.abs(nMax - nMin) <= EPS) return null;
const dx = wall.end.x - wall.start.x;
const dy = wall.end.y - wall.start.y;
const len = Math.hypot(dx, dy);
if (len < 1e-9) return null;
const ux = dx / len;
const uy = dy / len;
// Grundriss-Normale (Dicken-Achse) — RECHTE Normale (uy,−ux), bewusst NICHT
// leftNormal (anders als pushSegment/windowSymbol) — kombiniert mit dem
// (ebenfalls gespiegelten) Vorzeichen in resolveFrameNormalRange ergibt sich
// daraus physisch die korrekte aussen/innen-Seite (s. dortigen Kommentar).
const nx = uy;
const ny = -ux;
const positions: number[] = [];
for (let i = 0; i < 8; i++) {
const s = i & 1 ? to : from;
const z = i & 2 ? zTop : zBottom;
const t = i & 4 ? nMax : nMin;
positions.push(wall.start.x + ux * s + nx * t, wall.start.y + uy * s + ny * t, z);
}
return { positions, indices: [...OPENING_BOX_TRIS], kind: "imported", color };
}
/**
* Baut EIN sehr dünnes Prisma mit FREI orientierter Längsachse IN der Öffnungs-
* ebene — die diagonale Schwester von {@link openingAxisBox}. Die Längsachse
* verläuft vom Punkt `(u0,h0)` zum Punkt `(u1,h1)`, beide in Öffnungsebenen-
* koordinaten: `u` = Meter entlang der Wandrichtung (ab `wall.start`), `h` =
* absolute Welthöhe (Meter). Der Querschnitt spannt `±halfWidth` senkrecht zur
* Längsachse INNERHALB der Ebene (gedreht mit) und `[nBack,nFront]` entlang der
* Grundriss-Normalen (quer zur Wand, Tiefe). Damit lassen sich die schrägen
* SIA-Öffnungssymbole (Dreh-/Kipp-Diagonalen) als dünne, gedrehte dunkle Riegel
* AUF dem Glas erzeugen — der Renderer kennt nur Dreiecks-Meshes, keine Linien.
* `null` bei entarteter Länge (≤ EPS). 8 Ecken, dieselbe {@link OPENING_BOX_TRIS}-
* Topologie (doppelseitig gerendert ⇒ Winding egal; die drei Bits parametrisieren
* hier Endpunkt/Quer-Versatz/Tiefe).
*/
function openingPlaneBox(
wall: Wall,
u0: number,
h0: number,
u1: number,
h1: number,
halfWidth: number,
nFront: number,
nBack: number,
color: RRgb,
): RMesh | null {
const du = u1 - u0;
const dh = h1 - h0;
const segLen = Math.hypot(du, dh);
if (segLen < EPS) return null;
const dx = wall.end.x - wall.start.x;
const dy = wall.end.y - wall.start.y;
const wlen = Math.hypot(dx, dy);
if (wlen < 1e-9) return null;
const ux = dx / wlen;
const uy = dy / wlen;
// Grundriss-Normale (Dicken-Achse) — dieselbe Konvention wie openingAxisBox.
const nx = uy;
const ny = -ux;
// In der Ebene senkrecht zur Längsachse (u entlang Wand, h = Welthöhe):
// Rotation der normierten Richtung um 90°. `pu` wirkt auf die u-Achse (Wand),
// `ph` auf die Höhe — beide in Metern, orthonormal ⇒ echte `halfWidth`-Breite.
const pu = -dh / segLen;
const ph = du / segLen;
const positions: number[] = [];
for (let i = 0; i < 8; i++) {
const ui = i & 1 ? u1 : u0;
const hi = i & 1 ? h1 : h0;
const s = i & 2 ? 1 : -1;
const t = i & 4 ? nFront : nBack;
const u = ui + s * halfWidth * pu;
const h = hi + s * halfWidth * ph;
positions.push(wall.start.x + ux * u + nx * t, wall.start.y + uy * u + ny * t, h);
}
return { positions, indices: [...OPENING_BOX_TRIS], kind: "imported", color };
}
/** Klemmt `v` auf `[lo,hi]`. */
function clampRange(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
/**
* Rahmen-Tiefe quer zur Wand (Meter): explizite `frameDepth` des Typs, sonst
* {@link DEFAULT_FRAME_DEPTH} (auf die Wanddicke geklemmt, falls die Wand
* dünner als der Default-Rahmen ist).
*/
function resolveAcrossWallDepth(
frameDepth: number | undefined,
wallThickness: number,
): number {
const depth = frameDepth !== undefined && frameDepth > EPS ? frameDepth : DEFAULT_FRAME_DEPTH;
return Math.min(depth, wallThickness);
}
/**
* Quer-Versatz-Intervall `[nMin,nMax]` (Meter entlang der Wand-Normalen, s.
* {@link openingAxisBox}) des Rahmenkörpers — der 3D-Niederschlag des
* Schichteinzugs (`insetFromFace`/`insetFace`): die Rahmen-VORDERKANTE liegt
* bündig mit der gewählten Wandfläche, zurückversetzt um `insetFromFace`
* (0/fehlt ⇒ bündig), der Rahmenkörper erstreckt sich von dort aus `depth`
* Meter zur Wandmitte hin. "aussen" liegt an der POSITIVEN Normalen-Seite
* (kleinster Schicht-Offset in `../bands.ts::pushSegment`, s. dessen Kommentar
* zur Aussenseiten-Konvention via `leftNormal`), "innen" an der negativen. Das
* Ergebnis wird auf die halbe Wanddicke geklemmt — ein Schichteinzug darf den
* Rahmen nie aus der Wand hinausschieben.
*/
function resolveFrameNormalRange(
halfThickness: number,
depth: number,
insetFromFace: number,
face: "aussen" | "innen",
): { nMin: number; nMax: number } {
const sign = face === "innen" ? -1 : 1;
const front = sign * (halfThickness - insetFromFace);
const back = front - sign * depth;
const a = clampRange(front, -halfThickness, halfThickness);
const b = clampRange(back, -halfThickness, halfThickness);
return { nMin: Math.min(a, b), nMax: Math.max(a, b) };
}
/**
* Aufgelöste Rahmen-/Sprossen-Parameter EINER Öffnung (Tür oder Fenster) aus
* ihrem `DoorType`/`WindowType` (via `getDoorType`/`getWindowType`). `null`,
* wenn kein Typ auflösbar ist (`Opening.typeId` fehlt/verwaist) — DANN
* emittieren weder {@link emitOpeningFrames} noch die Rahmen-/Sprossen-Logik in
* {@link emitOpeningGlass} irgendetwas (Rückwärtskompatibilität: unveränderte
* Alt-Darstellung, s. Moduldoc-Kommentar in `../../toWalls3d.ts`).
*/
interface OpeningFrameParams {
/** Sichtbare Rahmen-/Flügelprofil-Breite in der Öffnungsebene (Meter). */
frameWidth: number;
/** Rahmen-Tiefe quer zur Wand (Meter, informativ — steckt bereits in nMin/nMax). */
acrossWallDepth: number;
/** Quer-Versatz-Intervall des Rahmenkörpers (s. {@link resolveFrameNormalRange}). */
nMin: number;
nMax: number;
/** Oberlicht-Höhe (Meter); 0 = kein Oberlicht/Kämpfer. */
transomHeight: number;
/** Schwelle/Sohlbank zeichnen: Fenster immer, Tür nur mit `threshold`. */
hasSill: boolean;
/** Flügelzahl (vertikale Mittelpfosten = wingCount−1); Türen immer 1. */
wingCount: number;
/** Kämpfer-Zeilen (horizontale Teilung = mullionRows−1); Türen immer 1. */
mullionRows: number;
/** Sprossen-Spalten (vertikale Teilung = mullionCols−1); Türen immer 1. */
mullionCols: number;
/** Ob eine Glasscheibe gezeichnet wird (Fenster immer, Tür nur `leafStyle: "glas"`). */
glazed: boolean;
/**
* Verglasungs-Scheibenzahl (1–3), render-wirksam (`glazingPanesOf`). Türen:
* immer 1 (keine typisierte Mehrfachverglasung). Steuert die Anzahl dünner,
* in Wandrichtung leicht versetzter Glasflächen in {@link glassPanesForOpening}.
*/
glazingPanes: 1 | 2 | 3;
/**
* Verglasungs-Anteil des HAUPTFELDS (0..1, von OBEN gemessen): 1 = voll
* verglast (Fenster, Ganzglastür), < 1 = nur der obere Anteil ist Glas, unten
* bleibt ein blindes Feld (z. B. Tür mit Oberglas, `DoorType.glazingRatio`).
* Fenster: immer 1.
*/
glazedFraction: number;
/**
* Flügeleinteilung (`sashesOfWindowType`) — je ÖFFENBAREM Flügel wird im
* 3D ein Flügelrahmen (Rahmen-Riegel innerhalb des Blendrahmens) erzeugt
* (Schachtelung Blendrahmen → Flügelrahmen → Glas). Türen: leer.
*/
sashes: SashDef[];
/** Halbe Wanddicke (Meter) — Bezug der Wand-Aussenfläche (+n) für Bank/Zargen-Umgriff. */
halfThickness: number;
/**
* Fensterbank (Sims) zeichnen: nur Fenster mit `sillBoard` ≠ "keine"/"innen"
* (fehlt ⇒ zeichnen, wie in der 2D-Ansicht). Türen: nie.
*/
drawSill: boolean;
/**
* Zargen-Umgriff (Bekleidung VOR beiden Wandflächen) zeichnen: nur Türen mit
* `frameKind === "zarge"`. Blockrahmen bleibt bündig (false).
*/
zargeCladding: boolean;
}
function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): OpeningFrameParams | null {
const wallThickness = wallThicknessSafe(project, wall);
const halfThickness = wallThickness / 2;
if (op.kind === "door") {
const dt = getDoorType(project, op);
if (!dt) return null;
const acrossWallDepth = resolveAcrossWallDepth(dt.frameDepth, wallThickness);
const { nMin, nMax } = resolveFrameNormalRange(
halfThickness,
acrossWallDepth,
dt.insetFromFace ?? 0,
dt.insetFace ?? "aussen",
);
return {
frameWidth: dt.frameWidth ?? DEFAULT_FRAME_WIDTH,
acrossWallDepth,
nMin,
nMax,
transomHeight: Math.max(0, dt.transomHeight ?? 0),
hasSill: dt.threshold === true,
// Zweiflüglige Tür ⇒ 2 Flügel = ein mittiger Anschlagpfosten (Stulp) im 3D.
wingCount: Math.max(1, Math.round(dt.leafCount ?? 1)),
mullionRows: 1,
mullionCols: 1,
glazed: dt.leafStyle === "glas",
glazingPanes: 1,
// Teilverglasung: glazingRatio (Glasanteil oben) bei Glasblatt; sonst voll.
glazedFraction: Math.max(0.05, Math.min(1, dt.glazingRatio ?? 1)),
sashes: [],
halfThickness,
drawSill: false,
// Zarge umgreift die Wand (Bekleidung auf beiden Flächen); Blockrahmen bleibt bündig.
zargeCladding: (dt.frameKind ?? "zarge") === "zarge",
};
}
const wt = getWindowType(project, op);
if (!wt) return null;
const acrossWallDepth = resolveAcrossWallDepth(wt.frameDepth, wallThickness);
const { nMin, nMax } = resolveFrameNormalRange(
halfThickness,
acrossWallDepth,
wt.insetFromFace ?? 0,
wt.insetFace ?? "aussen",
);
// Flügeltabelle: explizite `wt.sashes` gewinnen; sonst gleichmäßig aus dem
// effektiven Flügelzahl-Wert (Instanz-Override `op.wingCount` vor Typ-Default).
// wingCount wird DARAUS abgeleitet, damit Mittelpfosten (wingCount−1) und
// Flügelrahmen (je Flügel) konsistent bleiben.
let sashes = wt.sashes && wt.sashes.length > 0 ? wt.sashes : undefined;
if (!sashes) {
const n = Math.max(1, Math.round(op.wingCount ?? wt.wingCount ?? 1));
const opening = wt.kind === "fest" ? "fest" : wt.kind;
sashes = Array.from({ length: n }, (_, i) => ({
kind: "fluegel" as const,
autoWidth: true,
opening,
hingeSide: (i % 2 === 0 ? "left" : "right") as "left" | "right",
}));
}
const wingCount = Math.max(1, sashes.filter((s) => s.kind === "fluegel").length);
return {
frameWidth: wt.frameWidth ?? DEFAULT_FRAME_WIDTH,
acrossWallDepth,
nMin,
nMax,
transomHeight: Math.max(0, wt.transomHeight ?? 0),
hasSill: true,
wingCount,
mullionRows: Math.max(1, Math.round(wt.mullionRows ?? 1)),
mullionCols: Math.max(1, Math.round(wt.mullionCols ?? 1)),
glazed: true,
glazingPanes: glazingPanesOf(wt),
glazedFraction: 1,
sashes,
halfThickness,
// Fensterbank zeichnen, ausser explizit "keine"/"innen" (fehlt ⇒ zeichnen).
drawSill: wt.sillBoard !== "keine" && wt.sillBoard !== "innen",
zargeCladding: false,
};
}
/**
* Rahmen-/Sprossen-Riegel EINER Öffnung: Pfosten (links/rechts, volle
* Öffnungshöhe), Sturz (oben), Schwelle (nur wenn `params.hasSill`), optional
* ein Kämpfer-Riegel (Oberlicht-Trennung, `transomHeight>0`) sowie die
* Flügel-Mittelpfosten (`wingCount−1`, vertikal) und Kämpfer-Zeilen
* (`mullionRows−1`, horizontal) im Flügel-Bereich zwischen Schwelle/Sturz
* bzw. Schwelle/Kämpfer. Alle Rahmen-Riegel in {@link FRAME_RGB}.
*
* DetailLevel-Staffelung (VW-Detailgrad „fein" als Referenz):
* • „grob" — wird gar nicht aufgerufen (s. {@link emitOpeningFrames}).
* • „mittel"— Blendrahmen + Oberlicht-Kämpfer + Flügelrahmen (verschachtelt) +
* Fensterbank/Zargen-Umgriff, aber KEINE Sprossen und KEINE SIA-Symbole.
* • „fein" — zusätzlich Flügel-Mittelpfosten + Kämpfer-Zeilen (Sprossen) und
* die SIA-Öffnungssymbole (`openingPlaneBox`) auf dem Glas.
*/
function frameMeshesForOpening(
wall: Wall,
iv: { from: number; to: number },
vert: { zBottom: number; zTop: number },
params: OpeningFrameParams,
detail: DetailLevel = "fein",
): RMesh[] {
const out: RMesh[] = [];
const fine = detail === "fein";
const medium = detail === "mittel";
const sashesOn = fine || medium; // Flügelrahmen: mittel + fein
const detailBodies = fine || medium; // Bank + Zargen-Umgriff: mittel + fein
const fw = params.frameWidth;
// Generischer Riegel mit frei wählbarem Quer-Band [nA,nB] und Farbe.
const pushC = (
from: number,
to: number,
zBottom: number,
zTop: number,
nA: number,
nB: number,
color: RRgb,
): void => {
const mesh = openingAxisBox(wall, from, to, zBottom, zTop, nA, nB, color);
if (mesh) out.push(mesh);
};
// Blendrahmen-Riegel (volles Rahmen-Quer-Band nMin..nMax, Rahmenfarbe).
const push = (from: number, to: number, zBottom: number, zTop: number): void =>
pushC(from, to, zBottom, zTop, params.nMin, params.nMax, FRAME_RGB);
// Pfosten (Laibungsseiten), über die volle Öffnungshöhe durchlaufend.
push(iv.from, iv.from + fw, vert.zBottom, vert.zTop);
push(iv.to - fw, iv.to, vert.zBottom, vert.zTop);
// Sturz (oberer Riegel).
push(iv.from + fw, iv.to - fw, vert.zTop - fw, vert.zTop);
// Schwelle: Fenster immer, Tür nur mit Bodenanschlag (threshold).
if (params.hasSill) push(iv.from + fw, iv.to - fw, vert.zBottom, vert.zBottom + fw);
// Flügel-/Sash-Bereich zwischen Schwelle und Sturz, ggf. durch den Kämpfer
// (Oberlicht-Trennung) oben beschnitten.
const sashFrom = iv.from + fw;
const sashTo = iv.to - fw;
const sashBottom = vert.zBottom + (params.hasSill ? fw : 0);
let sashTop = vert.zTop - fw;
if (params.transomHeight > EPS) {
const splitZ = clampRange(vert.zTop - params.transomHeight, sashBottom, sashTop);
push(sashFrom, sashTo, splitZ - fw / 2, splitZ + fw / 2); // Kämpfer-Riegel
sashTop = splitZ - fw / 2;
}
// Flügel-Mittelpfosten + Kämpfer-Zeilen (Sprossen) nur bei FEINEM Detailgrad —
// bei „mittel" bleibt es beim reinen Rahmen (+ Oberlicht-Kämpfer oben).
if (fine) {
// Flügel-Mittelpfosten (vertikal, wingCount−1) im Sash-Bereich.
if (params.wingCount > 1 && sashTo - sashFrom > EPS) {
const cell = (sashTo - sashFrom) / params.wingCount;
for (let i = 1; i < params.wingCount; i++) {
const center = sashFrom + i * cell;
push(center - fw / 2, center + fw / 2, sashBottom, sashTop);
}
}
// Kämpfer-Zeilen (horizontal, mullionRows−1) im Sash-Bereich.
if (params.mullionRows > 1 && sashTop - sashBottom > EPS) {
const cell = (sashTop - sashBottom) / params.mullionRows;
for (let i = 1; i < params.mullionRows; i++) {
const center = sashBottom + i * cell;
push(sashFrom, sashTo, center - fw / 2, center + fw / 2);
}
}
// Sprossen-Spalten (vertikal, mullionCols−1) im Sash-Bereich.
if (params.mullionCols > 1 && sashTo - sashFrom > EPS) {
const cell = (sashTo - sashFrom) / params.mullionCols;
for (let i = 1; i < params.mullionCols; i++) {
const center = sashFrom + i * cell;
push(center - fw / 2, center + fw / 2, sashBottom, sashTop);
}
}
}
// Flügelrahmen je ÖFFENBAREM Flügel: ein eigener Rahmen-Riegel-Ring (links/
// rechts/oben/unten), der DIREKT am Blendrahmen ansetzt (kein Spalt — sonst
// schiene die Vollfeld-Glasscheibe als Streifen zwischen Blend- und Flügel-
// rahmen durch). Die Schachtelung Blendrahmen → Flügelrahmen → Glas liest sich
// über die Tiefe: der Flügelrahmen sitzt in einem EIGENEN, leicht ZURÜCK-
// GESETZTEN und flacheren Quer-Band (`sashBackN..sashFrontN`) hinter der
// Blendrahmen-Vorderkante — eine plastische Stufe nach innen (VW „fein"),
// ohne vor die Fassade auszukragen. Zusätzlich (nur „fein") die SIA-Öffnungs-
// symbole auf dem Glas. Feste Flügel bekommen weder Flügelrahmen noch Symbol.
if (sashesOn && params.sashes.length > 0 && sashTo - sashFrom > EPS && sashTop - sashBottom > EPS) {
const segs = resolveSashSpans(params.sashes, sashFrom, sashTo);
const sfw = fw * 0.7;
// Verschachteltes Flügelrahmen-Band: ~5 mm HINTER der Blendrahmen-Vorderkante
// (nMax = Aussenseite) und nur ~50 mm tief — abgesetzte Stufe nach innen,
// die Glas-Vorderkante (nMax − 0.02, Falzmass) liegt INNERHALB dieses Bands.
const sashFrontN = params.nMax - 0.005;
const sashBackN = Math.max(params.nMin, params.nMax - 0.055);
// Glas-Vorderkante (s. glassPanesForOpening: n1 = nMax − 0.02); die Symbole
// liegen als ~2.5 mm dünne dunkle Prismen KNAPP davor (weiter aussen).
const glassFrontN = params.nMax - 0.02;
const symBackN = glassFrontN;
const symFrontN = glassFrontN + 0.003;
const pushSash = (from: number, to: number, zB: number, zT: number): void =>
pushC(from, to, zB, zT, sashBackN, sashFrontN, FRAME_RGB);
const pushSymbol = (u0: number, h0: number, u1: number, h1: number): void => {
const mesh = openingPlaneBox(wall, u0, h0, u1, h1, SYMBOL_HALF_WIDTH, symFrontN, symBackN, SYMBOL_RGB);
if (mesh) out.push(mesh);
};
for (const seg of segs) {
const s = seg.sash;
if (s.kind !== "fluegel" || !s.opening || s.opening === "fest") continue;
// Bündig am Blendrahmen/Segmentrand ansetzen — die Stufe kommt aus der
// Tiefe (sashBackN..sashFrontN), nicht aus einem sichtbaren Glas-Spalt.
const cf = seg.from;
const ct = seg.to;
const bz = sashBottom;
const tz = sashTop;
if (ct - cf <= EPS || tz - bz <= EPS) continue;
pushSash(cf, cf + sfw, bz, tz); // linker Flügelholm
pushSash(ct - sfw, ct, bz, tz); // rechter Flügelholm
pushSash(cf, ct, tz - sfw, tz); // oberer Flügelriegel
pushSash(cf, ct, bz, bz + sfw); // unterer Flügelriegel
// SIA-Öffnungssymbole (nur „fein") auf dem Glasfeld INNERHALB des Flügel-
// rahmens — exakt wie in der 2D-Ansicht (s. toElevation): Dreh = V mit
// Spitze am Bandseiten-Mittel des Glasfelds, Kipp = V mit Spitze oben Mitte.
if (!fine) continue;
const gA = cf + sfw;
const gB = ct - sfw;
const gbz = bz + sfw;
const gtz = tz - sfw;
if (gB - gA <= EPS || gtz - gbz <= EPS) continue;
const kind = s.opening;
if (kind === "dreh" || kind === "drehkipp") {
const hingeLeft = (s.hingeSide ?? "left") === "left";
const apexU = hingeLeft ? gA : gB;
const apexH = (gbz + gtz) / 2;
const farU = hingeLeft ? gB : gA;
pushSymbol(farU, gbz, apexU, apexH);
pushSymbol(farU, gtz, apexU, apexH);
}
if (kind === "kipp" || kind === "drehkipp") {
const apexU = (gA + gB) / 2;
pushSymbol(gA, gbz, apexU, gtz);
pushSymbol(gB, gbz, apexU, gtz);
}
if (kind === "schiebe") {
// Schiebe: horizontaler Pfeil in Laufrichtung (Griff links ⇒ nach rechts).
const zc = (gbz + gtz) / 2;
const toRight = (s.hingeSide ?? "left") === "left";
const tail = toRight ? gA : gB;
const head = toRight ? gB : gA;
const barb = (head - tail) * 0.22;
pushSymbol(tail, zc, head, zc); // Schaft
pushSymbol(head - barb, gbz + (gtz - gbz) * 0.32, head, zc); // obere Spitze
pushSymbol(head - barb, gbz + (gtz - gbz) * 0.68, head, zc); // untere Spitze
}
}
}
// Fensterbank (Sims, VW „fein"/„mittel"): Bank-Quader unter der Öffnung,
// seitlich ~3 cm überstehend, nach aussen (Wand-Aussenfläche +halfThickness)
// auskragend, mit einer zweiten, dünneren Tropfkanten-Stufe an der Aussenkante
// darunter. Nur Fenster mit `params.drawSill` (sillBoard ≠ "keine"/"innen").
if (detailBodies && params.drawSill) {
const ov = 0.03; // seitlicher Überstand je Seite
const proj = 0.04; // Auskragung nach aussen (vor die Wandfläche)
const under = 0.02; // Einbindung unter den Rahmen (in den Reveal)
const bankH = 0.04; // Bankdicke
const dripH = 0.02; // Höhe der Tropfkanten-Stufe
const dripW = 0.02; // Tiefe der Tropfkante an der Aussenkante
const outerN = params.halfThickness; // Wand-Aussenfläche
const bankTop = vert.zBottom;
const bankBottom = bankTop - bankH;
// Bank
pushC(iv.from - ov, iv.to + ov, bankBottom, bankTop, outerN - under, outerN + proj, SILL_RGB);
// Tropfkante (dünnere Stufe darunter, an der Aussenkante)
pushC(iv.from - ov, iv.to + ov, bankBottom - dripH, bankBottom, outerN + proj - dripW, outerN + proj, SILL_RGB);
}
// Tür-Zarge mit Umgriff (VW): die Zarge umgreift die Wand — eine schmale
// Bekleidungs-Platte VOR beiden Wandflächen, als Ring (Pfosten links/rechts +
// Sturz) rund um die Öffnung, ~1.3 cm vor die Fläche vorstehend. Nur
// `frameKind === "zarge"`; Blockrahmen bleibt bündig (params.zargeCladding false).
if (detailBodies && params.zargeCladding) {
const proud = 0.013; // Vorstand vor die Wandfläche
const ext = 0.02; // Überstand auf die Wandfläche (über die Laibungskante hinaus)
const casW = fw; // Bekleidungsbreite (= Rahmenprofil)
for (const sign of [1, -1] as const) {
const faceN = sign * params.halfThickness;
const nA = faceN;
const nB = faceN + sign * proud;
pushC(iv.from - ext, iv.from + casW, vert.zBottom, vert.zTop + ext, nA, nB, FRAME_RGB); // Pfosten links
pushC(iv.to - casW, iv.to + ext, vert.zBottom, vert.zTop + ext, nA, nB, FRAME_RGB); // Pfosten rechts
pushC(iv.from - ext, iv.to + ext, vert.zTop - casW, vert.zTop + ext, nA, nB, FRAME_RGB); // Sturz
}
}
return out;
}
/**
* Löst die Flügeleinteilung auf das konkrete Achsintervall `[from,to]` (Meter)
* auf — feste Breiten (`kind:"pfosten"`/`autoWidth:false`) exakt, der Rest
* gleichmäßig auf die Restbreite. Pendant zu `sashSegments` in `opening.ts`
* (2D), aber direkt in Öffnungs-Achskoordinaten (3D).
*/
function resolveSashSpans(
sashes: SashDef[],
from: number,
to: number,
): { sash: SashDef; from: number; to: number }[] {
const width = Math.max(0, to - from);
let fixedSum = 0;
let autoCount = 0;
for (const s of sashes) {
if (s.kind === "pfosten") fixedSum += Math.max(0, s.postWidth ?? 0.06);
else if (s.autoWidth === false) fixedSum += Math.max(0, s.width ?? 0);
else autoCount++;
}
const autoW = autoCount > 0 ? Math.max(0, (width - fixedSum) / autoCount) : 0;
const out: { sash: SashDef; from: number; to: number }[] = [];
let cursor = from;
for (const s of sashes) {
const w =
s.kind === "pfosten"
? Math.max(0, s.postWidth ?? 0.06)
: s.autoWidth === false
? Math.max(0, s.width ?? 0)
: autoW;
out.push({ sash: s, from: cursor, to: cursor + w });
cursor += w;
}
return out;
}
/** Luftraum zwischen zwei Scheiben einer Mehrfachverglasung (Meter, informativ). */
const GLASS_PANE_GAP = 0.006;
/**
* Glasscheibe(n) EINER typisierten Öffnung (Fenster immer, Tür nur bei
* `params.glazed`): je Z-Band (Hauptfeld zwischen Schwelle und Sturz bzw.
* Kämpfer, plus — bei `transomHeight>0` — ein zweites Oberlicht-Band) werden
* `params.glazingPanes` dünne, in Wandrichtung (quer zur Wand, n-Achse) leicht
* versetzte Scheiben mit Luftraum dazwischen erzeugt (Isolierverglasung:
* 1 = Einfach-, 2 = Zweifach-, 3 = Dreifachverglasung). Bei `glazingPanes: 1`
* (Alt-Verhalten/Default) entsteht exakt EINE Scheibe an derselben Position wie
* bisher. Die Sprossen/Mittelpfosten aus {@link frameMeshesForOpening} liegen
* VOR diesen Scheiben (keine Pro-Feld-Aufteilung — bewusst einfach gehalten).
*/
function glassPanesForOpening(
wall: Wall,
iv: { from: number; to: number },
vert: { zBottom: number; zTop: number },
params: OpeningFrameParams,
): RMesh[] {
const out: RMesh[] = [];
const fw = params.frameWidth;
const sashFrom = iv.from + fw;
const sashTo = iv.to - fw;
const sashBottom = vert.zBottom + (params.hasSill ? fw : 0);
const sashTopFull = vert.zTop - fw;
// Die Scheibe(n) sitzen als DÜNNE Tafeln NAHE der Aussenfläche (nMax = +n-
// Seite, s. resolveFrameNormalRange) — mittig tief im Reveal wirkte das
// schwach/kaum sichtbar. Direkt hinter der Aussenkante (Falzmass ~2 cm, s.
// Fensterbau-Recherche) liest sich das Fenster klar, die Laibung/Reveal-
// Tiefe zeigt nach innen. Bei mehreren Scheiben rücken die weiteren mit
// `GLASS_PANE_GAP` Luftraum dazwischen weiter nach innen (Richtung `nMin`).
const glassThick = GLASS_HALF_THICK * 2;
const panes = Math.max(1, Math.min(3, Math.round(params.glazingPanes)));
const push = (zBottom: number, zTop: number): void => {
let n1 = params.nMax - 0.02;
for (let p = 0; p < panes; p++) {
const n0 = Math.max(params.nMin, n1 - glassThick);
if (n1 - n0 <= EPS) break;
const mesh = openingAxisBox(wall, sashFrom, sashTo, zBottom, zTop, n0, n1, GLASS_RGB);
if (mesh) out.push(mesh);
n1 = n0 - GLASS_PANE_GAP;
if (n1 <= params.nMin) break;
}
};
// Teilverglasung (Türen mit glazingRatio < 1): das HAUPTFELD ist nur im oberen
// `glazedFraction`-Anteil Glas; darunter bleibt ein blindes Blattfeld. Von der
// OBERKANTE des Hauptbands gemessen. Fenster: glazedFraction = 1 (voll).
const frac = Math.max(0.05, Math.min(1, params.glazedFraction));
const glazedBottom = (bBottom: number, bTop: number) =>
bTop - frac * (bTop - bBottom);
if (params.transomHeight > EPS) {
const splitZ = clampRange(vert.zTop - params.transomHeight, sashBottom, sashTopFull);
const mainTop = splitZ - fw / 2;
if (params.glazed) push(glazedBottom(sashBottom, mainTop), mainTop); // Hauptglas (ggf. nur oben).
push(splitZ + fw / 2, sashTopFull); // Oberlicht-Scheibe (immer voll, auch Vollblatt-Tür).
} else if (params.glazed) {
push(glazedBottom(sashBottom, sashTopFull), sashTopFull);
}
return out;
}
/**
* Türblatt EINER typisierten Tür als solide Blatt-Tafel im Hauptfeld (zwischen
* den Rahmen-Pfosten, über Schwelle bis Sturz bzw. Kämpfer): Vollblatt
* (`leafStyle` ≠ "glas") füllt das ganze Feld, Teilverglasung
* (`glazingRatio` < 1) nur den blinden UNTEREN Rest unter dem Glasanteil
* (Anteil von OBEN gemessen, s. {@link glassPanesForOpening}). Das Blatt liegt
* mittig um die Glasebene (nMax − 0.03), ~4 cm dick ({@link DOOR_LEAF_THICK}),
* in {@link DOOR_LEAF_RGB} — damit Türen im 3D als Türen lesbar sind statt als
* offene Löcher mit Rahmen. Fenster: nie (glazedFraction = 1 ⇒ kein Blatt).
*/
function doorLeafPanels(
wall: Wall,
iv: { from: number; to: number },
vert: { zBottom: number; zTop: number },
params: OpeningFrameParams,
): RMesh[] {
const out: RMesh[] = [];
const fw = params.frameWidth;
const leafFrom = iv.from + fw;
const leafTo = iv.to - fw;
const leafBottom = vert.zBottom + (params.hasSill ? fw : 0);
let fieldTop = vert.zTop - fw;
if (params.transomHeight > EPS) {
const splitZ = clampRange(vert.zTop - params.transomHeight, leafBottom, fieldTop);
fieldTop = splitZ - fw / 2; // Hauptfeld endet am Kämpfer, Oberlicht bleibt Glas.
}
// Blinder Blattanteil: alles unterhalb des (von oben gemessenen) Glasanteils.
const frac = params.glazed ? Math.max(0.05, Math.min(1, params.glazedFraction)) : 0;
const leafTop = fieldTop - frac * (fieldTop - leafBottom);
if (leafTop - leafBottom <= EPS || leafTo - leafFrom <= EPS) return out;
const n1 = params.nMax - 0.02 + DOOR_LEAF_THICK / 2;
const n0 = Math.max(params.nMin, n1 - DOOR_LEAF_THICK);
const mesh = openingAxisBox(wall, leafFrom, leafTo, leafBottom, leafTop, n0, n1, DOOR_LEAF_RGB);
if (mesh) out.push(mesh);
return out;
}
/**
* Emittiert je Fensteröffnung eine Glasscheibe und je Türöffnung die Füllung
* (Blatt-Tafel, s. {@link doorLeafPanels}, plus Scheibe bei Glasanteil/
* Oberlicht) als rohe Quader-Meshes — damit Fenster/Türen im 3D lesbar sind
* statt als blosse Löcher in der Wand.
*
* OHNE auflösbaren Typ (`getWindowType`/`getDoorType` liefert `undefined`,
* z. B. `Opening.typeId` fehlt): unverändertes ALT-Verhalten — EINE Vollscheibe
* über die geklemmte Öffnungs-Achse (`openingInterval`) und -Höhe
* (`openingVerticalExtent`), quer ±{@link GLASS_HALF_THICK} mittig in der
* Wand (Rückwärtskompatibilität, s. Moduldoc-Kommentar).
*
* MIT Typ: die Scheibe(n) sitzen im Flügel-Bereich INNERHALB des Rahmens (s.
* {@link glassPanesForOpening}), quer entlang des über den Schichteinzug
* bestimmten Rahmen-Versatzes (s. {@link resolveFrameNormalRange}) statt fix
* mittig — bei Oberlicht (`transomHeight>0`) kommt eine zweite Scheibe im
* Oberlicht-Band dazu. Die zugehörigen Rahmen-/Sprossen-Riegel emittiert
* separat {@link emitOpeningFrames} (eigene Farbe, eigene Funktion — leichter
* einzeln zu testen/filtern als eine gemeinsame Emission).
*
* `positions` liegen — wie bei den übrigen Meshes — in MODELL-Metern als
* flaches (x, y, z=Höhe)-Array; render3d bildet sie selbst nach world
* (x, Höhe, y) ab und rendert die Meshes doppelseitig (append_context_mesh),
* daher ist die Winding-Reihenfolge der Dreiecke unerheblich. `kind: "imported"`
* mit gesetzter `color` ⇒ render3d zeichnet die Scheibe in GLASS_RGB.
*/
export function emitOpeningGlass(project: Project): RMesh[] {
const out: RMesh[] = [];
for (const wall of project.walls) {
for (const op of openingsOfWall(project, wall.id)) {
const iv = openingInterval(wall, op);
if (!iv) continue;
const vert = openingVerticalExtent(project, wall, op);
if (vert.zTop - vert.zBottom <= EPS) continue;
if (op.kind === "window") {
const params = resolveOpeningFrame(project, wall, op);
if (!params) {
// Kein Typ auflösbar -> Alt-Verhalten (fixe Vollscheibe, mittig).
const mesh = openingAxisBox(
wall,
iv.from,
iv.to,
vert.zBottom,
vert.zTop,
-GLASS_HALF_THICK,
GLASS_HALF_THICK,
GLASS_RGB,
);
if (mesh) out.push(mesh);
continue;
}
out.push(...glassPanesForOpening(wall, iv, vert, params));
} else {
// Tür: Blatt-Tafel (Vollblatt bzw. blinder Rest unter der Teilverglasung)
// + Scheibe(n) für Glasanteil/Oberlicht. Ohne auflösbaren Typ: nichts
// (Alt-Verhalten: offenes Loch).
const params = resolveOpeningFrame(project, wall, op);
if (params) {
out.push(...doorLeafPanels(wall, iv, vert, params));
out.push(...glassPanesForOpening(wall, iv, vert, params));
}
}
}
}
return out;
}
/**
* Emittiert je LEGACY-Tür (`project.doors` — das alte flache Türfeld VOR dem
* Öffnungs-Typsystem, z. B. `Door.hostWallId/position/width/height` ohne
* `typeId`) EIN Blatt-Panel als rohen Quader. `emitWall` (`../bands.ts`)
* schneidet für diese Türen zwar bereits ein Loch in die Wand, aber
* `emitOpeningGlass`/`emitOpeningFrames` durchlaufen NUR `project.openings`
* (das neue Typsystem) — ohne diese Funktion blieb eine Legacy-Tür ein
* blankes Loch, durch das man hindurchsieht (Nutzer-Report: "Türen fehlen im
* 3D komplett"). Analog zum Alt-Verhalten typloser Fenster
* ({@link emitOpeningGlass}: EINE Vollscheibe ohne Rahmen) — hier EIN
* Vollblatt ohne Rahmen/Zarge (das Rahmen-System gehört exklusiv zu
* `project.openings`, s. {@link resolveOpeningFrame}). `positions` in
* MODELL-Metern.
*/
export function emitLegacyDoorLeaves(project: Project): RMesh[] {
const out: RMesh[] = [];
for (const wall of project.walls) {
const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
if (axisLen < 1e-9) continue;
const { zBottom } = wallVerticalExtent(project, wall);
for (const d of project.doors ?? []) {
if (d.hostWallId !== wall.id) continue;
const from = Math.max(0, Math.min(d.position, axisLen));
const to = Math.max(from, Math.min(d.position + d.width, axisLen));
if (to - from <= EPS) continue;
const zTop = zBottom + d.height;
if (zTop - zBottom <= EPS) continue;
const mesh = openingAxisBox(
wall,
from,
to,
zBottom,
zTop,
-DOOR_LEAF_THICK / 2,
DOOR_LEAF_THICK / 2,
DOOR_LEAF_RGB,
);
if (mesh) out.push(mesh);
}
}
return out;
}
/**
* Emittiert je Öffnung MIT auflösbarem Typ (`getDoorType`/`getWindowType`) die
* Rahmen-/Sprossen-Riegel (Zarge/Blendrahmen, Sturz, Schwelle, Kämpfer,
* Mittelpfosten — s. {@link frameMeshesForOpening}) als rohe Quader-Meshes in
* {@link FRAME_RGB}. Öffnungen OHNE auflösbaren Typ (`typeId` fehlt/verwaist)
* bekommen KEINEN Rahmen — exakt das Alt-Verhalten vor der Typ-Vertiefung
* (Rückwärtskompatibilität).
*/
export function emitOpeningFrames(project: Project, detail: DetailLevel = "fein"): RMesh[] {
// Grob: keine Rahmen/Sprossen — die Öffnung bleibt Loch + Scheibe (schlank).
if (detail === "grob") return [];
const out: RMesh[] = [];
for (const wall of project.walls) {
for (const op of openingsOfWall(project, wall.id)) {
const iv = openingInterval(wall, op);
if (!iv) continue;
const vert = openingVerticalExtent(project, wall, op);
if (vert.zTop - vert.zBottom <= EPS) continue;
const params = resolveOpeningFrame(project, wall, op);
if (!params) continue;
out.push(...frameMeshesForOpening(wall, iv, vert, params, detail));
}
}
return out;
}
/**
* Rollladen-/Sonnenschutzkasten als 3D-Box über der Öffnung (nur Fenster mit
* `WindowType.shading.create`, nur mittel/fein). Ein heller Quader, aussen an
* der Aussenfläche (params.nMax) ansetzend und um `box.depth` nach aussen
* ragend, von der Öffnungs-OK um `box.height` nach oben. Breite = `box.width`
* (0/fehlt ⇒ Öffnungsbreite), mittig über der Öffnung. Pendant zur 2D-Kontur
* (`window-shading` in generatePlan/openings.ts).
*/
export function emitOpeningShading(project: Project, detail: DetailLevel = "fein"): RMesh[] {
if (detail === "grob") return [];
const out: RMesh[] = [];
for (const wall of project.walls) {
for (const op of openingsOfWall(project, wall.id)) {
if (op.kind !== "window") continue;
const wt = getWindowType(project, op);
if (!wt?.shading?.create) continue;
const iv = openingInterval(wall, op);
if (!iv) continue;
const vert = openingVerticalExtent(project, wall, op);
if (vert.zTop - vert.zBottom <= EPS) continue;
const params = resolveOpeningFrame(project, wall, op);
if (!params) continue;
const box = wt.shading.box;
const h = Math.max(0, box.height);
const d = Math.max(0, box.depth);
if (h <= EPS || d <= EPS) continue;
const span = box.width > 0 ? box.width : iv.to - iv.from;
const center = (iv.from + iv.to) / 2;
const from = center - span / 2;
const to = center + span / 2;
// Aussen an der Aussenfläche (nMax) ansetzen und um die Kastentiefe hinaus.
const nOuter = params.nMax;
const mesh = openingAxisBox(
wall,
from,
to,
vert.zTop,
vert.zTop + h,
nOuter,
nOuter + d,
SHADING_RGB,
);
if (mesh) out.push(mesh);
}
}
return out;
}
+147
View File
@@ -0,0 +1,147 @@
// Dach-Domäne der 3D-Ableitung: emittiert Dächer (`project.roofs`) als
// geschlossene, geschichtete Dreiecks-Meshes (Eindeckung → innen) aus den
// geneigten Dachflächen (`RoofGeometry.planes`) — jede Schicht ein eigener
// solider Slab mit ihrer Bauteilfarbe/-textur.
import type { Project } from "../../model/types";
import { getComponent, roofLayers } from "../../model/types";
import { roofBaseElevation, roofGeometry } from "../../geometry/roof";
import type { Vec3 as RoofVec3 } from "../../geometry/roof";
import { EPS, hexToRgb, resolveComponentMaterialLayer, type RMesh, type RRgb } from "./shared";
/** Warmer Terrakotta-Grauton für Dachflächen/-giebel — hebt das Dach klar von Wand/Decke/Kontext ab. */
const ROOF_RGB: RRgb = [0.72, 0.45, 0.36];
/**
* Trianguliert ein KONVEXES 3D-Polygon per Dreiecks-Fächer ab Vertex 0 und hängt
* die Dreiecke an `positions`/`indices` an. `roofGeometry` (Vertrag in
* `geometry/roof.ts`) liefert für `planes`/`gables` ausschliesslich konvexe
* Polygone (Dreieck/Quader bei den meisten Formen, ein Fünfeck-Giebel bei
* Mansarde) — ein Fächer trianguliert diese korrekt, ohne Ear-Clipping.
*/
function pushFanTriangles(positions: number[], indices: number[], pts: RoofVec3[]): void {
if (pts.length < 3) return;
const base = positions.length / 3;
for (const p of pts) positions.push(p[0], p[1], p[2]);
for (let i = 1; i < pts.length - 1; i++) indices.push(base, base + i, base + i + 1);
}
/**
* Robuste Flächennormale (Newell) einer Polygon-Ebene in MODELL-Koordinaten
* ([x, y, z=Höhe]), nach OBEN orientiert (positive Höhen-Komponente) — für die
* Dach-Dickenversetzung (Unterseite = Oberseite − Normale·Dicke).
*/
function planeNormalUp(pts: RoofVec3[]): [number, number, number] {
let nx = 0;
let ny = 0;
let nz = 0;
for (let i = 0; i < pts.length; i++) {
const c = pts[i];
const d = pts[(i + 1) % pts.length];
nx += (c[1] - d[1]) * (c[2] + d[2]);
ny += (c[2] - d[2]) * (c[0] + d[0]);
nz += (c[0] - d[0]) * (c[1] + d[1]);
}
const len = Math.hypot(nx, ny, nz) || 1;
const u: [number, number, number] = [nx / len, ny / len, nz / len];
return u[2] < 0 ? [-u[0], -u[1], -u[2]] : u;
}
/**
* Eine Dachfläche als SOLIDER Slab (Oberseite + um `thickness` entlang der
* Normalen nach unten versetzte Unterseite + umlaufende Seitenwände) — gibt dem
* Dach eine echte Materialdicke statt einer papierdünnen Fläche. render3d
* rendert doppelseitig, Winding ist unerheblich.
*/
function pushRoofSlab(
positions: number[],
indices: number[],
pts: RoofVec3[],
thickness: number,
): void {
if (pts.length < 3 || thickness <= EPS) {
pushFanTriangles(positions, indices, pts);
return;
}
const nrm = planeNormalUp(pts);
const bottom: RoofVec3[] = pts.map((p) => [
p[0] - nrm[0] * thickness,
p[1] - nrm[1] * thickness,
p[2] - nrm[2] * thickness,
]);
pushFanTriangles(positions, indices, pts); // Oberseite
pushFanTriangles(positions, indices, bottom); // Unterseite
// Seitenwände: je Kante ein Quad (zwei Dreiecke) zwischen Ober- und Unterseite.
for (let i = 0; i < pts.length; i++) {
const j = (i + 1) % pts.length;
const base = positions.length / 3;
positions.push(pts[i][0], pts[i][1], pts[i][2]);
positions.push(pts[j][0], pts[j][1], pts[j][2]);
positions.push(bottom[j][0], bottom[j][1], bottom[j][2]);
positions.push(bottom[i][0], bottom[i][1], bottom[i][2]);
indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
}
}
/**
* Emittiert Dächer (`project.roofs`) als geschlossene Dreiecks-Meshes: sowohl die
* geneigten Dachflächen (`RoofGeometry.planes`) als auch die Giebelflächen
* (`RoofGeometry.gables`) — erst zusammen bilden sie einen von aussen
* geschlossenen Körper (die Giebel schliessen die offenen Enden bei Sattel/
* Mansarde, die First-/Grat-/Knicklinien sind reine Grundriss-Hilfslinien und
* werden hier nicht gebraucht). Traufhöhe über `roofBaseElevation`
* (Override oder Geschoss-Oberkante), Flächen über `roofGeometry`. Farbe:
* `roof.color` (falls gültiges Hex), sonst {@link ROOF_RGB}. `positions` in
* MODELL-Metern (x, y, z=Höhe); render3d rendert Kontext-Meshes doppelseitig,
* die Winding-Reihenfolge ist also unerheblich.
*/
export function emitRoofs(project: Project): RMesh[] {
const out: RMesh[] = [];
for (const roof of project.roofs ?? []) {
const eavesZ = roofBaseElevation(project, roof);
const g = roofGeometry(roof, eavesZ);
const overrideColor = roof.color ? hexToRgb(roof.color, ROOF_RGB) : null;
// Schichtaufbau (aussen/Eindeckung → innen). Jede Schicht wird zu einem
// eigenen RMesh mit ihrer Bauteilfarbe, entlang der Flächennormalen unter die
// vorige gestapelt. Leere componentId (einschichtige thickness-Fallback) ⇒
// Default-Dachfarbe. Eine Roof.color-Übersteuerung gilt für alle Schichten.
const layers = roofLayers(project, roof);
layers.reduce((offsetAbove, layer) => {
const t = Math.max(0, layer.thickness);
if (t <= EPS) return offsetAbove;
const color =
overrideColor ??
(layer.componentId ? hexToRgb(getComponent(project, layer.componentId).color, ROOF_RGB) : ROOF_RGB);
// Material-Textur-Ebene fürs Textured-Rendering (z. B. echte Dachziegel-
// Textur der Eindeckung) — nur wenn KEIN Farb-Override greift (Override
// = bewusst eine einheitliche Farbe für alle Schichten, kein Bauteil-Bezug).
const materialIndex =
!overrideColor && layer.componentId
? resolveComponentMaterialLayer(project, layer.componentId)
: undefined;
const positions: number[] = [];
const indices: number[] = [];
for (const plane of g.planes) {
// Oberseite dieser Schicht = Dachfläche um `offsetAbove` entlang der
// Normalen nach innen versetzt; Slab-Dicke = Schichtdicke.
const nrm = planeNormalUp(plane.pts);
const top: RoofVec3[] = plane.pts.map((p) => [
p[0] - nrm[0] * offsetAbove,
p[1] - nrm[1] * offsetAbove,
p[2] - nrm[2] * offsetAbove,
]);
pushRoofSlab(positions, indices, top, t);
}
if (indices.length > 0) out.push({ positions, indices, kind: "extrusion", color, materialIndex });
return offsetAbove + t;
}, 0);
// Giebel-ENDFÜLLUNG bewusst NICHT mehr emittieren: die dreieckige Giebelfläche
// wurde früher als papierdünne Einzelfläche gebaut ("komischer Abschluss" an
// den Sattel-/Mansard-Enden) und lag zudem deckungsgleich vor der Giebelwand,
// die ohnehin bis an die Dach-Unterkante hochgeklemmt wird (s.
// `../bands.ts::clipPieceToRoofs`) — also doppelt und optisch als dünne
// 2D-Schicht sichtbar. Es wird nur noch das Dach SELBST (die geneigten
// Slab-Flächen) konstruiert; der Giebel entsteht aus der Wand darunter.
}
return out;
}
+385
View File
@@ -0,0 +1,385 @@
// Gemeinsame Grundlage der 3D-Ableitung: das Serde-Vokabular (`RWall`/`RSlab`/
// `RMesh`/…, matcht 1:1 render3d::types) sowie die Farb-/Schraffur-/Material-
// Auflösung, die über mehrere Bauteil-Domänen hinweg identisch gelten muss
// (Hex→RGB, dominante Schicht, Schnitt-Schraffur, Material-Textur-Ebene).
//
// Jede Datei unter `toWalls3d/` importiert von hier, NIE von `../toWalls3d`
// selbst — das hielte die Orchestrator-Datei (`toWalls3d.ts`) andernfalls in
// einem Zirkelimport mit jeder einzelnen Domäne.
import type { HatchPattern, Layer, Project } from "../../model/types";
import { getCeilingType, getComponent, getWallType, roofLayers } from "../../model/types";
import { resolveHatch } from "../generatePlan";
export type RVec2 = [number, number];
export type RRgb = [number, number, number];
/**
* Eine aufgelöste Wandschicht: Dicke + 3D-Albedo-Farbe (aus dem Component).
* Wird seit der TS-seitigen Schicht-Zerlegung (Option B, siehe toWalls3d.ts-
* Moduldoc) NICHT mehr an render3d durchgereicht — jede Schicht ist eine eigene
* Voll-Box; das `RWall.layers`-Feld bleibt für Serde-Kompatibilität, aber stets leer.
*/
export interface RLayer {
thickness: number;
color: RRgb;
}
/**
* Schnitt-Schraffur eines Bauteil-Bandes für den 3D-Live-Schnitt — matcht 1:1
* render3d::types::Hatch. Nur die drei Felder, die der prozedurale Cap-Shader
* (`CAP_WGSL`) auswertet: Muster-Id, Winkel (Grad), Skalierung. MONOCHROM (Tinte
* auf Papier, wie die 2D-Direktive `HATCH_INK`) — es wandert nur das MUSTER mit,
* KEINE Farbe. Muster-Id: 0 = none, 1 = solid, 2 = diagonal, 3 = crosshatch,
* 4 = insulation (Zickzack/Dämmung). Synchron zu halten mit `hatchPatternId`
* (unten), `types::Hatch` (Rust) und `CAP_WGSL`.
*/
export interface RHatch {
pattern: number;
angle: number;
scale: number;
/**
* Strichstärke der Schraffur-Musterlinien in mm-Papier (aus dem LineStyle des
* Hatch, `HatchRender.lineWeight`; Default 0.13). Matcht render3d::Hatch.lineWeight
* — der Cap-Shader setzt die Musterlinien-Breite daraus (dieselbe mm→Welt-
* Kalibrierung wie die Trennlinien), sodass die Schraffur-Stärke im 3D pro
* Schraffur einstellbar ist (z. B. SIA `hatch-hair` 0.02 mm vs. `thin` 0.13 mm).
*/
lineWeight?: number;
}
/** Bildet ein 2D-{@link HatchPattern} auf die Muster-Id von {@link RHatch}/CAP_WGSL ab. */
function hatchPatternId(p: HatchPattern): number {
switch (p) {
case "solid":
return 1;
case "diagonal":
return 2;
case "crosshatch":
return 3;
case "insulation":
return 4;
case "none":
default:
return 0;
}
}
/**
* Löst die Schnitt-Schraffur eines Bauteils (per `Component.hatchId`) zu einem
* {@link RHatch} auf — dasselbe Muster/Winkel/Skalierung wie der 2D-Schnitt
* (`generatePlan.ts::resolveHatch`, hier bewusst NICHT aufgerufen: nur die drei
* render3d-relevanten Felder werden direkt aus dem `HatchStyle` gezogen, die
* Farbe bleibt monochrom im Shader). `undefined`, wenn das Bauteil/die Schraffur
* nicht auflösbar ist (der Cap-Shader fällt dann auf sein Diagonalmuster zurück).
*/
export function resolveComponentHatch(
project: Project,
componentId: string,
axisAngleDeg?: number,
): RHatch | undefined {
try {
const comp = getComponent(project, componentId);
if (!comp.hatchId) return undefined;
// GENAU der 2D-Schnitt-Pfad (`splitWallLayers`/`splitSlabLayers` →
// `resolveHatch`): `axisAngleDeg` = 90 für WAND-Bänder (die geschnittene Wand
// steht vertikal, `relativeToWall`-Muster drehen mit), `undefined` für DECKEN
// (kein Wandachsen-Bezug → absoluter Winkel). Wir übernehmen den ENDWINKEL
// (`HatchRender.angle`, bereits relativeToWall-korrigiert) UND die Musterlinien-
// Stärke (`lineWeight`, aus dem LineStyle) — nicht mehr den rohen Hatch-Winkel.
const hr = resolveHatch(project, comp.hatchId, axisAngleDeg);
return { pattern: hatchPatternId(hr.pattern), angle: hr.angle, scale: hr.scale, lineWeight: hr.lineWeight };
} catch {
return undefined;
}
}
/**
* Schnitt-Schraffur der dicksten (dominanten) Schicht eines Schichtstapels.
* `axisAngleDeg` wie {@link resolveComponentHatch} (90 für Wand, undefined für Decke).
*/
export function dominantLayerHatch(
layers: Layer[],
project: Project,
axisAngleDeg?: number,
): RHatch | undefined {
if (layers.length === 0) return undefined;
const dominant = layers.reduce((best, l) => (l.thickness > best.thickness ? l : best));
return resolveComponentHatch(project, dominant.componentId, axisAngleDeg);
}
/**
* Geordnete Liste der EINDEUTIGEN Material-Farbkarten-URLs (`Component.material.
* color`), die von Wand-/Decken-Bauteilen des Projekts benutzt werden — in
* deterministischer First-Seen-Reihenfolge über `project.walls` (Wandtyp-Schichten)
* dann `project.ceilings`. Die POSITION in dieser Liste (+1) ist der Textur-Array-
* Index (`WallInput.materialIndex`, 1-basiert; Ebene 0 = Fallback-Schachbrett), den
* sowohl {@link resolveComponentMaterialLayer} (Zuweisung je Band) als auch der
* JS-Decoder (`viewport/materialTextures`) verwenden — so stimmen Band-Index und
* hochgeladene Textur-Ebene ohne separaten Map-Transport überein. Memoisiert je
* Projekt-REFERENZ (der Store ist immutabel — jede Änderung liefert ein neues
* Objekt, also kein Stale-Risiko), damit die per-Band-Auflösung nicht quadratisch wird.
*/
const materialMapCache = new WeakMap<Project, string[]>();
export function wallMaterialColorMaps(project: Project): string[] {
const cached = materialMapCache.get(project);
if (cached) return cached;
const seen = new Set<string>();
const list: string[] = [];
const consider = (componentId: string): void => {
try {
const url = getComponent(project, componentId).material?.color;
if (url && !seen.has(url)) {
seen.add(url);
list.push(url);
}
} catch {
// Unauflösbares Bauteil überspringen.
}
};
for (const w of project.walls) {
try {
for (const l of getWallType(project, w).layers) consider(l.componentId);
} catch {
// Kein auflösbarer Wandtyp.
}
}
for (const c of project.ceilings ?? []) {
try {
for (const l of getCeilingType(project, c).layers) consider(l.componentId);
} catch {
// Kein auflösbarer Deckentyp.
}
}
for (const r of project.roofs ?? []) {
for (const l of roofLayers(project, r)) {
if (l.componentId) consider(l.componentId);
}
}
materialMapCache.set(project, list);
return list;
}
/**
* Textur-Array-Ebene (1-basiert) der Material-Farbkarte eines Bauteils, oder
* `undefined`, wenn das Bauteil kein Material mit Farbkarte trägt. Ebene 0 ist das
* Fallback-Schachbrett — deshalb `indexOf + 1`. Siehe {@link wallMaterialColorMaps}.
*/
export function resolveComponentMaterialLayer(project: Project, componentId: string): number | undefined {
try {
const url = getComponent(project, componentId).material?.color;
if (!url) return undefined;
const idx = wallMaterialColorMaps(project).indexOf(url);
return idx >= 0 ? idx + 1 : undefined;
} catch {
return undefined;
}
}
/**
* Ein rechteckiges LOCH (Fenster/Tür) in EINEM Wandkörper — matcht 1:1
* render3d::types::Hole. KONVENTION: `from`/`to` sind Achsen-Meter ab
* `RWall.start` (0..Achslänge, dieselbe Achse wie die Öffnungs-Intervalle);
* `zBottom`/`zTop` sind ABSOLUTE Weltmeter (NICHT relativ zur Wand-UK — anders
* als `Opening.sillHeight`). Eine Tür ist der Sonderfall
* `zBottom == RWall.baseElevation` (Loch bis zur Wand-UK). render3d stanzt die
* Löcher als echte Aussparungen in die beiden Langseiten (Rechteck-Gitter-
* Zerlegung + Laibungsflächen), statt die Wand in Pfeiler/Brüstung/Sturz zu
* zerlegen.
*/
export interface RHole {
from: number;
to: number;
zBottom: number;
zTop: number;
}
/**
* Schnitt-Verschneidungs-/Linienstil-Metadaten EINES geschnittenen Bauteil-Bandes
* für den 3D-Live-Schnitt — matcht 1:1 render3d::types::CutBandMeta. Trägt die
* `joinPriority` (Schnitt-Boolean-Dominanz: die stärkere Schicht schneidet die
* schwächere, `section_boolean::subtract_dominant_bands`), die `componentId` (MERGE-
* Regel gleichartiger Bänder) sowie Farbe/Stärke der Schnitt-Umrisslinie (P2, aus
* dem `jointLineStyleId`-LineStyle der Schicht). Alle Felder optional — fehlt eines,
* greift auf der Rust-Seite der jeweilige Default (keine Subtraktion / Default-
* Schnittkante).
*/
export interface RCutMeta {
joinPriority?: number;
componentId?: string;
lineColor?: RRgb;
lineWeight?: number;
}
export interface RWall {
start: RVec2;
end: RVec2;
thickness: number;
height: number;
baseElevation: number;
color: RRgb;
/**
* Immer leer: die Schichten werden TS-seitig in eigene Voll-Boxen zerlegt
* (Option B, siehe toWalls3d.ts-Moduldoc). Feld bleibt nur zur Serde-
* Kompatibilität mit render3d::WallInput (leer ⇒ Vollkörper-Extrusion in `color`).
*/
layers: RLayer[];
/**
* Id der Ursprungs-Wand (`project.walls[].id`). Alle Öffnungs-Teilquader einer
* Wand tragen dieselbe Id. Rein für den TS-seitigen Pick-Raycast (raycast3d);
* der render3d/Serde-Pfad ignoriert das Feld (kein `deny_unknown_fields`,
* serde verwirft unbekannte Felder).
*/
wallId: string;
/**
* Echte rechteckige Löcher (Fenster/Türen) in DIESEM Wandkörper (siehe
* {@link RHole}). Nur im 3D-Viewer-Pfad (`projectToModel3d` mit
* `layeredWalls:true`) gesetzt: dort emittiert `emitWall` EINEN RWall pro
* Schicht-Band über die volle Achse mit ALLEN Öffnungen als `holes` — statt der
* Pfeiler/Brüstung/Sturz-Segment-Boxen. So bildet EIN Körper auch versetzt
* übereinanderliegende Fenster ab (das kann die Segment-Zerlegung nicht). Der
* Schnitt-Pfad (`layeredWalls:false`) setzt `holes` NICHT und segmentiert
* unverändert weiter (Owner-Kompatibilität mit `toSection.ts`, s. u.). Fehlt/
* leer ⇒ Vollkörper ohne Aussparung (render3d: `#[serde(default)]`). `holes`
* und die Segment-Zerlegung schliessen sich damit gegenseitig aus.
*/
holes?: RHole[];
/**
* Schnitt-Schraffur dieses Wand-Bandes (siehe {@link RHatch}) — matcht
* render3d::WallInput.hatch. Im layered-3D-Pfad trägt jede Materiallage die
* Schraffur IHRES Bauteils, sodass der 3D-Live-Schnitt jede geschnittene
* Schicht in ihrem Muster (Beton/Dämmung/Backstein/…) zeichnet. Fehlt sie
* (unauflösbares Bauteil), zeichnet der Cap-Shader sein Diagonal-Fallback.
*/
hatch?: RHatch;
/**
* Material-Textur-Ebene dieses Wand-Bandes für den Stil `Textured` (matcht
* render3d::WallInput.materialIndex, 1-basiert; Ebene 0 = Fallback-Schachbrett).
* Aus `Component.material.color` über {@link wallMaterialColorMaps} aufgelöst;
* fehlt sie, zeigt render3d das Schachbrett. Der Schnitt/Serde-Pfad ignoriert es.
*/
materialIndex?: number;
/**
* Schnitt-Verschneidungs-/Linienstil-Metadaten dieses Wand-Bandes (siehe
* {@link RCutMeta}) — matcht render3d::WallInput.cut. Im layered-3D-Pfad trägt
* jede Materiallage ihre `joinPriority`/`componentId`/Schnittkanten-Stil, sodass
* der 3D-Live-Schnitt die Boolean-Dominanz + Fugenlinien wie der 2D-Schnitt macht.
*/
cut?: RCutMeta;
}
/** Eine extrudierte Deckenplatte: geschlossener Grundriss-Umriss + Z-Ausdehnung. */
export interface RSlab {
outline: RVec2[];
zBottom: number;
zTop: number;
color: RRgb;
/**
* Id der Ursprungs-Decke (`project.ceilings[].id`). Nur für den TS-seitigen
* Pick-Raycast (raycast3d); der render3d/Serde-Pfad ignoriert das Feld
* (kein `deny_unknown_fields`).
*/
ceilingId: string;
/** Schnitt-Schraffur der Decke (dominante Schicht, siehe {@link RHatch}). */
hatch?: RHatch;
/** Schnitt-Verschneidungs-/Linienstil-Metadaten dieser Decken-Schicht (siehe {@link RCutMeta}). */
cut?: RCutMeta;
}
/** Art eines rohen Kontext-Meshes (bestimmt in render3d die Default-Einfärbung). */
export type RMeshKind = "terrain" | "imported" | "extrusion";
/**
* Ein rohes Dreiecks-Mesh aus dem Geo-Kontext (Terrain-TIN oder importiertes
* Gebäude-/DXF-Volumen). `positions` ist das flache (x,y,z=Höhe…)-Array in
* MODELL-Metern — genau wie `ImportedMesh`/`TerrainMesh` es hält; render3d bildet
* es selbst nach world (x, z, y) ab (analog Wände/Decken). Matcht
* render3d::types::MeshInput.
*/
export interface RMesh {
positions: number[];
indices: number[];
kind: RMeshKind;
color: RRgb;
/** Optionale Pro-Vertex-Albedo (flach `[r,g,b, …]`, 0..1) — ein Wert je
* Positions-Vertex. Damit wird z. B. ein SWISSIMAGE-Luftbild aufs Gelände-TIN
* „drapiert" (jeder Vertex trägt seinen Bildpunkt). Fehlt/zu kurz -> `color`
* fürs ganze Mesh. Matcht `render3d::types::MeshInput.vertexColors`. */
vertexColors?: number[];
/** Material-Textur-Ebene (1-basiert, siehe {@link wallMaterialColorMaps}) für
* den Stil „Textured" — bislang nur von Dach-Schichten gesetzt. Fehlt sie,
* zeigt render3d das Fallback-Schachbrett. Matcht
* `render3d::types::MeshInput.materialIndex`. */
materialIndex?: number;
}
/** Modell-Bündel für den nativen 3D-Renderer (Wände + Decken + Kontext-Meshes). */
export interface RModel3d {
walls: RWall[];
slabs: RSlab[];
meshes: RMesh[];
}
/** Warmer, neutraler Wand-Grundton (wie render3d default). */
export const WALL_RGB: RRgb = [0.82, 0.8, 0.76];
/** Etwas hellerer, kühlerer Ton für Deckenplatten (heben sich von Wänden ab). */
export const SLAB_RGB: RRgb = [0.86, 0.86, 0.88];
/** Gedämpftes Lehm-/Geländegrün-grau (three.js mats.terrain = 0xa9b0a2). */
export const TERRAIN_RGB: RRgb = [169 / 255, 176 / 255, 162 / 255];
/** Neutrales Hellgrau für importierte Meshes (three.js mats.mesh = 0x9fa6ae). */
export const IMPORTED_RGB: RRgb = [159 / 255, 166 / 255, 174 / 255];
/** Fallback-Farbe für 2D-Zeichnungen ohne color/lineStyle/Kategorie (wie drawing2DColor in Viewport3D.tsx, "#888888"). */
export const DRAWING_LINE_FALLBACK_RGB: RRgb = [0x88 / 255, 0x88 / 255, 0x88 / 255];
/** Ignoriere entartete vertikale Ausschnitte (Rundungsrauschen). */
export const EPS = 1e-4;
/**
* Wandelt eine Hex-Farbe ("#rrggbb", Component-Farbe im Plan) in float-RGB
* (0..1, render3d-Konvention) um. Ungültiges/fehlendes Format fällt auf den
* neutralen Wand-Grundton zurück (nie ein hartes Fehlbild).
*/
export function hexToRgb(hex: string, fallback: RRgb = WALL_RGB): RRgb {
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex ?? "");
if (!m) return fallback;
const v = parseInt(m[1], 16);
return [((v >> 16) & 0xff) / 255, ((v >> 8) & 0xff) / 255, (v & 0xff) / 255];
}
/**
* Repräsentative Bauteilfarbe eines Schichtaufbaus: die Farbe der DICKSTEN
* Schicht (i. d. R. die tragende/dominante Schicht, z. B. Mauerwerk/Beton) —
* einfache Näherung, solange Wände/Decken als EIN Vollkörper emittiert werden
* (kein Schicht-Rendering in 3D). Fällt auf `fallback` zurück, wenn keine
* Schicht vorhanden ist oder deren Component nicht auflösbar ist.
*/
export function dominantLayerColor(layers: Layer[], project: Project, fallback: RRgb): RRgb {
if (layers.length === 0) return fallback;
const dominant = layers.reduce((best, l) => (l.thickness > best.thickness ? l : best));
try {
return hexToRgb(getComponent(project, dominant.componentId).color, fallback);
} catch {
return fallback;
}
}
/**
* Löst den Schnitt-UMRISSLINIEN-Stil einer Schicht aus ihrem
* `Layer.jointLineStyleId` (LineStyle) auf: Farbe (hex→RGB 0..1) + Strichstärke
* (mm-Papier) — dieselbe Quelle wie die 2D-Schichtfuge (`generatePlan/walls.ts`).
* Fehlt der Verweis/LineStyle, `undefined` (der 3D-Cut fällt dann auf seinen
* Default-Schnittkanten-Stil zurück, siehe `section_fill::build_cut_cap_lines`).
* Bewusst tolerant (`find` statt `getLineStyle`), wie im 2D — ein verwaister
* Verweis darf den 3D-Push nicht werfen. Von {@link ../bands.resolveWallBands}
* UND `../ceilings.emitSlabs` genutzt (Wand- wie Deckenschicht teilen dieselbe
* Fugen-Auflösung).
*/
export function resolveCutLineStyle(
project: Project,
jointLineStyleId: string | undefined,
): { color?: RRgb; weight?: number } | undefined {
if (!jointLineStyleId) return undefined;
const ls = project.lineStyles.find((l) => l.id === jointLineStyleId);
if (!ls) return undefined;
return { color: hexToRgb(ls.color, DRAWING_LINE_FALLBACK_RGB), weight: ls.weight };
}
+155
View File
@@ -0,0 +1,155 @@
// Treppen-Domäne der 3D-Ableitung: emittiert Treppen als extrudierte RSlabs
// (Tritte + Podest, tragart-abhängige Untersicht) — der WASM/wgpu-Renderer
// kennt sonst KEINE Treppen (nur Wände/Decken/Meshes). Für Ortbeton-Läufe
// zusätzlich ein glattes Dreiecks-Mesh der schrägen Laufplatten-Untersicht.
import type { Project, Vec2 } from "../../model/types";
import { getStairType } from "../../model/types";
import { stairVerticalExtent } from "../../model/wall";
import { stairGeometry } from "../../geometry/stair";
import { leftNormal, normalize } from "../../model/geometry";
import { EPS, SLAB_RGB, hexToRgb, type RMesh, type RRgb, type RSlab, type RVec2 } from "./shared";
/** Neutraler Beton-Grauton für Treppen (wie der Three.js-Pfad `addStairMeshes`). */
const STAIR_RGB: RRgb = hexToRgb("#9aa0a6", SLAB_RGB);
/** Laufplattendicke einer Ortbetontreppe (Meter). */
const CONCRETE_WAIST = 0.18;
/**
* Emittiert Treppen als RSlabs (extrudierte Grundriss-Polygone) — der WASM/wgpu-
* Renderer kennt sonst KEINE Treppen (nur Wände/Decken/Meshes), sodass Treppen im
* Standard-3D unsichtbar blieben. Jeder Tritt ist ein extrudiertes Polygon; das
* ist exakt eine RSlab. Tragart über `getStairType`:
* • "massiv" / kein Typ — Vollblock je Tritt von der Treppen-UK bis zur Tritt-OK
* (gestuftes Massiv, Treppe auf Erdreich/Sockel).
* • "beton" — Ortbeton-Laufplatte: je Tritt ein Block von der SCHRÄGEN Untersicht
* (eine Stufe + Laufplattendicke unter der Tritt-OK, an der Treppen-UK gekappt)
* bis zur Tritt-OK. Die stufig gestapelten Blöcke bilden die schräge, UNTEN
* OFFENE Untersicht der klassischen Betontreppe.
* • "wange"/"aufgesattelt"/"spindel" — nur eine dünne Tritt-Platte der Dicke
* `treadThickness` an der Tritt-OK, darunter offen.
* Podest (L/Wendel) als Vollblock bis zur halben Steighöhe. Setzstufen/Nase/
* Geländer sind Verfeinerungen (Three.js-Pfad) — hier zählt zuerst die SICHTBARKEIT.
* `ceilingId` trägt hier die Treppen-Id (nur TS-seitiges Tagging; render3d ignoriert
* das Feld, und der Pick läuft separat über `pickGeometry`).
*/
export function emitStairs(project: Project): RSlab[] {
const out: RSlab[] = [];
for (const stair of project.stairs ?? []) {
const { zBottom, zTop } = stairVerticalExtent(project, stair);
const totalRise = zTop - zBottom;
if (totalRise <= EPS) continue;
const geo = stairGeometry(stair, totalRise);
const st = getStairType(project, stair);
const structure = st?.structure ?? "massiv";
// Gerade Betontreppe: die GLATTE Laufplatte kommt als echtes Mesh
// (emitStairMeshes) — hier keine Slab-Näherung emittieren (sonst doppelt).
if (structure === "beton" && stair.shape === "straight") continue;
const treadThickness = st?.treadThickness ?? 0.04;
const riser = geo.riserHeight;
for (const tr of geo.treads) {
if (tr.pts.length < 3) continue;
const top = zBottom + tr.topRise;
const bottom =
structure === "massiv"
? zBottom // Vollblock bis zur Treppen-UK
: structure === "beton"
? Math.max(zBottom, top - riser - CONCRETE_WAIST) // schräge Laufplatte
: top - treadThickness; // offene Tritte (Wange/aufgesattelt/Spindel)
if (top - bottom <= EPS) continue;
out.push({
outline: tr.pts.map((p) => [p.x, p.y] as RVec2),
zBottom: bottom,
zTop: top,
color: STAIR_RGB,
ceilingId: stair.id,
});
}
if (geo.landing && geo.landing.length >= 3) {
out.push({
outline: geo.landing.map((p) => [p.x, p.y] as RVec2),
zBottom,
zTop: zBottom + totalRise * 0.5,
color: STAIR_RGB,
ceilingId: stair.id,
});
}
}
return out;
}
/**
* Glatte Ortbeton-Laufplatte (Tragart "beton") als echtes Dreiecks-Mesh — nur für
* GERADE Läufe. Anders als die stufige Slab-Näherung ({@link emitStairs}) entsteht
* eine SPIEGELGLATTE schräge Untersicht: je Tritt ein konvexes Prisma mit
* HORIZONTALER Oberkante (= Trittfläche) und SCHRÄGER Unterkante auf EINER
* gemeinsamen Soffit-Geraden. Weil alle Prismen-Unterkanten exakt auf dieser
* Geraden liegen, bildet die Vereinigung eine durchgehende glatte Schräge; die
* Höhensprünge der Oberkanten bleiben als sichtbare Setzstufen. Prismen sind
* konvex → triviale, robuste Triangulierung (kein Ear-Clipping). Positions in
* MODELL-Metern (x, y, z=Höhe); render3d bildet nach world (x, z, y) ab.
*/
export function emitStairMeshes(project: Project): RMesh[] {
const out: RMesh[] = [];
for (const stair of project.stairs ?? []) {
const st = getStairType(project, stair);
if (!st || st.structure !== "beton") continue;
if (stair.shape !== "straight") continue; // glattes Mesh nur für gerade Läufe
if (stair.runLength <= EPS) continue;
const { zBottom, zTop } = stairVerticalExtent(project, stair);
const totalRise = zTop - zBottom;
if (totalRise <= EPS) continue;
const geo = stairGeometry(stair, totalRise);
const u = normalize(stair.dir); // Laufrichtung (Grundriss)
const w = leftNormal(u); // Querrichtung (Breite)
const runTotal = stair.runLength;
// Laufplattendicke (vertikal): an die Steigung gekoppelt, geklemmt.
const waistV = Math.min(0.24, Math.max(0.14, geo.riserHeight * 1.2));
// Soffit-Höhe entlang des Laufs: eine Gerade PARALLEL zur Trittkantenlinie
// (Steigung totalRise/runTotal), um `waistV` nach unten versetzt → KONSTANTE
// Plattendicke über den ganzen Lauf (sonst würde die Platte nach oben dicker).
// An der Treppen-UK gekappt, damit die unterste Stufe auf dem Boden aufsitzt
// statt darunter zu schweben.
const soffitH = (runS: number) => {
const s = Math.min(Math.max(runS, 0), runTotal);
return Math.max(zBottom, zBottom + (s / runTotal) * totalRise - waistV);
};
const runOf = (p: Vec2) => (p.x - stair.start.x) * u.x + (p.y - stair.start.y) * u.y;
const sideOf = (p: Vec2) => (p.x - stair.start.x) * w.x + (p.y - stair.start.y) * w.y;
const positions: number[] = [];
const indices: number[] = [];
for (const tr of geo.treads) {
if (tr.pts.length < 4) continue;
// Vier Eckpunkte nach Lauf-Parameter trennen (2 kleinste = Startkante, 2
// grösste = Endkante), je Kante nach Quer-Parameter sortieren (0 = links).
const byRun = [...tr.pts].sort((a, b) => runOf(a) - runOf(b));
const startEdge = [byRun[0], byRun[1]].sort((a, b) => sideOf(a) - sideOf(b));
const endEdge = [byRun[2], byRun[3]].sort((a, b) => sideOf(a) - sideOf(b));
const runS = Math.min(runOf(byRun[0]), runOf(byRun[1]));
const runE = Math.max(runOf(byRun[2]), runOf(byRun[3]));
const topH = zBottom + tr.topRise;
const bS = soffitH(runS);
const bE = soffitH(runE);
if (topH - Math.max(bS, bE) <= EPS) continue;
const base = positions.length / 3;
const push = (p: Vec2, z: number) => positions.push(p.x, p.y, z);
// 0/1 Startkante unten (soffit), 2/3 Endkante unten, 4/5 Start oben, 6/7 End oben.
push(startEdge[0], bS); push(startEdge[1], bS);
push(endEdge[0], bE); push(endEdge[1], bE);
push(startEdge[0], topH); push(startEdge[1], topH);
push(endEdge[0], topH); push(endEdge[1], topH);
const quad = (a: number, b: number, c: number, d: number) =>
indices.push(base + a, base + b, base + c, base + a, base + c, base + d);
quad(0, 1, 3, 2); // Untersicht (schräge Soffit-Fläche)
quad(4, 6, 7, 5); // Oberseite (Trittfläche)
quad(0, 4, 5, 1); // Startkante (Setzstufen-Front der Vorstufe sichtbar)
quad(2, 3, 7, 6); // Endkante
quad(0, 2, 6, 4); // linke Wange
quad(1, 5, 7, 3); // rechte Wange
}
if (indices.length === 0) continue;
out.push({ positions, indices, kind: "extrusion", color: STAIR_RGB });
}
return out;
}