task(G3): title block with attributes - frame + attributed texts (ATTDEF convention), auto-insert as layout frame block
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user