diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 79fb334..b891bda 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "dxf-parser": "^1.1.2", "pdf-lib": "^1.17.1", + "pixi.js": "^8.20.1", "rbush": "^4.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -661,6 +662,11 @@ "pako": "^1.0.10" } }, + "node_modules/@pixi/colord": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz", + "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==" + }, "node_modules/@playwright/test": { "version": "1.62.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", @@ -1094,6 +1100,11 @@ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true }, + "node_modules/@types/earcut": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz", + "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1257,6 +1268,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@webgpu/types": { + "version": "0.1.72", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz", + "integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/abstract-leveldown": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz", @@ -1559,6 +1583,11 @@ "loglevel": "^1.7.1" } }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==" + }, "node_modules/electron-to-chromium": { "version": "1.5.378", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz", @@ -1629,6 +1658,11 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==" + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -1678,6 +1712,14 @@ "node": ">=6.9.0" } }, + "node_modules/gifuct-js": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz", + "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==", + "dependencies": { + "js-binary-schema-parser": "^2.0.3" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -1737,6 +1779,11 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true }, + "node_modules/ismobilejs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", + "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==" + }, "node_modules/isomorphic.js": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", @@ -1746,6 +1793,11 @@ "url": "https://github.com/sponsors/dmonad" } }, + "node_modules/js-binary-schema-parser": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz", + "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2377,6 +2429,11 @@ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" }, + "node_modules/parse-svg-path": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz", + "integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==" + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -2424,6 +2481,27 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pixi.js": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.20.1.tgz", + "integrity": "sha512-akLVBMLvQbaEViqsmVraK0Njer1q4Q4lm4iNKuzvBiFrV8q+6/+HluJN3sWIwO663IBeQtUUS/CaXFJZs6ffXQ==", + "dependencies": { + "@pixi/colord": "^2.9.6", + "@types/earcut": "^3.0.0", + "@webgpu/types": "^0.1.69", + "@xmldom/xmldom": "^0.8.15", + "earcut": "^3.0.2", + "eventemitter3": "^5.0.1", + "gifuct-js": "^2.1.2", + "ismobilejs": "^1.1.1", + "parse-svg-path": "^0.2.0", + "tiny-lru": "^11.4.7" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/pixijs" + } + }, "node_modules/playwright": { "version": "1.62.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", @@ -2754,6 +2832,14 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "node_modules/tiny-lru": { + "version": "11.4.7", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz", + "integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==", + "engines": { + "node": ">=12" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index b67f6be..3cc968d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "dependencies": { "dxf-parser": "^1.1.2", "pdf-lib": "^1.17.1", + "pixi.js": "^8.20.1", "rbush": "^4.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/src/render/pixi/elementDrawers.ts b/frontend/src/render/pixi/elementDrawers.ts new file mode 100644 index 0000000..f979eda --- /dev/null +++ b/frontend/src/render/pixi/elementDrawers.ts @@ -0,0 +1,136 @@ +/** + * elementDrawers – Reine Beschreibungs-Fabrik (Task C2). + * + * Wandelt CADElemente in eine Liste von Primitiven um (line/rect/circle/ + * polyline/text/arc). Der PixiRenderer (C3) konsumiert diese Deskriptoren + * und überführt sie in konkrete Grafikobjekte. + * + * Vorteil dieser Zwischenschicht: + * - Vollständig unit-testbar OHNE WebGL/GPU + * - Backend-unabhängig (Pixi jetzt, Canvas2D oder andere später) + * + * Konventionen entsprechen dem Legacy RenderEngine.draw*: + * - rect: x/y = Zentrum der BBox + * - circle/arc: radius über properties.radius (Fallback width/2) + * - arc-Winkel in Grad, 0° = 3 Uhr, gegen den Uhrzeigersinn + */ +import type { CADElement } from '../../types/cad.types'; +import type { Pt } from '../../tools/modification/geometry'; + +/** Zeichenprimitiv — kleinste darstellbare Einheit. */ +export type ShapePrimitive = + | { kind: 'line'; from: Pt; to: Pt; stroke: string; strokeWidth: number } + | { kind: 'rect'; x: number; y: number; w: number; h: number; fill: string | null; stroke: string; strokeWidth: number } + | { kind: 'circle'; center: Pt; radius: number; fill: string | null; stroke: string; strokeWidth: number } + | { kind: 'polyline'; points: Pt[]; close: boolean; stroke: string; strokeWidth: number } + | { kind: 'text'; at: Pt; text: string; fontSize: number; color: string } + | { kind: 'arc'; center: Pt; radius: number; startDeg: number; endDeg: number; stroke: string; strokeWidth: number }; + +const FALLBACK_STROKE = '#e0e0e0'; + +function asPoint(p: unknown): Pt { + const q = p as { x?: number; y?: number }; + return { x: Number(q?.x ?? 0), y: Number(q?.y ?? 0) }; +} + +function fillOrNull(raw: unknown): string | null { + return typeof raw === 'string' && raw !== 'none' ? raw : null; +} + +/** + * Wandelt ein CADElement in seine Zeichenprimitive um. + * Gibt ein leeres Array zurück für unbekannte Typen bzw. Elemente, + * die nichts Sichtbares beitragen (z.B. leerer Text). + */ +export function elementToPrimitives(el: CADElement): ShapePrimitive[] { + const props = (el.properties ?? {}) as Record; + const stroke = (props.stroke as string | undefined) ?? FALLBACK_STROKE; + const sw = (props.strokeWidth as number | undefined) ?? 1; + + switch (el.type) { + case 'line': { + if ( + props.x1 === undefined || props.y1 === undefined || + props.x2 === undefined || props.y2 === undefined + ) { + return []; + } + return [{ + kind: 'line', + from: { x: props.x1 as number, y: props.y1 as number }, + to: { x: props.x2 as number, y: props.y2 as number }, + stroke, + strokeWidth: sw, + }]; + } + + case 'rect': { + // Legacy-BBox-Konvention: x/y ist das Zentrum des Rechtecks + return [{ + kind: 'rect', + x: el.x - el.width / 2, + y: el.y - el.height / 2, + w: el.width, + h: el.height, + fill: fillOrNull(props.fill), + stroke, + strokeWidth: sw, + }]; + } + + case 'circle': { + const radius = (props.radius as number | undefined) ?? el.width / 2; + return [{ + kind: 'circle', + center: { x: el.x, y: el.y }, + radius, + fill: fillOrNull(props.fill), + stroke, + strokeWidth: sw, + }]; + } + + case 'arc': { + const radius = (props.radius as number | undefined) ?? el.width / 2; + return [{ + kind: 'arc', + center: { x: el.x, y: el.y }, + radius, + startDeg: (props.startAngle as number | undefined) ?? 0, + endDeg: (props.endAngle as number | undefined) ?? 360, + stroke, + strokeWidth: sw, + }]; + } + + case 'polyline': + case 'polygon': { + const pts = Array.isArray(props.points) + ? (props.points as unknown[]).map(asPoint) + : []; + if (pts.length < 2) return []; + return [{ + kind: 'polyline', + points: pts, + close: el.type === 'polygon', + stroke, + strokeWidth: sw, + }]; + } + + case 'text': { + const txt = String(props.text ?? ''); + if (!txt) return []; // leeren Platzhalter nicht rendern + return [{ + kind: 'text', + at: { x: el.x, y: el.y }, + text: txt, + fontSize: (props.fontSize as number | undefined) ?? 12, + color: (props.color as string | undefined) ?? stroke, + }]; + } + + default: + return []; + } +} diff --git a/frontend/tests/elementDrawers.test.ts b/frontend/tests/elementDrawers.test.ts new file mode 100644 index 0000000..f3b87c4 --- /dev/null +++ b/frontend/tests/elementDrawers.test.ts @@ -0,0 +1,115 @@ +/** + * elementToPrimitives Tests (Task C2). + * Deckt alle unterstützten Typen + Legacy-Konventionen + Grenzfälle ab. + */ +import { describe, it, expect } from 'vitest'; +import { elementToPrimitives } from '../src/render/pixi/elementDrawers'; +import type { CADElement } from '../src/types/cad.types'; + +function el(id: string, type: string, props: Record = {}, geom: Partial = {}): CADElement { + return { + id, + type, + layerId: 'l', + x: 0, + y: 0, + width: 10, + height: 10, + properties: props, + ...geom, + } as unknown as CADElement; +} + +describe('elementToPrimitives', () => { + it('line: aus properties.x1..y2 mit stroke/sw', () => { + const prims = elementToPrimitives(el('l', 'line', { + x1: 0, y1: 0, x2: 100, y2: 50, stroke: '#f00', strokeWidth: 3, + })); + expect(prims.length).toBe(1); + expect(prims[0]).toEqual({ + kind: 'line', from: { x: 0, y: 0 }, to: { x: 100, y: 50 }, + stroke: '#f00', strokeWidth: 3, + }); + }); + + it('line ohne Koordinaten ergibt leere Liste', () => { + expect(elementToPrimitives(el('l', 'line', {}))).toEqual([]); + }); + + it('rect: x/y ist Zentrum der BBox (Legacy-Konvention)', () => { + const prims = elementToPrimitives(el('r', 'rect', { fill: '#123456' }, { + x: 100, y: 50, width: 80, height: 40, + })); + expect(prims.length).toBe(1); + const p = prims[0]; + if (p.kind !== 'rect') throw new Error('erwartete rect'); + expect(p.x).toBe(60); // 100 − 40 + expect(p.y).toBe(30); // 50 − 20 + expect(p.w).toBe(80); + expect(p.h).toBe(40); + expect(p.fill).toBe('#123456'); + }); + + it('rect fill none wird zu null', () => { + const prims = elementToPrimitives(el('r', 'rect', { fill: 'none' })); + const p = prims[0]; + if (p.kind !== 'rect') throw new Error('erwartete rect'); + expect(p.fill).toBeNull(); + }); + + it('circle: radius mit Fallback width/2', () => { + const withExplicit = elementToPrimitives(el('c', 'circle', { radius: 25, fill: '#abcdef' }, { x: 5, y: 5 })); + const p = withExplicit[0]; + if (p.kind !== 'circle') throw new Error('erwartete circle'); + expect(p.radius).toBe(25); + expect(p.center).toEqual({ x: 5, y: 5 }); + expect(p.fill).toBe('#abcdef'); + + const byWidth = elementToPrimitives(el('c', 'circle', {}, { width: 120 })); + const pb = byWidth[0]; + if (pb.kind !== 'circle') throw new Error('erwartete circle'); + expect(pb.radius).toBe(60); // fallback + }); + + it('arc: Winkel in Grad wie Legacy', () => { + const prims = elementToPrimitives(el('a', 'arc', { + radius: 40, startAngle: 0, endAngle: 90, + }, { x: 0, y: 0 })); + const p = prims[0]; + if (p.kind !== 'arc') throw new Error('erwartete arc'); + expect(p.startDeg).toBe(0); + expect(p.endDeg).toBe(90); + expect(p.radius).toBe(40); + }); + + it('polyline/polygon: points-Array, close je nach Typ', () => { + const pts = [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }]; + const open = elementToPrimitives(el('pl', 'polyline', { points: pts })); + const closed = elementToPrimitives(el('pg', 'polygon', { points: pts })); + expect(open.length).toBe(1); + expect(closed.length).toBe(1); + if (open[0].kind !== 'polyline' || closed[0].kind !== 'polyline') { + throw new Error('erwartete polyline'); + } + expect(open[0].close).toBe(false); + expect(closed[0].close).toBe(true); + }); + + it('text: leer nicht gerendert; gefuellt mit fontSize/color', () => { + expect(elementToPrimitives(el('t', 'text', { text: '' }))).toEqual([]); + + const filled = elementToPrimitives(el('t', 'text', { + text: 'Bühne A', fontSize: 18, color: '#ffffff', + }, { x: 50, y: 75 })); + expect(filled.length).toBe(1); + const p = filled[0]; + if (p.kind !== 'text') throw new Error('erwartete text'); + expect(p.text).toBe('Bühne A'); + expect(p.fontSize).toBe(18); + expect(p.color).toBe('#ffffff'); + }); + + it('unbekannter Typ ergibt leere Liste (renderer-sicher)', () => { + expect(elementToPrimitives(el('x', 'definitely-unknown'))).toEqual([]); + }); +});