Compare commits

..

2 Commits

3 changed files with 305 additions and 3 deletions
+4 -3
View File
@@ -634,7 +634,7 @@ Kurz-Tasks (je gleiche Struktur: implementieren im passenden builtin-Paket + Wor
- [x] G1 Datenmodell layouts (Yjs Map: layoutId→{name, viewport{center,scale}, frameBlockRef}), Backend persistence folgt automatisch via drawing json *(2026-08-29: LayoutDefinition/LayoutViewport in cad.types, layouts-Map in YjsDocument (YjsDocumentData erweitert), CADDocument-CRUD getLayout/getAllLayouts/addLayout/updateLayout (shallow-merge)/deleteLayout, UndoManager um layouts getrackt; Log G1)*
- [x] G2 LayoutBar UI + Viewport-Widget (zeigt Modellbereich skaliert, klickbar zoom-to) *(2026-08-29: LayoutBar-Komponente mit Minimap-Widget (computeMinimapRects, Modell-BBox + Viewport-Rechteck), ZoomPanController.setView(center,scale), CanvasArea-__v2LayoutBridge, App-Mount; Log G2)*
- [x] G3 Titelblock-Block mit Attributen (Projekt/Datum/Maßstab/Autor) auto-insert *(2026-08-29: titleBlockService mit createTitleBlockElements (Rahmen + Titel + 4 attributierte Texte nach F5/F6-ATTDEF-Konvention tag/attdef/prompt), insertTitleBlockForLayout setzt frameBlockRef; CADDocument-Block-CRUD getBlock/addBlock; LayoutBar-Button 🏷; Log G3)*
- [ ] G4 PDF-Export je Layout im Ausgabemaßstab (pdf-lib; Linienbreiten aus Layern; monochrome Option)
- [x] G4 PDF-Export je Layout im Ausgabemaßstab (pdf-lib; Linienbreiten aus Layern; monochrome Option) *(2026-08-29: exportLayoutPDF über elementToPrimitives (C2-Konventionen — korrekte Circle-Zentrum-Behandlung, im Gegensatz zum Legacy-exportPDF mit x+width/2-Bug), Viewport-skaliert A4 landscape, Layer-Dash-Patterns + Breiten, monochrome, Titelblock-Mitzeichnung; Log G4)*
- [ ] G5 Browser-Print stylesheet (@media print: nur Layout)
## Phase H — Modi & Branchenneutralität
@@ -667,6 +667,7 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|---|---|---|---|---|---|
| 2026-08-29 | G4 | PDF-Export je Layout: exportLayoutPDF(doc, layoutId, options{monochrome,margin}) NEU in pdfExport.ts (additiv — Legacy-exportPDF unangetastet): A4 landscape 842×595pt; Ausgabemaßstab = layout.viewport.scale, sichtbarer Welt-Ausschnitt aus center berechnet; Y-Flip (PDF bottom-left vs CAD top-down) in toPage-Transform; Rendering über elementToPrimitives (C2) statt duplizierter Typ-Switches → garantiert identische Konventionen wie Pixi-Renderer (Legacy-exportPDF hätte Circle um width/2 verschoben gezeichnet — gleiche Bugklasse wie DXF-Writer vor F6, dort belassen und im Log vermerkt); Linienbreiten: properties.strokeWidth sonst Layer-lineType (solid 0.7pt/dashed 0.9pt/dotted 0.5pt) mit Dash-Patterns [4,3]/[0.5,2]; monochrome erzwingt rgb(0,0,0) + keine Dashes; unsichtbare Layer gefiltert; Arc→32 Segmente (Grad CCW); Titelblock aus frameBlockRef wird unskaliert unten-rechts mitgezeichnet (Block-Elemente um Ursprung an Ecken-Offset); drawPrimitive deckt alle 6 Primitive-Kinds | Frontend: 580/580 Tests (+6 pdfLayoutExport.test.ts — PDF via PDFDocument.load verifiziert: Seitenzahl 1, Größe 842×595, Frame-Block-Fall, monochrome, leerer Viewport, unknown-ID-throw, unsichtbare Layer), tsc 0, Build EXIT 0 | cb23638 | G4 COMPLETE; nächster Task G5 (Print-Stylesheet) |
| 2026-08-29 | G3 | Titelblock-Block mit Attributen: (1) titleBlockService NEU — createTitleBlockElements (reine Funktion): Rahmen rect 180×100 (App-Konvention x/y=Zentrum) + Titel-Label + 4 attributierte Texte PROJEKT/DATUM/MASSSTAB/AUTOR mit tag/attdef:true/prompt-Metadaten und text=TAG=Wert (exakt die F5-Parser- und F6-Writer-Konvention → DXF-ATTDEF-Roundtrip & D12-Formular-ready), leere Meta → Fallback —; (2) insertTitleBlockForLayout: Blockdefinition (category frame) via doc.addBlock + frameBlockRef im Layout via updateLayout, Re-Insert ersetzt Referenz (neue Block-ID), fehlendes Layout → throw; (3) CADDocument um getBlock/addBlock erweitert (bewusst NICHT undo-getrackt — konsistent zum UndoManager-Design-Kommentar); (4) LayoutBar-Button 🏷 je Layout: fügt/aktualisiert Titelblock mit Layout-Name als Projekt, heutiges Datum, Maßstab aus viewport.scale (1:1/scale gerundet), Autor WebCAD; Test-Zwischenfall: eigene Assertion prüfte attdef über alle 5 Texte inkl. Titel-Label (bewusst kein Attribut) — auf getaggte Texte gescoped | Frontend: 574/574 Tests (+6 titleBlock.test.ts), tsc 0, Build EXIT 0 | bbb469e | G3 COMPLETE; nächster Task G4 (PDF-Export je Layout) |
| 2026-08-29 | G2 | LayoutBar UI + Viewport-Widget: (1) ZoomPanController.setView(center, scale) NEU — Welt-Punkt exakt ins Canvas-Zentrum bei Scale, geclamped 0.01100; (2) LayoutBar-Komponente: listet Layouts des aktiven Docs (documentService-Fallback mit onActiveDocumentChanged-Subscription + onChanged-Live-Reload), je Eintrag SVG-Minimap (Modellbereich als Rechteck + Viewport-Ausschnitt blau — scale-abhängig sichtbar kleiner), Klick = zoom-to via Bridge, „+“ speichert aktuellen Viewport als Layout, 🗑 löscht; (3) computeMinimapRects als reine exportierte Funktion (Modell-BBox füllt Widget mit 6px Padding, ohne Elemente Volles-Widget-Fallback, Viewport = 800×600/scale nominal); (4) CanvasArea-__v2LayoutBridge nach E6b-Muster (zoomTo mit render-Trigger, getView aus getViewport+getScale); (5) App.tsx mountet LayoutBar über StatusBar; CSS angehängt; Zwischenfälle selbst gefixt: view-Parameter-Kollision in computeMinimapRects (Umbenennung viewRect) + Regex-Double-Escape im Test | Frontend: 568/568 Tests (+11 layoutBar.test.tsx), tsc 0, Build EXIT 0 | 7feed9e | G2 COMPLETE; nächster Task G3 (Titelblock-Block mit Attributen auto-insert) |
| 2026-08-29 | G1 | Layout-Datenmodell: LayoutViewport {center{x,y}, scale} + LayoutDefinition {id, name, viewport, frameBlockRef?} in cad.types.ts; YjsDocument.data um layouts-Map (YjsDocumentData-Interface additiv erweitert + getLayouts-Getter); CADDocument um Layout-CRUD erweitert: getLayout/getAllLayouts/addLayout/updateLayout (Top-Level ersetzt, viewport + center SHALLOW gemergt — center/scale einzeln änderbar, frameBlockRef bleibt bei reinem name-Patch erhalten)/deleteLayout (still bei unbekannter ID); Y.UndoManager um layouts-Map getrackt → jedes add/update/delete = eigener Undo-Schritt (2-Schritte-Undo-Test: add→delete→undo→undo verifiziert); Persistence läuft automatisch über bestehende drawing-json-Serialisierung (Y-Doc-Snapshot) | Frontend: 557/557 Tests (+6 layouts.test.ts), tsc 0, Build EXIT 0 | e750a45 | G1 COMPLETE — Datenmodell steht; nächster Task G2 (LayoutBar UI + Viewport-Widget) |
@@ -735,5 +736,5 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
- `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2)
---
*Nächster offener Schritt: **G4** (PDF-Export je Layout im Ausgabemaßstab: pdf-lib, Linienbreiten aus Layern, monochrome Option). Danach: G5 Print-Stylesheet, Phase H, Rest-D-Tools.*
*Status 2026-08-29: Phasen B+A+C+D+E+F komplett, G1+G2+G3 fertig (Layouts: Datenmodell + Bar + Titelblock), BUG-1-Kollisionsklasse geschlossen. FE 574/574 Tests, BE 272/272, tsc/Build grün. Alle Commits auf Forgejo main.*
*Nächster offener Schritt: **G5** (Browser-Print stylesheet: @media print nur Layout). Danach: Rest-D-Tools (D1D4/D9/D10-path/D11/D12), Phase H (Modi/Templates/Rollen/i18n/Flächen), Phase I (Release).*
*Status 2026-08-29: Phasen B+A+C+D+E+F komplett, G1G4 fertig (Layouts: Datenmodell, Bar, Titelblock, PDF-Export), BUG-1-Kollisionsklasse geschlossen. FE 580/580 Tests, BE 272/272, tsc/Build grün. Alle Commits auf Forgejo main.*
+202
View File
@@ -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;
}
}
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Task G4 PDF-Export je Layout im Ausgabemaßstab.
*
* exportLayoutPDF rendert den Layout-Viewport (center/scale) auf eine A4-
* Seite (landscape), skaliert Weltkoordinaten in den Ausgabemaßstab,
* berücksichtigt Linienbreiten aus Layern (lineType) und unterstützt
* eine monochrome Option. Titelblock (frameBlockRef) wird mitgezeichnet.
* Rückgabe: PDF-Bytes, per PDFDocument.load verifizierbar.
*/
import { describe, it, expect } from 'vitest';
import { PDFDocument } from 'pdf-lib';
import { CADDocument } from '../src/kernel/document/CADDocument';
import { createInMemoryDoc } from './helpers/inMemoryDoc';
import { exportLayoutPDF } from '../src/services/pdfExport';
import type { CADElement, LayoutDefinition } from '../src/types/cad.types';
function line(id: string, x1: number, y1: number, x2: number, y2: number, layerId = 'layer-0'): CADElement {
return {
id, type: 'line', layerId,
x: Math.min(x1, x2), y: Math.min(y1, y2),
width: Math.abs(x2 - x1), height: Math.abs(y2 - y1),
properties: { x1, y1, x2, y2, stroke: '#ff0000' },
} as CADElement;
}
function mkDoc(els: CADElement[], layouts: LayoutDefinition[]): CADDocument {
const { ydoc } = createInMemoryDoc();
const doc = new CADDocument(ydoc);
for (const e of els) doc.addElement(e);
for (const l of layouts) doc.addLayout(l);
return doc;
}
const LAYOUT: LayoutDefinition = {
id: 'layout-1', name: 'A4 Plan',
viewport: { center: { x: 50, y: 50 }, scale: 1 },
};
describe('G4: exportLayoutPDF', () => {
it('liefert valides PDF mit genau einer A4-landscape-Seite', async () => {
const doc = mkDoc([line('l1', 0, 0, 100, 100)], [LAYOUT]);
const bytes = await exportLayoutPDF(doc, 'layout-1');
expect(bytes.byteLength).toBeGreaterThan(500);
const pdf = await PDFDocument.load(bytes);
expect(pdf.getPageCount()).toBe(1);
const { width, height } = pdf.getPage(0).getSize();
expect(width).toBe(842); // A4 landscape pt
expect(height).toBe(595);
});
it('Titelblock-Elemente (frameBlockRef) werden mitgezeichnet', async () => {
const doc = mkDoc([line('l1', 0, 0, 100, 0)], [LAYOUT]);
// Titelblock-Definition anlegen und referenzieren
const blockId = 'title-1';
doc.addBlock({
id: blockId, name: 'Titelblock', description: '', category: 'frame',
elements: [line('tb1', 0, 0, 50, 0)],
});
doc.updateLayout('layout-1', { frameBlockRef: blockId });
const bytes = await exportLayoutPDF(doc, 'layout-1');
const pdf = await PDFDocument.load(bytes);
expect(pdf.getPageCount()).toBe(1);
// Strukturelle Prüfung: Export läuft fehlerfrei mit Frame-Block
expect(bytes.byteLength).toBeGreaterThan(500);
});
it('monochrome Option exportiert ohne Farbinformationen zu verlieren', async () => {
const doc = mkDoc([line('l1', 0, 0, 100, 100)], [LAYOUT]);
const bytes = await exportLayoutPDF(doc, 'layout-1', { monochrome: true });
const pdf = await PDFDocument.load(bytes);
expect(pdf.getPageCount()).toBe(1);
});
it('leeres Layout (keine Elemente im Viewport) exportiert trotzdem eine Seite', async () => {
const doc = mkDoc([], [LAYOUT]);
const bytes = await exportLayoutPDF(doc, 'layout-1');
const pdf = await PDFDocument.load(bytes);
expect(pdf.getPageCount()).toBe(1);
});
it('Layout ohne existierende ID → throw', async () => {
const doc = mkDoc([], []);
await expect(exportLayoutPDF(doc, 'gibts-nicht')).rejects.toThrow();
});
it('unsichtbare Layer werden übersprungen (Export läuft fehlerfrei)', async () => {
const { ydoc } = createInMemoryDoc();
const doc = new CADDocument(ydoc);
ydoc.data.layers.set('layer-0', {
id: 'layer-0', name: '0', visible: false, locked: false,
color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null,
});
doc.addElement(line('l1', 0, 0, 100, 100));
doc.addLayout(LAYOUT);
const bytes = await exportLayoutPDF(doc, 'layout-1');
const pdf = await PDFDocument.load(bytes);
expect(pdf.getPageCount()).toBe(1);
});
});