3d2d4d6321
Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung (konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text- Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback, falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform) fuer fluessige Interaktion ohne React-Re-Render je Frame. Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM (Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext- Import, Tauri-Compute-Boundary-PoC).
1180 lines
41 KiB
TypeScript
1180 lines
41 KiB
TypeScript
// Grundriss-Generator: erzeugt aus dem semantischen Modell den
|
||
// 2D-Grundriss als abstrakte Vektor-Primitive (später → SVG).
|
||
//
|
||
// Kern-Erkenntnis: Der Grundriss entsteht NICHT durch Zerschneiden eines
|
||
// 3D-Meshes, sondern direkt aus den Parametern (Wandachsen, Schichtaufbau,
|
||
// Öffnungen) — exakt, schnell, sauber.
|
||
|
||
import type {
|
||
Ceiling,
|
||
Drawing2D,
|
||
HatchPattern,
|
||
LayerCategory,
|
||
Opening,
|
||
Project,
|
||
Room,
|
||
Stair,
|
||
Vec2,
|
||
Wall,
|
||
} from "../model/types";
|
||
import {
|
||
flattenCategories,
|
||
getCeilingType,
|
||
getComponent,
|
||
getHatch,
|
||
getLineStyle,
|
||
getWallType,
|
||
openingsOfWall,
|
||
roomsOfFloor,
|
||
stairsOfFloor,
|
||
wallTypeThickness,
|
||
} from "../model/types";
|
||
import { evaluateRoom, siaLabel } from "../geometry/roomArea";
|
||
import type { RichTextDoc } from "../text/richText";
|
||
import { docFromText } from "../text/richText";
|
||
import { roomStampToDoc, roomStampExtraLines } from "../model/roomStamp";
|
||
import { stairVerticalExtent, wallReferenceOffset } from "../model/wall";
|
||
import { stairGeometry, stairCut } from "../geometry/stair";
|
||
import { doorSymbol, openingInterval, windowSymbol } from "../geometry/opening";
|
||
import {
|
||
add,
|
||
along,
|
||
clippedBand,
|
||
leftNormal,
|
||
normalize,
|
||
scale,
|
||
sub,
|
||
wallCorners,
|
||
} from "../model/geometry";
|
||
|
||
/**
|
||
* 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";
|
||
import { computeJoins } from "../model/joins";
|
||
import type { WallCuts } from "../model/joins";
|
||
|
||
/** Stroke-Farbe der Schicht-Polygone (dunkles Grau). */
|
||
const POCHE_STROKE = "#2b3039";
|
||
|
||
/** Tinte für den Schwarz-Weiss-Modus: einheitliches Dunkel für alle Striche. */
|
||
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·devicePixelRatio in konstante Bildschirm-Pixel um (non-scaling-stroke),
|
||
// damit Linien beim Zoomen ihre Papiergröße behalten und der Massstab stimmt.
|
||
|
||
/** Dünne Trennlinie zwischen den Schichten (Schichtfugen) in mm. */
|
||
const LAYER_LINE_MM = 0.13;
|
||
/** Stärke der Wand-Umrisslinie je Detailgrad, als Faktor auf die Ebenen-lw. */
|
||
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). */
|
||
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"). */
|
||
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;
|
||
/** Farbe der Musterlinien bzw. der Vollfüllung (pattern==="solid"). */
|
||
color: string;
|
||
/** Linienstärke der Musterlinien in mm. */
|
||
lineWeight: number;
|
||
/** Strichmuster der Musterlinien in mm; null = durchgezogen. */
|
||
dash: number[] | null;
|
||
}
|
||
|
||
/**
|
||
* 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. Wird kein Resolver übergeben, gilt für
|
||
* jede sichtbare Kategorie {render:true, greyed:false} (bisheriges Verhalten).
|
||
*/
|
||
export interface CategoryDisplay {
|
||
render: boolean;
|
||
greyed: boolean;
|
||
}
|
||
|
||
/** Funktion, die je Kategorie-Code die Render-Entscheidung liefert. */
|
||
export type CategoryDisplayResolver = (code: string) => CategoryDisplay;
|
||
|
||
/** Standard-Resolver: jede (bereits gefilterte) Kategorie normal zeichnen. */
|
||
const SHOW_ALL: CategoryDisplayResolver = () => ({ render: true, greyed: 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;
|
||
/**
|
||
* 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;
|
||
}
|
||
| {
|
||
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;
|
||
/** 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;
|
||
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.
|
||
*/
|
||
extraLines: string[];
|
||
/** 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;
|
||
};
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
function resolveHatch(project: Project, hatchId: string): HatchRender {
|
||
const h = getHatch(project, hatchId);
|
||
const ls = h.lineStyleId ? getLineStyle(project, h.lineStyleId) : null;
|
||
return {
|
||
pattern: h.pattern,
|
||
scale: h.scale,
|
||
angle: h.angle,
|
||
color: h.color,
|
||
lineWeight: ls?.weight ?? 0.13,
|
||
dash: ls?.dash ?? null,
|
||
};
|
||
}
|
||
|
||
export interface Plan {
|
||
primitives: Primitive[];
|
||
bounds: { minX: number; minY: number; maxX: number; maxY: number };
|
||
}
|
||
|
||
/**
|
||
* Erzeugt den Grundriss eines Geschosses, gefiltert nach sichtbaren Ebenen
|
||
* (Kategorie-Codes).
|
||
*
|
||
* Enthält Wände dieses Geschosses, deren `categoryCode` ∈ `visibleCodes`. Eine
|
||
* Türöffnung gehört zur Wandgeometrie und wird stets ausgespart; das
|
||
* Tür-SYMBOL (Blatt + Bogen) erscheint nur, wenn der Tür-Code sichtbar ist.
|
||
*/
|
||
export function generatePlan(
|
||
project: Project,
|
||
floorId: string,
|
||
visibleCodes: Set<string>,
|
||
categoryDisplay: CategoryDisplayResolver = SHOW_ALL,
|
||
detail: DetailLevel = "mittel",
|
||
showReferenceLines = false,
|
||
mono = false,
|
||
): Plan {
|
||
const primitives: Primitive[] = [];
|
||
// Aktives Geschoss (für die Grundriss-Schnitthöhe der Treppen).
|
||
const floor = project.drawingLevels.find((z) => z.id === floorId);
|
||
// Sichtbarkeit wie bisher; der Darstellungsmodus verfeinert sie zusätzlich:
|
||
// render=false blendet die Wand aus, greyed=true dimmt sie.
|
||
const walls = project.walls.filter(
|
||
(w) =>
|
||
w.floorId === floorId &&
|
||
visibleCodes.has(w.categoryCode) &&
|
||
categoryDisplay(w.categoryCode).render,
|
||
);
|
||
|
||
// Code → Kategorie-Strichstärke (mm), damit die Wand-Umrisslinie aus dem
|
||
// Modell (Ebene) stammt statt aus einer Magie-Konstante.
|
||
const lwByCode = categoryLwMap(project.layers);
|
||
const colorByCode = categoryColorMap(project.layers);
|
||
|
||
// Freie 2D-Zeichengeometrie dieses Geschosses (wie Wände nach sichtbaren
|
||
// Kategorien + Darstellungsmodus gefiltert). Wird über der Wand-Poché
|
||
// gezeichnet (am Ende angehängt).
|
||
const drawings = project.drawings2d.filter(
|
||
(d) =>
|
||
d.levelId === floorId &&
|
||
visibleCodes.has(d.categoryCode) &&
|
||
categoryDisplay(d.categoryCode).render,
|
||
);
|
||
|
||
// Decken dieses Geschosses (wie Wände nach sichtbaren Kategorien +
|
||
// Darstellungsmodus gefiltert). Eine Decke liegt (als Slab) über der
|
||
// Schnittebene → im Grundriss als gefüllter/schraffierter Umriss dargestellt,
|
||
// UNTER der Wand-Poché (damit die Wände darüber lesbar bleiben).
|
||
const ceilings = (project.ceilings ?? []).filter(
|
||
(c) =>
|
||
c.floorId === floorId &&
|
||
visibleCodes.has(c.categoryCode) &&
|
||
categoryDisplay(c.categoryCode).render,
|
||
);
|
||
for (const ceiling of ceilings) {
|
||
const greyed = categoryDisplay(ceiling.categoryCode).greyed;
|
||
const lwMm = lwByCode.get(ceiling.categoryCode) ?? WALL_FALLBACK_MM;
|
||
addCeilingPoche(primitives, project, ceiling, greyed, detail, lwMm);
|
||
}
|
||
|
||
// Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel
|
||
// (Name + Fläche + SIA-Tag). UNTER der Wand-Poché gezeichnet, damit die Wände
|
||
// lesbar bleiben. Der Stempel (Text) entfällt beim Detailgrad „grob".
|
||
const rooms = roomsOfFloor(project, floorId).filter(
|
||
(r) =>
|
||
visibleCodes.has(r.categoryCode) && categoryDisplay(r.categoryCode).render,
|
||
);
|
||
for (const room of rooms) {
|
||
const greyed = categoryDisplay(room.categoryCode).greyed;
|
||
addRoomArea(primitives, room, greyed, detail);
|
||
}
|
||
|
||
// Gehrungs-Schnittlinien einmal pro Plan berechnen (auf der gefilterten Menge).
|
||
const joins = computeJoins(project, walls);
|
||
|
||
for (const wall of walls) {
|
||
const wallGreyed = categoryDisplay(wall.categoryCode).greyed;
|
||
const wallLwMm = lwByCode.get(wall.categoryCode) ?? WALL_FALLBACK_MM;
|
||
// Türen (Legacy) + Öffnungen (Fenster/Türen) am Wandhost: BEIDE sparen die
|
||
// Wand-Poché aus. Aus beiden wird eine gemeinsame Lücken-Intervall-Liste.
|
||
const doors = project.doors.filter((d) => d.hostWallId === wall.id);
|
||
const openings = openingsOfWall(project, wall.id);
|
||
const gaps = wallGaps(wall, doors, openings);
|
||
const cuts = joins.get(wall.id) ?? { startCut: null, endCut: null };
|
||
addWallPoche(primitives, project, wall, gaps, cuts, wallGreyed, detail, wallLwMm);
|
||
// Referenzlinie: dünne gestrichelte Wand-Achslinie (von der Oberleiste
|
||
// umschaltbar). Wird über der Poché gezeichnet, damit sie sichtbar bleibt.
|
||
if (showReferenceLines) addReferenceLine(primitives, wall, wallGreyed);
|
||
// Tür-Symbol (Legacy-Door) nur, wenn die Kategorie sichtbar ist und der Modus
|
||
// sie zeigt.
|
||
for (const door of doors) {
|
||
const d = categoryDisplay(door.categoryCode);
|
||
if (visibleCodes.has(door.categoryCode) && d.render) {
|
||
const doorLwMm = lwByCode.get(door.categoryCode) ?? LAYER_LINE_MM;
|
||
addDoorSymbol(primitives, wall, door, d.greyed, detail, doorLwMm);
|
||
}
|
||
}
|
||
// Öffnungs-Symbole (Fenster/Tür) je Detailgrad + Auswahl-taggbar.
|
||
for (const o of openings) {
|
||
const d = categoryDisplay(o.categoryCode);
|
||
if (visibleCodes.has(o.categoryCode) && d.render) {
|
||
const oLwMm = lwByCode.get(o.categoryCode) ?? LAYER_LINE_MM;
|
||
addOpeningSymbol(primitives, project, wall, o, d.greyed, detail, oLwMm);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Treppen dieses Geschosses: Tritte + Lauflinie + Auf-/Abpfeil, mit dem
|
||
// Grundriss-Schnitt (Tritte unter der Schnitthöhe durchgezogen, darüber
|
||
// gestrichelt/verdeckt + SIA-Bruchlinie). Über der Wand-Poché gezeichnet.
|
||
const cutHeight = floor?.cutHeight ?? 1.0;
|
||
const stairs = stairsOfFloor(project, floorId).filter(
|
||
(s) =>
|
||
visibleCodes.has(s.categoryCode) && categoryDisplay(s.categoryCode).render,
|
||
);
|
||
for (const stair of stairs) {
|
||
const greyed = categoryDisplay(stair.categoryCode).greyed;
|
||
const lwMm = lwByCode.get(stair.categoryCode) ?? WALL_FALLBACK_MM;
|
||
addStairSymbol(primitives, project, stair, greyed, lwMm, cutHeight);
|
||
}
|
||
|
||
// 2D-Zeichengeometrie zuletzt (über der Poché).
|
||
for (const d of drawings) {
|
||
const greyed = categoryDisplay(d.categoryCode).greyed;
|
||
addDrawing2D(primitives, project, d, greyed, lwByCode, colorByCode);
|
||
}
|
||
|
||
// Kontext-Konturen (Höhenlinien) als dezente Linien zeichnen — UNTER der
|
||
// Wand-Poché wäre ideal, aber da sie eine eigene gedämpfte Farbe tragen, ist
|
||
// die Reihenfolge unkritisch; wir hängen sie als Referenz an.
|
||
addContextContours(primitives, project);
|
||
|
||
// Schwarz-Weiss (mono): nachträglich alle Farben auf reine Tinte zwingen.
|
||
// Element-/Kategorie-/Hatch-Farben werden ignoriert, Füllungen werden weiss,
|
||
// Musterlinien/Striche schwarz — so entsteht ein reiner S/W-Plan.
|
||
if (mono) toMono(primitives);
|
||
|
||
return {
|
||
primitives,
|
||
bounds: computeBounds(project, walls, drawings, ceilings, stairs, rooms),
|
||
};
|
||
}
|
||
|
||
/** 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;
|
||
|
||
/**
|
||
* Zeichnet die Konturen (Höhenlinien) der Kontext-Schicht `project.context` als
|
||
* dünne, gedämpfte Referenz-Linien in den Grundriss. Die Z-Höhe wird ignoriert
|
||
* (reine 2D-Projektion). TerrainMeshes/ImportedMeshes erscheinen im Plan NICHT
|
||
* (3D genügt). Rein additiv — Wand-/Drawing-Darstellung bleibt unberührt.
|
||
*/
|
||
function addContextContours(out: Primitive[], project: Project): void {
|
||
const context = project.context ?? [];
|
||
for (const obj of context) {
|
||
if (obj.type !== "contourSet") continue;
|
||
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,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Post-Pass für den Schwarz-Weiss-Modus: ersetzt alle Farb-Informationen der
|
||
* Plan-Primitive durch einheitliche Tinte/Weiss. Wirkt in-place.
|
||
* - Polygone: Füllung → weiss, Umriss → Tinte, Schraffur-Musterlinien → Tinte
|
||
* (Vollfüllung „solid" wird zu Weiss neutralisiert, damit keine Poché-Farbe).
|
||
* - Linien: explizite Strichfarbe → Tinte (CSS-Klassen sind bereits neutral).
|
||
* - Bögen tragen keine Farbe (rein CSS, neutral) → unverändert.
|
||
*/
|
||
function toMono(primitives: Primitive[]): void {
|
||
for (const p of primitives) {
|
||
if (p.kind === "polygon") {
|
||
// Vollfüllung „solid" deckte die Poché farbig — in mono auf Weiss setzen,
|
||
// damit nur der Umriss trägt. Linien-/Kreuzschraffur bleibt, aber in Tinte.
|
||
if (p.hatch.pattern === "solid") {
|
||
p.hatch = { ...p.hatch, color: "#ffffff" };
|
||
} else if (p.hatch.pattern !== "none") {
|
||
p.hatch = { ...p.hatch, color: MONO_INK };
|
||
}
|
||
p.fill = p.fill === "none" ? "none" : "#ffffff";
|
||
p.stroke = MONO_INK;
|
||
} else if (p.kind === "line") {
|
||
p.color = MONO_INK;
|
||
} else if (p.kind === "text") {
|
||
p.color = MONO_INK;
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Baut eine Map Kategorie-Code → Strichstärke (mm) über den ganzen Baum. */
|
||
function categoryLwMap(layers: LayerCategory[]): Map<string, number> {
|
||
const map = new Map<string, number>();
|
||
for (const c of flattenCategories(layers)) map.set(c.code, c.lw);
|
||
return map;
|
||
}
|
||
|
||
/** Baut eine Map Kategorie-Code → Farbe über den ganzen Baum. */
|
||
function categoryColorMap(layers: LayerCategory[]): Map<string, string> {
|
||
const map = new Map<string, string>();
|
||
for (const c of flattenCategories(layers)) map.set(c.code, c.color);
|
||
return map;
|
||
}
|
||
|
||
/**
|
||
* Leitet ein 2D-Zeichenelement in Plan-Primitive ab (docs/design/drawing-tools.md
|
||
* §7.2). Farbe/Strichstärke kommen aus dem optionalen LineStyle, sonst aus der
|
||
* Kategorie. Phase 1 deckt line/polyline/rect ab (alles → line-Primitive);
|
||
* circle/arc/text folgen mit den Primitive-Erweiterungen (Phase 3).
|
||
*/
|
||
function addDrawing2D(
|
||
out: Primitive[],
|
||
project: Project,
|
||
d: Drawing2D,
|
||
greyed: boolean,
|
||
lwByCode: Map<string, number>,
|
||
colorByCode: Map<string, string>,
|
||
): void {
|
||
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;
|
||
const weightMm = d.weightMm ?? ls?.weight ?? lwByCode.get(d.categoryCode) ?? WALL_FALLBACK_MM;
|
||
const dash = ls?.dash ?? null;
|
||
const seg = (a: Vec2, b: Vec2) =>
|
||
out.push({ kind: "line", a, b, cls: "draw2d", weightMm, dash, 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,
|
||
fill: 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,
|
||
hatch: d.hatchId ? resolveHatch(project, d.hatchId) : 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": {
|
||
// Kreis als geschlossene Polygon-Approximation (N Segmente) — nutzt die
|
||
// vorhandene Füll-/Strich-Pipeline, ohne ein eigenes Kreis-Primitiv.
|
||
const N = 64;
|
||
const pts: Vec2[] = [];
|
||
for (let i = 0; i < N; i++) {
|
||
const t = (i / N) * Math.PI * 2;
|
||
pts.push({ x: g.center.x + g.r * Math.cos(t), y: g.center.y + g.r * Math.sin(t) });
|
||
}
|
||
fillPoly(pts);
|
||
for (let i = 0; i < N; i++) seg(pts[i], pts[(i + 1) % N]);
|
||
break;
|
||
}
|
||
// arc/text: Primitive-Erweiterungen folgen später.
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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").
|
||
*/
|
||
/**
|
||
* 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);
|
||
}
|
||
|
||
function addWallPoche(
|
||
out: Primitive[],
|
||
project: Project,
|
||
wall: Wall,
|
||
gaps: WallGap[],
|
||
cuts: WallCuts,
|
||
greyed: boolean,
|
||
detail: DetailLevel,
|
||
wallLwMm: number,
|
||
): 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;
|
||
// 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): Farbe des tragenden Bauteils (höchste Priorität).
|
||
const simpleFill = backboneColor(project, wt);
|
||
|
||
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 (detail === "grob") {
|
||
// Eine Sammelfläche über die volle Dicke, ohne Schraffur, dicker Umriss.
|
||
out.push({
|
||
kind: "polygon",
|
||
pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut),
|
||
fill: simpleFill,
|
||
stroke,
|
||
strokeWidthMm: outlineMm,
|
||
hatch: NO_HATCH,
|
||
greyed,
|
||
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 Schichtfugen werden dünn gestrichen.
|
||
let off = refOff - total / 2;
|
||
for (const layer of wt.layers) {
|
||
const comp = getComponent(project, layer.componentId);
|
||
out.push({
|
||
kind: "polygon",
|
||
pts: clippedBand(p1, p2, off, off + layer.thickness, startCut, endCut),
|
||
fill: comp.color,
|
||
stroke,
|
||
strokeWidthMm: layerLineMm,
|
||
hatch: resolveHatch(project, comp.hatchId),
|
||
greyed,
|
||
wallId,
|
||
});
|
||
off += layer.thickness;
|
||
}
|
||
// Kräftige Wand-Umrisslinie über die volle Dicke (nur Kontur, keine Füllung),
|
||
// damit die Wand sich klar von den dünnen Schichtfugen abhebt.
|
||
out.push({
|
||
kind: "polygon",
|
||
pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut),
|
||
fill: "none",
|
||
stroke,
|
||
strokeWidthMm: outlineMm,
|
||
hatch: NO_HATCH,
|
||
greyed,
|
||
wallId,
|
||
});
|
||
}
|
||
}
|
||
|
||
/** Farbe des tragenden Bauteils (höchste joinPriority) für die grobe Poché. */
|
||
function backboneColor(project: Project, wt: Project["wallTypes"][number]): string {
|
||
let best: { color: string; prio: number } | null = null;
|
||
for (const layer of wt.layers) {
|
||
const comp = getComponent(project, layer.componentId);
|
||
if (!best || comp.joinPriority > best.prio)
|
||
best = { color: comp.color, prio: comp.joinPriority };
|
||
}
|
||
return best?.color ?? "#9aa0a6";
|
||
}
|
||
|
||
/**
|
||
* 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).
|
||
*/
|
||
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: doorLwMm,
|
||
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: doorLwMm * 0.6,
|
||
dash: [0.06, 0.04], // gestrichelt in mm Papier
|
||
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: doorLwMm,
|
||
greyed,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ö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).
|
||
*/
|
||
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;
|
||
// Türblatt (alle Stufen).
|
||
out.push({
|
||
kind: "line",
|
||
a: sym.hinge,
|
||
b: sym.openEnd,
|
||
cls: "door-leaf",
|
||
weightMm: lwMm,
|
||
greyed,
|
||
openingId: o.id,
|
||
});
|
||
// mittel + fein: Schwenkbogen von geschlossen → offen.
|
||
if (detail !== "grob") {
|
||
out.push({
|
||
kind: "arc",
|
||
center: sym.hinge,
|
||
from: sym.closedEnd,
|
||
to: sym.openEnd,
|
||
r: sym.radius,
|
||
cls: "door-swing",
|
||
weightMm: lwMm * 0.6,
|
||
dash: [0.06, 0.04],
|
||
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: lwMm,
|
||
greyed,
|
||
openingId: o.id,
|
||
});
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Fenster: Rahmen (mittel/fein) + Glaslinie(n) je Detailgrad.
|
||
const glassCount = detail === "fein" ? 2 : 1;
|
||
const sym = windowSymbol(project, wall, o, glassCount);
|
||
if (!sym) return;
|
||
if (detail !== "grob") {
|
||
// Rahmen-Rechteck als reine Umrisslinie (Poché-Stroke, keine Füllung).
|
||
out.push({
|
||
kind: "polygon",
|
||
pts: sym.frame,
|
||
fill: "none",
|
||
stroke: o.color ?? POCHE_STROKE,
|
||
strokeWidthMm: lwMm,
|
||
hatch: NO_HATCH,
|
||
greyed,
|
||
openingId: o.id,
|
||
});
|
||
}
|
||
for (const [a, b] of sym.glassLines) {
|
||
out.push({
|
||
kind: "line",
|
||
a,
|
||
b,
|
||
cls: "window-glass",
|
||
weightMm: lwMm * 0.7,
|
||
greyed,
|
||
openingId: o.id,
|
||
});
|
||
}
|
||
}
|
||
|
||
/** Schraffur-Platzhalter „ohne" (für reine Umriss-/Sammelflächen). */
|
||
const NO_HATCH: HatchRender = {
|
||
pattern: "none",
|
||
scale: 1,
|
||
angle: 0,
|
||
color: POCHE_STROKE,
|
||
lineWeight: LAYER_LINE_MM,
|
||
dash: null,
|
||
};
|
||
|
||
/**
|
||
* Referenzlinie einer Wand: dünne, gestrichelte Achslinie entlang der Wandachse
|
||
* (start → end). Von der Oberleiste umschaltbar; rein darstellend.
|
||
*/
|
||
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,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* Im Detailgrad „grob" entfällt die Schraffur (nur Fläche + Umriss); „mittel"/
|
||
* „fein" zeigen die Component-Schraffur. `lwMm` ist die Kategorie-Strichstärke.
|
||
*/
|
||
function addCeilingPoche(
|
||
out: Primitive[],
|
||
project: Project,
|
||
ceiling: Ceiling,
|
||
greyed: boolean,
|
||
detail: DetailLevel,
|
||
lwMm: number,
|
||
): void {
|
||
const pts = ceiling.outline;
|
||
if (pts.length < 3) return;
|
||
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 outlineMm = lwMm * OUTLINE_DETAIL_FACTOR[detail];
|
||
const ceilingId = ceiling.id;
|
||
|
||
// Gefüllte + schraffierte Fläche (Component-Poché). Im Detailgrad „grob" ohne
|
||
// Schraffur (reine Füllung), sonst die Bauteil-Schraffur.
|
||
out.push({
|
||
kind: "polygon",
|
||
pts,
|
||
fill: comp ? comp.color : "none",
|
||
stroke,
|
||
strokeWidthMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail],
|
||
hatch: comp && detail !== "grob" ? resolveHatch(project, comp.hatchId) : NO_HATCH,
|
||
greyed,
|
||
ceilingId,
|
||
});
|
||
// Kräftige Umrisslinie über die volle Decke (nur Kontur, keine Füllung).
|
||
out.push({
|
||
kind: "polygon",
|
||
pts,
|
||
fill: "none",
|
||
stroke,
|
||
strokeWidthMm: outlineMm,
|
||
hatch: NO_HATCH,
|
||
greyed,
|
||
ceilingId,
|
||
});
|
||
}
|
||
|
||
/** Deckkraft (0..255) der transluzenten Raum-Füllung → 2-stelliges Hex-Suffix. */
|
||
const ROOM_FILL_ALPHA = "33"; // ~20 % Deckkraft
|
||
/** 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 transluzente farbige Füllfläche über
|
||
* dem lichten Umriss + eine dünne 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. Der Stempel entfällt beim Detailgrad
|
||
* „grob"; Fläche/Umfang/Schwerpunkt werden aus `boundary` abgeleitet (nie
|
||
* gespeichert). Wird UNTER der Wand-Poché gezeichnet.
|
||
*/
|
||
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;
|
||
|
||
// Transluzente Farbfüllung.
|
||
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),
|
||
})
|
||
: [
|
||
`${res.netArea.toFixed(2)} m²`,
|
||
`${room.siaCategory} · ${siaLabel(room.siaCategory)}`,
|
||
];
|
||
out.push({
|
||
kind: "text",
|
||
at,
|
||
doc,
|
||
extraLines,
|
||
basePt: ROOM_STAMP_PT,
|
||
color: stroke,
|
||
roomId: room.id,
|
||
greyed,
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
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.
|
||
const [h1, tip, h2] = geo.arrow.head;
|
||
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 });
|
||
}
|
||
|
||
const length = (w: Wall): number => Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y);
|
||
|
||
function computeBounds(
|
||
project: Project,
|
||
walls: Wall[],
|
||
drawings: Drawing2D[] = [],
|
||
ceilings: Ceiling[] = [],
|
||
stairs: Stair[] = [],
|
||
rooms: Room[] = [],
|
||
) {
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
const acc = (p: Vec2) => {
|
||
minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
|
||
maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y);
|
||
};
|
||
for (const w of walls) {
|
||
const t = wallTypeThickness(getWallType(project, w));
|
||
for (const c of wallCorners(w.start, w.end, t)) acc(c);
|
||
}
|
||
for (const c of ceilings) for (const p of c.outline) acc(p);
|
||
for (const r of rooms) for (const p of r.boundary) acc(p);
|
||
for (const s of stairs) {
|
||
const { zBottom, zTop } = stairVerticalExtent(project, s);
|
||
const geo = stairGeometry(s, zTop - zBottom);
|
||
for (const tr of geo.treads) for (const p of tr.pts) acc(p);
|
||
if (geo.landing) for (const p of geo.landing) acc(p);
|
||
}
|
||
for (const d of drawings) {
|
||
const g = d.geom;
|
||
if (g.shape === "line") { acc(g.a); acc(g.b); }
|
||
else if (g.shape === "polyline") g.pts.forEach(acc);
|
||
else if (g.shape === "rect") { acc(g.min); acc(g.max); }
|
||
else if (g.shape === "circle" || g.shape === "arc") {
|
||
acc({ x: g.center.x - g.r, y: g.center.y - g.r });
|
||
acc({ x: g.center.x + g.r, y: g.center.y + g.r });
|
||
} else if (g.shape === "text") acc(g.at);
|
||
}
|
||
// Kontext-Konturen mit einrahmen, damit „Einpassen" sie ebenfalls zeigt.
|
||
for (const obj of project.context ?? []) {
|
||
if (obj.type !== "contourSet") continue;
|
||
for (const c of obj.contours) for (const p of c.pts) acc(p);
|
||
}
|
||
if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 1, maxY: 1 };
|
||
return { minX, minY, maxX, maxY };
|
||
}
|