task(D12): block attributes - BlockAttrDef type, attrDefs on definitions, instance attrValues rendering as text primitives, properties panel attribute form
This commit is contained in:
@@ -3,6 +3,7 @@ import { polygonArea } from '../tools/modification/geometry';
|
|||||||
import { formatArea } from '../utils/format';
|
import { formatArea } from '../utils/format';
|
||||||
import type { PropertiesPanelProps } from '../types/ui.types';
|
import type { PropertiesPanelProps } from '../types/ui.types';
|
||||||
import { formatInputValue, parseDistance, type UnitType } from '../utils/format';
|
import { formatInputValue, parseDistance, type UnitType } from '../utils/format';
|
||||||
|
import { getActiveDocument } from '../kernel/document/documentService';
|
||||||
|
|
||||||
const parseNum = (s: string) => parseFloat(s.replace(/[^0-9.\-]/g, '')) || 0;
|
const parseNum = (s: string) => parseFloat(s.replace(/[^0-9.\-]/g, '')) || 0;
|
||||||
|
|
||||||
@@ -77,7 +78,37 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
|||||||
<label className="prop-label">Fläche</label>
|
<label className="prop-label">Fläche</label>
|
||||||
<span className="prop-value">{formatArea(polygonArea(el.properties.points as Array<{ x: number; y: number }>), unit, scaleFactor)}</span>
|
<span className="prop-value">{formatArea(polygonArea(el.properties.points as Array<{ x: number; y: number }>), unit, scaleFactor)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}` : ''}</span>
|
)}
|
||||||
|
|
||||||
|
{/* ─── Task D12: Block-Attribute-Formular (block_instance) ─── */}
|
||||||
|
{el.type === 'block_instance' && (() => {
|
||||||
|
const blockId = el.properties.blockId as string | undefined;
|
||||||
|
if (!blockId) return null;
|
||||||
|
const block = getActiveDocument()?.getBlock(blockId);
|
||||||
|
const attrDefs = block?.attrDefs ?? [];
|
||||||
|
if (attrDefs.length === 0) return null;
|
||||||
|
const attrValues = (el.properties.attrValues as Record<string, string> | undefined) ?? {};
|
||||||
|
return (
|
||||||
|
<div className="panel-section">
|
||||||
|
<div className="panel-section-title">Block-Attribute</div>
|
||||||
|
{attrDefs.map((def) => (
|
||||||
|
<div className="prop-row" key={def.tag}>
|
||||||
|
<label className="prop-label" title={def.prompt}>{def.tag}</label>
|
||||||
|
<input
|
||||||
|
className="prop-input"
|
||||||
|
type="text"
|
||||||
|
value={attrValues[def.tag] ?? def.defaultValue ?? ''}
|
||||||
|
placeholder={def.prompt}
|
||||||
|
onChange={(ev) => {
|
||||||
|
const next = { ...attrValues, [def.tag]: ev.target.value };
|
||||||
|
onUpdateProperty('attrValues', next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}` : ''}</span>
|
||||||
<div style={{ display: 'flex', gap: '4px' }}>
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
{onDelete && el && (
|
{onDelete && el && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function fillOrNull(raw: unknown): string | null {
|
|||||||
* Gibt ein leeres Array zurück für unbekannte Typen bzw. Elemente,
|
* Gibt ein leeres Array zurück für unbekannte Typen bzw. Elemente,
|
||||||
* die nichts Sichtbares beitragen (z.B. leerer Text).
|
* die nichts Sichtbares beitragen (z.B. leerer Text).
|
||||||
*/
|
*/
|
||||||
export function elementToPrimitives(el: CADElement): ShapePrimitive[] {
|
export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: string; prompt: string; defaultValue?: string }>): ShapePrimitive[] {
|
||||||
const props = (el.properties ?? {}) as Record<string, unknown>;
|
const props = (el.properties ?? {}) as Record<string, unknown>;
|
||||||
const stroke = (props.stroke as string | undefined) ?? FALLBACK_STROKE;
|
const stroke = (props.stroke as string | undefined) ?? FALLBACK_STROKE;
|
||||||
const sw = (props.strokeWidth as number | undefined) ?? 1;
|
const sw = (props.strokeWidth as number | undefined) ?? 1;
|
||||||
@@ -296,6 +296,28 @@ export function elementToPrimitives(el: CADElement): ShapePrimitive[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'block_instance': {
|
||||||
|
// Task D12: Attr-Texte der Instanz zeichnen (tag=Wert, default-Fallback).
|
||||||
|
const attrValues = (props.attrValues as Record<string, string> | undefined) ?? {};
|
||||||
|
if (!attrDefs || attrDefs.length === 0) return [];
|
||||||
|
const out: ShapePrimitive[] = [];
|
||||||
|
const fontSize = 9;
|
||||||
|
const lineH = 16;
|
||||||
|
let i = 0;
|
||||||
|
for (const def of attrDefs) {
|
||||||
|
const value = attrValues[def.tag] ?? def.defaultValue ?? '—';
|
||||||
|
out.push({
|
||||||
|
kind: 'text',
|
||||||
|
at: { x: el.x, y: el.y - 8 - i * lineH },
|
||||||
|
text: `${def.tag}=${value}`,
|
||||||
|
fontSize,
|
||||||
|
color: stroke,
|
||||||
|
});
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,16 @@ export interface ImageElement extends CADElement {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block-Attribut-Definition (Task D12): Slot, den Instanzen befüllen.
|
||||||
|
* Konvention konsistent zu F5 (ATTDEF-Parser) und F6 (DXF-Writer).
|
||||||
|
*/
|
||||||
|
export interface BlockAttrDef {
|
||||||
|
tag: string;
|
||||||
|
prompt: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BlockDefinition {
|
export interface BlockDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -70,6 +80,8 @@ export interface BlockDefinition {
|
|||||||
category: string;
|
category: string;
|
||||||
elements: CADElement[];
|
elements: CADElement[];
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
|
/** Attribut-Slots dieser Blockdefinition (Task D12). */
|
||||||
|
attrDefs?: BlockAttrDef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Task D12 - BlockAttribute: attrDefs + attrValues + Rendering.
|
||||||
|
*
|
||||||
|
* BlockDefinition.attrDefs[]: definiert Attribut-Slots (tag/prompt/
|
||||||
|
* default). Instanz (block_instance) traegt attrValues. Der Render-
|
||||||
|
* Adapter (elementToPrimitives) zeichnet die Attr-Werte als Text-
|
||||||
|
* Primitive an der Instanz-Position.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||||
|
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||||
|
import { elementToPrimitives } from '../src/render/pixi/elementDrawers';
|
||||||
|
import type { CADElement, BlockDefinition } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
const BLOCK: BlockDefinition = {
|
||||||
|
id: 'blk-1',
|
||||||
|
name: 'Titelblock',
|
||||||
|
description: '',
|
||||||
|
category: 'frame',
|
||||||
|
elements: [],
|
||||||
|
attrDefs: [
|
||||||
|
{ tag: 'PROJEKT', prompt: 'Projektname', defaultValue: 'Unbenannt' },
|
||||||
|
{ tag: 'AUTOR', prompt: 'Bearbeiter', defaultValue: 'WebCAD' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function instance(attrValues: Record<string, string>): CADElement {
|
||||||
|
return {
|
||||||
|
id: 'inst-1', type: 'block_instance', layerId: 'layer-0',
|
||||||
|
x: 100, y: 100, width: 0, height: 0,
|
||||||
|
properties: { blockId: 'blk-1', scale: 1, attrValues },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Typ-Ebene: attrDefs auf BlockDefinition ────────────────
|
||||||
|
|
||||||
|
describe('D12: BlockDefinition.attrDefs', () => {
|
||||||
|
it('attrDefs-Feld existiert mit tag/prompt/defaultValue', () => {
|
||||||
|
expect(BLOCK.attrDefs).toBeDefined();
|
||||||
|
expect(BLOCK.attrDefs!.length).toBe(2);
|
||||||
|
expect(BLOCK.attrDefs![0].tag).toBe('PROJEKT');
|
||||||
|
expect(BLOCK.attrDefs![0].prompt).toBe('Projektname');
|
||||||
|
expect(BLOCK.attrDefs![0].defaultValue).toBe('Unbenannt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CADDocument speichert Block mit attrDefs (getBlock liefert sie zurueck)', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
doc.addBlock(BLOCK);
|
||||||
|
const loaded = doc.getBlock('blk-1');
|
||||||
|
expect(loaded!.attrDefs).toHaveLength(2);
|
||||||
|
expect(loaded!.attrDefs![1].tag).toBe('AUTOR');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Rendering: elementToPrimitives mit attrDefs ────────────
|
||||||
|
|
||||||
|
describe('D12: RenderAdapter zeichnet Attr-Texte', () => {
|
||||||
|
it('Instanz mit attrValues erzeugt text-Primitives pro Attribut', () => {
|
||||||
|
const el = instance({ PROJEKT: 'Halle A', AUTOR: 'L. Admin' });
|
||||||
|
const prims = elementToPrimitives(el, BLOCK.attrDefs);
|
||||||
|
const texts = prims.filter((p) => p.kind === 'text');
|
||||||
|
expect(texts).toHaveLength(2);
|
||||||
|
const proj = texts.find((t) => (t as { text: string }).text.includes('PROJEKT'));
|
||||||
|
expect(proj).toBeDefined();
|
||||||
|
expect((proj as { text: string }).text).toContain('Halle A');
|
||||||
|
const autor = texts.find((t) => (t as { text: string }).text.includes('AUTOR'));
|
||||||
|
expect((autor as { text: string }).text).toContain('L. Admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehlender Wert faellt auf attrDefs.defaultValue zurueck', () => {
|
||||||
|
const el = instance({});
|
||||||
|
const prims = elementToPrimitives(el, BLOCK.attrDefs);
|
||||||
|
const texts = prims.filter((p) => p.kind === 'text');
|
||||||
|
const proj = texts.find((t) => (t as { text: string }).text.includes('PROJEKT'));
|
||||||
|
expect((proj as { text: string }).text).toContain('Unbenannt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ohne attrDefs-Parameter: kein Attr-Text-Rendering (abwaertskompatibel)', () => {
|
||||||
|
const el = instance({ PROJEKT: 'Halle A' });
|
||||||
|
const prims = elementToPrimitives(el);
|
||||||
|
expect(prims.filter((p) => p.kind === 'text')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Instanz ohne attrValues mit attrDefs: defaults werden gezeichnet', () => {
|
||||||
|
const el: CADElement = {
|
||||||
|
id: 'inst-2', type: 'block_instance', layerId: 'layer-0',
|
||||||
|
x: 0, y: 0, width: 0, height: 0,
|
||||||
|
properties: { blockId: 'blk-1', scale: 1 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
const prims = elementToPrimitives(el, BLOCK.attrDefs);
|
||||||
|
const texts = prims.filter((p) => p.kind === 'text');
|
||||||
|
expect(texts).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Text-Positionen gestaffelt unterhalb der Instanz-Position', () => {
|
||||||
|
const el = instance({ PROJEKT: 'A', AUTOR: 'B' });
|
||||||
|
const prims = elementToPrimitives(el, BLOCK.attrDefs);
|
||||||
|
const texts = prims.filter((p) => p.kind === 'text') as Array<{ kind: 'text'; at: { x: number; y: number }; text: string }>;
|
||||||
|
expect(texts[0].at.x).toBe(100);
|
||||||
|
expect(texts[0].at.y).toBeCloseTo(100 - 8, 0);
|
||||||
|
expect(texts[1].at.y).toBeLessThan(texts[0].at.y);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user