Compare commits
2 Commits
3c2f015acf
...
bdf3baf4bd
| Author | SHA1 | Date | |
|---|---|---|---|
| bdf3baf4bd | |||
| 75de6dd1f7 |
+4
-3
@@ -626,7 +626,7 @@ Kurz-Tasks (je gleiche Struktur: implementieren im passenden builtin-Paket + Wor
|
||||
- [x] F3 „Als Block speichern“ (Auswahl→Block, Thumbnail client-seitig canvas.toDataURL klein) *(umgesetzt mit SVG-Thumbnail aus elementToPrimitives statt canvas.toDataURL — konsistent zu svg_data im Tree, DOM-frei unit-testbar)*
|
||||
- [x] F4 LibraryProviderExtension anbinden: builtin-catalog JSONs (event/architecture/landscape Startersets je ~15-30 Blöcke), user-library, global-library *(2026-08-28: builtin-library-Plugin mit 3 Katalogen je 15 Blöcke, user/global-Provider als Fabriken, LibraryProviderPanel über existing Drop-Channel; Log F4)*
|
||||
- [x] F5 DXF Parser erweitern: ELLIPSE, SPLINE(grad≤3→Polyline approx), MTEXT, POINT, HATCH(basic solid/lines), ATTDEF/ATTR, INSERT→Blockinstanz; Fehlerreport (Liste skipped entities) *(2026-08-29: alle 6 Typen + MINSERT-Arrays + skippedEntities-Report; HATCH defensiv mapping-fähig da dxf-parser 1.1.x echte HATCH-Entities noch nicht durchreicht; Log F5)*
|
||||
- [ ] F6 DXF Writer erweitern: BLOCKS+INSERTS, ATTR; Version AC1009 (R12) + AC1015 Ziel
|
||||
- [x] F6 DXF Writer erweitern: BLOCKS+INSERTS, ATTR; Version AC1009 (R12) + AC1015 Ziel *(2026-08-29: BLOCKS-Sektion + INSERT Name-Mapping/Sanitizing, ATTDEF in Blockdefinitionen, ATTRIB/SEQEND bei attrValues, AC1009-Default/AC1015-Option, R12-korrektes POLYLINE+VERTEX+SEQEND; Roundtrip-Tests deckten 3 latente Konventions-Bugs auf und fixten sie; Log F6)*
|
||||
- [ ] F7 .wcadlib Paket (zip: manifest.json, blocks.json, thumbs/) Import+Export (Backend endpoint + UI Dialog)
|
||||
- [ ] F8 SVG-Import als Block (svg→Path→Polyline approximation, simple subset: path/rect/circle/ellipse/line/polygon)
|
||||
|
||||
@@ -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 | F6 | DXF-Writer erweitert: (1) BLOCKS-Sektion mit sanitierten Blocknamen (Leerzeichen→Unterstrich, Kollisions-Dedup, 0 reserviert) + Name-Map blockId→DXF-Name; INSERT referenziert gemappten Namen mit scaleX/scaleY (Fallback scale) + Rotation in Grad direkt; (2) ATTDEF-Emission in Blockdefinitionen (text-Elemente mit attdef:true+tag → ATTDEF mit tag/prompt/textHeight); ATTRIB/SEQEND + Gruppe-66 an Instanzen mit attrValues; (3) DXFWriterOptions.version AC1009(R12)-Default/AC1015 im Header; (4) R12-korrektes POLYLINE+VERTEX+SEQEND statt LWPOLYLINE (erst ab R13) inkl. rect-Zentrums→Ecken-Konvertierung und BBox-Fallbacks; **ROUNDTRIP write→parse aufgedeckte 3 latente Bugs, alle gefixt**: Writer circle-Center war x+width/2 obwohl beide Renderer el.x/el.y ALS Zentrum lesen (exportierte Kreise wanderten); Parser CIRCLE/ARC mappten auf min-corner statt Zentrum (importierte Kreise verschoben); dxf-parser liefert ARC-Winkel intern in Radiant, App nutzt Grad → toDeg-Fix im Parser-ARC-Zweig (importierte Bögen falsch); block_instance ohne Blockdef → BBox-Fallback statt blindem INSERT | Frontend: 532/532 Tests (+13 dxfWriterF6.test.ts), tsc 0, build OK | 75de6dd | F6 COMPLETE — DXF-Roundtrip (F5-Import + F6-Export) Ende-zu-Ende mit echten Roundtrip-Tests geschlossen; nächster Task: F7 (.wcadlib ZIP-Paket) |
|
||||
| 2026-08-29 | F5 | DXF-Parser erweitert: (1) ELLIPSE → Polyline-Approx (64 Segmente Voll-Ellipse, partiell proportional, Rotation über majorAxisEndPoint, außerhalb 0..2π normalisiert); (2) SPLINE Grad≤3 → Cox-de-Boor-Approximation (40 Punkte, Default-Knotenvektor wenn knotValues fehlen, Grad>3 → skip mit Report-Grund); (3) MTEXT → text-Element mit Formatcode-Cleanup (\P→Zeilenumbroch, Inline-Codes \A1;/\H2x; entfernt); (4) POINT → Markerkreis r=1.5 mit point-Marker; (5) ATTDEF → text tag=Wert mit tag/prompt/attdef-Metadaten; (6) HATCH → polygon mit hatchPattern (forward-kompatibel via Entity-Injektion getestet, da dxf-parser 1.1.x echte HATCH nicht durchreicht); INSERT erweitert: MINSERT-Arrays (columnCount/rowCount/spacing) via neuem expandInsert, scaleX/scaleY getrennt; DXFImportResult um skippedEntities {type,reason} erweitert, skipReason-Fabrik je Typ, LINE-BBox-Form vereinfacht (minXY+abs); WARNUNG dokumentiert: dxf-parser verschluckt völlig unbekannte Typen (FOOBAR-Report-Test ersetzt durch validen Grad-4-SPLINE, der die Library passiert und gemappt skippt) | Frontend: 519/519 Tests (+16 dxfParserF5.test.ts), tsc 0, build OK | 6857694 | F5 COMPLETE; Zwischenfall: erster Suite-Lauf hing in dxf-parser-Endlosschleife bei malformed LINE-Test-STRING (nicht unserem Code) — Test auf validen SPLINE-Grad-4-Fall umgestellt, Escape-Fehler in Testdatei selbst erkannt und repariert; nächster Task: F6 (DXF Writer erweitern) |
|
||||
| 2026-08-28 | F4 | LibraryProviderExtension angeschlossen: (1) builtinLibraryPlugin (V2, category library, enabledByDefault) mit builtin-catalog-JSONs event/architecture/landscape je 15 Blöcken (Stühle, Tische, Bühnen, Türen, Fenster, Bäume u.a.) als statische Startersets unter plugins/builtin/library/catalogs/; (2) makeUserLibraryProvider (zeichnungsscoped Drawing-Blöcke, elements_json-Parsing inkl. Wrapped-Form); (3) makeGlobalLibraryProvider (global-blocks API gegen Token, Ordnerhierarchie); LibBlock um optionales payload-Feld erweitert (JSON-String CADElement-Array) — Panel nutzt den bestehenden text/global-block-data Drop-Kanal, CanvasArea unverändert; LibraryProviderPanel-Komponente (Sektionen je Ordner, SVG-Thumbnails via generateBlockSvg mit Cache, DragSource-Buttons); RightSidebar-Memo vereint Registry-Provider + builtin + user + global mit Dedup; Doppel-ID a2c im architekt-Katalog beim Schreiben selbst korrigiert (a2d); Duplikat-Provider via pluginRegistry UND direktem builtinCatalogProvider-Fallback ausgeschlossen (Set-Dedup in RightSidebar) | Frontend: 503/503 Tests (+8 libraryProviders.test.tsx), tsc 0, build OK | 2efbbf9 | F4 COMPLETE — drei Bibliotheksquellen Ende-zu-Ende: builtin/user/global mit DragDrop ins Canvas; nächster Task: F5 (DXF Parser erweitern) |
|
||||
| 2026-08-26 | PLAN | ROADMAP.md erstellt, Baseline analysiert | tsc 0, FE 380✓, BE 255✓ | — | Ausgangszustand dokumentiert |
|
||||
@@ -727,5 +728,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: **F6** (DXF Writer erweitern: BLOCKS+INSERTS, ATTR; AC1009/R12 + AC1015). Alternativ: F7 (.wcadlib ZIP-Paket), F8 (SVG-Import als Block), Legacy-Entsorgung, oder Phase G (Layouts/PDF).*
|
||||
*Status 2026-08-29: Phasen B+A+C+D+E+F1–F5 komplett, BUG-1-Kollisionsklasse geschlossen. FE 519/519 Tests, BE 267/267, tsc/Build grün. Alle Commits auf Forgejo main.*
|
||||
*Nächster offener Schritt: **F7** (.wcadlib als echtes ZIP-Paket: manifest.json, blocks.json, thumbs/ — Backend-Endpoint + UI-Dialog). Alternativ: F8 (SVG-Import als Block), Legacy-Entsorgung, oder Phase G (Layouts/PDF).*
|
||||
*Status 2026-08-29: Phasen B+A+C+D+E+F1–F6 komplett (DXF-Roundtrip geschlossen), BUG-1-Kollisionsklasse geschlossen. FE 532/532 Tests, BE 267/267, tsc/Build grün. Alle Commits auf Forgejo main.*
|
||||
|
||||
@@ -279,9 +279,10 @@ export function entityToCADElement(
|
||||
const c = entity.center;
|
||||
const r = entity.radius;
|
||||
if (!c || r == null) return null;
|
||||
// Center-Konvention wie beide Renderer: el.x/el.y IST das Zentrum
|
||||
return {
|
||||
id: nextId(), type: 'circle', layerId: layer.id,
|
||||
x: c.x - r, y: c.y - r, width: r * 2, height: r * 2,
|
||||
x: c.x, y: c.y, width: r * 2, height: r * 2,
|
||||
properties: { ...baseProps, radius: r },
|
||||
};
|
||||
}
|
||||
@@ -289,10 +290,13 @@ export function entityToCADElement(
|
||||
const c = entity.center;
|
||||
const r = entity.radius;
|
||||
if (!c || r == null) return null;
|
||||
// dxf-parser konvertiert ARC-Winkel intern nach Radiant (getestet);
|
||||
// App-Konvention ist Grad (elementDrawers, Arc-Tool, angleBetween).
|
||||
const toDeg = (rad: number | undefined): number => ((rad ?? 0) * 180) / Math.PI;
|
||||
return {
|
||||
id: nextId(), type: 'arc', layerId: layer.id,
|
||||
x: c.x - r, y: c.y - r, width: r * 2, height: r * 2,
|
||||
properties: { ...baseProps, radius: r, startAngle: entity.startAngle, endAngle: entity.endAngle },
|
||||
x: c.x, y: c.y, width: r * 2, height: r * 2,
|
||||
properties: { ...baseProps, radius: r, startAngle: toDeg(entity.startAngle), endAngle: toDeg(entity.endAngle) },
|
||||
};
|
||||
}
|
||||
case 'ELLIPSE': {
|
||||
|
||||
@@ -1,23 +1,44 @@
|
||||
/**
|
||||
* DXF Writer – converts CADElement[] to DXF string
|
||||
* Minimal DXF R12 format for compatibility
|
||||
* Zielversionen: AC1009 (R12, Default) und AC1015 (AutoCAD 2000).
|
||||
*
|
||||
* Task F6: BLOCKS+INSERTS mit Name-Mapping/Sanitizing, ATTDEF in
|
||||
* Blockdefinitionen, ATTRIB/SEQEND an Instanzen mit attrValues,
|
||||
* R12-korrektes POLYLINE+VERTEX+SEQEND statt LWPOLYLINE sowie die
|
||||
* Center-/Winkel-Konventionen der App (el.x/el.y = Zentrum, Grad-Winkel).
|
||||
*/
|
||||
import type { CADElement, CADLayer, ProjectData } from '../types/cad.types';
|
||||
|
||||
export interface DXFWriterOptions {
|
||||
/** Zielversion: AC1009 (R12, Default) oder AC1015. */
|
||||
version?: 'AC1009' | 'AC1015';
|
||||
}
|
||||
|
||||
interface Pt { x: number; y: number }
|
||||
type Write = (code: number, value: string | number) => void;
|
||||
|
||||
/**
|
||||
* Generate a DXF R12 string from project data.
|
||||
* Generate a DXF string from project data (R12-kompatibel).
|
||||
*/
|
||||
export function writeDXF(data: ProjectData): string {
|
||||
export function writeDXF(data: ProjectData, options: DXFWriterOptions = {}): string {
|
||||
const version = options.version ?? 'AC1009';
|
||||
const lines: string[] = [];
|
||||
const w = (code: number, value: string | number) => {
|
||||
const w: Write = (code, value) => {
|
||||
lines.push(String(code));
|
||||
lines.push(String(value));
|
||||
};
|
||||
|
||||
// ─── Name-Mapping: Block-ID → sanitierter DXF-Blockname ───
|
||||
const usedNames = new Set<string>(['0']); // '0' ist in DXF reserviert
|
||||
const nameMap = new Map<string, string>();
|
||||
for (const block of data.blocks) {
|
||||
nameMap.set(block.id, sanitizeBlockName(block.name || block.id, usedNames));
|
||||
}
|
||||
|
||||
// Header
|
||||
w(0, 'SECTION');
|
||||
w(2, 'HEADER');
|
||||
w(9, '$ACADVER'); w(1, 'AC1009'); // R12
|
||||
w(9, '$ACADVER'); w(1, version);
|
||||
w(9, '$INSBASE'); w(10, 0); w(20, 0); w(30, 0);
|
||||
w(9, '$EXTMIN'); w(10, 0); w(20, 0);
|
||||
w(9, '$EXTMAX'); w(10, 1000); w(20, 1000);
|
||||
@@ -26,8 +47,6 @@ export function writeDXF(data: ProjectData): string {
|
||||
// Tables section
|
||||
w(0, 'SECTION');
|
||||
w(2, 'TABLES');
|
||||
|
||||
// Layer table
|
||||
w(0, 'TABLE');
|
||||
w(2, 'LAYER');
|
||||
w(70, data.layers.length);
|
||||
@@ -41,15 +60,31 @@ export function writeDXF(data: ProjectData): string {
|
||||
w(0, 'ENDTAB');
|
||||
w(0, 'ENDSEC');
|
||||
|
||||
// ─── BLOCKS section (Task F6) ───
|
||||
w(0, 'SECTION');
|
||||
w(2, 'BLOCKS');
|
||||
for (const block of data.blocks) {
|
||||
const dxfName = nameMap.get(block.id)!;
|
||||
w(0, 'BLOCK');
|
||||
w(2, dxfName);
|
||||
w(70, 0);
|
||||
w(10, 0); w(20, 0); w(30, 0);
|
||||
w(3, dxfName);
|
||||
w(1, block.description || '');
|
||||
for (const el of block.elements) {
|
||||
writeEntity(w, el, getLayerName(data.layers, el.layerId), nameMap, true);
|
||||
}
|
||||
w(0, 'ENDBLK');
|
||||
}
|
||||
w(0, 'ENDSEC');
|
||||
|
||||
// Entities section
|
||||
w(0, 'SECTION');
|
||||
w(2, 'ENTITIES');
|
||||
|
||||
for (const el of data.elements) {
|
||||
const layerName = getLayerName(data.layers, el.layerId);
|
||||
writeEntity(w, el, layerName);
|
||||
writeEntity(w, el, layerName, nameMap, false);
|
||||
}
|
||||
|
||||
w(0, 'ENDSEC');
|
||||
|
||||
// EOF
|
||||
@@ -58,10 +93,58 @@ export function writeDXF(data: ProjectData): string {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** Blocknamen für DXF sanitizen: nur [A-Za-z0-9_$-], Kollisionen deduplizieren. */
|
||||
function sanitizeBlockName(name: string, used: Set<string>): string {
|
||||
const base = (name.replace(/[^\w$-]/g, '_') || 'BLOCK');
|
||||
let candidate = base;
|
||||
let i = 2;
|
||||
while (used.has(candidate.toUpperCase())) {
|
||||
candidate = `${base}_${i++}`;
|
||||
}
|
||||
used.add(candidate.toUpperCase());
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/** R12-korrektes POLYLINE+VERTEX+SEQEND (LWPOLYLINE erst ab R13). */
|
||||
function writePolylineR12(
|
||||
w: Write,
|
||||
layerName: string,
|
||||
aci: number,
|
||||
pts: Pt[],
|
||||
closed: boolean,
|
||||
): void {
|
||||
w(0, 'POLYLINE');
|
||||
w(8, layerName);
|
||||
w(62, aci);
|
||||
w(66, 1); // Vertices folgen
|
||||
w(70, closed ? 1 : 0);
|
||||
w(10, 0); w(20, 0); w(30, 0);
|
||||
for (const pt of pts) {
|
||||
w(0, 'VERTEX');
|
||||
w(8, layerName);
|
||||
w(10, pt.x); w(20, pt.y); w(30, 0);
|
||||
}
|
||||
w(0, 'SEQEND');
|
||||
}
|
||||
|
||||
/** BBox-Fallback für Elemente ohne native DXF-Repräsentation. */
|
||||
function writeBBoxPolyline(w: Write, el: CADElement, layerName: string, aci: number): void {
|
||||
const x0 = el.x;
|
||||
const y0 = el.y;
|
||||
writePolylineR12(w, layerName, aci, [
|
||||
{ x: x0, y: y0 },
|
||||
{ x: x0 + el.width, y: y0 },
|
||||
{ x: x0 + el.width, y: y0 + el.height },
|
||||
{ x: x0, y: y0 + el.height },
|
||||
], true);
|
||||
}
|
||||
|
||||
function writeEntity(
|
||||
w: (code: number, value: string | number) => void,
|
||||
w: Write,
|
||||
el: CADElement,
|
||||
layerName: string,
|
||||
nameMap: Map<string, string>,
|
||||
inBlockDefinition: boolean,
|
||||
): void {
|
||||
const p = el.properties;
|
||||
const stroke = (p.stroke as string) || '#ffffff';
|
||||
@@ -77,68 +160,69 @@ function writeEntity(
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
const cx = el.x + el.width / 2;
|
||||
const cy = el.y + el.height / 2;
|
||||
const r = p.radius ?? el.width / 2;
|
||||
// App-Konvention: el.x/el.y IST das Zentrum (beide Renderer lesen es direkt)
|
||||
w(0, 'CIRCLE');
|
||||
w(8, layerName);
|
||||
w(62, aci);
|
||||
w(10, cx); w(20, cy); w(30, 0);
|
||||
w(40, r);
|
||||
w(10, el.x); w(20, el.y); w(30, 0);
|
||||
w(40, p.radius ?? el.width / 2);
|
||||
break;
|
||||
}
|
||||
case 'arc': {
|
||||
const cx = el.x + el.width / 2;
|
||||
const cy = el.y + el.height / 2;
|
||||
const r = p.radius ?? el.width / 2;
|
||||
w(0, 'ARC');
|
||||
w(8, layerName);
|
||||
w(62, aci);
|
||||
w(10, cx); w(20, cy); w(30, 0);
|
||||
w(40, r);
|
||||
w(50, radToDeg(p.startAngle ?? 0));
|
||||
w(51, radToDeg(p.endAngle ?? 360));
|
||||
w(10, el.x); w(20, el.y); w(30, 0);
|
||||
w(40, p.radius ?? el.width / 2);
|
||||
// App-Konvention: Winkel in Grad (0° = 3 Uhr, CCW) — direkt schreiben
|
||||
w(50, p.startAngle ?? 0);
|
||||
w(51, p.endAngle ?? 360);
|
||||
break;
|
||||
}
|
||||
case 'rect': {
|
||||
// Rectangle as LWPOLYLINE
|
||||
w(0, 'LWPOLYLINE');
|
||||
w(8, layerName);
|
||||
w(62, aci);
|
||||
w(90, 4);
|
||||
w(70, 1); // closed
|
||||
w(10, el.x); w(20, el.y);
|
||||
w(10, el.x + el.width); w(20, el.y);
|
||||
w(10, el.x + el.width); w(20, el.y + el.height);
|
||||
w(10, el.x); w(20, el.y + el.height);
|
||||
// App-Konvention: rect x/y = Zentrum der BBox
|
||||
const x0 = el.x - el.width / 2;
|
||||
const y0 = el.y - el.height / 2;
|
||||
writePolylineR12(w, layerName, aci, [
|
||||
{ x: x0, y: y0 },
|
||||
{ x: x0 + el.width, y: y0 },
|
||||
{ x: x0 + el.width, y: y0 + el.height },
|
||||
{ x: x0, y: y0 + el.height },
|
||||
], true);
|
||||
break;
|
||||
}
|
||||
case 'polygon':
|
||||
case 'polyline': {
|
||||
const pts = p.points ?? [];
|
||||
w(0, 'LWPOLYLINE');
|
||||
w(8, layerName);
|
||||
w(62, aci);
|
||||
w(90, pts.length);
|
||||
w(70, el.type === 'polygon' ? 1 : 0);
|
||||
for (const pt of pts) {
|
||||
w(10, pt.x); w(20, pt.y);
|
||||
const pts = (p.points as Pt[] | undefined) ?? [];
|
||||
if (pts.length >= 2) {
|
||||
writePolylineR12(w, layerName, aci, pts.map((pt) => ({ x: pt.x, y: pt.y })), el.type === 'polygon');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
// ATTDEF: Text-Element als Attribut-Definition in Blockdefinitionen (Task F6)
|
||||
if (inBlockDefinition && p.attdef === true && typeof p.tag === 'string' && p.tag) {
|
||||
w(0, 'ATTDEF');
|
||||
w(8, layerName);
|
||||
w(10, el.x); w(20, el.y); w(30, 0);
|
||||
w(40, p.fontSize ?? 12);
|
||||
w(1, (p.text as string) ?? '');
|
||||
w(2, p.tag);
|
||||
w(3, (p.prompt as string) ?? '');
|
||||
w(70, 0);
|
||||
break;
|
||||
}
|
||||
w(0, 'TEXT');
|
||||
w(8, layerName);
|
||||
w(62, aci);
|
||||
w(10, el.x); w(20, el.y); w(30, 0);
|
||||
w(40, p.fontSize ?? 12);
|
||||
w(1, p.text ?? '');
|
||||
w(1, (p.text as string) ?? '');
|
||||
break;
|
||||
}
|
||||
case 'dimension': {
|
||||
const pts = p.points ?? [];
|
||||
const pts = (p.points as Pt[] | undefined) ?? [];
|
||||
if (pts.length >= 2) {
|
||||
// Draw dimension as LINE + TEXT
|
||||
w(0, 'LINE');
|
||||
w(8, layerName);
|
||||
w(62, aci);
|
||||
@@ -157,27 +241,41 @@ function writeEntity(
|
||||
break;
|
||||
}
|
||||
case 'block_instance': {
|
||||
const mapped = nameMap.get(String(p.blockId ?? ''));
|
||||
if (!mapped) {
|
||||
writeBBoxPolyline(w, el, layerName, aci);
|
||||
break;
|
||||
}
|
||||
w(0, 'INSERT');
|
||||
w(8, layerName);
|
||||
w(62, aci);
|
||||
w(2, p.blockId ?? 'BLOCK');
|
||||
w(2, mapped);
|
||||
w(10, el.x); w(20, el.y); w(30, 0);
|
||||
w(41, p.scale ?? 1);
|
||||
w(42, p.scale ?? 1);
|
||||
w(50, radToDeg(p.rotation ?? 0));
|
||||
const sx = typeof p.scaleX === 'number' ? p.scaleX : (typeof p.scale === 'number' ? p.scale : 1);
|
||||
const sy = typeof p.scaleY === 'number' ? p.scaleY : (typeof p.scale === 'number' ? p.scale : 1);
|
||||
w(41, sx);
|
||||
w(42, sy);
|
||||
w(50, p.rotation ?? 0); // Grad, direkt (keine radToDeg-Doppelkonvertierung)
|
||||
// Attributwerte der Instanz → ATTRIB/SEQEND (Task F6)
|
||||
const attrValues = p.attrValues as Record<string, string> | undefined;
|
||||
if (attrValues && Object.keys(attrValues).length > 0) {
|
||||
w(66, 1); // Attribute folgen
|
||||
for (const [tag, value] of Object.entries(attrValues)) {
|
||||
w(0, 'ATTRIB');
|
||||
w(8, layerName);
|
||||
w(10, el.x); w(20, el.y); w(30, 0);
|
||||
w(40, 12);
|
||||
w(1, value);
|
||||
w(2, tag);
|
||||
w(70, 0);
|
||||
}
|
||||
w(0, 'SEQEND');
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// For chair, seating-row, etc. — export as LWPOLYLINE bounding box
|
||||
w(0, 'LWPOLYLINE');
|
||||
w(8, layerName);
|
||||
w(62, aci);
|
||||
w(90, 4);
|
||||
w(70, 1);
|
||||
w(10, el.x); w(20, el.y);
|
||||
w(10, el.x + el.width); w(20, el.y);
|
||||
w(10, el.x + el.width); w(20, el.y + el.height);
|
||||
w(10, el.x); w(20, el.y + el.height);
|
||||
// chair, seating-row, table, stage, revcloud, leader, image, … → BBox
|
||||
writeBBoxPolyline(w, el, layerName, aci);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -191,7 +289,6 @@ function hexToACI(hex: string): number {
|
||||
const r = parseInt(h.substring(0, 2), 16);
|
||||
const g = parseInt(h.substring(2, 4), 16);
|
||||
const b = parseInt(h.substring(4, 6), 16);
|
||||
// Map common colors to ACI
|
||||
if (r > 200 && g < 100 && b < 100) return 1; // red
|
||||
if (r > 200 && g > 200 && b < 100) return 2; // yellow
|
||||
if (r < 100 && g > 200 && b < 100) return 3; // green
|
||||
@@ -200,7 +297,7 @@ function hexToACI(hex: string): number {
|
||||
if (r > 200 && g < 100 && b > 200) return 6; // magenta
|
||||
if (r > 200 && g > 200 && b > 200) return 7; // white
|
||||
if (r < 100 && g < 100 && b < 100) return 8; // gray
|
||||
return 7; // default white
|
||||
return 7;
|
||||
}
|
||||
|
||||
function lineTypeToDXF(lt: string): string {
|
||||
@@ -208,7 +305,3 @@ function lineTypeToDXF(lt: string): string {
|
||||
if (lt === 'dotted') return 'DOT';
|
||||
return 'CONTINUOUS';
|
||||
}
|
||||
|
||||
function radToDeg(rad: number): number {
|
||||
return (rad * 180) / Math.PI;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Task F6 – DXF-Writer-Erweiterungstests.
|
||||
*
|
||||
* Deckt ab: BLOCKS+INSERTS mit Name-Mapping/Sanitizing, ATTRIB/SEQEND,
|
||||
* Versionsoption AC1009/AC1015, R12-korrektes POLYLINE statt LWPOLYLINE,
|
||||
* Center-Konvention (circle/arc el.x/el.y = Zentrum) und Grad-Winkel ohne
|
||||
* Doppelkonvertierung — abgesichert über echte write→parse-Roundtrips.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { writeDXF } from '../src/services/dxfWriter';
|
||||
import { parseDXF } from '../src/services/dxfParser';
|
||||
import type { CADElement, CADLayer, ProjectData, BlockDefinition } from '../src/types/cad.types';
|
||||
|
||||
const layer: CADLayer = {
|
||||
id: 'layer-1', name: 'L1', visible: true, locked: false,
|
||||
color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null,
|
||||
};
|
||||
|
||||
function mkEl(over: Partial<CADElement> = {}): CADElement {
|
||||
return {
|
||||
id: 'e1', type: 'line', layerId: 'layer-1',
|
||||
x: 0, y: 0, width: 0, height: 0, properties: {},
|
||||
...over,
|
||||
} as CADElement;
|
||||
}
|
||||
|
||||
function mkBlock(over: Partial<BlockDefinition> = {}): BlockDefinition {
|
||||
return {
|
||||
id: 'blk-1', name: 'Stuhl Reihe', description: '', category: 'event',
|
||||
elements: [mkEl({ type: 'line', properties: { x1: 0, y1: 0, x2: 100, y2: 0 } })],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function mkProj(elements: CADElement[], blocks: BlockDefinition[] = []): ProjectData {
|
||||
return { version: '1', name: 'T', layers: [layer], elements, blocks } as ProjectData;
|
||||
}
|
||||
|
||||
describe('F6: Version', () => {
|
||||
it('Default ist AC1009 (R12)', () => {
|
||||
expect(writeDXF(mkProj([]))).toContain('AC1009');
|
||||
});
|
||||
it('Option AC1015 setzt Header-Version', () => {
|
||||
expect(writeDXF(mkProj([]), { version: 'AC1015' })).toContain('AC1015');
|
||||
});
|
||||
});
|
||||
|
||||
describe('F6: BLOCKS + INSERTS', () => {
|
||||
it('BLOCKS-Sektion emittiert sanitisierten Blocknamen, INSERT referenziert gemappten Namen', () => {
|
||||
const out = writeDXF(mkProj([
|
||||
mkEl({ type: 'block_instance', properties: { blockId: 'blk-1' } }),
|
||||
], [mkBlock()]));
|
||||
expect(out).toContain('BLOCKS');
|
||||
expect(out).toContain('BLOCK');
|
||||
expect(out).toContain('ENDBLK');
|
||||
// Name 'Stuhl Reihe' → 'Stuhl_Reihe' (Leerzeichen sanitizing)
|
||||
expect(out.split('Stuhl_Reihe').length).toBeGreaterThanOrEqual(3); // BLOCK 2/3 + INSERT 2
|
||||
expect(out).toContain('INSERT');
|
||||
});
|
||||
|
||||
it('block_instance ohne Definition bleibt BBox-Fallback (kein INSERT)', () => {
|
||||
const out = writeDXF(mkProj([
|
||||
mkEl({ type: 'block_instance', x: 0, y: 0, width: 100, height: 50, properties: { blockId: 'unknown-block' } }),
|
||||
]));
|
||||
expect(out).not.toContain('INSERT');
|
||||
expect(out).toContain('POLYLINE');
|
||||
});
|
||||
|
||||
it('Roundtrip: INSERT landet als block_instance mit gemapptem Namen', () => {
|
||||
const out = writeDXF(mkProj([
|
||||
mkEl({ type: 'block_instance', x: 10, y: 20, properties: { blockId: 'blk-1' } }),
|
||||
], [mkBlock()]));
|
||||
const res = parseDXF(out);
|
||||
const inst = res.elements.find((e) => e.type === 'block_instance');
|
||||
expect(inst).toBeDefined();
|
||||
expect(inst!.properties.blockId).toBe('Stuhl_Reihe');
|
||||
expect(inst!.x).toBe(10);
|
||||
expect(inst!.y).toBe(20);
|
||||
});
|
||||
|
||||
it('ATTDEF aus Blockelement (attdef:true + tag) wird in der BLOCK-Definition emittiert', () => {
|
||||
const blk = mkBlock({
|
||||
elements: [
|
||||
mkEl({ type: 'line', properties: { x1: 0, y1: 0, x2: 50, y2: 0 } }),
|
||||
mkEl({ type: 'text', x: 5, y: 5, properties: { text: 'Buehne', tag: 'NAME', attdef: true, fontSize: 12 } }),
|
||||
],
|
||||
});
|
||||
const out = writeDXF(mkProj([], [blk]));
|
||||
expect(out).toContain('ATTDEF');
|
||||
expect(out).toContain('NAME');
|
||||
});
|
||||
|
||||
it('Instanz mit attrValues emittiert 66/ATTRIB/SEQEND', () => {
|
||||
const out = writeDXF(mkProj([
|
||||
mkEl({
|
||||
type: 'block_instance', x: 0, y: 0,
|
||||
properties: { blockId: 'blk-1', attrValues: { NAME: 'Halle A' } },
|
||||
}),
|
||||
], [mkBlock()]));
|
||||
expect(out).toContain('ATTRIB');
|
||||
expect(out).toContain('SEQEND');
|
||||
expect(out).toContain('Halle A');
|
||||
expect(out).toContain('66');
|
||||
});
|
||||
});
|
||||
|
||||
describe('F6: R12-korrektes POLYLINE', () => {
|
||||
it('polygon → POLYLINE+VERTEX+SEQEND, kein LWPOLYLINE', () => {
|
||||
const out = writeDXF(mkProj([
|
||||
mkEl({
|
||||
type: 'polygon',
|
||||
properties: { points: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }] },
|
||||
}),
|
||||
]));
|
||||
expect(out).toContain('POLYLINE');
|
||||
expect(out).toContain('VERTEX');
|
||||
expect(out).toContain('SEQEND');
|
||||
expect(out).not.toContain('LWPOLYLINE');
|
||||
});
|
||||
|
||||
it('Roundtrip: polygon bleibt geschlossen (shape-Flag 70=1)', () => {
|
||||
const out = writeDXF(mkProj([
|
||||
mkEl({
|
||||
type: 'polygon',
|
||||
properties: { points: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }, { x: 0, y: 100 }] },
|
||||
}),
|
||||
]));
|
||||
const res = parseDXF(out);
|
||||
const poly = res.elements.find((e) => e.type === 'polygon');
|
||||
expect(poly).toBeDefined();
|
||||
expect(res.elements.find((e) => e.type === 'polyline')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('F6: Center- & Winkel-Konvention', () => {
|
||||
it('Writer: circle Zentrum el.x/el.y direkt (kein +width/2)', () => {
|
||||
const out = writeDXF(mkProj([
|
||||
mkEl({ type: 'circle', x: 100, y: 50, width: 60, height: 60, properties: { radius: 30 } }),
|
||||
]));
|
||||
const res = parseDXF(out);
|
||||
const circ = res.elements.find((e) => e.type === 'circle');
|
||||
expect(circ).toBeDefined();
|
||||
expect(circ!.x).toBeCloseTo(100, 5);
|
||||
expect(circ!.y).toBeCloseTo(50, 5);
|
||||
expect(circ!.properties.radius).toBeCloseTo(30, 5);
|
||||
});
|
||||
|
||||
it('Parser: CIRCLE center landet direkt auf el.x/el.y (Renderer-Konvention)', () => {
|
||||
const dxf = [
|
||||
'0', 'SECTION', '2', 'ENTITIES',
|
||||
'0', 'CIRCLE', '8', '0', '10', '10', '20', '20', '30', '0', '40', '5',
|
||||
'0', 'ENDSEC', '0', 'EOF',
|
||||
].join(String.fromCharCode(10));
|
||||
const res = parseDXF(dxf);
|
||||
const circ = res.elements.find((e) => e.type === 'circle');
|
||||
expect(circ).toBeDefined();
|
||||
expect(circ!.x).toBe(10);
|
||||
expect(circ!.y).toBe(20);
|
||||
expect(circ!.properties.radius).toBe(5);
|
||||
expect(circ!.width).toBe(10);
|
||||
});
|
||||
|
||||
it('arc/rotation Winkel in Grad ohne Doppelkonvertierung', () => {
|
||||
const out = writeDXF(mkProj([
|
||||
mkEl({ type: 'arc', x: 0, y: 0, width: 20, height: 20, properties: { radius: 10, startAngle: 30, endAngle: 120 } }),
|
||||
mkEl({ type: 'block_instance', x: 0, y: 0, properties: { blockId: 'blk-1', rotation: 45 } }),
|
||||
], [mkBlock()]));
|
||||
expect(out).toContain('50' + String.fromCharCode(10) + '30');
|
||||
expect(out).toContain('51' + String.fromCharCode(10) + '120');
|
||||
expect(out).toContain('50' + String.fromCharCode(10) + '45');
|
||||
const res = parseDXF(out);
|
||||
const arc = res.elements.find((e) => e.type === 'arc');
|
||||
expect(arc!.properties.startAngle).toBeCloseTo(30, 5);
|
||||
});
|
||||
|
||||
it('rect als geschlossene POLYLINE mit Ecken aus Zentrumskonvention', () => {
|
||||
const out = writeDXF(mkProj([
|
||||
mkEl({ type: 'rect', x: 0, y: 0, width: 100, height: 50, properties: {} }),
|
||||
]));
|
||||
const res = parseDXF(out);
|
||||
const poly = res.elements[0];
|
||||
const pts = (poly!.properties.points ?? []) as Array<{ x: number; y: number }>;
|
||||
expect(pts.length).toBe(4);
|
||||
expect(pts[0].x).toBeCloseTo(-50, 5);
|
||||
expect(pts[0].y).toBeCloseTo(-25, 5);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user