8fd8987b70
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).
281 lines
8.4 KiB
TypeScript
281 lines
8.4 KiB
TypeScript
// Schlanker, handgeschriebener DXF-Writer (ASCII, AutoCAD R2000/AC1015-kompatibel).
|
||
// Erzeugt eine gültige Datei mit HEADER-, TABLES- (LAYER) und ENTITIES-Sektion und
|
||
// korrektem EOF. Geometrie wird in MODELL-SPACE in ECHTEN Metern geschrieben
|
||
// ($INSUNITS = 6 = Meter); nachgelagerte Nutzer skalieren beim Plot.
|
||
//
|
||
// Bewusst minimaler Funktionsumfang (LINE, LWPOLYLINE, CIRCLE, ARC, TEXT), aber mit
|
||
// korrekten Gruppen-Codes, damit AutoCAD/LibreCAD/andere Viewer die Datei sauber
|
||
// öffnen. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||
|
||
/** Ein Layer im DXF (Kategorie): Name, ACI-Farbindex, Linienstärke (mm × 100). */
|
||
export interface DxfLayer {
|
||
/** Layer-Name (keine Leerzeichen/Sonderzeichen → werden bereinigt). */
|
||
name: string;
|
||
/** AutoCAD Color Index (1..255). */
|
||
color: number;
|
||
/** True-Color (0xRRGGBB) für Viewer mit 24-Bit-Farbe; optional. */
|
||
trueColor?: number;
|
||
/** Linienstärke in 1/100 mm (DXF-LWEIGHT-Konvention), z. B. 25 = 0.25 mm. */
|
||
lineWeight: number;
|
||
}
|
||
|
||
/** 2D-Punkt (Meter, Modell-Space). */
|
||
export interface DxfPoint {
|
||
x: number;
|
||
y: number;
|
||
}
|
||
|
||
/**
|
||
* Sammelt Entities und Layer und serialisiert sie zu einem gültigen DXF-String.
|
||
* Alle Koordinaten sind Meter (Modell-Space). Der Y-Wert wird 1:1 übernommen
|
||
* (DXF-Y zeigt wie das Weltmodell nach oben — keine Spiegelung wie beim SVG).
|
||
*/
|
||
export class DxfWriter {
|
||
private layers = new Map<string, DxfLayer>();
|
||
private entities: string[] = [];
|
||
private handle = 0x100;
|
||
|
||
/** Nächstes eindeutiges Entity-Handle (Hex), fortlaufend. */
|
||
private nextHandle(): string {
|
||
return (this.handle++).toString(16).toUpperCase();
|
||
}
|
||
|
||
/** Registriert einen Layer (idempotent über den Namen). */
|
||
addLayer(layer: DxfLayer): void {
|
||
const name = sanitizeLayerName(layer.name);
|
||
if (!this.layers.has(name)) {
|
||
this.layers.set(name, { ...layer, name });
|
||
}
|
||
}
|
||
|
||
/** Stellt sicher, dass ein Layer existiert; liefert den bereinigten Namen. */
|
||
private ensureLayer(name: string): string {
|
||
const clean = sanitizeLayerName(name);
|
||
if (!this.layers.has(clean)) {
|
||
this.layers.set(clean, { name: clean, color: 7, lineWeight: 25 });
|
||
}
|
||
return clean;
|
||
}
|
||
|
||
addLine(layer: string, a: DxfPoint, b: DxfPoint): void {
|
||
const l = this.ensureLayer(layer);
|
||
this.entities.push(
|
||
tag(0, "LINE"),
|
||
tag(5, this.nextHandle()),
|
||
tag(100, "AcDbEntity"),
|
||
tag(8, l),
|
||
tag(100, "AcDbLine"),
|
||
num(10, a.x),
|
||
num(20, a.y),
|
||
num(30, 0),
|
||
num(11, b.x),
|
||
num(21, b.y),
|
||
num(31, 0),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* LWPOLYLINE (leichte 2D-Polylinie). `closed` setzt das Ringflag (Bit 1 in
|
||
* Code 70), sodass die letzte Kante zurück zum ersten Punkt geschlossen wird.
|
||
*/
|
||
addPolyline(layer: string, pts: DxfPoint[], closed: boolean): void {
|
||
if (pts.length < 2) return;
|
||
const l = this.ensureLayer(layer);
|
||
this.entities.push(
|
||
tag(0, "LWPOLYLINE"),
|
||
tag(5, this.nextHandle()),
|
||
tag(100, "AcDbEntity"),
|
||
tag(8, l),
|
||
tag(100, "AcDbPolyline"),
|
||
tag(90, String(pts.length)),
|
||
tag(70, closed ? "1" : "0"),
|
||
);
|
||
for (const p of pts) {
|
||
this.entities.push(num(10, p.x), num(20, p.y));
|
||
}
|
||
}
|
||
|
||
addCircle(layer: string, center: DxfPoint, r: number): void {
|
||
const l = this.ensureLayer(layer);
|
||
this.entities.push(
|
||
tag(0, "CIRCLE"),
|
||
tag(5, this.nextHandle()),
|
||
tag(100, "AcDbEntity"),
|
||
tag(8, l),
|
||
tag(100, "AcDbCircle"),
|
||
num(10, center.x),
|
||
num(20, center.y),
|
||
num(30, 0),
|
||
num(40, r),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* ARC — Bogen um `center` mit Radius `r`, von `startAngle` bis `endAngle`
|
||
* (Grad, gegen den Uhrzeigersinn, DXF-Konvention).
|
||
*/
|
||
addArc(
|
||
layer: string,
|
||
center: DxfPoint,
|
||
r: number,
|
||
startAngle: number,
|
||
endAngle: number,
|
||
): void {
|
||
const l = this.ensureLayer(layer);
|
||
this.entities.push(
|
||
tag(0, "ARC"),
|
||
tag(5, this.nextHandle()),
|
||
tag(100, "AcDbEntity"),
|
||
tag(8, l),
|
||
tag(100, "AcDbCircle"),
|
||
num(10, center.x),
|
||
num(20, center.y),
|
||
num(30, 0),
|
||
num(40, r),
|
||
tag(100, "AcDbArc"),
|
||
num(50, startAngle),
|
||
num(51, endAngle),
|
||
);
|
||
}
|
||
|
||
/** TEXT — einzeiliger Text, Höhe in Metern, an `at` (unten links). */
|
||
addText(layer: string, at: DxfPoint, height: number, value: string): void {
|
||
const l = this.ensureLayer(layer);
|
||
this.entities.push(
|
||
tag(0, "TEXT"),
|
||
tag(5, this.nextHandle()),
|
||
tag(100, "AcDbEntity"),
|
||
tag(8, l),
|
||
tag(100, "AcDbText"),
|
||
num(10, at.x),
|
||
num(20, at.y),
|
||
num(30, 0),
|
||
num(40, height),
|
||
tag(1, sanitizeText(value)),
|
||
tag(100, "AcDbText"),
|
||
);
|
||
}
|
||
|
||
/** Anzahl bislang gesammelter Entities (für Tests/Validierung). */
|
||
get entityCount(): number {
|
||
// Jede Entity beginnt mit einem "0"-Gruppencode + Typ-Zeile.
|
||
let n = 0;
|
||
for (let i = 0; i < this.entities.length; i += 2) {
|
||
if (this.entities[i] === " 0") n++;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
/** Serialisiert die vollständige DXF-Datei als String (mit EOF). */
|
||
build(): string {
|
||
const out: string[] = [];
|
||
out.push(...this.headerSection());
|
||
out.push(...this.tablesSection());
|
||
out.push(...this.entitiesSection());
|
||
out.push(tag(0, "EOF"));
|
||
return out.join("\r\n") + "\r\n";
|
||
}
|
||
|
||
private headerSection(): string[] {
|
||
return [
|
||
tag(0, "SECTION"),
|
||
tag(2, "HEADER"),
|
||
tag(9, "$ACADVER"),
|
||
tag(1, "AC1015"), // AutoCAD 2000
|
||
tag(9, "$INSUNITS"),
|
||
tag(70, "6"), // 6 = Meter
|
||
tag(9, "$MEASUREMENT"),
|
||
tag(70, "1"), // 1 = metrisch
|
||
tag(0, "ENDSEC"),
|
||
];
|
||
}
|
||
|
||
private tablesSection(): string[] {
|
||
const out: string[] = [tag(0, "SECTION"), tag(2, "TABLES")];
|
||
|
||
// LTYPE-Tabelle mit dem obligatorischen Continuous-Linetype (Viewer erwarten ihn).
|
||
out.push(
|
||
tag(0, "TABLE"),
|
||
tag(2, "LTYPE"),
|
||
tag(70, "1"),
|
||
tag(0, "LTYPE"),
|
||
tag(2, "CONTINUOUS"),
|
||
tag(70, "0"),
|
||
tag(3, "Solid line"),
|
||
tag(72, "65"),
|
||
tag(73, "0"),
|
||
num(40, 0),
|
||
tag(0, "ENDTAB"),
|
||
);
|
||
|
||
// LAYER-Tabelle: Layer 0 (Pflicht) + alle Kategorie-Layer.
|
||
const layers = [...this.layers.values()];
|
||
out.push(tag(0, "TABLE"), tag(2, "LAYER"), tag(70, String(layers.length + 1)));
|
||
out.push(...this.layerRecord({ name: "0", color: 7, lineWeight: 25 }));
|
||
for (const layer of layers) out.push(...this.layerRecord(layer));
|
||
out.push(tag(0, "ENDTAB"));
|
||
|
||
out.push(tag(0, "ENDSEC"));
|
||
return out;
|
||
}
|
||
|
||
/** Ein LAYER-Datensatz (Name/Farbe/Linetype/Linienstärke, ggf. True-Color). */
|
||
private layerRecord(layer: DxfLayer): string[] {
|
||
const rec = [
|
||
tag(0, "LAYER"),
|
||
tag(5, this.nextHandle()),
|
||
tag(100, "AcDbSymbolTableRecord"),
|
||
tag(100, "AcDbLayerTableRecord"),
|
||
tag(2, layer.name),
|
||
tag(70, "0"), // Flags: nicht gefroren/gesperrt
|
||
tag(62, String(layer.color)), // ACI-Farbindex
|
||
tag(6, "CONTINUOUS"),
|
||
tag(370, String(layer.lineWeight)), // Linienstärke in 1/100 mm
|
||
];
|
||
// True-Color (Code 420, 24-Bit) für Viewer mit voller Farbtiefe.
|
||
if (layer.trueColor != null) {
|
||
rec.push(tag(420, String(layer.trueColor & 0xffffff)));
|
||
}
|
||
return rec;
|
||
}
|
||
|
||
private entitiesSection(): string[] {
|
||
return [tag(0, "SECTION"), tag(2, "ENTITIES"), ...this.entities, tag(0, "ENDSEC")];
|
||
}
|
||
}
|
||
|
||
/** Gruppen-Code-Zeile mit String-Wert (Code rechtsbündig auf 3 Stellen). */
|
||
function tag(code: number, value: string): string {
|
||
return `${String(code).padStart(3, " ")}\n${value}`;
|
||
}
|
||
|
||
/** Gruppen-Code-Zeile mit Zahl-Wert (feste, kompakte Dezimaldarstellung). */
|
||
function num(code: number, value: number): string {
|
||
return `${String(code).padStart(3, " ")}\n${fmt(value)}`;
|
||
}
|
||
|
||
/** Kompakte, verlustarme Zahlformatierung (bis 6 Nachkommastellen, ohne Nullen). */
|
||
function fmt(v: number): string {
|
||
if (!Number.isFinite(v)) return "0.0";
|
||
const s = v.toFixed(6);
|
||
// Nachlaufende Nullen entfernen, aber mindestens eine Nachkommastelle behalten.
|
||
return s.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, ".0");
|
||
}
|
||
|
||
/**
|
||
* Bereinigt einen Layer-Namen: DXF-verbotene Zeichen (< > / \ " : ; ? * | = `)
|
||
* und Leerzeichen werden zu „_". Leerer Name → "0".
|
||
*/
|
||
export function sanitizeLayerName(name: string): string {
|
||
const clean = name
|
||
.trim()
|
||
.replace(/[<>/\\":;?*|=`]/g, "_")
|
||
.replace(/\s+/g, "_");
|
||
return clean.length ? clean : "0";
|
||
}
|
||
|
||
/** Entfernt Zeilenumbrüche aus TEXT-Werten (einzeilige TEXT-Entity). */
|
||
function sanitizeText(value: string): string {
|
||
return value.replace(/[\r\n]+/g, " ");
|
||
}
|