task(C2a): pure shape-primitive factory for all core drawing types

This commit is contained in:
Agent Zero
2026-08-27 08:49:18 +02:00
parent 98db99dc87
commit b412a65c33
4 changed files with 338 additions and 0 deletions
+86
View File
@@ -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",
+1
View File
@@ -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",
+136
View File
@@ -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<string, unknown>;
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 [];
}
}
+115
View File
@@ -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<string, unknown> = {}, geom: Partial<CADElement> = {}): 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([]);
});
});