Files
DOSSIER-STANDALONE/src/export/exportDxf.ts
T
karim 3d2d4d6321 2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand
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).
2026-07-02 00:12:39 +02:00

316 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// DXF-Export des Grundrisses. Nutzt DIESELBE Quelle wie der PDF-Export — den
// Vektor-Plan (generatePlan → Primitive) — schreibt aber MODELL-SPACE-Geometrie
// in ECHTEN Metern (kein Papier-mm, keine Y-Spiegelung). So können nachgelagerte
// CAD-Nutzer beim Plot beliebig skalieren; die Datei ist masshaltig 1:1 in Metern.
//
// Layer entstehen aus den Kategorie-Ebenen des Projekts (Code + Name, Farbe,
// Linienstärke). Jede Entity liegt auf dem Layer ihrer Kategorie; Primitive ohne
// direkten Kategorie-Bezug (Tür-Symbole, Kontext, Schraffuren) landen auf
// sprechenden Sammel-Layern. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Plan, Primitive } from "../plan/generatePlan";
import type { LayerCategory, Project, Vec2 } from "../model/types";
import { flattenCategories } from "../model/types";
import { DxfWriter } from "./dxfWriter";
export interface ExportDxfOptions {
/** Titel/Geschossname (nur als Kommentar-freundlicher Dateiname genutzt). */
title?: string;
/** Schraffurlinien mit ausgeben (true) oder nur Umrisse/Flächen (false). */
includeHatches?: boolean;
/** Dateiname des Downloads (mit/ohne .dxf). */
fileName?: string;
}
/** Sammel-Layer-Namen für Primitive ohne eigene Kategorie. */
const LAYER_HATCH = "HATCH";
const LAYER_SYMBOLS = "SYMBOLS";
const LAYER_CONTEXT = "CONTEXT";
const LAYER_DEFAULT = "PLAN";
/**
* Baut aus einem Plan + Projekt einen vollständigen DXF-String (Meter-Modell-Space).
* Reihenfolge der Primitive bleibt erhalten. Rückgabe ist reiner ASCII-Text.
*/
export function buildPlanDxf(
plan: Plan,
project: Project,
opts: ExportDxfOptions = {},
): string {
const includeHatches = opts.includeHatches ?? true;
const dxf = new DxfWriter();
// 1) Layer aus den Kategorie-Ebenen anlegen (Code+Name als Layer-Name, Farbe,
// Linienstärke). So trägt die DXF echte, benannte Layer statt eines Flachs.
const cats = flattenCategories(project.layers);
const layerNameByCode = new Map<string, string>();
for (const c of cats) {
const name = layerName(c);
layerNameByCode.set(c.code, name);
dxf.addLayer({
name,
color: aciFromHex(c.color),
trueColor: rgbFromHex(c.color),
lineWeight: mmToLwe(c.lw),
});
}
// Sammel-Layer für kategorielose Primitive.
dxf.addLayer({ name: LAYER_SYMBOLS, color: 7, trueColor: 0x111111, lineWeight: 18 });
dxf.addLayer({ name: LAYER_HATCH, color: 8, trueColor: 0x888888, lineWeight: 13 });
dxf.addLayer({ name: LAYER_CONTEXT, color: 9, trueColor: 0x9aa3ad, lineWeight: 10 });
dxf.addLayer({ name: LAYER_DEFAULT, color: 7, trueColor: 0x111111, lineWeight: 18 });
// 2) Rückverweise Primitiv → Kategorie: Wände/2D-Elemente tragen nur ihre ID im
// Plan; die Kategorie liegt am Projekt-Objekt. Damit landet jede Wand-/Drawing-
// Entity auf dem richtigen Layer.
const codeByWall = new Map<string, string>();
for (const w of project.walls) codeByWall.set(w.id, w.categoryCode);
const codeByDrawing = new Map<string, string>();
for (const d of project.drawings2d) codeByDrawing.set(d.id, d.categoryCode);
const layerFor = (p: Primitive): string => {
if (p.kind === "polygon") {
if (p.wallId) return layerNameByCode.get(codeByWall.get(p.wallId) ?? "") ?? LAYER_DEFAULT;
if (p.drawingId) return layerNameByCode.get(codeByDrawing.get(p.drawingId) ?? "") ?? LAYER_DEFAULT;
return LAYER_DEFAULT;
}
if (p.kind === "line") {
if (p.cls === "context-line") return LAYER_CONTEXT;
if (p.drawingId) return layerNameByCode.get(codeByDrawing.get(p.drawingId) ?? "") ?? LAYER_DEFAULT;
// Tür-Blatt/-Rahmen und Wand-Achsen: Symbol-Sammellayer.
return LAYER_SYMBOLS;
}
// Bögen: Tür-Schwenkbogen → Symbole.
return LAYER_SYMBOLS;
};
// 3) Jedes Primitiv in DXF-Entities übersetzen.
for (const p of plan.primitives) {
emitPrimitive(dxf, p, layerFor(p), includeHatches);
}
return dxf.build();
}
/** Layer-Name aus einer Kategorie: "Code_Name" (bereinigt der Writer selbst). */
function layerName(c: LayerCategory): string {
return `${c.code}_${c.name}`;
}
/** Schreibt ein Plan-Primitiv als passende DXF-Entity(en). */
function emitPrimitive(
dxf: DxfWriter,
p: Primitive,
layer: string,
includeHatches: boolean,
): void {
switch (p.kind) {
case "line":
dxf.addLine(layer, p.a, p.b);
break;
case "arc":
emitArc(dxf, p, layer);
break;
case "polygon": {
const pts = p.pts;
if (pts.length < 2) break;
// Kreis-Approximationen (viele Segmente) bleiben als geschlossene Polylinie —
// masshaltig und robust. Geschlossene Ringe bekommen das Ring-Flag.
dxf.addPolyline(layer, pts, true);
// Schraffur als geklippte Linien (optional). Vollfüllung/„none" tragen keine
// sichtbaren Musterlinien → wir geben nur echte Muster (nicht solid/none) aus.
if (
includeHatches &&
p.hatch.pattern !== "none" &&
p.hatch.pattern !== "solid"
) {
for (const seg of hatchSegments(pts, p.hatch)) {
dxf.addLine(LAYER_HATCH, seg.a, seg.b);
}
}
break;
}
}
}
/**
* Bogen-Primitiv → DXF-ARC. DXF-Bögen laufen gegen den Uhrzeigersinn von
* startAngle nach endAngle. Der Plan-Bogen ist über from/to definiert; die
* Drehrichtung folgt dem Kreuzprodukt (positiv = CCW). Bei CW werden Start/Ende
* getauscht, damit der kurze Bogen in der richtigen Richtung entsteht.
*/
function emitArc(
dxf: DxfWriter,
p: Extract<Primitive, { kind: "arc" }>,
layer: string,
): void {
const a1 = angleDeg(p.center, p.from);
const a2 = angleDeg(p.center, p.to);
const v1 = { x: p.from.x - p.center.x, y: p.from.y - p.center.y };
const v2 = { x: p.to.x - p.center.x, y: p.to.y - p.center.y };
const cross = v1.x * v2.y - v1.y * v2.x;
if (cross >= 0) {
dxf.addArc(layer, p.center, p.r, a1, a2);
} else {
dxf.addArc(layer, p.center, p.r, a2, a1);
}
}
/** Winkel (Grad, 0..360) von `c` nach `p`, gegen den Uhrzeigersinn. */
function angleDeg(c: Vec2, p: Vec2): number {
const d = (Math.atan2(p.y - c.y, p.x - c.x) * 180) / Math.PI;
return d < 0 ? d + 360 : d;
}
// ── Schraffur als geklippte Vektorlinien (Meter) ────────────────────────────
// Wie planToPrintSvg, aber in WELT-Metern statt Papier-mm. Die Kachelgrösse der
// Bildschirm-PlanView (8 bzw. 10 Einheiten × scale, 1 Einheit = 1/90 m) wird
// direkt in einen Meter-Linienabstand übersetzt.
interface Seg {
a: Vec2;
b: Vec2;
}
function hatchSegments(
poly: Vec2[],
hatch: Extract<Primitive, { kind: "polygon" }>["hatch"],
): Seg[] {
const unitM = 1 / 90;
const tileM = (hatch.pattern === "insulation" ? 10 : 8) * hatch.scale * unitM;
const spacing = Math.max(0.02, tileM); // Mindestabstand in Metern
const angle = (hatch.angle * Math.PI) / 180;
const segs: Seg[] = [];
fillParallel(poly, angle, spacing, segs);
if (hatch.pattern === "crosshatch") fillParallel(poly, angle + Math.PI / 2, spacing, segs);
if (hatch.pattern === "insulation") fillParallel(poly, angle - Math.PI / 4, spacing, segs);
return segs;
}
/**
* Füllt ein Polygon mit parallelen Linien (Winkel/Abstand) und klippt jede Linie
* per Scanline auf das Polygon. Robust für konvexe wie einfache konkave Polygone.
*/
function fillParallel(poly: Vec2[], angle: number, spacing: number, out: Seg[]): void {
if (poly.length < 3 || spacing <= 0) return;
const dir = { x: Math.cos(angle), y: Math.sin(angle) };
const nrm = { x: -dir.y, y: dir.x };
let tMin = Infinity;
let tMax = -Infinity;
for (const p of poly) {
const t = p.x * nrm.x + p.y * nrm.y;
if (t < tMin) tMin = t;
if (t > tMax) tMax = t;
}
const start = Math.ceil(tMin / spacing) * spacing;
for (let t = start; t <= tMax; t += spacing) {
const xs: number[] = [];
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a = poly[j];
const b = poly[i];
const ta = a.x * nrm.x + a.y * nrm.y;
const tb = b.x * nrm.x + b.y * nrm.y;
if (ta === tb) continue;
const f = (t - ta) / (tb - ta);
if (f < 0 || f > 1) continue;
const px = a.x + (b.x - a.x) * f;
const py = a.y + (b.y - a.y) * f;
xs.push(px * dir.x + py * dir.y);
}
if (xs.length < 2) continue;
xs.sort((u, v) => u - v);
for (let k = 0; k + 1 < xs.length; k += 2) {
const u0 = xs[k];
const u1 = xs[k + 1];
if (u1 - u0 < 1e-6) continue;
out.push({
a: { x: nrm.x * t + dir.x * u0, y: nrm.y * t + dir.y * u0 },
b: { x: nrm.x * t + dir.x * u1, y: nrm.y * t + dir.y * u1 },
});
}
}
}
// ── Farb-Umrechnung ─────────────────────────────────────────────────────────
/** "#rrggbb" → 0xRRGGBB (24-Bit True-Color). Fallback dunkelgrau. */
export function rgbFromHex(hex: string): number {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return 0x111111;
return parseInt(m[1], 16);
}
/**
* Mappt eine Hex-Farbe auf den nächsten AutoCAD-Color-Index (ACI 1..255). Nutzt
* die feste ACI-Standardpalette (die ersten 9 Indizes sind die Grundfarben) und
* wählt per euklidischem RGB-Abstand den nächstliegenden. Robust genug, damit
* Viewer ohne True-Color-Support trotzdem sinnvolle Farben zeigen.
*/
export function aciFromHex(hex: string): number {
const rgb = rgbFromHex(hex);
const r = (rgb >> 16) & 0xff;
const g = (rgb >> 8) & 0xff;
const b = rgb & 0xff;
let best = 7;
let bestDist = Infinity;
for (const [idx, cr, cg, cb] of ACI_PALETTE) {
const d = (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2;
if (d < bestDist) {
bestDist = d;
best = idx;
}
}
return best;
}
/** Millimeter → DXF-LWEIGHT (1/100 mm), gerundet auf gültige Stufen ≥ 0. */
export function mmToLwe(mm: number): number {
return Math.max(0, Math.round(mm * 100));
}
/**
* Verkürzte ACI-Standardpalette [index, r, g, b]. Deckt die geläufigen Töne ab;
* für die Layer-Farben (meist gedämpfte Grau-/Bautöne) genügt das Nächste-Farbe-
* Matching. Index 7 (weiss/schwarz) und die Grautöne 250..255 sind enthalten.
*/
const ACI_PALETTE: ReadonlyArray<readonly [number, number, number, number]> = [
[1, 255, 0, 0],
[2, 255, 255, 0],
[3, 0, 255, 0],
[4, 0, 255, 255],
[5, 0, 0, 255],
[6, 255, 0, 255],
[7, 255, 255, 255],
[8, 128, 128, 128],
[9, 192, 192, 192],
[30, 255, 128, 0],
[40, 255, 191, 127],
[250, 51, 51, 51],
[251, 91, 91, 91],
[252, 132, 132, 132],
[253, 173, 173, 173],
[254, 214, 214, 214],
[255, 255, 255, 255],
];
/** Baut das DXF und löst den Download aus (Browser). */
export function savePlanDxf(
plan: Plan,
project: Project,
opts: ExportDxfOptions = {},
): void {
const text = buildPlanDxf(plan, project, opts);
const name = (opts.fileName ?? "grundriss").replace(/\.dxf$/i, "");
const blob = new Blob([text], { type: "application/dxf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${name}.dxf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}