task(G4): layout PDF export - viewport-scaled A4 landscape, elementToPrimitives rendering, layer line widths + dash patterns, monochrome option, title block inclusion
This commit is contained in:
@@ -3,6 +3,15 @@
|
||||
*/
|
||||
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
|
||||
import type { CADElement, CADLayer, ProjectData } from '../types/cad.types';
|
||||
import { elementToPrimitives, type ShapePrimitive } from '../render/pixi/elementDrawers';
|
||||
import type { CADDocument } from '../kernel/document/CADDocument';
|
||||
|
||||
export interface LayoutPDFOptions {
|
||||
/** Alles in Schwarz drucken (Task G4). */
|
||||
monochrome?: boolean;
|
||||
/** Seitenränder in pt (Default 20). */
|
||||
margin?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export project data to a PDF byte array.
|
||||
@@ -172,3 +181,196 @@ function hexToRgb(hex: string | undefined): ReturnType<typeof rgb> | null {
|
||||
const b = parseInt(h.substring(4, 6), 16) / 255;
|
||||
return rgb(r, g, b);
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Task G4 — PDF-Export je Layout im Ausgabemaßstab
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/** A4 landscape in Punkten. */
|
||||
const A4_LANDSCAPE_W = 842;
|
||||
const A4_LANDSCAPE_H = 595;
|
||||
|
||||
/**
|
||||
* Exportiert EIN Layout als PDF (A4 landscape).
|
||||
*
|
||||
* Der gespeicherte Viewport (center/scale) definiert den sichtbaren
|
||||
* Welt-Ausschnitt; die Zeichnung wird in den Ausgabemaßstab skaliert
|
||||
* (Welt-pt × viewport.scale). Geometrie läuft über elementToPrimitives
|
||||
* (C2) — identische Konventionen wie Pixi-Renderer (circle el.x/el.y =
|
||||
* Zentrum). Linienbreiten: properties.strokeWidth, andernfalls Linientyp
|
||||
* aus dem Layer (solid 0.7pt, dashed 0.9pt, dotted 0.5pt) mit passenden
|
||||
* Dash-Patterns. `monochrome` erzwingt Schwarz. Der Titelblock aus
|
||||
* layout.frameBlockRef wird mitgezeichnet (unskaliert als Rahmen unten
|
||||
* rechts, Elemente relativ zur Ecke).
|
||||
*/
|
||||
export async function exportLayoutPDF(
|
||||
doc: CADDocument,
|
||||
layoutId: string,
|
||||
options: LayoutPDFOptions = {},
|
||||
): Promise<Uint8Array> {
|
||||
const layout = doc.getLayout(layoutId);
|
||||
if (!layout) throw new Error(`exportLayoutPDF: Layout '${layoutId}' nicht gefunden`);
|
||||
|
||||
const margin = options.margin ?? 20;
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const page = pdfDoc.addPage([A4_LANDSCAPE_W, A4_LANDSCAPE_H]);
|
||||
const mono = options.monochrome === true;
|
||||
|
||||
const layers = doc.getAllLayers();
|
||||
const visibleLayerIds = new Set(layers.filter((l) => l.visible).map((l) => l.id));
|
||||
const layerById = new Map(layers.map((l) => [l.id, l]));
|
||||
|
||||
// ── Ausgabemaßstab: Welt → pt ──
|
||||
const scale = layout.viewport.scale;
|
||||
const viewW = (A4_LANDSCAPE_W - 2 * margin) / scale;
|
||||
const viewH = (A4_LANDSCAPE_H - 2 * margin) / scale;
|
||||
const worldMinX = layout.viewport.center.x - viewW / 2;
|
||||
const worldMinY = layout.viewport.center.y - viewH / 2;
|
||||
|
||||
// PDF-Y-Achse zeigt nach OBEN (bottom-left origin) — CAD nach unten:
|
||||
const toPage = (wx: number, wy: number): { x: number; y: number } => ({
|
||||
x: margin + (wx - worldMinX) * scale,
|
||||
y: A4_LANDSCAPE_H - margin - (wy - worldMinY) * scale,
|
||||
});
|
||||
|
||||
const colorFor = (el: CADElement): ReturnType<typeof rgb> => {
|
||||
if (mono) return rgb(0, 0, 0);
|
||||
const layer = layerById.get(el.layerId);
|
||||
const hex = (el.properties.stroke as string | undefined) ?? layer?.color;
|
||||
return hexToRgb(hex) ?? rgb(0, 0, 0);
|
||||
};
|
||||
|
||||
const lineWidthFor = (el: CADElement): number => {
|
||||
const explicit = el.properties.strokeWidth as number | undefined;
|
||||
if (typeof explicit === 'number' && explicit > 0) return explicit;
|
||||
const layer = layerById.get(el.layerId);
|
||||
switch (layer?.lineType) {
|
||||
case 'dashed': return 0.9;
|
||||
case 'dotted': return 0.5;
|
||||
default: return 0.7;
|
||||
}
|
||||
};
|
||||
|
||||
const dashFor = (el: CADElement): number[] | undefined => {
|
||||
if (mono) return undefined;
|
||||
const layer = layerById.get(el.layerId);
|
||||
if (layer?.lineType === 'dashed') return [4, 3];
|
||||
if (layer?.lineType === 'dotted') return [0.5, 2];
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const drawPrimitives = (els: CADElement[], transform: (wx: number, wy: number) => { x: number; y: number }, scaleOverride?: number): void => {
|
||||
for (const el of els) {
|
||||
if (!visibleLayerIds.has(el.layerId)) continue;
|
||||
const prims = elementToPrimitives(el);
|
||||
const lw = lineWidthFor(el) * (scaleOverride ?? scale);
|
||||
const dash = dashFor(el);
|
||||
const color = colorFor(el);
|
||||
for (const prim of prims) {
|
||||
drawPrimitive(page, prim, transform, lw, color, dash, font, mono);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ── Modellinhalt ──
|
||||
drawPrimitives(doc.getAllElements(), toPage);
|
||||
|
||||
// ── Titelblock (frameBlockRef) unten rechts, unskaliert ──
|
||||
if (layout.frameBlockRef) {
|
||||
const block = doc.getBlock(layout.frameBlockRef);
|
||||
if (block) {
|
||||
const FRAME_MARGIN = 12;
|
||||
// Block-Elemente liegen um (0,0) herum — an Ecke unten rechts verschieben
|
||||
const frameOriginX = A4_LANDSCAPE_W - margin - 190 - FRAME_MARGIN;
|
||||
const frameOriginY = margin + FRAME_MARGIN;
|
||||
const frameTransform = (wx: number, wy: number) => ({
|
||||
x: frameOriginX + wx,
|
||||
y: frameOriginY + wy,
|
||||
});
|
||||
drawPrimitives(block.elements, frameTransform, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return pdfDoc.save();
|
||||
}
|
||||
|
||||
/** Zeichnet ein einzelnes ShapePrimitive auf die PDF-Seite. */
|
||||
function drawPrimitive(
|
||||
page: any,
|
||||
prim: ShapePrimitive,
|
||||
transform: (wx: number, wy: number) => { x: number; y: number },
|
||||
lineWidth: number,
|
||||
color: ReturnType<typeof rgb>,
|
||||
dash: number[] | undefined,
|
||||
font: any,
|
||||
mono: boolean,
|
||||
): void {
|
||||
switch (prim.kind) {
|
||||
case 'line': {
|
||||
const s = transform(prim.from.x, prim.from.y);
|
||||
const e = transform(prim.to.x, prim.to.y);
|
||||
page.drawLine({ start: s, end: e, thickness: lineWidth, color, dashArray: dash });
|
||||
break;
|
||||
}
|
||||
case 'rect': {
|
||||
const tl = transform(prim.x, prim.y + prim.h);
|
||||
page.drawRectangle({
|
||||
x: tl.x, y: tl.y, width: prim.w, height: prim.h,
|
||||
borderColor: color, borderWidth: lineWidth,
|
||||
...(prim.fill ? { color: hexToRgb(prim.fill) ?? color } : {}),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
const c = transform(prim.center.x, prim.center.y);
|
||||
page.drawCircle({
|
||||
x: c.x, y: c.y, radius: prim.radius,
|
||||
borderColor: color, borderWidth: lineWidth,
|
||||
...(prim.fill ? { color: hexToRgb(prim.fill) ?? color } : {}),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'arc': {
|
||||
// Arc → 32 Liniensegmente (Grad, 0° = 3 Uhr, CCW — CAD-Konvention)
|
||||
const c = transform(prim.center.x, prim.center.y);
|
||||
const SEG = 32;
|
||||
const a0 = (prim.startDeg * Math.PI) / 180;
|
||||
const a1 = (prim.endDeg * Math.PI) / 180;
|
||||
let span = a1 - a0;
|
||||
if (span <= 0) span += 2 * Math.PI;
|
||||
let prev = { x: c.x + Math.cos(a0) * prim.radius, y: c.y + Math.sin(a0) * prim.radius };
|
||||
for (let i = 1; i <= SEG; i++) {
|
||||
const a = a0 + (span * i) / SEG;
|
||||
const pt = { x: c.x + Math.cos(a) * prim.radius, y: c.y + Math.sin(a) * prim.radius };
|
||||
page.drawLine({ start: prev, end: pt, thickness: lineWidth, color, dashArray: dash });
|
||||
prev = pt;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'polyline': {
|
||||
for (let i = 0; i < prim.points.length - 1; i++) {
|
||||
const s = transform(prim.points[i].x, prim.points[i].y);
|
||||
const e = transform(prim.points[i + 1].x, prim.points[i + 1].y);
|
||||
page.drawLine({ start: s, end: e, thickness: lineWidth, color, dashArray: dash });
|
||||
}
|
||||
if (prim.close && prim.points.length > 2) {
|
||||
const s = transform(prim.points[prim.points.length - 1].x, prim.points[prim.points.length - 1].y);
|
||||
const e = transform(prim.points[0].x, prim.points[0].y);
|
||||
page.drawLine({ start: s, end: e, thickness: lineWidth, color, dashArray: dash });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
const at = transform(prim.at.x, prim.at.y);
|
||||
page.drawText(prim.text, {
|
||||
x: at.x, y: at.y - prim.fontSize,
|
||||
size: prim.fontSize,
|
||||
font,
|
||||
color: mono ? rgb(0, 0, 0) : (hexToRgb(prim.color) ?? color),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user