Compare commits

..

2 Commits

6 changed files with 289 additions and 4 deletions
+4 -3
View File
@@ -633,7 +633,7 @@ Kurz-Tasks (je gleiche Struktur: implementieren im passenden builtin-Paket + Wor
## Phase G — Layouts & Plot
- [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)*
- [ ] G3 Titelblock-Block mit Attributen (Projekt/Datum/Maßstab/Autor) auto-insert
- [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)
- [ ] G5 Browser-Print stylesheet (@media print: nur Layout)
@@ -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 | 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) |
| 2026-08-29 | E7 | Event-Elemente-Polish: (1) ElementType um curtain/spotlight/barrier erweitert (cad.types.ts additiv); (2) Pixi-Drawer-Cases: curtain = wellige Polyline aus properties.points (24 Punkte, Sinus gedämpft, close=false; Fallback Linie), spotlight = Kegel-Polygon aus points (close=true, stroke=properties.color), barrier = Kreis-Paare aus properties.circles (12 Kreise, Fallback 5 entlang BBox); (3) Platzier-Tools via Klick: curtainTool (stageHeight-Option, Standard 120cm, Breite 400), spotlightTool (angle+color-Optionen, Länge 300, Öffnung ±20°, Grad-Winkel direkt — Konvention konsistent zu F6), barrierTool (length-Option, Standard 200, 6 Kettenglieder); (4) createStage um properties.height (Bühnenhöhe in cm) erweitert; event-tools v2.2.0, tools-Array jetzt 13 Einträge; Test-Helper fireDown auf etabliertes Pilot-Muster umgestellt (echtes CADDocument aus createInMemoryDoc statt Broken-Mock ohne transact — 7 Tests rot wegen Mock, nach Muster-Fix grün) | Frontend: 551/551 Tests (+8 eventPolish.test.ts), tsc 0, build OK | 9176d7c | **E7 COMPLETE — PHASE E VOLLSTÄNDIG**; nächster Schritt: Phase G (Layouts & Plot) ab G1 |
@@ -734,5 +735,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: **G3** (Titelblock-Block mit Attributen Projekt/Datum/Maßstab/Autor, auto-insert). Danach: G4 PDF-Export je Layout, G5 Print-Stylesheet.*
*Status 2026-08-29: Phasen B+A+C+D+E+F komplett, G1+G2 fertig (Layout-Datenmodell + LayoutBar), BUG-1-Kollisionsklasse geschlossen. FE 568/568 Tests, BE 272/272, tsc/Build grün. Alle Commits auf Forgejo main.*
*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.*
+16
View File
@@ -9,6 +9,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import type { LayoutDefinition } from '../types/cad.types';
import { getActiveDocument, onActiveDocumentChanged } from '../kernel/document/documentService';
import { insertTitleBlockForLayout } from '../services/titleBlockService';
export interface LayoutView {
center: { x: number; y: number };
@@ -172,6 +173,21 @@ const LayoutBar: React.FC<LayoutBarProps> = ({ doc: docProp, bridge: bridgeProp
</svg>
<span className="layout-bar-name">{l.name}</span>
</button>
<button
type="button"
className="layout-bar-tb"
title={`Titelblock für ${l.name} einfügen/aktualisieren`}
onClick={() => {
if (!doc) return;
insertTitleBlockForLayout(doc as never, l.id, {
project: l.name,
date: new Date().toISOString().slice(0, 10),
scale: `1:${Math.max(1, Math.round(1 / (l.viewport.scale || 1)))}`,
author: 'WebCAD',
});
reload();
}}
>🏷</button>
<button
type="button"
className="layout-bar-del"
+13 -1
View File
@@ -12,7 +12,7 @@
*/
import * as Y from 'yjs';
import { YjsDocument } from '../../crdt/YjsDocument';
import type { CADElement, CADLayer, LayoutDefinition } from '../../types/cad.types';
import type { CADElement, CADLayer, LayoutDefinition, BlockDefinition } from '../../types/cad.types';
export class CADDocument {
private readonly ydoc: YjsDocument;
@@ -99,6 +99,18 @@ export class CADDocument {
});
}
// ── Blöcke (Task G3 — bewusst NICHT undo-getrackt, siehe UndoManager-Kommentar) ──
/** Eine Blockdefinition per ID (oder undefined). */
getBlock(id: string): BlockDefinition | undefined {
return this.ydoc.data.blocks.get(id);
}
/** Fügt eine Blockdefinition hinzu (überschreibt gleiche ID). */
addBlock(block: BlockDefinition): void {
this.ydoc.data.blocks.set(block.id, block);
}
// ── Schreiben ────────────────────────────────────────────
/** Fügt ein Element hinzu (einzelner Aufruf = eigener Undo-Schritt). */
+146
View File
@@ -0,0 +1,146 @@
/**
* Task G3 Titelblock-Service.
*
* Erzeugt eine Titelblock-Blockdefinition mit attributierten Texten
* (Projekt/Datum/Maßstab/Autor) nach der ATTDEF-Konvention aus F5/F6
* (properties.tag + attdef:true + text 'TAG=Wert'), sodass:
* - der DXF-Writer (F6) sie als ATTDEF in Blockdefinitionen exportiert
* - das spätere PropertiesPanel (D12) daraus ein Formular bauen kann
* - Layouts über frameBlockRef referenzieren (G1)
*/
import type { CADElement, BlockDefinition } from '../types/cad.types';
import type { CADDocument } from '../kernel/document/CADDocument';
export const TITLE_BLOCK_NAME = 'Titelblock A4';
export interface TitleBlockMeta {
project?: string;
date?: string;
scale?: string;
author?: string;
}
interface AttrSlot {
tag: string;
prompt: string;
/** Position der Textzeile relativ zum Block-Ursprung (unten-links im Rahmen). */
dx: number;
dy: number;
}
const SLOTS: AttrSlot[] = [
{ tag: 'PROJEKT', prompt: 'Projektname', dx: 10, dy: 18 },
{ tag: 'DATUM', prompt: 'Datum der Zeichnung', dx: 10, dy: 36 },
{ tag: 'MASSSTAB', prompt: 'Zeichnungsmaßstab', dx: 10, dy: 54 },
{ tag: 'AUTOR', prompt: 'Bearbeiter/Autor', dx: 10, dy: 72 },
];
const FRAME_W = 180;
const FRAME_H = 100;
const FALLBACK = '\u2014'; // —
let idSeq = 0;
function nid(prefix: string): string {
return `titleblk-${prefix}-${Date.now()}-${idSeq++}`;
}
/**
* Titelblock-Elemente: Rahmen (Zentrum 0,0) + Titel + 4 attributierte Texte.
* Rein — keine Doc-Interaktion (unit-testbar).
*/
export function createTitleBlockElements(meta: TitleBlockMeta): CADElement[] {
const els: CADElement[] = [];
// Rahmen: rect in App-Konvention (x/y = Zentrum), Umriss unten-links
els.push({
id: nid('frame'),
type: 'rect',
layerId: 'layer-0',
x: 0,
y: 0,
width: FRAME_W,
height: FRAME_H,
properties: {
stroke: '#e0e0e0',
strokeWidth: 1.5,
fill: 'none',
},
});
// Titel (kein Attribut — fester Blockname als Beschriftung)
els.push({
id: nid('title'),
type: 'text',
layerId: 'layer-0',
x: FRAME_W / 2 - 10,
y: FRAME_H / 2 - 6,
width: 0,
height: 0,
properties: {
text: TITLE_BLOCK_NAME,
fontSize: 10,
stroke: '#9aa0a6',
},
});
const valueFor = (tag: string): string => {
switch (tag) {
case 'PROJEKT': return meta.project?.trim() || FALLBACK;
case 'DATUM': return meta.date?.trim() || FALLBACK;
case 'MASSSTAB': return meta.scale?.trim() || FALLBACK;
case 'AUTOR': return meta.author?.trim() || FALLBACK;
default: return FALLBACK;
}
};
for (const slot of SLOTS) {
els.push({
id: nid('attr'),
type: 'text',
layerId: 'layer-0',
x: -FRAME_W / 2 + slot.dx,
y: -FRAME_H / 2 + slot.dy,
width: 0,
height: 0,
properties: {
text: `${slot.tag}=${valueFor(slot.tag)}`,
fontSize: 9,
stroke: '#e0e0e0',
tag: slot.tag,
prompt: slot.prompt,
attdef: true,
},
});
}
return els;
}
/**
* Auto-insert: Erzeugt Blockdefinition + setzt frameBlockRef im Layout.
* Bestehende frameBlockRef wird ersetzt (Re-Insert aktualisiert Metadaten
* über einen neuen Block; der alte Block bleibt unreferenziert bestehen).
* @returns Block-ID des neuen Titelblocks.
* @throws wenn Layout nicht existiert.
*/
export function insertTitleBlockForLayout(
doc: CADDocument,
layoutId: string,
meta: TitleBlockMeta,
): string {
const layout = doc.getLayout(layoutId);
if (!layout) throw new Error(`insertTitleBlockForLayout: Layout '${layoutId}' nicht gefunden`);
const blockId = nid('blk');
const block: BlockDefinition = {
id: blockId,
name: TITLE_BLOCK_NAME,
description: 'Titelblock mit Attributen (Projekt/Datum/Maßstab/Autor) — Task G3',
category: 'frame',
elements: createTitleBlockElements(meta),
};
doc.addBlock(block);
doc.updateLayout(layoutId, { frameBlockRef: blockId });
return blockId;
}
+11
View File
@@ -2914,3 +2914,14 @@ body.drawer-open { overflow: hidden; }
border-radius: 4px;
}
.layout-bar-del:hover { color: #c0392b; background: rgba(192, 57, 43, 0.15); }
.layout-bar-tb {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
font-size: 11px;
padding: 2px;
border-radius: 4px;
}
.layout-bar-tb:hover { color: #4a90d9; background: rgba(74, 144, 217, 0.15); }
+99
View File
@@ -0,0 +1,99 @@
/**
* Task G3 Titelblock: Blockdefinition mit attributierten Texten
* (Projekt/Datum/Maßstab/Autor) + auto-insert als Layout-Frame.
*
* Die Text-Elemente tragen tag/attdef-Metadaten (F5/F6-Konvention),
* sodass der Titelblock als DXF ATTDEF/ATTRIB roundtripped und das
* spätere PropertiesPanel (D12) das Formular daraus bauen kann.
*/
import { describe, it, expect } from 'vitest';
import { CADDocument } from '../src/kernel/document/CADDocument';
import { createInMemoryDoc } from './helpers/inMemoryDoc';
import {
createTitleBlockElements,
insertTitleBlockForLayout,
TITLE_BLOCK_NAME,
type TitleBlockMeta,
} from '../src/services/titleBlockService';
import type { LayoutDefinition } from '../src/types/cad.types';
const META: TitleBlockMeta = {
project: 'Musterhalle A4',
date: '2026-08-29',
scale: '1:100',
author: 'L. Admin',
};
function mkDoc(): CADDocument {
const { ydoc } = createInMemoryDoc();
return new CADDocument(ydoc);
}
const LAYOUT: LayoutDefinition = {
id: 'layout-1',
name: 'A4 Plan',
viewport: { center: { x: 0, y: 0 }, scale: 1 },
};
describe('G3: createTitleBlockElements', () => {
it('Rahmen + Titel + 4 attributierte Texte mit Standard-Tags', () => {
const els = createTitleBlockElements(META);
const rects = els.filter((e) => e.type === 'rect');
const texts = els.filter((e) => e.type === 'text');
expect(rects.length).toBeGreaterThanOrEqual(1);
expect(texts.length).toBe(5); // Titel + PROJEKT + DATUM + MASSSTAB + AUTOR
const tags = texts.map((t) => t.properties.tag).filter(Boolean);
expect(tags).toEqual(['PROJEKT', 'DATUM', 'MASSSTAB', 'AUTOR']);
// Nur die attributierten Texte tragen attdef (Titel-Label bewusst nicht):
const attrTexts = texts.filter((t) => t.properties.tag);
expect(attrTexts.every((t) => t.properties.attdef === true)).toBe(true);
// Werte stehen im Text
const proj = texts.find((t) => t.properties.tag === 'PROJEKT');
expect(String(proj!.properties.text)).toContain('Musterhalle A4');
const autor = texts.find((t) => t.properties.tag === 'AUTOR');
expect(String(autor!.properties.text)).toContain('L. Admin');
});
it('Texte folgen der tag=Wert-Konvention des F5-ATTDEF-Parsers', () => {
const els = createTitleBlockElements(META);
const scale = els.find((e) => e.properties.tag === 'MASSSTAB')!;
expect(String(scale.properties.text)).toBe('MASSSTAB=1:100');
expect(scale.properties.prompt).toBeTruthy();
});
it('leere Meta erzeugt Füllzeichen statt undefined', () => {
const els = createTitleBlockElements({});
const proj = els.find((e) => e.properties.tag === 'PROJEKT')!;
expect(String(proj.properties.text)).toBe('PROJEKT=—');
});
});
describe('G3: insertTitleBlockForLayout (auto-insert als Frame)', () => {
it('erzeugt Blockdefinition im Doc und setzt frameBlockRef im Layout', () => {
const doc = mkDoc();
doc.addLayout(LAYOUT);
const blockId = insertTitleBlockForLayout(doc, 'layout-1', META);
expect(typeof blockId).toBe('string');
const layout = doc.getLayout('layout-1');
expect(layout!.frameBlockRef).toBe(blockId);
const block = doc.getBlock(blockId);
expect(block).toBeDefined();
expect(block!.name).toBe(TITLE_BLOCK_NAME);
expect(block!.category).toBe('frame');
expect(block!.elements.length).toBe(6); // 1 rect + 5 text
});
it('vorhandener frameBlockRef wird ersetzt (kein Duplikat-Block je Re-Insert)', () => {
const doc = mkDoc();
doc.addLayout(LAYOUT);
const first = insertTitleBlockForLayout(doc, 'layout-1', META);
const second = insertTitleBlockForLayout(doc, 'layout-1', { ...META, project: 'Neu' });
expect(second).not.toBe(first);
expect(doc.getLayout('layout-1')!.frameBlockRef).toBe(second);
});
it('Layout ohne frameBlockRef bleibt unverändert bei fehlendem Layout (throws)', () => {
const doc = mkDoc();
expect(() => insertTitleBlockForLayout(doc, 'gibts-nicht', META)).toThrow();
});
});