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).
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
// 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, " ");
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Vektor-PDF-Export des Grundrisses. Baut aus einem Plan (generatePlan →
|
||||
// Primitive) ein massstäbliches Druck-SVG (planToPrintSvg) und rendert dieses
|
||||
// per jsPDF + svg2pdf.js als ECHTES Vektor-PDF (Pfad-/Text-Operatoren, KEINE
|
||||
// Rasterung). Papierformat (A4/A3) + Ausrichtung (Hoch/Quer) wählbar; der Plan
|
||||
// wird zentriert aufs Blatt gesetzt, ein schlichtes Schriftfeld (Titel/Massstab)
|
||||
// optional unten rechts.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import { jsPDF } from "jspdf";
|
||||
import "svg2pdf.js";
|
||||
import type { Plan } from "../plan/generatePlan";
|
||||
import { planToPrintSvg } from "./planToPrintSvg";
|
||||
|
||||
/** Unterstützte Papierformate (Blattmasse in mm, Hochformat-Basis). */
|
||||
export type PaperFormat = "a4" | "a3";
|
||||
|
||||
/** Ausrichtung des Blatts. */
|
||||
export type Orientation = "portrait" | "landscape";
|
||||
|
||||
/** Blattmasse je Format im Hochformat (mm). */
|
||||
const PAGE_MM: Record<PaperFormat, { w: number; h: number }> = {
|
||||
a4: { w: 210, h: 297 },
|
||||
a3: { w: 297, h: 420 },
|
||||
};
|
||||
|
||||
export interface ExportPdfOptions {
|
||||
/** Massstab-Nenner N (1:N). */
|
||||
scaleDenominator: number;
|
||||
paper: PaperFormat;
|
||||
orientation: Orientation;
|
||||
/** Plantitel/Geschossname fürs Schriftfeld. */
|
||||
title: string;
|
||||
/** Optionaler Untertitel (z. B. Projektname). */
|
||||
subtitle?: string;
|
||||
/** Schriftfeld zeichnen (Titel/Massstab). Default true. */
|
||||
titleBlock?: boolean;
|
||||
/** Dateiname des Downloads (ohne/inkl. .pdf). */
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
/** Blattmasse (mm) nach Format + Ausrichtung. */
|
||||
export function pageSizeMm(
|
||||
paper: PaperFormat,
|
||||
orientation: Orientation,
|
||||
): { w: number; h: number } {
|
||||
const base = PAGE_MM[paper];
|
||||
return orientation === "landscape" ? { w: base.h, h: base.w } : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt das Vektor-PDF des Plans und liefert das jsPDF-Dokument zurück (der
|
||||
* Aufrufer speichert via {@link savePlanPdf} oder nutzt `doc.output(...)` zum
|
||||
* Testen). Das Schriftfeld nutzt die eingebaute Helvetica (Vektor-Text).
|
||||
*/
|
||||
export async function buildPlanPdf(
|
||||
plan: Plan,
|
||||
opts: ExportPdfOptions,
|
||||
): Promise<jsPDF> {
|
||||
const { w: pageW, h: pageH } = pageSizeMm(opts.paper, opts.orientation);
|
||||
|
||||
// Druck-SVG (mm-Koordinaten) aufbauen. Etwas mehr unterer Rand, falls ein
|
||||
// Schriftfeld gezeichnet wird (damit es nicht in den Plan ragt).
|
||||
const margin = 10;
|
||||
const print = planToPrintSvg(plan, {
|
||||
scaleDenominator: opts.scaleDenominator,
|
||||
pageWidthMm: pageW,
|
||||
pageHeightMm: pageH,
|
||||
marginMm: margin,
|
||||
});
|
||||
|
||||
const doc = new jsPDF({
|
||||
unit: "mm",
|
||||
format: [pageW, pageH],
|
||||
orientation: opts.orientation,
|
||||
compress: true,
|
||||
});
|
||||
|
||||
// SVG → PDF (Vektor). Die viewBox des SVG ist in mm (Papier-Benutzereinheiten)
|
||||
// und deckungsgleich mit der Blattgröße; svg2pdf bildet sie 1:1 auf das mm-Ziel
|
||||
// (Blattbreite/-höhe) ab.
|
||||
await doc.svg(print.svg, { x: 0, y: 0, width: pageW, height: pageH });
|
||||
|
||||
if (opts.titleBlock ?? true) {
|
||||
drawTitleBlock(doc, pageW, pageH, opts);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** Schlichtes Schriftfeld unten rechts: Titel, Untertitel, Massstab. */
|
||||
function drawTitleBlock(
|
||||
doc: jsPDF,
|
||||
pageW: number,
|
||||
pageH: number,
|
||||
opts: ExportPdfOptions,
|
||||
): void {
|
||||
const boxW = 70;
|
||||
const boxH = 22;
|
||||
const pad = 6;
|
||||
const x = pageW - boxW - pad;
|
||||
const y = pageH - boxH - pad;
|
||||
|
||||
doc.setDrawColor(17, 17, 17);
|
||||
doc.setLineWidth(0.25);
|
||||
doc.rect(x, y, boxW, boxH);
|
||||
// Trennlinie zwischen Titel-Block und Massstab-Zeile.
|
||||
doc.line(x, y + boxH - 7, x + boxW, y + boxH - 7);
|
||||
|
||||
doc.setTextColor(17, 17, 17);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(10);
|
||||
doc.text(clip(opts.title, 30), x + 3, y + 6);
|
||||
|
||||
if (opts.subtitle) {
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setFontSize(7);
|
||||
doc.text(clip(opts.subtitle, 40), x + 3, y + 11);
|
||||
}
|
||||
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setFontSize(9);
|
||||
doc.text(`1:${opts.scaleDenominator}`, x + 3, y + boxH - 2.2);
|
||||
const fmt = `${opts.paper.toUpperCase()} ${
|
||||
opts.orientation === "landscape" ? "quer" : "hoch"
|
||||
}`;
|
||||
doc.text(fmt, x + boxW - 3, y + boxH - 2.2, { align: "right" });
|
||||
}
|
||||
|
||||
/** Kürzt einen String auf maximal `max` Zeichen (mit Auslassung). */
|
||||
function clip(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max - 1) + "…" : s;
|
||||
}
|
||||
|
||||
/** Baut das PDF und löst den Download aus (Browser). */
|
||||
export async function savePlanPdf(
|
||||
plan: Plan,
|
||||
opts: ExportPdfOptions,
|
||||
): Promise<void> {
|
||||
const doc = await buildPlanPdf(plan, opts);
|
||||
const name = (opts.fileName ?? "grundriss").replace(/\.pdf$/i, "");
|
||||
doc.save(`${name}.pdf`);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
// Druck-SVG aus einem Plan (generatePlan → Primitive). Eigenständiges,
|
||||
// massstäbliches SVG für den VEKTOR-PDF-Export — NICHT vom Bildschirm-DOM
|
||||
// abgeleitet, sondern direkt aus den Plan-Primitiven (Polygon/Linie/Bogen).
|
||||
//
|
||||
// Kern-Idee (vgl. CONVENTIONS.md, docs/design/plans-output.md §3): Der Plan IST
|
||||
// Papier-Space. Welt-Meter werden im gewählten Massstab 1:N nach Papier-
|
||||
// Millimeter abgebildet (1 m = 1000/N mm). Das SVG-Benutzerkoordinatensystem ist
|
||||
// damit „mm": die viewBox ist in mm, Strichstärken (`stroke-width`) sind echte
|
||||
// physische mm — KEIN non-scaling-stroke (anders als die Bildschirm-PlanView).
|
||||
//
|
||||
// Schraffuren werden NICHT als <pattern> geschrieben (PDF-Konverter übernehmen
|
||||
// Pattern oft nicht sauber), sondern als echte, auf das Polygon geklippte
|
||||
// Vektorlinien direkt ins SVG. So bleibt das PDF rein vektoriell und scharf.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { HatchRender, Plan, Primitive } from "../plan/generatePlan";
|
||||
import type { Vec2 } from "../model/types";
|
||||
|
||||
/** SVG-Namespace für die programmatisch erzeugten Knoten. */
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/**
|
||||
* Standard-Stift-Stufen (mm). Beliebige Kategorie-Strichstärken werden auf die
|
||||
* NÄCHSTHÖHERE dieser ISO-nahen Stufen gequantelt, damit der Plan saubere,
|
||||
* unterscheidbare Linienstärken trägt (dünne Hilfslinien ≠ dicke Umrisse).
|
||||
*/
|
||||
const PEN_STEPS = [0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0] as const;
|
||||
|
||||
/** Untergrenze einer sichtbaren Druck-Strichstärke (mm) — vermeidet 0-Linien. */
|
||||
const MIN_PEN_MM = 0.13;
|
||||
|
||||
/** Quantelt eine mm-Strichstärke auf die nächste Stift-Stufe (≥ MIN_PEN_MM). */
|
||||
function quantizePen(mm: number): number {
|
||||
const w = Math.max(MIN_PEN_MM, mm);
|
||||
for (const step of PEN_STEPS) if (w <= step + 1e-6) return step;
|
||||
return PEN_STEPS[PEN_STEPS.length - 1];
|
||||
}
|
||||
|
||||
/** Ergebnis des Print-SVG-Aufbaus: das Element + Blattmasse (mm). */
|
||||
export interface PrintSvg {
|
||||
/** Das fertige, in mm bemaßte <svg>-Element (für svg2pdf). */
|
||||
svg: SVGSVGElement;
|
||||
/** Blattbreite in mm (= viewBox-Breite). */
|
||||
widthMm: number;
|
||||
/** Blatthöhe in mm (= viewBox-Höhe). */
|
||||
heightMm: number;
|
||||
/** Massstäblicher Inhalts-Rahmen (mm) innerhalb des Blattes (Plan-Bounds). */
|
||||
content: { x: number; y: number; w: number; h: number };
|
||||
}
|
||||
|
||||
export interface PrintSvgOptions {
|
||||
/** Massstab-Nenner N (1:N). 1 m = 1000/N mm auf dem Papier. */
|
||||
scaleDenominator: number;
|
||||
/** Blattbreite in mm (Papierformat, schon Hoch/Quer berücksichtigt). */
|
||||
pageWidthMm: number;
|
||||
/** Blatthöhe in mm. */
|
||||
pageHeightMm: number;
|
||||
/** Rand um den Plan-Inhalt in mm (weisser Blattrand). */
|
||||
marginMm?: number;
|
||||
}
|
||||
|
||||
/** Modell-Meter → Papier-Millimeter im Massstab 1:N. */
|
||||
function mmPerMeter(n: number): number {
|
||||
return 1000 / n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bildet einen Welt-Punkt (Meter) auf Papier-Millimeter ab. Der Inhalt wird so
|
||||
* verschoben, dass die Plan-Bounds (in mm) zentriert auf dem Blatt liegen.
|
||||
* Plan-Y zeigt nach oben → SVG-Y nach unten (Spiegelung), daher das Minus.
|
||||
*/
|
||||
interface Mapper {
|
||||
(p: Vec2): Vec2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt das Druck-SVG aus einem Plan. Reihenfolge der Primitive bleibt
|
||||
* erhalten (Zeichenreihenfolge = Stapelung); Schraffuren werden als geklippte
|
||||
* Linien zwischen Grundfüllung und Umriss eingefügt.
|
||||
*/
|
||||
export function planToPrintSvg(plan: Plan, opts: PrintSvgOptions): PrintSvg {
|
||||
const n = opts.scaleDenominator;
|
||||
const k = mmPerMeter(n); // mm je Meter
|
||||
// `marginMm` ist Teil des Vertrags (Aufrufer kann einen Rand wünschen); da der
|
||||
// Inhalt zentriert wird, liegt er ohnehin innerhalb des Blattes. Wir lesen ihn
|
||||
// bewusst, ohne ihn aktuell für einen Versatz zu nutzen (zentrierte Lage).
|
||||
void (opts.marginMm ?? 10);
|
||||
|
||||
const b = plan.bounds;
|
||||
// Inhalts-Maße in mm (massstäblich), unverschoben.
|
||||
const contentWmm = (b.maxX - b.minX) * k;
|
||||
const contentHmm = (b.maxY - b.minY) * k;
|
||||
|
||||
// Inhalt mittig aufs Blatt setzen. Der nutzbare Bereich ist Blatt minus Rand;
|
||||
// wir zentrieren den Plan-Block darin (clippen aber NICHT — bei zu grossem
|
||||
// Inhalt läuft er über das Blatt hinaus, was der Aufrufer per Format/Massstab
|
||||
// vermeidet; ein Warnhinweis liegt in der UI).
|
||||
const offsetXmm = (opts.pageWidthMm - contentWmm) / 2;
|
||||
// SVG-Y zeigt nach unten: der oberste Welt-Punkt (maxY) liegt oben auf dem
|
||||
// Blatt. Wir mappen maxY → offsetYmm.
|
||||
const offsetYmm = (opts.pageHeightMm - contentHmm) / 2;
|
||||
|
||||
const map: Mapper = (p) => ({
|
||||
x: offsetXmm + (p.x - b.minX) * k,
|
||||
y: offsetYmm + (b.maxY - p.y) * k,
|
||||
});
|
||||
|
||||
const svg = document.createElementNS(SVG_NS, "svg") as SVGSVGElement;
|
||||
svg.setAttribute("xmlns", SVG_NS);
|
||||
// Breite/Höhe als REINE Zahlen (= viewBox-Einheiten), KEINE „mm"-Einheit: so
|
||||
// bildet svg2pdf die viewBox (in mm-Benutzereinheiten) 1:1 auf das mm-Ziel
|
||||
// (doc.svg {width,height}) ab. Eine „297mm"-Einheit würde svg2pdf als
|
||||
// physische Größe lesen und zusätzlich pt-skalieren (Faktor 25.4/72) → falsch.
|
||||
svg.setAttribute("width", String(opts.pageWidthMm));
|
||||
svg.setAttribute("height", String(opts.pageHeightMm));
|
||||
svg.setAttribute("viewBox", `0 0 ${opts.pageWidthMm} ${opts.pageHeightMm}`);
|
||||
|
||||
// Weisses Blatt (deckt die volle Seite).
|
||||
const bg = document.createElementNS(SVG_NS, "rect");
|
||||
bg.setAttribute("x", "0");
|
||||
bg.setAttribute("y", "0");
|
||||
bg.setAttribute("width", String(opts.pageWidthMm));
|
||||
bg.setAttribute("height", String(opts.pageHeightMm));
|
||||
bg.setAttribute("fill", "#ffffff");
|
||||
svg.appendChild(bg);
|
||||
|
||||
// Jedes Primitiv in mm-Knoten übersetzen.
|
||||
for (const p of plan.primitives) {
|
||||
appendPrimitive(svg, p, map, k);
|
||||
}
|
||||
|
||||
return {
|
||||
svg,
|
||||
widthMm: opts.pageWidthMm,
|
||||
heightMm: opts.pageHeightMm,
|
||||
content: { x: offsetXmm, y: offsetYmm, w: contentWmm, h: contentHmm },
|
||||
};
|
||||
}
|
||||
|
||||
/** Schreibt ein einzelnes Primitiv (mit ggf. Schraffur) in das SVG. */
|
||||
function appendPrimitive(
|
||||
svg: SVGSVGElement,
|
||||
p: Primitive,
|
||||
map: Mapper,
|
||||
mmPerM: number,
|
||||
): void {
|
||||
const opacity = p.greyed ? "0.3" : undefined;
|
||||
switch (p.kind) {
|
||||
case "polygon":
|
||||
appendPolygon(svg, p, map, mmPerM, opacity);
|
||||
break;
|
||||
case "line": {
|
||||
const a = map(p.a);
|
||||
const b = map(p.b);
|
||||
const ln = lineNode(a, b, p.color ?? "#111111", quantizePen(p.weightMm), p.dash, mmPerM);
|
||||
if (opacity) ln.setAttribute("opacity", opacity);
|
||||
svg.appendChild(ln);
|
||||
break;
|
||||
}
|
||||
case "arc": {
|
||||
const node = arcNode(p, map, mmPerM);
|
||||
if (opacity) node.setAttribute("opacity", opacity);
|
||||
svg.appendChild(node);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polygon mit Füllung/Umriss/Schraffur. Reihenfolge wie in der PlanView:
|
||||
* • pattern "none" → reine Füllung (oder „none") + Umriss.
|
||||
* • pattern "solid" → Vollfüllung in der Hatch-Farbe + Umriss.
|
||||
* • Linien/Kreuz/Dämmung → Grundfüllung, dann geklippte Schraffurlinien, dann Umriss.
|
||||
*/
|
||||
function appendPolygon(
|
||||
svg: SVGSVGElement,
|
||||
p: Extract<Primitive, { kind: "polygon" }>,
|
||||
map: Mapper,
|
||||
mmPerM: number,
|
||||
opacity: string | undefined,
|
||||
): void {
|
||||
const ptsMm = p.pts.map(map);
|
||||
const ptsAttr = ptsMm.map((s) => `${round(s.x)},${round(s.y)}`).join(" ");
|
||||
const strokeMm = p.strokeWidthMm > 0 ? quantizePen(p.strokeWidthMm) : 0;
|
||||
|
||||
const g = document.createElementNS(SVG_NS, "g");
|
||||
if (opacity) g.setAttribute("opacity", opacity);
|
||||
|
||||
const pattern = p.hatch.pattern;
|
||||
if (pattern === "none") {
|
||||
g.appendChild(polyNode(ptsAttr, p.fill, p.stroke, strokeMm));
|
||||
} else if (pattern === "solid") {
|
||||
g.appendChild(polyNode(ptsAttr, p.hatch.color, p.stroke, strokeMm));
|
||||
} else {
|
||||
// Grundfüllung (ohne Umriss), dann die geklippten Schraffurlinien, dann der
|
||||
// Umriss obenauf (separat, damit er die Schraffur-Enden überdeckt).
|
||||
if (p.fill !== "none") {
|
||||
g.appendChild(polyNode(ptsAttr, p.fill, "none", 0));
|
||||
}
|
||||
for (const seg of hatchLines(ptsMm, p.hatch, mmPerM)) {
|
||||
g.appendChild(
|
||||
lineNode(
|
||||
seg.a,
|
||||
seg.b,
|
||||
p.hatch.color,
|
||||
quantizePen(p.hatch.lineWeight),
|
||||
p.hatch.dash,
|
||||
mmPerM,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (strokeMm > 0) {
|
||||
g.appendChild(polyNode(ptsAttr, "none", p.stroke, strokeMm));
|
||||
}
|
||||
}
|
||||
|
||||
svg.appendChild(g);
|
||||
}
|
||||
|
||||
/** Erzeugt einen <polygon>-Knoten (mm-Koordinaten als Attribut-String). */
|
||||
function polyNode(
|
||||
ptsAttr: string,
|
||||
fill: string,
|
||||
stroke: string,
|
||||
strokeMm: number,
|
||||
): SVGElement {
|
||||
const el = document.createElementNS(SVG_NS, "polygon");
|
||||
el.setAttribute("points", ptsAttr);
|
||||
el.setAttribute("fill", fill);
|
||||
if (stroke !== "none" && strokeMm > 0) {
|
||||
el.setAttribute("stroke", stroke);
|
||||
el.setAttribute("stroke-width", String(strokeMm));
|
||||
el.setAttribute("stroke-linejoin", "round");
|
||||
} else {
|
||||
el.setAttribute("stroke", "none");
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Erzeugt einen <line>-Knoten in mm; Strichmuster (mm Welt → mm Papier). */
|
||||
function lineNode(
|
||||
a: Vec2,
|
||||
b: Vec2,
|
||||
color: string,
|
||||
strokeMm: number,
|
||||
dash: number[] | null | undefined,
|
||||
_mmPerM: number,
|
||||
): SVGElement {
|
||||
const el = document.createElementNS(SVG_NS, "line");
|
||||
el.setAttribute("x1", String(round(a.x)));
|
||||
el.setAttribute("y1", String(round(a.y)));
|
||||
el.setAttribute("x2", String(round(b.x)));
|
||||
el.setAttribute("y2", String(round(b.y)));
|
||||
el.setAttribute("stroke", color);
|
||||
el.setAttribute("stroke-width", String(strokeMm));
|
||||
el.setAttribute("stroke-linecap", "round");
|
||||
if (dash && dash.length) {
|
||||
// Strichmuster ist in mm Papier definiert (wie weightMm), daher direkt.
|
||||
el.setAttribute("stroke-dasharray", dash.map((d) => round(d)).join(" "));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt einen Bogen als <path> (A-Kommando). Der Bogen-Radius ist in Metern →
|
||||
* Papier-mm. Drehrichtung aus dem Kreuzprodukt im Papier-Raum (Y nach unten).
|
||||
*/
|
||||
function arcNode(
|
||||
p: Extract<Primitive, { kind: "arc" }>,
|
||||
map: Mapper,
|
||||
mmPerM: number,
|
||||
): SVGElement {
|
||||
const c = map(p.center);
|
||||
const from = map(p.from);
|
||||
const to = map(p.to);
|
||||
const r = p.r * mmPerM;
|
||||
const v1 = { x: from.x - c.x, y: from.y - c.y };
|
||||
const v2 = { x: to.x - c.x, y: to.y - c.y };
|
||||
const cross = v1.x * v2.y - v1.y * v2.x;
|
||||
const sweep = cross > 0 ? 1 : 0;
|
||||
const d = `M ${round(from.x)} ${round(from.y)} A ${round(r)} ${round(r)} 0 0 ${sweep} ${round(to.x)} ${round(to.y)}`;
|
||||
const el = document.createElementNS(SVG_NS, "path");
|
||||
el.setAttribute("d", d);
|
||||
el.setAttribute("fill", "none");
|
||||
el.setAttribute("stroke", "#111111");
|
||||
el.setAttribute("stroke-width", String(quantizePen(p.weightMm)));
|
||||
if (p.dash && p.dash.length) {
|
||||
el.setAttribute("stroke-dasharray", p.dash.map((x) => round(x)).join(" "));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Auf 3 Nachkommastellen runden (kompaktes, exaktes SVG). */
|
||||
function round(v: number): number {
|
||||
return Math.round(v * 1000) / 1000;
|
||||
}
|
||||
|
||||
// ── Schraffur als geklippte Vektorlinien ─────────────────────────────────────
|
||||
|
||||
interface Seg {
|
||||
a: Vec2;
|
||||
b: Vec2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt die Schraffurlinien eines Polygons (in Papier-mm), auf das Polygon
|
||||
* geklippt. Die Kachelgrösse der Bildschirm-PlanView (8 px bzw. 14 px bei
|
||||
* „insulation", × Maßstab) wird hier in einen mm-Linienabstand übersetzt, damit
|
||||
* der Druck der Bildschirm-Dichte nahekommt:
|
||||
* • diagonal/crosshatch: parallele Linienscharen im Winkel `angle`.
|
||||
* • crosshatch: zusätzlich die um 90° gedrehte Schar.
|
||||
* • insulation: vereinfacht als eine dichtere diagonale Schar (echte Wellen-
|
||||
* linie wäre als Pfad möglich; eine Linienschar liest sich im Druck sauber
|
||||
* und bleibt rein vektoriell — offener Punkt im Report).
|
||||
*/
|
||||
function hatchLines(polyMm: Vec2[], hatch: HatchRender, mmPerM: number): Seg[] {
|
||||
// Kachel der PlanView: 8 viewBox-Einheiten (= px bei dpi-Bezug) × scale. In der
|
||||
// PlanView ist 1 viewBox-Einheit = 1/PX_PER_M m = 1/90 m. Auf Papier sind das
|
||||
// (1/90)·mmPerM mm. So bleibt die Strich-Dichte massstäblich konsistent.
|
||||
const unitMm = (1 / 90) * mmPerM;
|
||||
const tileMm = (hatch.pattern === "insulation" ? 10 : 8) * hatch.scale * unitMm;
|
||||
const spacing = Math.max(0.5, tileMm); // Mindestabstand, sonst zu dicht
|
||||
|
||||
const angle = (hatch.angle * Math.PI) / 180;
|
||||
const segs: Seg[] = [];
|
||||
fillParallel(polyMm, angle, spacing, segs);
|
||||
if (hatch.pattern === "crosshatch") {
|
||||
fillParallel(polyMm, angle + Math.PI / 2, spacing, segs);
|
||||
}
|
||||
if (hatch.pattern === "insulation") {
|
||||
// Zweite, gegenläufige Schar deutet die Dämmungs-Textur an (dichter Filz).
|
||||
fillParallel(polyMm, angle - Math.PI / 4, spacing, segs);
|
||||
}
|
||||
return segs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Füllt das Polygon mit parallelen Linien im gegebenen Winkel/Abstand und klippt
|
||||
* jede Linie auf das Polygon (Scanline über die Polygon-Schnittpunkte). Robust
|
||||
* für konvexe wie konkave (einfache) Polygone.
|
||||
*/
|
||||
function fillParallel(
|
||||
poly: Vec2[],
|
||||
angle: number,
|
||||
spacing: number,
|
||||
out: Seg[],
|
||||
): void {
|
||||
if (poly.length < 3 || spacing <= 0) return;
|
||||
// Linienrichtung d und Normale nrm (Projektionsachse).
|
||||
const dir = { x: Math.cos(angle), y: Math.sin(angle) };
|
||||
const nrm = { x: -dir.y, y: dir.x };
|
||||
|
||||
// Projektion der Polygonpunkte auf die Normale → [tMin, tMax]-Band der Linien.
|
||||
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;
|
||||
}
|
||||
// Linien an t = tMin + i·spacing; für jede die Schnitt-Intervalle ermitteln.
|
||||
const start = Math.ceil(tMin / spacing) * spacing;
|
||||
for (let t = start; t <= tMax; t += spacing) {
|
||||
const xs: number[] = []; // Parameter entlang dir der Schnittpunkte
|
||||
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;
|
||||
// Schneidet die Kante das Niveau t?
|
||||
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); // Position entlang dir
|
||||
}
|
||||
if (xs.length < 2) continue;
|
||||
xs.sort((u, v) => u - v);
|
||||
// Paarweise Intervalle = Polygon-Inneres entlang der Linie.
|
||||
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;
|
||||
// Punkt = t·nrm + u·dir.
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user