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).
316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
// 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);
|
||
}
|