Compare commits
63 Commits
126277ab94
...
b9a4bce2ff
| Author | SHA1 | Date | |
|---|---|---|---|
| b9a4bce2ff | |||
| b470ac3346 | |||
| 9aed0e2163 | |||
| 5605993462 | |||
| 8223d159f8 | |||
| eb1b8692a8 | |||
| 6a1d0db7a1 | |||
| e8aac3baa6 | |||
| 042c546ebe | |||
| cefc508736 | |||
| cdb04baff7 | |||
| d5b6395f4f | |||
| 9b658f31fb | |||
| 129ddcc61f | |||
| 0abfc3e0be | |||
| eb8d6231c5 | |||
| 738e4e8373 | |||
| 124b924457 | |||
| 8eb5a06816 | |||
| 8b1cca0324 | |||
| 8cde5d7f35 | |||
| dde5077bf9 | |||
| b412a65c33 | |||
| 98db99dc87 | |||
| c962e755ec | |||
| ebe8cd32d4 | |||
| 69141d604a | |||
| fbf72e0b6d | |||
| e370552055 | |||
| 216bf6360a | |||
| 25e721d3d3 | |||
| e76b3efe20 | |||
| 19f838fbf8 | |||
| ebafcf0c65 | |||
| 76f305fc6f | |||
| cf67635732 | |||
| 7565016200 | |||
| 1d598daba1 | |||
| 1d3d9bd191 | |||
| ca1c3c08f3 | |||
| 0c852d18ee | |||
| 464ddb91cc | |||
| af46f0028a | |||
| 239257c8b0 | |||
| 4850f0584b | |||
| 0dfe3b5870 | |||
| 9216a9051e | |||
| 8204de81c4 | |||
| 4b31f49781 | |||
| d54a67662b | |||
| 09a120d800 | |||
| ddfeadac58 | |||
| 4a7a8f912f | |||
| 5d7fa23825 | |||
| cba9358235 | |||
| 725879562e | |||
| 3b34181c54 | |||
| 7f4768c205 | |||
| d93ee5308c | |||
| d8f29e42e3 | |||
| 95b14cf86b | |||
| 1bbd653d34 | |||
| b9eb69fc81 |
+1428
File diff suppressed because it is too large
Load Diff
Generated
+86
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dxf-parser": "^1.1.2",
|
"dxf-parser": "^1.1.2",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
|
"pixi.js": "^8.20.1",
|
||||||
"rbush": "^4.0.1",
|
"rbush": "^4.0.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -661,6 +662,11 @@
|
|||||||
"pako": "^1.0.10"
|
"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": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.62.0",
|
"version": "1.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
|
||||||
@@ -1094,6 +1100,11 @@
|
|||||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -1257,6 +1268,19 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"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": {
|
"node_modules/abstract-leveldown": {
|
||||||
"version": "6.2.3",
|
"version": "6.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz",
|
||||||
@@ -1559,6 +1583,11 @@
|
|||||||
"loglevel": "^1.7.1"
|
"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": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.378",
|
"version": "1.5.378",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz",
|
||||||
@@ -1629,6 +1658,11 @@
|
|||||||
"@types/estree": "^1.0.0"
|
"@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": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
@@ -1678,6 +1712,14 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/html-encoding-sniffer": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||||
@@ -1737,6 +1779,11 @@
|
|||||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/isomorphic.js": {
|
||||||
"version": "0.2.5",
|
"version": "0.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
|
||||||
@@ -1746,6 +1793,11 @@
|
|||||||
"url": "https://github.com/sponsors/dmonad"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
|
"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": {
|
"node_modules/parse5": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||||
@@ -2424,6 +2481,27 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/playwright": {
|
||||||
"version": "1.62.0",
|
"version": "1.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
|
||||||
@@ -2754,6 +2832,14 @@
|
|||||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dxf-parser": "^1.1.2",
|
"dxf-parser": "^1.1.2",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
|
"pixi.js": "^8.20.1",
|
||||||
"rbush": "^4.0.1",
|
"rbush": "^4.0.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Phase-A-Verifikation (ROADMAP Übergabepunkt):
|
||||||
|
* Beweist mit echtem Chromium bei gesetzttem VITE_TOOL_DISPATCHER=v2:
|
||||||
|
* 1. Ribbon-V2-Sektion rendert die registrierten V2-Werkzeuge
|
||||||
|
* 2. Werkzeugwechsel über den v2tool:-Kanal funktioniert
|
||||||
|
* 3. Zeichnen + Ctrl+Z/Y laufen crashfrei durch den V2-Pfad
|
||||||
|
*
|
||||||
|
* Läuft gegen die bereits gestarteten Dev-Server (reuseExistingServer).
|
||||||
|
*/
|
||||||
|
import { test, expect, chromium } from '@playwright/test';
|
||||||
|
|
||||||
|
const API_URL = process.env.API_URL || 'http://localhost:3001';
|
||||||
|
const BASE_URL = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
|
|
||||||
|
async function api(method: string, endpoint: string, body?: unknown, token?: string) {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
const res = await fetch(`${API_URL}${endpoint}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
return { status: res.status, body: await res.json() };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Phase-A: V2-Pfad end-to-end (Ribbon-Sektion, Toolwechsel, Zeichnen+Undo crashfrei)', async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
|
||||||
|
|
||||||
|
// ── Auth & Navigation (Muster aus diagnose-editor.test.ts) ──
|
||||||
|
const email = `pa-verify-${Date.now()}@example.com`;
|
||||||
|
const reg = await api('POST', '/api/auth/register', { email, password: 'Test1234!', name: 'PA' });
|
||||||
|
expect(reg.status).toBe(201);
|
||||||
|
const token = reg.body.session.token;
|
||||||
|
|
||||||
|
const proj = await api('POST', '/api/projects', { name: `PA-${Date.now()}`, description: 'x' }, token);
|
||||||
|
expect(proj.status).toBe(201);
|
||||||
|
|
||||||
|
await page.goto(`${BASE_URL}/`);
|
||||||
|
await page.evaluate((t) => localStorage.setItem('auth_token', t), token);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await page.waitForSelector('.dashboard-project-card', { timeout: 15000 });
|
||||||
|
await page.locator('.dashboard-project-card').first().click();
|
||||||
|
await page.waitForSelector('canvas', { timeout: 15000 });
|
||||||
|
await page.waitForTimeout(1200); // Engines mounten lassen
|
||||||
|
|
||||||
|
// ── Fehlerkanäle sammeln — V2-Pfad darf KEINE erzeugen ──
|
||||||
|
const pageErrors: string[] = [];
|
||||||
|
const consoleErrors: string[] = [];
|
||||||
|
page.on('pageerror', (err) => pageErrors.push(err.message));
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 1) Ribbon: Zeichenflächen-Tab öffnen und V2-Sektion prüfen ──
|
||||||
|
await page.locator('.ribbon-tab', { hasText: 'Zeichenfläche' }).first().click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
const v2Section = page.locator('text=V2:').first();
|
||||||
|
await expect(v2Section).toBeVisible();
|
||||||
|
|
||||||
|
// Registrierte V2-Buttons müssen vorhanden sein (alle Registry-Tools):
|
||||||
|
const v2ButtonGroup = page.locator('[data-v2tool]');
|
||||||
|
const buttonCount = await v2ButtonGroup.count();
|
||||||
|
expect(buttonCount).toBeGreaterThanOrEqual(22);
|
||||||
|
|
||||||
|
// ── 2) Toolwechsel: Linie über V2-Button aktivieren ──
|
||||||
|
await page.locator('[data-v2tool="line"]').first().click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// ── 3) Zeichnen auf dem Canvas (Zug in der Mitte) ──
|
||||||
|
const canvas = page.locator('canvas').first();
|
||||||
|
const box = await canvas.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
const cx = box!.x + box!.width / 2;
|
||||||
|
const cy = box!.y + box!.height / 2;
|
||||||
|
|
||||||
|
await page.mouse.move(cx - 100, cy - 50);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(cx + 100, cy + 50, { steps: 8 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
// ── 4) Undo/Redo Shortcuts crashfrei ──
|
||||||
|
await page.keyboard.press('Control+z');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.keyboard.press('Control+y');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// ── 4b) Task C4: PixiRenderer hat die Elemente im Cache ──
|
||||||
|
const pixiInfo = await page.evaluate(() => {
|
||||||
|
const debug = (window as unknown as { __v2Debug?: { info(): { cachedElements: number; ready: boolean } | null } }).__v2Debug;
|
||||||
|
return debug?.info() ?? null;
|
||||||
|
});
|
||||||
|
expect(pixiInfo).not.toBeNull();
|
||||||
|
expect(pixiInfo!.ready).toBe(true);
|
||||||
|
expect(pixiInfo!.cachedElements).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Nach Undo muss der Cache leer sein (Referenzdiff synchronisiert)
|
||||||
|
await page.keyboard.press('Control+z');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const pixiInfoAfterUndo = await page.evaluate(() => {
|
||||||
|
const debug = (window as unknown as { __v2Debug?: { info(): { cachedElements: number; ready: boolean } | null } }).__v2Debug;
|
||||||
|
return debug?.info() ?? null;
|
||||||
|
});
|
||||||
|
expect(pixiInfoAfterUndo!.cachedElements).toBe(0);
|
||||||
|
|
||||||
|
// Kein Seitenfehler durch den gesamten V2-Ablauf:
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
// Konsolenfehler nur tolerieren, wenn sie nichts mit dem V2-Pfad zu tun haben:
|
||||||
|
const v2Related = consoleErrors.filter((e) => /dispatcher|v2tool|undo/i.test(e));
|
||||||
|
expect(v2Related).toEqual([]);
|
||||||
|
|
||||||
|
// ── 5) Sichtbelege ──
|
||||||
|
await page.screenshot({ path: 'playwright-tests/screenshots/phase-a-canvas.png', fullPage: false });
|
||||||
|
await page.keyboard.press('Control+z');
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
await page.screenshot({ path: 'playwright-tests/screenshots/phase-a-after-undo.png', fullPage: false });
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
+22
-2
@@ -18,6 +18,8 @@ import StatusBar from './components/StatusBar';
|
|||||||
import MobileDrawers from './components/MobileDrawers';
|
import MobileDrawers from './components/MobileDrawers';
|
||||||
import BackgroundImport from './components/BackgroundImport';
|
import BackgroundImport from './components/BackgroundImport';
|
||||||
import HistoryPanel from './components/HistoryPanel';
|
import HistoryPanel from './components/HistoryPanel';
|
||||||
|
import V2HistoryPanel from './components/V2HistoryPanel';
|
||||||
|
import { V2_TOOLS_ENABLED } from './kernel/featureFlags';
|
||||||
import SettingsModal from './components/SettingsModal';
|
import SettingsModal from './components/SettingsModal';
|
||||||
import InlineTextEditor from './components/InlineTextEditor';
|
import InlineTextEditor from './components/InlineTextEditor';
|
||||||
import { BackgroundService, type BackgroundConfig } from './services/backgroundService';
|
import { BackgroundService, type BackgroundConfig } from './services/backgroundService';
|
||||||
@@ -1147,6 +1149,15 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
if (action === 'measure') { setActiveTool('dimension'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Messwerkzeug aktiv', type: 'info' }]); return; }
|
if (action === 'measure') { setActiveTool('dimension'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Messwerkzeug aktiv', type: 'info' }]); return; }
|
||||||
if (action === 'history') { setHistoryPanelOpen((prev) => !prev); return; }
|
if (action === 'history') { setHistoryPanelOpen((prev) => !prev); return; }
|
||||||
|
|
||||||
|
// ─── V2 tools (Task A4.13): 'v2tool:<id>' → aktives Tool umschalten. Der
|
||||||
|
// bestehende CanvasArea-Sync aus A4.1 routet es an den Dispatcher (Flag aktiv).
|
||||||
|
if (action.startsWith('v2tool:')) {
|
||||||
|
const toolId = action.slice('v2tool:'.length);
|
||||||
|
setActiveTool(toolId);
|
||||||
|
setCommandHistory((prev) => [...prev, { prefix: '·', text: `V2-Werkzeug aktiv: ${toolId}`, type: 'info' }]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Background ───
|
// ─── Background ───
|
||||||
if (action === 'background-import') { setBgImportOpen(true); return; }
|
if (action === 'background-import') { setBgImportOpen(true); return; }
|
||||||
|
|
||||||
@@ -1563,6 +1574,11 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
activeTab={activeRibbonTab}
|
activeTab={activeRibbonTab}
|
||||||
onTabChange={setActiveRibbonTab}
|
onTabChange={setActiveRibbonTab}
|
||||||
onAction={handleRibbonAction}
|
onAction={handleRibbonAction}
|
||||||
|
onIsV2ToolActive={(toolId: string) =>
|
||||||
|
V2_TOOLS_ENABLED &&
|
||||||
|
activeTool === toolId &&
|
||||||
|
!!pluginRegistry.getToolV2(toolId)
|
||||||
|
}
|
||||||
canvasBgColor={canvasBgColor}
|
canvasBgColor={canvasBgColor}
|
||||||
gridColor={gridColor}
|
gridColor={gridColor}
|
||||||
rulerEnabled={rulerEnabled}
|
rulerEnabled={rulerEnabled}
|
||||||
@@ -1728,7 +1744,11 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
onApply={handleBgApply}
|
onApply={handleBgApply}
|
||||||
backgroundService={bgServiceRef.current}
|
backgroundService={bgServiceRef.current}
|
||||||
/>
|
/>
|
||||||
{historyPanelOpen && (
|
{historyPanelOpen && (V2_TOOLS_ENABLED ? (
|
||||||
|
<V2HistoryPanel
|
||||||
|
onClose={() => setHistoryPanelOpen(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<HistoryPanel
|
<HistoryPanel
|
||||||
entries={historyEntries}
|
entries={historyEntries}
|
||||||
onJumpTo={(entryId: string) => {
|
onJumpTo={(entryId: string) => {
|
||||||
@@ -1741,7 +1761,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
}}
|
}}
|
||||||
onClose={() => setHistoryPanelOpen(false)}
|
onClose={() => setHistoryPanelOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
))}
|
||||||
{exportFormatOpen && (
|
{exportFormatOpen && (
|
||||||
<div className="export-format-overlay" onClick={() => setExportFormatOpen(false)}>
|
<div className="export-format-overlay" onClick={() => setExportFormatOpen(false)}>
|
||||||
<div className="export-format-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="export-format-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ export class RenderEngine {
|
|||||||
private snapPoints: SnapPoint[] = [];
|
private snapPoints: SnapPoint[] = [];
|
||||||
private activeSnapPoint: SnapPoint | null = null;
|
private activeSnapPoint: SnapPoint | null = null;
|
||||||
private previewElement: CADElement | null = null;
|
private previewElement: CADElement | null = null;
|
||||||
|
/** Gecachtes Hintergrundbild (BUG-4-Fix): nur 1× pro backgroundSrc laden. */
|
||||||
|
private bgImage: HTMLImageElement | null = null;
|
||||||
|
/** src des zuletzt erfolgreich geladenen Hintergrundbilds. */
|
||||||
|
private bgSrcLoaded = '';
|
||||||
private dpr = 1;
|
private dpr = 1;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -379,13 +383,30 @@ export class RenderEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private drawBackground(): void {
|
private drawBackground(): void {
|
||||||
// Background image rendering with transform
|
// Background image rendering with transform.
|
||||||
|
// BUG-4-Fix: Das Bild wird nur EINMAL pro backgroundSrc geladen und gecacht;
|
||||||
|
// vorher wurde pro Frame ein neues Image erzeugt (Flackern/Rerender-Schleife).
|
||||||
|
const src = this.options.backgroundSrc!;
|
||||||
|
|
||||||
|
// Quellwechsel: Cache invalidieren und neu laden
|
||||||
|
if (!this.bgImage || this.bgSrcLoaded !== src) {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.src = this.options.backgroundSrc!;
|
img.onload = () => {
|
||||||
if (!img.complete) {
|
this.bgImage = img;
|
||||||
img.onload = () => this.render();
|
this.bgSrcLoaded = src;
|
||||||
|
this.render(); // einmalig nach erfolgreichem Laden neu zeichnen
|
||||||
|
};
|
||||||
|
img.src = src;
|
||||||
|
if (img.complete && img.naturalWidth > 0) {
|
||||||
|
// Bereits aus Browser-Cache verfügbar (z.B. dataURL)
|
||||||
|
this.bgImage = img;
|
||||||
|
this.bgSrcLoaded = src;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gecachtes Bild zeichnen
|
||||||
|
const img = this.bgImage;
|
||||||
this.ctx.save();
|
this.ctx.save();
|
||||||
this.ctx.globalAlpha = this.options.backgroundOpacity;
|
this.ctx.globalAlpha = this.options.backgroundOpacity;
|
||||||
const s = this.zoomPan.getScale();
|
const s = this.zoomPan.getScale();
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ import { SpatialIndex } from '../canvas/SpatialIndex';
|
|||||||
import { LayerManager } from '../canvas/LayerManager';
|
import { LayerManager } from '../canvas/LayerManager';
|
||||||
import { GroupManager } from '../tools/modification/GroupTool';
|
import { GroupManager } from '../tools/modification/GroupTool';
|
||||||
import { formatCoordinate } from '../utils/format';
|
import { formatCoordinate } from '../utils/format';
|
||||||
|
import { InteractionDispatcher } from '../interaction/dispatcher';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
import { CADDocument } from '../kernel/document/CADDocument';
|
||||||
|
import { YjsDocument } from '../crdt/YjsDocument';
|
||||||
|
import { setActiveDocument, getActiveDocument } from '../kernel/document/documentService';
|
||||||
|
import { V2_TOOLS_ENABLED, USE_PIXI_RENDERER } from '../kernel/featureFlags';
|
||||||
|
import { PixiRenderer } from '../render/pixi/PixiRenderer';
|
||||||
|
import { pluginRegistry } from '../plugins';
|
||||||
|
|
||||||
const CanvasArea: React.FC<CanvasAreaProps> = ({
|
const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||||
cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled,
|
cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled,
|
||||||
@@ -26,6 +34,13 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
const zoomPanRef = useRef<ZoomPanController | null>(null);
|
const zoomPanRef = useRef<ZoomPanController | null>(null);
|
||||||
const renderEngineRef = useRef<RenderEngine | null>(null);
|
const renderEngineRef = useRef<RenderEngine | null>(null);
|
||||||
const interactionRef = useRef<InteractionEngine | null>(null);
|
const interactionRef = useRef<InteractionEngine | null>(null);
|
||||||
|
const dispatcherRef = useRef<InteractionDispatcher | null>(null);
|
||||||
|
/** Task C2: PixiJS-Canvas-Ref für V2-Elemente-Overlay. */
|
||||||
|
const pixiCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
/** Task C2: Aktiver PixiJS-Renderer (für Cleanup). */
|
||||||
|
const pixiRendererRef = useRef<PixiRenderer | null>(null);
|
||||||
|
/** Bereits an onElementCreated gemeldete V2-Element-IDs (verhindert Doppelmeldung bei jedem onChanged-Frame). */
|
||||||
|
const announcedV2Ids = useRef<Set<string>>(new Set());
|
||||||
const spatialIndexRef = useRef<SpatialIndex | null>(null);
|
const spatialIndexRef = useRef<SpatialIndex | null>(null);
|
||||||
const layerManagerRef = useRef<LayerManager | null>(null);
|
const layerManagerRef = useRef<LayerManager | null>(null);
|
||||||
const selectionEngineRef = useRef<SelectionEngine | null>(null);
|
const selectionEngineRef = useRef<SelectionEngine | null>(null);
|
||||||
@@ -44,6 +59,128 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
canvas, zoomPan, renderEngine, snapEngine, selectionEngine, spatialIndex, layerManager,
|
canvas, zoomPan, renderEngine, snapEngine, selectionEngine, spatialIndex, layerManager,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Task A2/A4.14: V2-Pfad ist seit Verifikation STANDARD — Opt-out via
|
||||||
|
// VITE_TOOL_DISPATCHER=legacy. Siehe kernel/featureFlags.ts.
|
||||||
|
if (V2_TOOLS_ENABLED) {
|
||||||
|
const ydoc = new YjsDocument();
|
||||||
|
ydoc.data.layers.set('layer-0', {
|
||||||
|
id: 'layer-0', name: 'Standard', visible: true, locked: false,
|
||||||
|
color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null,
|
||||||
|
} as never);
|
||||||
|
const v2Doc = new CADDocument(ydoc);
|
||||||
|
// App-Level-Zugriffspunkt für HistoryPanel/Tastenkürzel (Task A4.11):
|
||||||
|
setActiveDocument(v2Doc);
|
||||||
|
|
||||||
|
// ── Task C2/C4: PixiJS-Renderer parallel schalten ──
|
||||||
|
// DEFENSIV: Ein Ausfall von Pixi (z.B. WebGL im Headless-Browser)
|
||||||
|
// darf NIEMALS den kompletten Editor-Mount brechen. Im Fehlerfall
|
||||||
|
// läuft weiter ohne Overlay.
|
||||||
|
let pixi: PixiRenderer | null = null;
|
||||||
|
const pixiCanvas = pixiCanvasRef.current;
|
||||||
|
if (pixiCanvas && USE_PIXI_RENDERER) {
|
||||||
|
try {
|
||||||
|
pixi = new PixiRenderer();
|
||||||
|
pixiRendererRef.current = pixi;
|
||||||
|
pixi.init(pixiCanvas);
|
||||||
|
pixi.waitForReady().then(() => {
|
||||||
|
// Task C4: Debug-Hook für Playwright-E2E-Assertions
|
||||||
|
(window as unknown as { __v2Debug: unknown }).__v2Debug = {
|
||||||
|
renderer: pixi,
|
||||||
|
info: () => pixiRendererRef.current?.getDebugInfo() ?? null,
|
||||||
|
};
|
||||||
|
}).catch((err) => {
|
||||||
|
logger.warn('Pixi init failed — V2-Overlay deaktiviert:', err);
|
||||||
|
pixi = null;
|
||||||
|
pixiRendererRef.current = null;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Pixi init exception — V2-Overlay deaktiviert:', err);
|
||||||
|
pixi = null;
|
||||||
|
pixiRendererRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Letztes bekanntes Elementobjekt je ID (Referenzdiff statt JSON). */
|
||||||
|
const lastKnownEl = new Map<string, CADElement>();
|
||||||
|
const syncPixi = (): void => {
|
||||||
|
if (!pixi) return;
|
||||||
|
void pixi.waitForReady().then(() => {
|
||||||
|
const all = v2Doc.getAllElements();
|
||||||
|
const visibleIds = new Set<string>();
|
||||||
|
for (const el of all) {
|
||||||
|
visibleIds.add(el.id);
|
||||||
|
// Nur neu zeichnen wenn sich die OBJEKTREFERENZ geändert hat:
|
||||||
|
if (lastKnownEl.get(el.id) !== el) {
|
||||||
|
lastKnownEl.set(el.id, el);
|
||||||
|
pixi!.drawElement(el, {
|
||||||
|
id: 'layer-0', name: 'Standard', visible: true, locked: false,
|
||||||
|
color: '#ffffff', lineType: 'solid', transparency: 0,
|
||||||
|
sortOrder: 0, parentId: null,
|
||||||
|
} as never, {
|
||||||
|
color: '#ffffff', lineType: 'solid', transparency: 0, visible: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Gelöschte IDs aus dem Renderer entfernen:
|
||||||
|
for (const knownId of lastKnownEl.keys()) {
|
||||||
|
if (!visibleIds.has(knownId)) {
|
||||||
|
lastKnownEl.delete(knownId);
|
||||||
|
pixi!.removeElement(knownId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Committete V2-Elemente fließen über den etablierten Kanal in UI/State:
|
||||||
|
v2Doc.onChanged(() => {
|
||||||
|
for (const el of v2Doc.getAllElements()) {
|
||||||
|
if (!announcedV2Ids.current.has(el.id)) {
|
||||||
|
announcedV2Ids.current.add(el.id);
|
||||||
|
onElementCreated(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syncPixi(); // Task C2: V2-Elemente in den PixiJS-Overlay-Renderer spiegeln
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatcherRef.current = new InteractionDispatcher(canvas, {
|
||||||
|
zoomPan,
|
||||||
|
setStatus: (msg) => {
|
||||||
|
/* StatusBar-Bridge folgt in A4 */
|
||||||
|
},
|
||||||
|
setPreview: (el) => renderEngine.setPreviewElement(el as CADElement | null),
|
||||||
|
getDoc: () => v2Doc,
|
||||||
|
getSelectionBridge: () => ({
|
||||||
|
getIds: () => Array.from(selectionEngine.getSelectedIds()),
|
||||||
|
setIds: (ids) => selectionEngine.selectByIds(ids, false),
|
||||||
|
hitTest: (wx, wy, tol) => renderEngine.hitTest(wx, wy, tol),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Task C3fin: Snap-Marks + Selection-Handles im Pixi-Overlay ──
|
||||||
|
if (pixi && USE_PIXI_RENDERER) {
|
||||||
|
// Snap-Marks bei Mausbewegung (wenn ein V2-Tool aktiv ist):
|
||||||
|
const snapMove = (e: PointerEvent) => {
|
||||||
|
if (!dispatcherRef.current?.getActive()) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const world = zoomPan.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||||
|
const snapResult = snapEngine.snap(world.x, world.y);
|
||||||
|
const marks = snapResult.point
|
||||||
|
? [{ x: snapResult.point.x, y: snapResult.point.y, type: (snapResult.point as { type?: string }).type ?? 'snap' }]
|
||||||
|
: [];
|
||||||
|
pixi!.setSnapPoints(marks);
|
||||||
|
};
|
||||||
|
canvas.addEventListener('pointermove', snapMove);
|
||||||
|
|
||||||
|
// Selection-Handles bei Änderung:
|
||||||
|
const origSetIds = selectionEngine.selectByIds.bind(selectionEngine);
|
||||||
|
selectionEngine.selectByIds = (ids: string[], additive: boolean) => {
|
||||||
|
origSetIds(ids, additive);
|
||||||
|
const selected = v2Doc.getAllElements().filter((el) => ids.includes(el.id));
|
||||||
|
pixi!.setSelection({ selectedIds: ids, elements: selected });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
logger.info('InteractionDispatcher v2 aktiviert (VITE_TOOL_DISPATCHER=v2)');
|
||||||
|
}
|
||||||
|
|
||||||
zoomPanRef.current = zoomPan;
|
zoomPanRef.current = zoomPan;
|
||||||
renderEngineRef.current = renderEngine;
|
renderEngineRef.current = renderEngine;
|
||||||
interactionRef.current = interaction;
|
interactionRef.current = interaction;
|
||||||
@@ -66,6 +203,11 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
interaction.detach();
|
interaction.detach();
|
||||||
|
dispatcherRef.current?.destroy();
|
||||||
|
dispatcherRef.current = null;
|
||||||
|
pixiRendererRef.current?.destroy(); // Task C2: WebGL-Kontext freigeben
|
||||||
|
pixiRendererRef.current = null;
|
||||||
|
setActiveDocument(null);
|
||||||
zoomPanRef.current = null;
|
zoomPanRef.current = null;
|
||||||
renderEngineRef.current = null;
|
renderEngineRef.current = null;
|
||||||
interactionRef.current = null;
|
interactionRef.current = null;
|
||||||
@@ -229,9 +371,34 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
const interaction = interactionRef.current;
|
const interaction = interactionRef.current;
|
||||||
if (!interaction) return;
|
if (!interaction) return;
|
||||||
interaction.setTool(activeTool as ToolType);
|
interaction.setTool(activeTool as ToolType);
|
||||||
|
// Task A4.1: V2-Werkzeuge zusätzlich an den Dispatcher routen (Flag aktiv).
|
||||||
|
// Legacy bleibt parallel aktiv für noch nicht migrierte Tools.
|
||||||
|
dispatcherRef.current?.setActive(
|
||||||
|
pluginRegistry.getToolV2(activeTool) ? activeTool : null,
|
||||||
|
);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeTool]);
|
}, [activeTool]);
|
||||||
|
|
||||||
|
// Task A4.11: Globale Undo/Redo-Shortcuts wirken nur auf das V2-Dokument,
|
||||||
|
// wenn es registriert ist (= Flag aktiv). Legacy-History bleibt unberührt.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (!(e.ctrlKey || e.metaKey)) return;
|
||||||
|
const doc = getActiveDocument();
|
||||||
|
if (!doc) return; // Flag aus → Legacy-Verhalten unangetastet
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
if (key === 'z' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (doc.undo()) logger.info('V2 undo');
|
||||||
|
} else if (key === 'y' || (key === 'z' && e.shiftKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (doc.redo()) logger.info('V2 redo');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Sync selected template to interaction engine
|
// Sync selected template to interaction engine
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interaction = interactionRef.current;
|
const interaction = interactionRef.current;
|
||||||
@@ -338,6 +505,22 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
|||||||
<span style={{ color: 'var(--color-text-faint)' }}>{unit}</span>
|
<span style={{ color: 'var(--color-text-faint)' }}>{unit}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Task C2-Integration: PixiJS-Overlay für V2-Elemente — liegt ÜBER dem
|
||||||
|
Legacy-Canvas (z-index höher), rendert nur Elemente aus dem V2-Dokument.
|
||||||
|
Pointer-Events gehen durch (pointerEvents:none), damit Drag/Drop weiter
|
||||||
|
auf dem Legacy-Canvas funktioniert. */}
|
||||||
|
{V2_TOOLS_ENABLED && (
|
||||||
|
<canvas
|
||||||
|
ref={pixiCanvasRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="canvas-svg"
|
className="canvas-svg"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { RibbonBarProps, RibbonTab } from '../types/ui.types';
|
import type { RibbonBarProps, RibbonTab } from '../types/ui.types';
|
||||||
|
import { pluginRegistry } from '../plugins';
|
||||||
|
import { V2_TOOLS_ENABLED } from '../kernel/featureFlags';
|
||||||
|
|
||||||
const tabs: Array<{ id: RibbonTab; label: string; svg: React.ReactNode }> = [
|
const tabs: Array<{ id: RibbonTab; label: string; svg: React.ReactNode }> = [
|
||||||
{ id: 'start', label: 'Start', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg> },
|
{ id: 'start', label: 'Start', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg> },
|
||||||
@@ -15,6 +17,7 @@ const RibbonBar: React.FC<RibbonBarProps> = ({
|
|||||||
activeTab, onTabChange, onAction,
|
activeTab, onTabChange, onAction,
|
||||||
canvasBgColor = '#1e1e2e', gridColor = '#3a3a4a', rulerEnabled = true,
|
canvasBgColor = '#1e1e2e', gridColor = '#3a3a4a', rulerEnabled = true,
|
||||||
onCanvasBgColorChange, onGridColorChange, onToggleRuler,
|
onCanvasBgColorChange, onGridColorChange, onToggleRuler,
|
||||||
|
onIsV2ToolActive,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<nav className="ribbon" role="navigation" aria-label="Hauptwerkzeuge">
|
<nav className="ribbon" role="navigation" aria-label="Hauptwerkzeuge">
|
||||||
@@ -36,6 +39,38 @@ const RibbonBar: React.FC<RibbonBarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ribbon-content" id="ribbon-content">
|
<div className="ribbon-content" id="ribbon-content">
|
||||||
|
{/* ===== V2 TOOLS (Task A4.13/A4.14: Standard aktiv — Opt-out via VITE_TOOL_DISPATCHER=legacy) ===== */}
|
||||||
|
{activeTab === 'canvas' && V2_TOOLS_ENABLED && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center',
|
||||||
|
padding: '8px 4px', width: '100%',
|
||||||
|
borderBottom: '1px solid var(--color-border, #333)', marginBottom: '8px',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '11px', color: 'var(--color-ki, #8a8aff)', marginRight: '4px' }}>V2:</span>
|
||||||
|
{pluginRegistry.getToolsV2().map((tool) => {
|
||||||
|
const isActive = onIsV2ToolActive?.(tool.manifest.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tool.manifest.id}
|
||||||
|
data-v2tool={tool.manifest.id}
|
||||||
|
title={tool.manifest.description ?? tool.manifest.label}
|
||||||
|
onClick={() => onAction(`v2tool:${tool.manifest.id}`)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '4px',
|
||||||
|
padding: '5px 9px', borderRadius: '4px', fontSize: '12px',
|
||||||
|
cursor: 'pointer', border: '1px solid var(--color-border, #333)',
|
||||||
|
background: isActive ? 'rgba(80,250,123,0.15)' : 'var(--color-bg-secondary, #2a2a3a)',
|
||||||
|
color: isActive ? '#50fa7b' : 'inherit',
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontFamily: 'monospace' }}>{tool.manifest.icon}</span>
|
||||||
|
<span>{tool.manifest.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* ===== START TAB ===== */}
|
{/* ===== START TAB ===== */}
|
||||||
{activeTab === 'start' && (
|
{activeTab === 'start' && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* V2HistoryPanel – History-Anzeige für das V2-Dokument (Task A4.12).
|
||||||
|
*
|
||||||
|
* Liest die Undo/Redo-Stacks des aktiven Dokuments (documentService).
|
||||||
|
* Klick auf einen aktiven Eintrag: macht alles DANACH rückgängig.
|
||||||
|
* Redo-Einträge sind rein informativ (gezielter Sprung nicht nativ).
|
||||||
|
*
|
||||||
|
* Wird statt des Legacy HistoryPanel gerendert, wenn
|
||||||
|
* VITE_TOOL_DISPATCHER=v2 gesetzt ist (App.tsx entscheidet).
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { getActiveDocument, undoActive, redoActive } from '../kernel/document/documentService';
|
||||||
|
import type { CADDocument } from '../kernel/document/CADDocument';
|
||||||
|
|
||||||
|
type StackKind = 'undo' | 'redo';
|
||||||
|
interface StackItemInfo {
|
||||||
|
kind: StackKind;
|
||||||
|
index: number; // Position im jeweiligen Stack (0 = ältester)
|
||||||
|
}
|
||||||
|
|
||||||
|
const V2HistoryPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||||
|
const [doc, setDoc] = useState<CADDocument | null>(getActiveDocument());
|
||||||
|
const [, bump] = useState(0);
|
||||||
|
|
||||||
|
// Auf Dokumentwechsel reagieren + bei Doc-Änderung neu rendern
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubChange = doc?.onChanged(() => bump((b) => b + 1));
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const active = getActiveDocument();
|
||||||
|
if (active !== doc) setDoc(active);
|
||||||
|
}, 500); // einfacher Registry-Poll bis A5 einen Hook liefert
|
||||||
|
return () => {
|
||||||
|
unsubChange?.();
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [doc]);
|
||||||
|
|
||||||
|
const um = doc?.getUndoManager();
|
||||||
|
const undoCount = um ? um.undoStack.length : 0;
|
||||||
|
const redoCount = um ? um.redoStack.length : 0;
|
||||||
|
const items: Array<StackItemInfo & { isCurrent: boolean }> = [];
|
||||||
|
// Neueste zuerst auflisten:
|
||||||
|
for (let i = undoCount - 1; i >= 0; i--) {
|
||||||
|
items.push({ kind: 'undo', index: i, isCurrent: i === undoCount - 1 });
|
||||||
|
}
|
||||||
|
for (let i = redoCount - 1; i >= 0; i--) {
|
||||||
|
items.push({ kind: 'redo', index: i, isCurrent: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Macht alle Schritte nach slotIndex rückgängig (slot 0 = ältester). */
|
||||||
|
const undoUntilSlot = (slotIndex: number): void => {
|
||||||
|
if (!doc) return;
|
||||||
|
const targetLen = slotIndex + 1;
|
||||||
|
let guard = 0;
|
||||||
|
while (
|
||||||
|
doc.canUndo() &&
|
||||||
|
doc.getUndoManager().undoStack.length > targetLen &&
|
||||||
|
guard < 500
|
||||||
|
) {
|
||||||
|
doc.undo();
|
||||||
|
guard++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const shellStyle: React.CSSProperties = {
|
||||||
|
position: 'fixed', top: '50%', right: '320px', transform: 'translateY(-50%)',
|
||||||
|
background: 'var(--color-bg-primary, #1e1e2e)', borderRadius: '8px',
|
||||||
|
padding: '16px', width: '280px', maxHeight: '60vh', overflow: 'auto',
|
||||||
|
border: '1px solid var(--color-border, #333)', zIndex: 900,
|
||||||
|
color: 'var(--color-text, #e0e0e0)',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!doc || items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={shellStyle}>
|
||||||
|
<Header onClose={onClose} count={items.length} />
|
||||||
|
<p style={{ fontSize: '13px', color: '#888' }}>
|
||||||
|
{doc ? 'Keine Historie vorhanden.' : 'Kein V2-Dokument aktiv (Flag aus?).'}
|
||||||
|
</p>
|
||||||
|
<UndoRedoButtons doc={doc} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={shellStyle}>
|
||||||
|
<Header onClose={onClose} count={items.length} />
|
||||||
|
<UndoRedoButtons doc={doc} />
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={`${item.kind}-${item.index}`}
|
||||||
|
onClick={() => item.kind === 'undo' && undoUntilSlot(item.index)}
|
||||||
|
title={item.kind === 'undo'
|
||||||
|
? 'Klicken: alles danach rückgängig machen'
|
||||||
|
: 'Redo-Eintrag (nur anzeigbar)'}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '8px',
|
||||||
|
padding: '6px 8px', borderRadius: '4px',
|
||||||
|
cursor: item.kind === 'undo' ? 'pointer' : 'default',
|
||||||
|
border: item.isCurrent && item.kind === 'undo'
|
||||||
|
? '1px solid rgba(80,250,123,0.4)' : 'none',
|
||||||
|
textAlign: 'left', width: '100%',
|
||||||
|
background: item.isCurrent && item.kind === 'undo'
|
||||||
|
? 'rgba(80, 250, 123, 0.12)' : item.kind === 'redo'
|
||||||
|
? 'rgba(255,255,255,0.03)' : 'transparent',
|
||||||
|
color: item.kind === 'redo' ? '#888' : 'var(--color-text, #e0e0e0)',
|
||||||
|
fontWeight: item.isCurrent && item.kind === 'undo' ? 600 : 400,
|
||||||
|
fontSize: '13px', opacity: item.kind === 'redo' ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '10px', minWidth: '44px', color: '#666' }}>
|
||||||
|
{item.kind === 'undo' ? `Schritt ${item.index + 1}` : 'Redo'}
|
||||||
|
</span>
|
||||||
|
{item.isCurrent && item.kind === 'undo' && (
|
||||||
|
<span style={{ fontSize: '10px', color: '#50fa7b' }}>● aktuell</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function Header({ onClose, count }: { onClose: () => void; count: number }): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '15px' }}>Historie V2{count > 0 ? ` (${count})` : ''}</h3>
|
||||||
|
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', fontSize: '18px' }}>×</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UndoRedoButtons({ doc }: { doc: CADDocument | null }): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginBottom: doc && (doc.canUndo() || doc.canRedo()) ? '10px' : '4px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => undoActive()}
|
||||||
|
disabled={!doc?.canUndo()}
|
||||||
|
title="Strg+Z"
|
||||||
|
style={btnStyle(doc?.canUndo() ?? false)}
|
||||||
|
>↶ Undo</button>
|
||||||
|
<button
|
||||||
|
onClick={() => redoActive()}
|
||||||
|
disabled={!doc?.canRedo()}
|
||||||
|
title="Strg+Y"
|
||||||
|
style={btnStyle(doc?.canRedo() ?? false)}
|
||||||
|
>↷ Redo</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function btnStyle(enabled: boolean): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
padding: '5px 12px', borderRadius: '4px', fontSize: '12px',
|
||||||
|
cursor: enabled ? 'pointer' : 'not-allowed',
|
||||||
|
border: '1px solid var(--color-border, #333)',
|
||||||
|
background: 'var(--color-bg-secondary, #2a2a3a)',
|
||||||
|
color: enabled ? 'inherit' : '#555',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default V2HistoryPanel;
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* dispatcher.ts – InteractionDispatcher (Task A2).
|
||||||
|
*
|
||||||
|
* Ersetzt langfristig den 1157-Zeilen-Switch der InteractionEngine:
|
||||||
|
* - Routet DOM-Pointer-/Key-Events an das AKTIVE V2-Werkzeug (ToolExtensionV2)
|
||||||
|
* - Normalisiert Events (pointer.ts) zu Weltkoordinaten
|
||||||
|
* - Führt eine ToolSession als Down/Up-Guard
|
||||||
|
* - Kein Werkzeugwissen! Alle Logik liegt in den Plugins.
|
||||||
|
*
|
||||||
|
* Aktivierung per Feature-Flag VITE_TOOL_DISPATCHER=v2 (CanvasArea, A2 Schritt 4).
|
||||||
|
*/
|
||||||
|
import type { ToolExtensionV2, ToolContext } from '../plugins/types';
|
||||||
|
import { pluginRegistry } from '../plugins/PluginRegistry';
|
||||||
|
import { toToolPointerEvent } from './pointer';
|
||||||
|
import { ToolSession } from './session';
|
||||||
|
import type { ZoomPanController } from '../canvas/ZoomPanController';
|
||||||
|
|
||||||
|
export interface DispatcherDeps {
|
||||||
|
zoomPan: ZoomPanController;
|
||||||
|
/** Liefert Optionsstände je ToolId (typisch: toolStore.optionValues). */
|
||||||
|
getOptions?(toolId: string): Record<string, unknown>;
|
||||||
|
/** Statuszeile (typisch: StatusBar-Bridge). */
|
||||||
|
setStatus?(msg: string): void;
|
||||||
|
/** Live-Vorschau-Ziel (typisch RenderEngine.setPreviewElement). */
|
||||||
|
setPreview?(el: unknown | null): void;
|
||||||
|
/** Tool-Nachschlag überschreibbar (Default: aktive Registry-Tools). */
|
||||||
|
toolLookup?(toolId: string): ToolExtensionV2 | undefined;
|
||||||
|
/** Dokument-Fassade für Tools (ab A3; lazy pro Event abgefragt). */
|
||||||
|
getDoc?(): import('../kernel/document/CADDocument').CADDocument | undefined;
|
||||||
|
/** Selektions-Bridge für Tools (ab A3; lazy pro Event abgefragt). */
|
||||||
|
getSelectionBridge?(): import('../plugins/types').ToolSelectionBridge;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InteractionDispatcher {
|
||||||
|
private readonly canvas: HTMLCanvasElement;
|
||||||
|
private readonly deps: DispatcherDeps;
|
||||||
|
private readonly session = new ToolSession();
|
||||||
|
private active: ToolExtensionV2 | null = null;
|
||||||
|
private bound = false;
|
||||||
|
|
||||||
|
/** Zuletzt gesetzter Status-Text (für Tests/Debug). */
|
||||||
|
lastStatus = '';
|
||||||
|
|
||||||
|
constructor(canvas: HTMLCanvasElement, deps: DispatcherDeps) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.deps = deps;
|
||||||
|
this.attach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Setzt das aktive Werkzeug (null = deaktiviert Routing). */
|
||||||
|
setActive(toolId: string | null): void {
|
||||||
|
if (this.active && this.session.isActive) {
|
||||||
|
// Sitzung sauber abbrechen, bevor gewechselt wird
|
||||||
|
this.active.handlers.cancel?.(this.buildContext());
|
||||||
|
this.session.end();
|
||||||
|
}
|
||||||
|
if (!toolId) {
|
||||||
|
this.active = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lookup = this.deps.toolLookup ?? ((id: string) => pluginRegistry.getToolV2(id));
|
||||||
|
const tool = lookup(toolId);
|
||||||
|
if (!tool) {
|
||||||
|
console.warn(`[InteractionDispatcher] kein aktives V2-Tool '${toolId}'`);
|
||||||
|
this.active = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.active = tool;
|
||||||
|
this.setStatus(`${tool.manifest.label} aktiv`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aktives Werkzeug (oder null). */
|
||||||
|
getActive(): ToolExtensionV2 | null {
|
||||||
|
return this.active;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt alle Event-Listener (aufräumen beim Unmount). */
|
||||||
|
destroy(): void {
|
||||||
|
if (!this.bound) return;
|
||||||
|
this.canvas.removeEventListener('pointerdown', this.onDown);
|
||||||
|
window.removeEventListener('pointermove', this.onMove);
|
||||||
|
window.removeEventListener('pointerup', this.onUp);
|
||||||
|
window.removeEventListener('keydown', this.onKey);
|
||||||
|
this.bound = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── intern ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
private attach(): void {
|
||||||
|
if (this.bound) return;
|
||||||
|
this.canvas.addEventListener('pointerdown', this.onDown);
|
||||||
|
window.addEventListener('pointermove', this.onMove);
|
||||||
|
window.addEventListener('pointerup', this.onUp);
|
||||||
|
window.addEventListener('keydown', this.onKey);
|
||||||
|
this.bound = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildContext(): import('../plugins/types').FullToolContext {
|
||||||
|
const toolId = this.active?.manifest.id ?? '';
|
||||||
|
return {
|
||||||
|
options: this.deps.getOptions?.(toolId) ?? {},
|
||||||
|
setStatus: (msg) => this.setStatus(msg),
|
||||||
|
setPreview: (el) => this.deps.setPreview?.(el),
|
||||||
|
doc: this.deps.getDoc?.(),
|
||||||
|
selection: this.deps.getSelectionBridge?.(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private setStatus(msg: string): void {
|
||||||
|
this.lastStatus = msg;
|
||||||
|
this.deps.setStatus?.(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onDown = (e: PointerEvent): void => {
|
||||||
|
if (!this.active) return;
|
||||||
|
// Rechtsklick = Abbruch wie ESC
|
||||||
|
if (e.button === 2) {
|
||||||
|
this.cancelActive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tpe = toToolPointerEvent(e, this.canvas, this.deps.zoomPan);
|
||||||
|
this.session.begin();
|
||||||
|
this.active.handlers.down?.(tpe, this.buildContext());
|
||||||
|
};
|
||||||
|
|
||||||
|
private onMove = (e: PointerEvent): void => {
|
||||||
|
if (!this.active || !this.session.isActive) return;
|
||||||
|
const tpe = toToolPointerEvent(e, this.canvas, this.deps.zoomPan);
|
||||||
|
this.active.handlers.move?.(tpe, this.buildContext());
|
||||||
|
};
|
||||||
|
|
||||||
|
private onUp = (e: PointerEvent): void => {
|
||||||
|
if (!this.active || !this.session.isActive) return;
|
||||||
|
const tpe = toToolPointerEvent(e, this.canvas, this.deps.zoomPan);
|
||||||
|
try {
|
||||||
|
this.active.handlers.up?.(tpe, this.buildContext());
|
||||||
|
} finally {
|
||||||
|
this.session.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onKey = (e: KeyboardEvent): void => {
|
||||||
|
if (!this.active) return;
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
this.cancelActive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const consumed = this.active.handlers.key?.(e, this.buildContext()) === true;
|
||||||
|
if (consumed) e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
private cancelActive(): void {
|
||||||
|
if (!this.active) return;
|
||||||
|
this.active.handlers.cancel?.(this.buildContext());
|
||||||
|
this.session.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -761,7 +761,23 @@ export class InteractionEngine {
|
|||||||
if (this.modifySelected.length === 1) {
|
if (this.modifySelected.length === 1) {
|
||||||
const first = this.state.points[0];
|
const first = this.state.points[0];
|
||||||
const dist = distance(first, pt);
|
const dist = distance(first, pt);
|
||||||
const offsetted = offsetElement(this.modifySelected[0], dist);
|
const base = this.modifySelected[0];
|
||||||
|
// Seitigkeit bestimmen: Kreuzprodukt der Linienrichtung mit Klickvektor.
|
||||||
|
// cross > 0 → Klick links der Laufrichtung (p1→p2) → side=+1, sonst -1.
|
||||||
|
let side: 1 | -1 = 1;
|
||||||
|
if (base.type === 'line' && base.properties.x1 !== undefined && base.properties.y1 !== undefined) {
|
||||||
|
const p1x = base.properties.x1 as number;
|
||||||
|
const p1y = base.properties.y1 as number;
|
||||||
|
const p2x = base.properties.x2 as number;
|
||||||
|
const p2y = base.properties.y2 as number;
|
||||||
|
const cross = (p2x - p1x) * (pt.y - p1y) - (p2y - p1y) * (pt.x - p1x);
|
||||||
|
side = cross >= 0 ? 1 : -1;
|
||||||
|
} else if ((base.type === 'circle' || base.type === 'arc') && base.properties.radius !== undefined) {
|
||||||
|
// Kreis/Bogen: Klick außerhalb → vergrößern (+1), innerhalb → verkleinern (-1)
|
||||||
|
const clickDist = Math.hypot(pt.x - base.x, pt.y - base.y);
|
||||||
|
side = clickDist >= base.properties.radius ? 1 : -1;
|
||||||
|
}
|
||||||
|
const offsetted = offsetElement(base, dist, side);
|
||||||
// Create as new element (copy)
|
// Create as new element (copy)
|
||||||
this.onElementCreated?.({ ...offsetted, id: this.generateId() });
|
this.onElementCreated?.({ ...offsetted, id: this.generateId() });
|
||||||
this.resetModify();
|
this.resetModify();
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* pointer.ts – DOM-PointerEvent → ToolPointerEvent Normalisierung (Task A2).
|
||||||
|
* Konvertiert Bildschirmkoordinaten in Weltkoordinaten über ZoomPanController.
|
||||||
|
*/
|
||||||
|
import type { ToolPointerEvent } from '../plugins/types';
|
||||||
|
import type { ZoomPanController } from '../canvas/ZoomPanController';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalisiert ein Pointer/Mouse-Event zu einem ToolPointerEvent.
|
||||||
|
* @param e DOM-Event (MouseEvent ist Obermenge von PointerEvent-Feldern)
|
||||||
|
* @param canvas Das Canvas-Element (für offset-Koordinaten)
|
||||||
|
* @param zoomPan aktiver Zoom/Pan-Controller
|
||||||
|
*/
|
||||||
|
export function toToolPointerEvent(
|
||||||
|
e: MouseEvent,
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
zoomPan: ZoomPanController,
|
||||||
|
): ToolPointerEvent {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const sx = e.clientX - rect.left;
|
||||||
|
const sy = e.clientY - rect.top;
|
||||||
|
const world = zoomPan.screenToWorld(sx, sy);
|
||||||
|
return {
|
||||||
|
world: { x: world.x, y: world.y },
|
||||||
|
screen: { x: sx, y: sy },
|
||||||
|
button: e.button,
|
||||||
|
shift: e.shiftKey,
|
||||||
|
alt: e.altKey,
|
||||||
|
ctrl: e.ctrlKey || e.metaKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fangt weitere Pointer-Events auf dem Canvas bis zum release (Pen/Tablet- Komfort). */
|
||||||
|
export function capturePointer(canvas: HTMLCanvasElement, e: PointerEvent): void {
|
||||||
|
if (typeof canvas.setPointerCapture === 'function') {
|
||||||
|
try {
|
||||||
|
canvas.setPointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
/* pointerId evtl. schon ungültig — unkritisch */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gibt einen vorherigen Pointer-Capture frei. */
|
||||||
|
export function releasePointer(canvas: HTMLCanvasElement, e: PointerEvent): void {
|
||||||
|
if (typeof canvas.releasePointerCapture === 'function') {
|
||||||
|
try {
|
||||||
|
canvas.releasePointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
/* ignorieren */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* session.ts – Minimaler Werkzeug-Sitzungszustand (Task A2).
|
||||||
|
*
|
||||||
|
* phase 'idle' : kein Ziehen aktiv, Tool wartet auf down
|
||||||
|
* phase 'active' : down ist erfolgt; Tool zieht/zeichnet bis up/cancel
|
||||||
|
*
|
||||||
|
* Der Dispatcher nutzt das, um move-Events nur während aktiver Sitzung an
|
||||||
|
* Handler durchzureichen (und als Guard gegen verlorene up-Events).
|
||||||
|
*/
|
||||||
|
export type ToolPhase = 'idle' | 'active';
|
||||||
|
|
||||||
|
export class ToolSession {
|
||||||
|
private current: ToolPhase = 'idle';
|
||||||
|
|
||||||
|
/** Ist eine Zeichensitzung aktiv (zwischen down und up)? */
|
||||||
|
get isActive(): boolean {
|
||||||
|
return this.current === 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aktuelle Phase. */
|
||||||
|
get phase(): ToolPhase {
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Startet eine Sitzung (nach einem gültigen down). */
|
||||||
|
begin(): void {
|
||||||
|
this.current = 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beendet die Sitzung regulär (up) oder abgebrochen (cancel/ESC). */
|
||||||
|
end(): void {
|
||||||
|
this.current = 'idle';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* CADDocument – Fassade über YjsDocument (Task A2b).
|
||||||
|
*
|
||||||
|
* - Elementar-CRUD auf CADElement/CADLayer
|
||||||
|
* - `transact(fn)`: Bündelt Änderungen zu EINEM Undo-Schritt
|
||||||
|
* (kollaborativ korrekt via Y.UndoManager)
|
||||||
|
* - `undo()/redo()` wrappen den Manager; HistoryPanel liest später daraus (A4)
|
||||||
|
* - Keine Tool-Logik! Nur Dokument-Operationen.
|
||||||
|
*
|
||||||
|
* Nutzung künftiger V2-Werkzeuge (ab A3):
|
||||||
|
* doc.transact(() => doc.addElement(line)); // 1 Klick = 1 Undo-Schritt
|
||||||
|
*/
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { YjsDocument } from '../../crdt/YjsDocument';
|
||||||
|
import type { CADElement, CADLayer } from '../../types/cad.types';
|
||||||
|
|
||||||
|
export class CADDocument {
|
||||||
|
private readonly ydoc: YjsDocument;
|
||||||
|
private readonly undoManager: Y.UndoManager;
|
||||||
|
|
||||||
|
/** @param ydoc Netzwerkfrei erstellbar (createInMemoryDoc) oder CRDT-verbunden. */
|
||||||
|
constructor(ydoc: YjsDocument) {
|
||||||
|
this.ydoc = ydoc;
|
||||||
|
// Trackt alle Operationen auf Elements + Layers → gemeinsame Undo-Historie.
|
||||||
|
// Blocks/Groups/Configs bewusst NICHT tracken: Werkzeuge ändern primär Elemente/Layer.
|
||||||
|
// captureTimeout 0: KEIN automatisches Mergen zeitnaher Transaktionen —
|
||||||
|
// jede explizite transact() bleibt EIGENER Undo-Schritt (deterministisch für Tools).
|
||||||
|
this.undoManager = new Y.UndoManager([this.ydoc.data.elements, this.ydoc.data.layers], {
|
||||||
|
captureTimeout: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lesen ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Ein Element per ID (oder undefined). */
|
||||||
|
getElement(id: string): CADElement | undefined {
|
||||||
|
return this.ydoc.data.elements.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Elemente als Array. */
|
||||||
|
getAllElements(): CADElement[] {
|
||||||
|
return Array.from(this.ydoc.data.elements.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Layer per ID (oder undefined). */
|
||||||
|
getLayer(id: string): CADLayer | undefined {
|
||||||
|
return this.ydoc.data.layers.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Layer als Array. */
|
||||||
|
getAllLayers(): CADLayer[] {
|
||||||
|
return Array.from(this.ydoc.data.layers.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schreiben ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Fügt ein Element hinzu (einzelner Aufruf = eigener Undo-Schritt). */
|
||||||
|
addElement(el: CADElement): void {
|
||||||
|
this.transact(() => {
|
||||||
|
this.ydoc.data.elements.set(el.id, el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert ein Element: Top-Level-Felder werden ersetzt,
|
||||||
|
* `patch.properties` wird SHALLOW in bestehende Properties gemergt
|
||||||
|
* (Details wie fill/rotation einzeln änderbar, ohne den Rest zu verlieren).
|
||||||
|
* @throws wenn Element nicht existiert
|
||||||
|
*/
|
||||||
|
updateElement(id: string, patch: Partial<CADElement>): void {
|
||||||
|
const existing = this.ydoc.data.elements.get(id);
|
||||||
|
if (!existing) throw new Error(`updateElement: Element '${id}' nicht gefunden`);
|
||||||
|
const { properties, ...rest } = patch;
|
||||||
|
const merged: CADElement = {
|
||||||
|
...existing,
|
||||||
|
...rest,
|
||||||
|
properties: properties ? { ...existing.properties, ...properties } : existing.properties,
|
||||||
|
};
|
||||||
|
this.transact(() => {
|
||||||
|
this.ydoc.data.elements.set(id, merged);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt mehrere Elemente in EINER Transaktion. Ignoriert unbekannte IDs still. */
|
||||||
|
deleteElements(ids: string[]): void {
|
||||||
|
this.transact(() => {
|
||||||
|
for (const id of ids) {
|
||||||
|
if (this.ydoc.data.elements.has(id)) {
|
||||||
|
this.ydoc.data.elements.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Transaktion & Undo ───────────────────────────────────
|
||||||
|
|
||||||
|
/** Führt fn aus; ALLE darin getätigten Y-Änderungen werden EIN Undo-Schritt. */
|
||||||
|
transact(fn: () => void): void {
|
||||||
|
this.ydoc.doc.transact(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Macht den letzten Schritt rückgängig. false wenn nichts da. */
|
||||||
|
undo(): boolean {
|
||||||
|
if (!this.canUndo()) return false;
|
||||||
|
this.undoManager.undo();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stellt einen Rückgängig-Schritt wieder her. false wenn nichts da. */
|
||||||
|
redo(): boolean {
|
||||||
|
if (!this.canRedo()) return false;
|
||||||
|
this.undoManager.redo();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gibt es etwas zum Rückgängigmachen? */
|
||||||
|
canUndo(): boolean {
|
||||||
|
return this.undoManager.undoStack.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gibt es etwas zum Wiederherstellen? */
|
||||||
|
canRedo(): boolean {
|
||||||
|
return this.undoManager.redoStack.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exponiert den Manager (HistoryPanel liest Stacklängen für die UI, A4). */
|
||||||
|
getUndoManager(): Y.UndoManager {
|
||||||
|
return this.undoManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subscriptions ────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Benachrichtigung bei JEDEM Doc-Update (inkl. Remote über CRDT). */
|
||||||
|
onChanged(cb: () => void): () => void {
|
||||||
|
return this.ydoc.onChange(cb);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* documentService – Prozessweiter Zugriffspunkt auf das aktive CADDocument.
|
||||||
|
*
|
||||||
|
* Warum: CanvasArea besitzt die V2-Dokumentinstanz (Task A4.1), aber App-Level
|
||||||
|
* UI (HistoryPanel, Tastenkürzel Strg+Z/Y) benötigt Zugriff OHNE prop-
|
||||||
|
* threading durch die God-Component. Dieser Service ist die bewusste,
|
||||||
|
* dokumentierte Ausnahme von der Reinheitsregel — nur EINE Instanz aktiv.
|
||||||
|
*
|
||||||
|
* Setzen/Entfernen übernimmt CanvasArea im Mount-/Cleanup-Zyklus.
|
||||||
|
*/
|
||||||
|
import type { CADDocument } from './CADDocument';
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
let activeDoc: CADDocument | null = null;
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
/** Registriert das aktive Dokument (CanvasArea-Mount). */
|
||||||
|
export function setActiveDocument(doc: CADDocument | null): void {
|
||||||
|
activeDoc = doc;
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aktives Dokument oder null (z.B. wenn Flag aus / Canvas unmounted). */
|
||||||
|
export function getActiveDocument(): CADDocument | null {
|
||||||
|
return activeDoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Änderungsabonnement (für React-Hooks). Liefert Unsubscribe. */
|
||||||
|
export function onActiveDocumentChanged(cb: Listener): () => void {
|
||||||
|
listeners.add(cb);
|
||||||
|
return () => listeners.delete(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bequemlichkeit: globaler Undo am aktiven Dokument. false wenn kein Doc/Stack leer. */
|
||||||
|
export function undoActive(): boolean {
|
||||||
|
return activeDoc?.undo() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bequemlichkeit: globales Redo am aktiven Dokument. */
|
||||||
|
export function redoActive(): boolean {
|
||||||
|
return activeDoc?.redo() ?? false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* featureFlags – Zentrale Feature-Schalter (Task A4.14).
|
||||||
|
*
|
||||||
|
* V2_TOOLS_ENABLED: Ab v2.0 ist der Plugin-first-Pfad (InteractionDispatcher,
|
||||||
|
* CADDocument, dynamisches Ribbon, V2HistoryPanel) STANDARDMÄSSIG AKTIV.
|
||||||
|
* Opt-out für den Legacy-Pfad: VITE_TOOL_DISPATCHER=legacy setzen.
|
||||||
|
*
|
||||||
|
* Begründung Historie: Vor A4.14 war V2 opt-in ('v2' explizit nötig), weil
|
||||||
|
* noch keine Browser-Verifikation existierte. Die Playwright-Verifikation
|
||||||
|
* (phase-a-verify.test.ts, grün) hat das Risiko entwertet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Ist der V2-Werkzeugpfad (Dispatcher/CADDocument) aktiv? Default: ja. */
|
||||||
|
export const V2_TOOLS_ENABLED: boolean =
|
||||||
|
import.meta.env.VITE_TOOL_DISPATCHER !== 'legacy';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task C4: Pixi als Primär-Renderer für V2-Elemente (Overlay über Legacy).
|
||||||
|
* Default: ja (durch phase-a-verify E2E abgesichert).
|
||||||
|
* Opt-out: VITE_USE_PIXI=false.
|
||||||
|
*/
|
||||||
|
export const USE_PIXI_RENDERER: boolean =
|
||||||
|
import.meta.env.VITE_USE_PIXI !== 'false';
|
||||||
@@ -9,14 +9,29 @@ import type {
|
|||||||
ToolExtension,
|
ToolExtension,
|
||||||
CommandExtension,
|
CommandExtension,
|
||||||
ImportExportExtension,
|
ImportExportExtension,
|
||||||
|
// API v2 (Task A1):
|
||||||
|
PluginV2,
|
||||||
|
ToolExtensionV2,
|
||||||
|
PanelExtension,
|
||||||
|
LibraryProviderExtension,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
/** Interner Zustand eines registrierten V2-Plugins. */
|
||||||
|
interface PluginStateV2 {
|
||||||
|
plugin: PluginV2;
|
||||||
|
enabled: boolean;
|
||||||
|
loaded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
class PluginRegistryClass {
|
class PluginRegistryClass {
|
||||||
private plugins = new Map<string, Plugin>();
|
private plugins = new Map<string, Plugin>();
|
||||||
private states = new Map<string, PluginState>();
|
private states = new Map<string, PluginState>();
|
||||||
private context: PluginContext | null = null;
|
private context: PluginContext | null = null;
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
// ── API v2 (Task A1) ─────────────────────────────────────
|
||||||
|
private v2Plugins = new Map<string, PluginStateV2>();
|
||||||
|
|
||||||
/** Set the plugin context (called once on app init) */
|
/** Set the plugin context (called once on app init) */
|
||||||
setContext(ctx: PluginContext) {
|
setContext(ctx: PluginContext) {
|
||||||
this.context = ctx;
|
this.context = ctx;
|
||||||
@@ -164,6 +179,96 @@ class PluginRegistryClass {
|
|||||||
this.enable(id);
|
this.enable(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const [id, state] of this.v2Plugins) {
|
||||||
|
if (state.plugin.manifest.enabledByDefault && !state.enabled) {
|
||||||
|
this.enableV2(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════ API v2 (Task A1 — additiv) ═══════════
|
||||||
|
|
||||||
|
/** Registriert ein V2-Plugin. Doppelte IDs werden verworfen (Warnung). */
|
||||||
|
registerV2(plugin: PluginV2): void {
|
||||||
|
const { id } = plugin.manifest;
|
||||||
|
if (this.v2Plugins.has(id)) {
|
||||||
|
console.warn(`[PluginRegistry] V2-Plugin '${id}' bereits registriert – verworfen`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.v2Plugins.set(id, {
|
||||||
|
plugin,
|
||||||
|
enabled: false,
|
||||||
|
loaded: false,
|
||||||
|
});
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt ein V2-Plugin (ruft onDestroy wenn geladen). */
|
||||||
|
unregisterV2(pluginId: string): void {
|
||||||
|
const state = this.v2Plugins.get(pluginId);
|
||||||
|
if (!state) return;
|
||||||
|
if (state.loaded) state.plugin.onDestroy?.();
|
||||||
|
this.v2Plugins.delete(pluginId);
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aktiviert ein V2-Plugin (onInit einmalig, dann onActivate). */
|
||||||
|
enableV2(pluginId: string): void {
|
||||||
|
const state = this.v2Plugins.get(pluginId);
|
||||||
|
if (!state || state.enabled) return;
|
||||||
|
state.enabled = true;
|
||||||
|
if (!state.loaded) {
|
||||||
|
state.plugin.onInit?.();
|
||||||
|
state.loaded = true;
|
||||||
|
}
|
||||||
|
state.plugin.onActivate?.();
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deaktiviert ein V2-Plugin. */
|
||||||
|
disableV2(pluginId: string): void {
|
||||||
|
const state = this.v2Plugins.get(pluginId);
|
||||||
|
if (!state || !state.enabled) return;
|
||||||
|
state.enabled = false;
|
||||||
|
state.plugin.onDeactivate?.();
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist dieses V2-Plugin aktiv? */
|
||||||
|
isV2Enabled(pluginId: string): boolean {
|
||||||
|
return this.v2Plugins.get(pluginId)?.enabled ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Tools aus AKTIVEN V2-Plugins (flach über alle Plugins). */
|
||||||
|
getToolsV2(): ToolExtensionV2[] {
|
||||||
|
const tools: ToolExtensionV2[] = [];
|
||||||
|
for (const state of this.v2Plugins.values()) {
|
||||||
|
if (state.enabled && state.plugin.tools) tools.push(...state.plugin.tools);
|
||||||
|
}
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein bestimmtes V2-Tool per ID (nur aus aktiven Plugins). */
|
||||||
|
getToolV2(toolId: string): ToolExtensionV2 | undefined {
|
||||||
|
return this.getToolsV2().find((t) => t.manifest.id === toolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Panels aktiver V2-Plugins. */
|
||||||
|
getPanels(): PanelExtension[] {
|
||||||
|
const panels: PanelExtension[] = [];
|
||||||
|
for (const state of this.v2Plugins.values()) {
|
||||||
|
if (state.enabled && state.plugin.panels) panels.push(...state.plugin.panels);
|
||||||
|
}
|
||||||
|
return panels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Library-Provider aktiver V2-Plugins. */
|
||||||
|
getLibraryProviders(): LibraryProviderExtension[] {
|
||||||
|
const providers: LibraryProviderExtension[] = [];
|
||||||
|
for (const state of this.v2Plugins.values()) {
|
||||||
|
if (state.enabled && state.plugin.libraryProviders) providers.push(...state.plugin.libraryProviders);
|
||||||
|
}
|
||||||
|
return providers;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* core-annotate – Built-in V2-Plugin (Task A4.4).
|
||||||
|
*
|
||||||
|
* Werkzeuge: text (Platzierung — Inhalt via InlineTextEditor/PropertiesPanel,
|
||||||
|
* Bestandskanal onTextEdit bleibt), dimension (2-Punkt linear), leader
|
||||||
|
* (2-Punkt Pfeil+Label).
|
||||||
|
*/
|
||||||
|
import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types';
|
||||||
|
import type { CADElement } from '../../../types/cad.types';
|
||||||
|
import type { Pt } from '../../../tools/modification/geometry';
|
||||||
|
|
||||||
|
const uid = (prefix: string): string =>
|
||||||
|
`${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
|
||||||
|
/** Gemeinsamer 2-Punkt-Sitzungsstart für dimension/leader. */
|
||||||
|
let twoPointStart: Pt | null = null;
|
||||||
|
|
||||||
|
function optsOf(ctx: FullToolContext): { stroke: string; layerId: string } {
|
||||||
|
return {
|
||||||
|
stroke: (ctx.options.strokeColor as string | undefined) ?? '#e0e0e0',
|
||||||
|
layerId: (ctx.options.layerId as string | undefined) ?? 'layer-0',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDimension(start: Pt, end: Pt, ctx: FullToolContext): CADElement {
|
||||||
|
const o = optsOf(ctx);
|
||||||
|
return {
|
||||||
|
id: uid('dim'),
|
||||||
|
type: 'dimension',
|
||||||
|
layerId: o.layerId,
|
||||||
|
x: (start.x + end.x) / 2,
|
||||||
|
y: (start.y + end.y) / 2,
|
||||||
|
width: Math.hypot(end.x - start.x, end.y - start.y), // Legacy: dist als Breite
|
||||||
|
height: 20,
|
||||||
|
properties: { x1: start.x, y1: start.y, x2: end.x, y2: end.y },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLeader(start: Pt, end: Pt, ctx: FullToolContext): CADElement {
|
||||||
|
const o = optsOf(ctx);
|
||||||
|
return {
|
||||||
|
id: uid('leader'),
|
||||||
|
type: 'leader',
|
||||||
|
layerId: o.layerId,
|
||||||
|
x: end.x,
|
||||||
|
y: end.y,
|
||||||
|
width: Math.abs(end.x - start.x),
|
||||||
|
height: Math.abs(end.y - start.y),
|
||||||
|
properties: { x1: start.x, y1: start.y, x2: end.x, y2: end.y, text: '', fontSize: 12, stroke: o.stroke, strokeWidth: 1 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generischer 2-Punkt-Annotate-Fluss (down→Preview→up commit). */
|
||||||
|
function make2PointAnnotate(
|
||||||
|
id: 'dimension' | 'leader',
|
||||||
|
label: string,
|
||||||
|
icon: string,
|
||||||
|
build: (a: Pt, b: Pt, ctx: FullToolContext) => CADElement,
|
||||||
|
): ToolExtensionV2 {
|
||||||
|
return {
|
||||||
|
manifest: { id, label, icon, ribbonTab: 'format', tags: ['basic'] },
|
||||||
|
optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
twoPointStart = e.world;
|
||||||
|
ctx.setStatus(`${label}: Startpunkt gesetzt – Ende wählen`);
|
||||||
|
},
|
||||||
|
move(e, ctx) {
|
||||||
|
if (!twoPointStart) return;
|
||||||
|
ctx.setPreview?.(build(twoPointStart, e.world, ctx as FullToolContext));
|
||||||
|
},
|
||||||
|
up(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!twoPointStart || !c.doc) { twoPointStart = null; return; }
|
||||||
|
const el = build(twoPointStart, e.world, c);
|
||||||
|
c.doc.transact(() => c.doc!.addElement(el));
|
||||||
|
twoPointStart = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus(`${label} erstellt (${el.id})`);
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
twoPointStart = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus(`${label} abgebrochen`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* text-Werkzeug (A4.4): Klick platziert Textelement mit leerem Inhalt;
|
||||||
|
* Bearbeitung läuft über den bestehenden InlineTextEditor/onTextEdit-Kanal.
|
||||||
|
*/
|
||||||
|
export const textTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'text',
|
||||||
|
label: 'Text',
|
||||||
|
icon: 'T',
|
||||||
|
ribbonTab: 'format',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Text platzieren und über Editor beschriften',
|
||||||
|
},
|
||||||
|
optionsSchema: [{ key: 'fontSize', label: 'Größe', type: 'number', min: 6, max: 72 }],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!c.doc) return;
|
||||||
|
const layerId = (c.options.layerId as string | undefined) ?? 'layer-0';
|
||||||
|
const fontSize = (c.options.fontSize as number | undefined) ?? 12;
|
||||||
|
const el = {
|
||||||
|
id: uid('text'),
|
||||||
|
type: 'text',
|
||||||
|
layerId,
|
||||||
|
x: e.world.x,
|
||||||
|
y: e.world.y,
|
||||||
|
width: 100,
|
||||||
|
height: 20,
|
||||||
|
properties: { text: '', fontSize },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
c.doc.transact(() => c.doc!.addElement(el));
|
||||||
|
ctx.setStatus(`Text platziert (${el.id}) – now via Editor`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dimensionTool: ToolExtensionV2 = make2PointAnnotate('dimension', 'Bemaßung', '\u2194', buildDimension);
|
||||||
|
export const leaderTool: ToolExtensionV2 = make2PointAnnotate('leader', 'Leader', '\u21b1', buildLeader);
|
||||||
|
|
||||||
|
/** V2-Plugin: Beschriftung/Bemaßung (A4.4). */
|
||||||
|
export const coreAnnotatePlugin: PluginV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'core-annotate',
|
||||||
|
name: 'Beschriftung (Kern)',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'web-cad team',
|
||||||
|
description: 'Text, Bemaßung, Leader (V2).',
|
||||||
|
category: 'tools',
|
||||||
|
enabledByDefault: true,
|
||||||
|
},
|
||||||
|
tools: [textTool, dimensionTool, leaderTool],
|
||||||
|
};
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
/**
|
||||||
|
* core-drawing – Built-in V2-Plugin (Task A3 Pilot).
|
||||||
|
*
|
||||||
|
* Werkzeuge: line (Migrations-Pilot aus dem Legacy-Switch).
|
||||||
|
* Elemente werden über CADDocument committed → kollaborativ korrektes Undo.
|
||||||
|
*
|
||||||
|
* Sitzungszustand (Startpunkt der aktuellen Linie) lebt als Modul-Member,
|
||||||
|
* NICHT in options: Options sind geteilter UI-Store-Zustand.
|
||||||
|
*/
|
||||||
|
import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types';
|
||||||
|
import type { CADElement } from '../../../types/cad.types';
|
||||||
|
import type { Pt } from '../../../tools/modification/geometry';
|
||||||
|
|
||||||
|
const uid = (prefix: string): string =>
|
||||||
|
`${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
|
||||||
|
/** Startpunkt der laufenden Linie (nur innerhalb einer down→up-Sitzung gesetzt). */
|
||||||
|
let lineStart: Pt | null = null;
|
||||||
|
/** Startpunkt des laufenden Rechtecks (A4.2). */
|
||||||
|
let rectStart: Pt | null = null;
|
||||||
|
/** Zentrum des laufenden Kreises (A4.3). */
|
||||||
|
let circleCenter: Pt | null = null;
|
||||||
|
/** Laufender Bogen: Phase 0=nichts, 1=Zentrum gesetzt, 2=Radius+Startwinkel gesetzt. */
|
||||||
|
let arcPhase = 0;
|
||||||
|
let arcCenter: Pt | null = null;
|
||||||
|
let arcStartPt: Pt | null = null;
|
||||||
|
/** Laufende Polyline/Polygon-Punkte; Commit per ENTER. */
|
||||||
|
let polyPoints: Pt[] = [];
|
||||||
|
/** Laufende RevCloud-Punkte (A4.5); Commit per ENTER. */
|
||||||
|
let cloudPoints: Pt[] = [];
|
||||||
|
|
||||||
|
function optsOf(ctx: FullToolContext): { stroke: string; layerId: string } {
|
||||||
|
return {
|
||||||
|
stroke: (ctx.options.strokeColor as string | undefined) ?? '#e0e0f0',
|
||||||
|
layerId: (ctx.options.layerId as string | undefined) ?? 'layer-0',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLine(start: Pt, end: Pt, ctx: FullToolContext): CADElement {
|
||||||
|
const stroke = (ctx.options.strokeColor as string | undefined) ?? '#e0e0f0';
|
||||||
|
const layerId = (ctx.options.layerId as string | undefined) ?? 'layer-0';
|
||||||
|
return {
|
||||||
|
id: uid('line'),
|
||||||
|
type: 'line',
|
||||||
|
layerId,
|
||||||
|
x: (start.x + end.x) / 2,
|
||||||
|
y: (start.y + end.y) / 2,
|
||||||
|
width: Math.abs(end.x - start.x),
|
||||||
|
height: Math.abs(end.y - start.y),
|
||||||
|
properties: { x1: start.x, y1: start.y, x2: end.x, y2: end.y, stroke, strokeWidth: 1 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRect(cornerA: Pt, cornerB: Pt, ctx: FullToolContext): CADElement {
|
||||||
|
const stroke = (ctx.options.strokeColor as string | undefined) ?? '#e0e0f0';
|
||||||
|
const layerId = (ctx.options.layerId as string | undefined) ?? 'layer-0';
|
||||||
|
const minX = Math.min(cornerA.x, cornerB.x);
|
||||||
|
const minY = Math.min(cornerA.y, cornerB.y);
|
||||||
|
const w = Math.abs(cornerB.x - cornerA.x);
|
||||||
|
const h = Math.abs(cornerB.y - cornerA.y);
|
||||||
|
return {
|
||||||
|
id: uid('rect'),
|
||||||
|
type: 'rect',
|
||||||
|
layerId,
|
||||||
|
x: minX + w / 2, // BBox-Zentrum (Legacy-Konvention)
|
||||||
|
y: minY + h / 2,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
properties: { stroke, strokeWidth: 1 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* line-Werkzeug: down setzt Startpunkt, move zeigt Live-Vorschau,
|
||||||
|
* up committet via doc.transact (1 Linie = 1 Undo-Schritt).
|
||||||
|
*/
|
||||||
|
export const lineTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'line',
|
||||||
|
label: 'Linie',
|
||||||
|
icon: '\u2571',
|
||||||
|
ribbonTab: 'canvas',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Zeichnet eine Linie (Klick Start, Klick Ende)',
|
||||||
|
},
|
||||||
|
optionsSchema: [
|
||||||
|
{ key: 'strokeColor', label: 'Farbe', type: 'color' },
|
||||||
|
],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
lineStart = e.world;
|
||||||
|
ctx.setStatus(`Linie ab (${lineStart.x.toFixed(0)}, ${lineStart.y.toFixed(0)}) – Ende wählen`);
|
||||||
|
},
|
||||||
|
move(e, ctx) {
|
||||||
|
if (!lineStart) return;
|
||||||
|
ctx.setPreview?.(buildLine(lineStart, e.world, ctx));
|
||||||
|
},
|
||||||
|
up(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!lineStart || !c.doc) {
|
||||||
|
lineStart = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const el = buildLine(lineStart, e.world, c);
|
||||||
|
c.doc.transact(() => {
|
||||||
|
// addElement öffnet inneres transact — Yjs verschachtelt sie → EIN Undo-Schritt
|
||||||
|
c.doc!.addElement(el);
|
||||||
|
});
|
||||||
|
lineStart = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus(`Linie erstellt (${el.id})`);
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
lineStart = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus('Linie abgebrochen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* rect-Werkzeug (A4.2): down setzt Ecke, move zeigt BBox-Vorschau,
|
||||||
|
* up committet via doc.transact. Portiert aus Legacy case 'rect'
|
||||||
|
* (BBox-Konvention: x/y = Zentrum, width/height absolut).
|
||||||
|
*/
|
||||||
|
export const rectTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'rect',
|
||||||
|
label: 'Rechteck',
|
||||||
|
icon: '\u25ad',
|
||||||
|
ribbonTab: 'canvas',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Zieht ein Rechteck auf (Klick Ecke, Klick Gegenecke)',
|
||||||
|
},
|
||||||
|
optionsSchema: [
|
||||||
|
{ key: 'strokeColor', label: 'Farbe', type: 'color' },
|
||||||
|
],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
rectStart = e.world;
|
||||||
|
ctx.setStatus(`Rechteck ab (${rectStart.x.toFixed(0)}, ${rectStart.y.toFixed(0)}) – Gegenecke wählen`);
|
||||||
|
},
|
||||||
|
move(e, ctx) {
|
||||||
|
if (!rectStart) return;
|
||||||
|
ctx.setPreview?.(buildRect(rectStart, e.world, ctx));
|
||||||
|
},
|
||||||
|
up(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!rectStart || !c.doc) {
|
||||||
|
rectStart = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const el = buildRect(rectStart, e.world, c);
|
||||||
|
c.doc.transact(() => {
|
||||||
|
c.doc!.addElement(el);
|
||||||
|
});
|
||||||
|
rectStart = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus(`Rechteck erstellt (${el.id})`);
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
rectStart = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus('Rechteck abgebrochen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* circle-Werkzeug (A4.3): Zentrum→Radius wie Legacy (Radius = Distanz).
|
||||||
|
*/
|
||||||
|
export const circleTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'circle', label: 'Kreis', icon: '\u25cb', ribbonTab: 'canvas', tags: ['basic'],
|
||||||
|
description: 'Kreis: Zentrum klicken, Radius ziehen',
|
||||||
|
},
|
||||||
|
optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) { circleCenter = e.world; ctx.setStatus('Kreiszentrum gesetzt – Radius ziehen'); },
|
||||||
|
move(e, ctx) {
|
||||||
|
if (!circleCenter) return;
|
||||||
|
const r = Math.hypot(e.world.x - circleCenter.x, e.world.y - circleCenter.y);
|
||||||
|
ctx.setPreview?.(buildCircle(circleCenter, r, ctx));
|
||||||
|
},
|
||||||
|
up(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!circleCenter || !c.doc) { circleCenter = null; return; }
|
||||||
|
const r = Math.hypot(e.world.x - circleCenter.x, e.world.y - circleCenter.y);
|
||||||
|
const el = buildCircle(circleCenter, r, c);
|
||||||
|
c.doc.transact(() => c.doc!.addElement(el));
|
||||||
|
circleCenter = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus(`Kreis erstellt (${el.id})`);
|
||||||
|
},
|
||||||
|
cancel(ctx) { circleCenter = null; ctx.setPreview?.(null); ctx.setStatus('Kreis abgebrochen'); },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* arc-Werkzeug (A4.3): 3-Klick wie Legacy — Zentrum, Startpunkt (Radius+
|
||||||
|
* Startwinkel), Endpunkt (Endwinkel). Winkel in Grad CCW.
|
||||||
|
*/
|
||||||
|
export const arcTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'arc', label: 'Bogen', icon: '\u2322', ribbonTab: 'canvas', tags: ['basic'],
|
||||||
|
description: 'Bogen: Zentrum, Startpunkt, Endpunkt',
|
||||||
|
},
|
||||||
|
optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (arcPhase === 0) {
|
||||||
|
arcCenter = e.world;
|
||||||
|
arcPhase = 1;
|
||||||
|
ctx.setStatus('Bogenzentrum gesetzt \u2013 Startpunkt w\u00e4hlen');
|
||||||
|
} else if (arcPhase === 1) {
|
||||||
|
arcStartPt = e.world;
|
||||||
|
arcPhase = 2;
|
||||||
|
ctx.setStatus('Bogen: Endpunkt w\u00e4hlen');
|
||||||
|
} else if (arcPhase === 2 && arcCenter && arcStartPt) {
|
||||||
|
// Dritter Klick committet (klick-progressiv wie Legacy — up feuert im
|
||||||
|
// echten Browser immer direkt nach down, darf deshalb NICHT committen)
|
||||||
|
if (!c.doc) { arcPhase = 0; arcCenter = null; arcStartPt = null; return; }
|
||||||
|
const el = buildArc(arcCenter, arcStartPt, e.world, c);
|
||||||
|
c.doc.transact(() => c.doc!.addElement(el));
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus(`Bogen erstellt (${el.id})`);
|
||||||
|
arcPhase = 0;
|
||||||
|
arcCenter = null;
|
||||||
|
arcStartPt = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
move(e, ctx) {
|
||||||
|
if (arcPhase === 1 && arcCenter) {
|
||||||
|
const r = Math.hypot(e.world.x - arcCenter.x, e.world.y - arcCenter.y);
|
||||||
|
// Vollkreis-Guide in Phasen-Preview (Legacy-Konvention)
|
||||||
|
ctx.setPreview?.(buildArcFullGuide(arcCenter, r, ctx));
|
||||||
|
} else if (arcPhase === 2 && arcCenter && arcStartPt) {
|
||||||
|
ctx.setPreview?.(buildArc(arcCenter, arcStartPt, e.world, ctx));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
arcPhase = 0; arcCenter = null; arcStartPt = null;
|
||||||
|
ctx.setPreview?.(null); ctx.setStatus('Bogen abgebrochen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* polyline-Werkzeug (A4.3): Mehrklick fügt Punkte; ENTER committet ab 2 Punkten.
|
||||||
|
*/
|
||||||
|
export const polylineTool: ToolExtensionV2 = makePolyTool('polyline', 'Polylinie', 2, '\u2500');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* polygon-Werkzeug (A4.3): wie Polylinie, aber geschlossen; ENTER ab 3 Punkten.
|
||||||
|
*/
|
||||||
|
export const polygonTool: ToolExtensionV2 = makePolyTool('polygon', 'Polygon', 3, '\u2b21');
|
||||||
|
|
||||||
|
function makePolyTool(type: 'polyline' | 'polygon', label: string, minPoints: number, icon: string): ToolExtensionV2 {
|
||||||
|
return {
|
||||||
|
manifest: { id: type, label, icon, ribbonTab: 'canvas', tags: ['basic'], description: `${label}: Punkte klicken, ENTER beendet` },
|
||||||
|
optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
polyPoints.push(e.world);
|
||||||
|
if (!isPolyType(type)) return;
|
||||||
|
ctx.setStatus(`${label}: ${polyPoints.length} Punkte – ENTER beendet`);
|
||||||
|
},
|
||||||
|
move(e, ctx) {
|
||||||
|
if (polyPoints.length === 0) return;
|
||||||
|
const pts = [...polyPoints, e.world];
|
||||||
|
ctx.setPreview?.(buildPoly(type, pts, ctx));
|
||||||
|
},
|
||||||
|
up() { /* commit nur via ENTER */ },
|
||||||
|
key(e, ctx) {
|
||||||
|
if (e.key !== 'Enter') return false;
|
||||||
|
if (polyPoints.length < minPoints) {
|
||||||
|
ctx.setStatus(`${label}: mindestens ${minPoints} Punkte nötig`);
|
||||||
|
return true; // konsumiert
|
||||||
|
}
|
||||||
|
const el = buildPoly(type, polyPoints, ctx as FullToolContext);
|
||||||
|
const doc = (ctx as FullToolContext).doc;
|
||||||
|
if (!doc) { resetPoly(); return true; }
|
||||||
|
doc.transact(() => doc.addElement(el));
|
||||||
|
resetPoly();
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus(`${label} erstellt (${el.id})`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
resetPoly();
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus(`${label} abgebrochen`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPolyType(t: string): boolean {
|
||||||
|
return t === 'polyline' || t === 'polygon';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPoly(): void {
|
||||||
|
polyPoints = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Build-Helfer A4.3 ───────────────────────────────────────
|
||||||
|
|
||||||
|
function buildCircle(center: Pt, radius: number, ctx: FullToolContext): CADElement {
|
||||||
|
const o = optsOf(ctx);
|
||||||
|
return {
|
||||||
|
id: uid('circle'), type: 'circle', layerId: o.layerId,
|
||||||
|
x: center.x, y: center.y, width: radius * 2, height: radius * 2,
|
||||||
|
properties: { radius, stroke: o.stroke, strokeWidth: 1 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function anglesOf(center: Pt, start: Pt, end: Pt): { radius: number; startAngle: number; endAngle: number } {
|
||||||
|
const radius = Math.hypot(start.x - center.x, start.y - center.y);
|
||||||
|
const startAngle = (Math.atan2(start.y - center.y, start.x - center.x) * 180) / Math.PI;
|
||||||
|
const endAngle = (Math.atan2(end.y - center.y, end.x - center.x) * 180) / Math.PI;
|
||||||
|
return { radius, startAngle, endAngle };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArc(center: Pt, startPt: Pt, endPt: Pt, ctx: FullToolContext): CADElement {
|
||||||
|
const o = optsOf(ctx);
|
||||||
|
const a = anglesOf(center, startPt, endPt);
|
||||||
|
return {
|
||||||
|
id: uid('arc'), type: 'arc', layerId: o.layerId,
|
||||||
|
x: center.x, y: center.y, width: a.radius * 2, height: a.radius * 2,
|
||||||
|
properties: { radius: a.radius, startAngle: a.startAngle, endAngle: a.endAngle, stroke: o.stroke, strokeWidth: 1 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArcFullGuide(center: Pt, radius: number, ctx: FullToolContext): CADElement {
|
||||||
|
// Vollkreis-Vorschau als Radien-Leitfaden (Legacy-Verhalten Phase 1)
|
||||||
|
const o = optsOf(ctx);
|
||||||
|
return {
|
||||||
|
id: uid('arc'), type: 'circle', layerId: o.layerId,
|
||||||
|
x: center.x, y: center.y, width: radius * 2, height: radius * 2,
|
||||||
|
properties: { radius, stroke: o.stroke, strokeWidth: 1, guideOnly: true },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPoly(type: 'polyline' | 'polygon', pts: Pt[], ctx: FullToolContext): CADElement {
|
||||||
|
const o = optsOf(ctx);
|
||||||
|
const xs = pts.map((p) => p.x);
|
||||||
|
const ys = pts.map((p) => p.y);
|
||||||
|
return {
|
||||||
|
id: uid(type), type, layerId: o.layerId,
|
||||||
|
x: (Math.min(...xs) + Math.max(...xs)) / 2,
|
||||||
|
y: (Math.min(...ys) + Math.max(...ys)) / 2,
|
||||||
|
width: Math.max(...xs) - Math.min(...xs),
|
||||||
|
height: Math.max(...ys) - Math.min(...ys),
|
||||||
|
properties: { points: pts.map((p) => ({ x: p.x, y: p.y })), stroke: o.stroke, strokeWidth: 1 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* revcloud-Werkzeug (A4.5): Mehrklick-Revisionswolke wie Poly-Muster;
|
||||||
|
* ENTER committet ab 3 Punkten. Legacy-Konvention: arcHeight 8.
|
||||||
|
*/
|
||||||
|
export const revcloudTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'revcloud', label: 'RevCloud', icon: '\u2611', ribbonTab: 'format', tags: ['pro'],
|
||||||
|
description: 'Revisionswolke: Punkte klicken, ENTER beendet',
|
||||||
|
},
|
||||||
|
optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
cloudPoints.push(e.world);
|
||||||
|
ctx.setStatus(`RevCloud: ${cloudPoints.length} Punkte \u2013 ENTER beendet`);
|
||||||
|
},
|
||||||
|
move(e, ctx) {
|
||||||
|
if (cloudPoints.length === 0) return;
|
||||||
|
ctx.setPreview?.(buildRevCloud([...cloudPoints, e.world], ctx));
|
||||||
|
},
|
||||||
|
up() { /* commit via ENTER */ },
|
||||||
|
key(e, ctx) {
|
||||||
|
if (e.key !== 'Enter') return false;
|
||||||
|
if (cloudPoints.length < 3) {
|
||||||
|
ctx.setStatus('RevCloud: mindestens 3 Punkte n\u00f6tig');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!c.doc) { cloudPoints = []; return true; }
|
||||||
|
const el = buildRevCloud(cloudPoints, c);
|
||||||
|
c.doc.transact(() => c.doc!.addElement(el));
|
||||||
|
cloudPoints = [];
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus(`RevCloud erstellt (${el.id})`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
cloudPoints = [];
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus('RevCloud abgebrochen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildRevCloud(pts: Pt[], ctx: FullToolContext): CADElement {
|
||||||
|
const o = optsOf(ctx);
|
||||||
|
const xs = pts.map((p) => p.x);
|
||||||
|
const ys = pts.map((p) => p.y);
|
||||||
|
return {
|
||||||
|
id: uid('revcloud'), type: 'revcloud', layerId: o.layerId,
|
||||||
|
x: (Math.min(...xs) + Math.max(...xs)) / 2,
|
||||||
|
y: (Math.min(...ys) + Math.max(...ys)) / 2,
|
||||||
|
width: Math.max(...xs) - Math.min(...xs),
|
||||||
|
height: Math.max(...ys) - Math.min(...ys),
|
||||||
|
properties: { points: pts.map((p) => ({ x: p.x, y: p.y })), arcHeight: 8, fill: 'none', stroke: '#e0e0e0', strokeWidth: 1.5 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** V2-Plugin: Kern-Zeichenwerkzeuge (A4.x schrittweise migriert). */
|
||||||
|
export const coreDrawingPlugin: PluginV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'core-drawing',
|
||||||
|
name: 'Zeichnen (Kern)',
|
||||||
|
version: '1.3.0',
|
||||||
|
author: 'web-cad team',
|
||||||
|
description: 'Zeichenwerkzeuge V2: Linie, Rechteck, Kreis, Bogen, Polylinie, Polygon, RevCloud.',
|
||||||
|
category: 'tools',
|
||||||
|
enabledByDefault: true,
|
||||||
|
},
|
||||||
|
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool],
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* core-measure – Built-in V2-Plugin (Task A4.5).
|
||||||
|
*
|
||||||
|
* measure: 2 Klicks → Distanz+Winkel-Anzeige im Status. KEIN Element-Commit
|
||||||
|
* (Legacy-Verhalten getreu — Messung ist Information, keine Zeichnung).
|
||||||
|
* Distanzformatierung via bestehendem formatDistance(unit, scaleFactor).
|
||||||
|
*/
|
||||||
|
import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types';
|
||||||
|
import { formatDistance } from '../../../utils/format';
|
||||||
|
|
||||||
|
type MeasurePhase = 0 | 1; // 0=wartet auf Start, 1=wartet auf Ende
|
||||||
|
let measureStart: import('../../../tools/modification/geometry').Pt | null = null;
|
||||||
|
|
||||||
|
export const measureTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'measure',
|
||||||
|
label: 'Messen',
|
||||||
|
icon: '\u2194',
|
||||||
|
ribbonTab: 'view',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Distanz und Winkel zwischen zwei Punkten messen',
|
||||||
|
},
|
||||||
|
optionsSchema: [],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!measureStart) {
|
||||||
|
measureStart = e.world;
|
||||||
|
ctx.setStatus('Messung: Endpunkt klicken');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Zweiter Klick → anzeigen (Legacy-Ausgabeformat)
|
||||||
|
const rawDist = Math.hypot(e.world.x - measureStart.x, e.world.y - measureStart.y);
|
||||||
|
const angle = (Math.atan2(e.world.y - measureStart.y, e.world.x - measureStart.x) * 180) / Math.PI;
|
||||||
|
const unit = (c.options.unit as string | undefined) ?? 'mm';
|
||||||
|
const scale = (c.options.scaleFactor as number | undefined) ?? 1;
|
||||||
|
const distStr = formatDistance(rawDist, unit as never, scale);
|
||||||
|
ctx.setStatus(`distance: ${distStr} angle: ${angle.toFixed(1)}\u00b0`);
|
||||||
|
measureStart = null;
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
measureStart = null;
|
||||||
|
ctx.setStatus('Messung abgebrochen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** V2-Plugin: Messwerkzeuge. */
|
||||||
|
export const coreMeasurePlugin: PluginV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'core-measure',
|
||||||
|
name: 'Messen (Kern)',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'web-cad team',
|
||||||
|
description: 'Abstandswinkel-Messung ohne Element-Erzeugung.',
|
||||||
|
category: 'tools',
|
||||||
|
enabledByDefault: true,
|
||||||
|
},
|
||||||
|
tools: [measureTool],
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* event-tools – Built-in V2-Plugin (Task A4.9).
|
||||||
|
*
|
||||||
|
* Fachwerkzeuge für Veranstaltungsplanung: chair, seating-row,
|
||||||
|
* seating-block, table, stage — alle Einzelklick-Platzierer über
|
||||||
|
* den (durch BUG-1-Fix korrigierten) SeatingService.
|
||||||
|
*
|
||||||
|
* Jede Platzierung = doc.transact → 1 Undo-Schritt (Multi-Element-Sets
|
||||||
|
* wie seating-block erscheinen/verschwinden gemeinsam).
|
||||||
|
*/
|
||||||
|
import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types';
|
||||||
|
import type { CADElement } from '../../../types/cad.types';
|
||||||
|
import { SeatingService } from '../../../services/seatingService';
|
||||||
|
|
||||||
|
const seating = new SeatingService();
|
||||||
|
|
||||||
|
/** Erzeugt ein Einzelklick-Platzierer-Werkzeug über einen placer. */
|
||||||
|
function makePlaceTool(
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
icon: string,
|
||||||
|
placer: (x: number, y: number, layerId: string) => CADElement | CADElement[],
|
||||||
|
statusNoun: string,
|
||||||
|
): ToolExtensionV2 {
|
||||||
|
return {
|
||||||
|
manifest: {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
ribbonTab: 'insert',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: `${statusNoun} per Klick platzieren`,
|
||||||
|
},
|
||||||
|
optionsSchema: [],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!c.doc) return;
|
||||||
|
const layerId = (c.options.layerId as string | undefined) ?? 'layer-0';
|
||||||
|
const placed = placer(e.world.x, e.world.y, layerId);
|
||||||
|
const els = Array.isArray(placed) ? placed : [placed];
|
||||||
|
c.doc.transact(() => {
|
||||||
|
for (const el of els) c.doc!.addElement(el);
|
||||||
|
});
|
||||||
|
ctx.setStatus(`${statusNoun} platziert (${els.length} Element${els.length === 1 ? '' : 'e'})`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chairTool = makePlaceTool('chair', 'Stuhl', '\u2570',
|
||||||
|
(x, y, layerId) => seating.createChair(x, y, layerId), 'Stuhl');
|
||||||
|
|
||||||
|
export const seatingRowTool = makePlaceTool('seating-row', 'Stuhldreihe', '\u2500\u2570\u2500',
|
||||||
|
(x, y, layerId) => seating.createSeatingRow(x, y, layerId), 'Reihe');
|
||||||
|
|
||||||
|
export const seatingBlockTool = makePlaceTool('seating-block', 'Stuhlblock', '\u2565',
|
||||||
|
(x, y, layerId) => seating.createSeatingBlock(x, y, layerId), 'Block');
|
||||||
|
|
||||||
|
export const tableTool = makePlaceTool('table', 'Tisch', '\u25cb',
|
||||||
|
(x, y, layerId) => seating.createTable(x, y, layerId), 'Tisch');
|
||||||
|
|
||||||
|
export const stageTool = makePlaceTool('stage', 'Bühne', '\u25ad',
|
||||||
|
(x, y, layerId) => seating.createStage(x, y, layerId), 'Bühne');
|
||||||
|
|
||||||
|
/** V2-Plugin: Event-Domain (Task A4.9). */
|
||||||
|
export const eventToolsV2Plugin: PluginV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'event-tools',
|
||||||
|
name: 'Event-Bestuhlung',
|
||||||
|
version: '2.0.0',
|
||||||
|
author: 'web-cad team',
|
||||||
|
description: 'Stühle, Reihen, Blöcke, Tische, Bühne (V2).',
|
||||||
|
category: 'elements',
|
||||||
|
enabledByDefault: true,
|
||||||
|
},
|
||||||
|
tools: [chairTool, seatingRowTool, seatingBlockTool, tableTool, stageTool],
|
||||||
|
};
|
||||||
@@ -12,15 +12,42 @@ export type {
|
|||||||
CommandExtension,
|
CommandExtension,
|
||||||
ImportExportExtension,
|
ImportExportExtension,
|
||||||
PropertyField,
|
PropertyField,
|
||||||
|
// API v2 (Task A1):
|
||||||
|
PluginV2,
|
||||||
|
ToolExtensionV2,
|
||||||
|
ToolManifestPart,
|
||||||
|
ToolPointerEvent,
|
||||||
|
ToolContext,
|
||||||
|
PanelExtension,
|
||||||
|
LibraryProviderExtension,
|
||||||
|
LibFolder,
|
||||||
|
LibBlock,
|
||||||
|
PluginCategoryV2,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// Built-in plugins
|
// Built-in plugins
|
||||||
export { eventToolsPlugin } from './builtin/eventTools';
|
export { eventToolsPlugin } from './builtin/eventTools';
|
||||||
|
export { coreDrawingPlugin } from './builtin/core-drawing';
|
||||||
|
export { coreModifyPlugin } from './builtin/core-modify';
|
||||||
|
export { coreAnnotatePlugin } from './builtin/core-annotate';
|
||||||
|
export { coreMeasurePlugin } from './builtin/core-measure';
|
||||||
|
export { eventToolsV2Plugin } from './builtin/event-tools';
|
||||||
|
|
||||||
import { pluginRegistry } from './PluginRegistry';
|
import { pluginRegistry } from './PluginRegistry';
|
||||||
import { eventToolsPlugin } from './builtin/eventTools';
|
import { eventToolsPlugin } from './builtin/eventTools';
|
||||||
|
import { coreDrawingPlugin } from './builtin/core-drawing';
|
||||||
|
import { coreModifyPlugin } from './builtin/core-modify';
|
||||||
|
import { coreAnnotatePlugin } from './builtin/core-annotate';
|
||||||
|
import { coreMeasurePlugin } from './builtin/core-measure';
|
||||||
|
import { eventToolsV2Plugin } from './builtin/event-tools';
|
||||||
|
|
||||||
/** Register all built-in plugins */
|
/** Register all built-in plugins */
|
||||||
export function registerBuiltinPlugins() {
|
export function registerBuiltinPlugins() {
|
||||||
pluginRegistry.register(eventToolsPlugin);
|
pluginRegistry.register(eventToolsPlugin);
|
||||||
|
// API v2 (Task A3+):
|
||||||
|
pluginRegistry.registerV2(coreDrawingPlugin);
|
||||||
|
pluginRegistry.registerV2(coreModifyPlugin);
|
||||||
|
pluginRegistry.registerV2(coreAnnotatePlugin);
|
||||||
|
pluginRegistry.registerV2(coreMeasurePlugin);
|
||||||
|
pluginRegistry.registerV2(eventToolsV2Plugin);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,3 +116,131 @@ export interface PluginState {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// ─── API v2 (Task A1, additiv — siehe ROADMAP TEIL 2.4) ──
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
import type { Pt } from '../tools/modification/geometry';
|
||||||
|
|
||||||
|
/** Manifest-Teil eines V2-Werkzeugs. */
|
||||||
|
export interface ToolManifestPart {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
ribbonTab: string;
|
||||||
|
shortcut?: string;
|
||||||
|
/** 'basic' = auch im Einfach-Modus sichtbar (Task H1). */
|
||||||
|
tags?: Array<'basic' | 'pro'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalisiertes Pointer-Event mit Welt- und Bildschirmkoordinaten. */
|
||||||
|
export interface ToolPointerEvent {
|
||||||
|
world: Pt;
|
||||||
|
screen: Pt;
|
||||||
|
button: number;
|
||||||
|
shift: boolean;
|
||||||
|
alt: boolean;
|
||||||
|
ctrl: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kontext, den der Dispatcher einem aktiven V2-Tool übergibt. */
|
||||||
|
export interface ToolContext {
|
||||||
|
/** Aktueller Optionsstand für dieses Tool (aus toolStore). */
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
/** Statuszeilentext setzen. */
|
||||||
|
setStatus(msg: string): void;
|
||||||
|
/** Live-Vorschau setzen (null = Vorschau entfernen). Ab A3 an Renderer angebunden. */
|
||||||
|
setPreview?(el: CADElement | null): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selektions-/Treffer-Bridge für Werkzeuge (ab A3, von CanvasArea injiziert). */
|
||||||
|
export interface ToolSelectionBridge {
|
||||||
|
/** Aktuell selektierte Element-IDs. */
|
||||||
|
getIds(): string[];
|
||||||
|
/** Setzt die Selektion vollständig. */
|
||||||
|
setIds(ids: string[]): void;
|
||||||
|
/** Element-Treffer an Weltposition (oder null). */
|
||||||
|
hitTest(worldX: number, worldY: number, tolerancePx: number): CADElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erweiterter Kontext für Tools mit Dokument-/Selektionszugriff (A3). */
|
||||||
|
export interface FullToolContext extends ToolContext {
|
||||||
|
/** Dokumentzugriff (transact/add/update/delete). */
|
||||||
|
doc?: import('../kernel/document/CADDocument').CADDocument;
|
||||||
|
/** Selektion + HitTest-Bridge. */
|
||||||
|
selection?: ToolSelectionBridge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** V2-Werkzeug mit eigenen Pointer-Handlern (ersetzt langfristig ToolExtension). */
|
||||||
|
export interface ToolExtensionV2 {
|
||||||
|
manifest: ToolManifestPart & { description?: string };
|
||||||
|
/** Optionsleisten-Schema (wiederverwendet PropertyField). */
|
||||||
|
optionsSchema: PropertyField[];
|
||||||
|
handlers: {
|
||||||
|
down?(e: ToolPointerEvent, ctx: ToolContext): void;
|
||||||
|
move?(e: ToolPointerEvent, ctx: ToolContext): void;
|
||||||
|
up?(e: ToolPointerEvent, ctx: ToolContext): void;
|
||||||
|
/** true = Tastendruck konsumiert. */
|
||||||
|
key?(e: KeyboardEvent, ctx: ToolContext): boolean;
|
||||||
|
/** ESC / Rechtsklick. */
|
||||||
|
cancel?(ctx: ToolContext): void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenes Sidebar-Panel eines Plugins. */
|
||||||
|
export interface PanelExtension {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slot: 'left' | 'right';
|
||||||
|
render(): HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ordner einer Bibliothek (LibraryProvider). */
|
||||||
|
export interface LibFolder {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
parentId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Block einer Bibliothek (Metadaten; Payload provider-spezifisch). */
|
||||||
|
export interface LibBlock {
|
||||||
|
id: string;
|
||||||
|
folderId: string;
|
||||||
|
name: string;
|
||||||
|
tags?: string[];
|
||||||
|
thumbnail?: string; // dataURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liefert Kataloge — Kern der Plugin-erweiterbaren Library (Phase F). */
|
||||||
|
export interface LibraryProviderExtension {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
listFolders(): Promise<LibFolder[]>;
|
||||||
|
listBlocks(folderId: string): Promise<LibBlock[]>;
|
||||||
|
getBlock(id: string): Promise<LibBlock>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erweiterte Manifest-Kategorie inkl. library. */
|
||||||
|
export type PluginCategoryV2 = PluginManifest['category'] | 'library';
|
||||||
|
|
||||||
|
/** Plugin-API v2 (registriert parallel zum alten Plugin-Interface). */
|
||||||
|
export interface PluginV2 {
|
||||||
|
manifest: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
description: string;
|
||||||
|
category: PluginCategoryV2;
|
||||||
|
enabledByDefault?: boolean;
|
||||||
|
minAppVersion?: string;
|
||||||
|
permissions?: Array<'canvas' | 'network' | 'storage'>;
|
||||||
|
};
|
||||||
|
tools?: ToolExtensionV2[];
|
||||||
|
panels?: PanelExtension[];
|
||||||
|
libraryProviders?: LibraryProviderExtension[];
|
||||||
|
onInit?(): void;
|
||||||
|
onActivate?(): void;
|
||||||
|
onDeactivate?(): void;
|
||||||
|
onDestroy?(): void;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* MockAdapter – Headless-Implementierung des RenderAdapter (Task C1).
|
||||||
|
* Zeichnet nichts; protokolliert alle Aufrufe für assertions in Unit-Tests.
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
RenderAdapter,
|
||||||
|
Viewport,
|
||||||
|
GridConfig,
|
||||||
|
BackgroundDrawConfig,
|
||||||
|
RulerConfig,
|
||||||
|
LayerStyle,
|
||||||
|
OverlayPainter,
|
||||||
|
SelectionState,
|
||||||
|
} from '../types';
|
||||||
|
import type { CADElement, CADLayer } from '../../types/cad.types';
|
||||||
|
import type { Pt } from '../../tools/modification/geometry';
|
||||||
|
|
||||||
|
export interface RecordedCall {
|
||||||
|
method: string;
|
||||||
|
args: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MockAdapter implements RenderAdapter {
|
||||||
|
readonly calls: RecordedCall[] = [];
|
||||||
|
private canvas: HTMLCanvasElement | null = null;
|
||||||
|
nextHitTestResult: CADElement | null = null;
|
||||||
|
|
||||||
|
// ── Recording helper ─────────────────────────────────────
|
||||||
|
|
||||||
|
private rec(method: string, args: unknown[]): void {
|
||||||
|
this.calls.push({ method, args });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-Helfer: alle Aufrufe einer Methode. */
|
||||||
|
callsOf(method: string): RecordedCall[] {
|
||||||
|
return this.calls.filter((c) => c.method === method);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-Helfer: Anzahl Aufrufe einer Methode. */
|
||||||
|
countCalls(method: string): number {
|
||||||
|
return this.callsOf(method).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.calls.length = 0;
|
||||||
|
this.nextHitTestResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RenderAdapter Implementierung (reines Recording) ─────
|
||||||
|
|
||||||
|
init(canvas: HTMLCanvasElement): void {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.rec('init', [canvas]);
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(width: number, height: number, dpr: number): void {
|
||||||
|
this.rec('resize', [width, height, dpr]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setViewport(vp: Viewport): void {
|
||||||
|
this.rec('setViewport', [vp]);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.rec('destroy', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(color: string): void {
|
||||||
|
this.rec('clear', [color]);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawGrid(cfg: GridConfig): void {
|
||||||
|
this.rec('drawGrid', [cfg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawBackground(img: HTMLImageElement | null, cfg: BackgroundDrawConfig): void {
|
||||||
|
this.rec('drawBackground', [img, cfg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawRuler(cfg: RulerConfig): void {
|
||||||
|
this.rec('drawRuler', [cfg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawElement(el: CADElement, layer: CADLayer, style: LayerStyle): void {
|
||||||
|
this.rec('drawElement', [el, layer, style]);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeElement(id: string): void {
|
||||||
|
this.rec('removeElement', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearElements(): void {
|
||||||
|
this.rec('clearElements', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPreview(el: CADElement | null): void {
|
||||||
|
this.rec('drawPreview', [el]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelection(sel: SelectionState): void {
|
||||||
|
this.rec('setSelection', [sel]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnapPoints(pts: Array<{ x: number; y: number; type: string }>): void {
|
||||||
|
this.rec('setSnapPoints', [pts]);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay(draw: (g: OverlayPainter) => void): void {
|
||||||
|
const painter: OverlayPainter = {
|
||||||
|
line(a: Pt, b: Pt, color: string, width?: number) {},
|
||||||
|
rect(x: number, y: number, w: number, h: number, strokeColor: string) {},
|
||||||
|
circle(center: Pt, radius: number, strokeColor: string) {},
|
||||||
|
text(text: string, at: Pt, color: string, fontSize?: number) {},
|
||||||
|
};
|
||||||
|
draw(painter);
|
||||||
|
this.rec('overlay', [null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
hitTest(world: Pt, tolerancePx: number): CADElement | null {
|
||||||
|
this.rec('hitTest', [world, tolerancePx]);
|
||||||
|
return this.nextHitTestResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
/**
|
||||||
|
* PixiRenderer – WebGL-Implementierung des RenderAdapter (Task C2).
|
||||||
|
*
|
||||||
|
* - PixiJS v8 wird LAZY per dynamischem import() geladen — ein Ausfall des
|
||||||
|
* Pixi-Moduls (z.B. fehlendes WebGL im Headless-Browser) beeinflusst damit
|
||||||
|
* NICHT den Modul-Import der App (kein weißer Screen).
|
||||||
|
* - async Application.init(); danach synchron nutzbar über waitForReady()
|
||||||
|
* - Inkrementell: gerenderte Container pro Element-ID gecacht;
|
||||||
|
* removeElement/clearElements halten Cache und Szene konsistent
|
||||||
|
* - drawElement konsumiert die testbaren Shape-Primitives (elementDrawers)
|
||||||
|
*/
|
||||||
|
import type { Application, Container, Graphics, Text } from 'pixi.js';
|
||||||
|
import type {
|
||||||
|
RenderAdapter,
|
||||||
|
Viewport,
|
||||||
|
GridConfig,
|
||||||
|
BackgroundDrawConfig,
|
||||||
|
RulerConfig,
|
||||||
|
LayerStyle,
|
||||||
|
OverlayPainter,
|
||||||
|
SelectionState,
|
||||||
|
} from '../types';
|
||||||
|
import type { CADElement, CADLayer } from '../../types/cad.types';
|
||||||
|
import type { Pt } from '../../tools/modification/geometry';
|
||||||
|
import { elementToPrimitives } from './elementDrawers';
|
||||||
|
|
||||||
|
export class PixiRenderer implements RenderAdapter {
|
||||||
|
private app: Application | null = null;
|
||||||
|
private canvas: HTMLCanvasElement | null = null;
|
||||||
|
private root: Container | null = null;
|
||||||
|
private overlayLayer: Container | null = null;
|
||||||
|
private graphicsCache = new Map<string, Container>();
|
||||||
|
private viewport: Viewport = { x: 0, y: 0, scale: 1, width: 800, height: 600 };
|
||||||
|
private readyResolve: (() => void) | null = null;
|
||||||
|
private readyReject: ((err: unknown) => void) | null = null;
|
||||||
|
readonly ready: Promise<void>;
|
||||||
|
private pixiModule: typeof import('pixi.js') | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.ready = new Promise<void>((resolve, reject) => {
|
||||||
|
this.readyResolve = resolve;
|
||||||
|
this.readyReject = reject;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wartet auf Abschluss der Initialisierung; rejected bei Pixi-Fehler. */
|
||||||
|
async waitForReady(): Promise<void> {
|
||||||
|
await this.ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Task C4: true sobald setupApp fertig ist (via readyResolve gesetzt). */
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
|
// ── Lifecycle ────────────────────────────────────────────
|
||||||
|
|
||||||
|
init(canvas: HTMLCanvasElement): void {
|
||||||
|
this.canvas = canvas;
|
||||||
|
// LAZY-IMPORT: pixi.js wird erst jetzt geholt. Bei Fehler → ready rejected,
|
||||||
|
// CanvasArea deaktiviert das Overlay und die App läuft normal weiter.
|
||||||
|
void import('pixi.js')
|
||||||
|
.then((mod) => {
|
||||||
|
this.pixiModule = mod;
|
||||||
|
return this.setupApp(mod.Application, mod.Container, canvas);
|
||||||
|
})
|
||||||
|
.then(() => this.readyResolve?.())
|
||||||
|
.catch((err) => {
|
||||||
|
this.readyReject?.(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupApp(
|
||||||
|
ApplicationCtor: typeof Application,
|
||||||
|
ContainerCtor: typeof Container,
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
): Promise<void> {
|
||||||
|
const app = new ApplicationCtor();
|
||||||
|
this.app = app;
|
||||||
|
return app.init({
|
||||||
|
canvas,
|
||||||
|
width: this.viewport.width,
|
||||||
|
height: this.viewport.height,
|
||||||
|
backgroundAlpha: 0,
|
||||||
|
antialias: true,
|
||||||
|
autoDensity: false,
|
||||||
|
preference: 'webgl',
|
||||||
|
}).then(() => {
|
||||||
|
if (!this.app) return;
|
||||||
|
const root = new ContainerCtor();
|
||||||
|
this.root = root;
|
||||||
|
this.app.stage.addChild(root);
|
||||||
|
const overlay = new ContainerCtor();
|
||||||
|
this.overlayLayer = overlay;
|
||||||
|
this.app.stage.addChild(overlay);
|
||||||
|
this.applyViewportTransform();
|
||||||
|
this.initialized = true; // Task C4: fuer getDebugInfo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(width: number, height: number, _dpr: number): void {
|
||||||
|
if (!this.app?.renderer) return;
|
||||||
|
this.viewport.width = width;
|
||||||
|
this.viewport.height = height;
|
||||||
|
this.app.renderer.resize(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
setViewport(vp: Viewport): void {
|
||||||
|
this.viewport = vp;
|
||||||
|
this.applyViewportTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
try {
|
||||||
|
this.app?.destroy(true, { children: true });
|
||||||
|
} catch {
|
||||||
|
// Pixi darf im Teardown nichts mehr werfen
|
||||||
|
}
|
||||||
|
this.app = null;
|
||||||
|
this.root = null;
|
||||||
|
this.overlayLayer = null;
|
||||||
|
this.graphicsCache.clear();
|
||||||
|
this.canvas = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Basis-Zeichenoperationen ────────────────────────────
|
||||||
|
|
||||||
|
clear(_color: string): void {
|
||||||
|
// Hintergrund kommt vom Canvas-CSS; Pixi leert Buffer beim render.
|
||||||
|
}
|
||||||
|
|
||||||
|
drawGrid(cfg: GridConfig): void {
|
||||||
|
if (!cfg.enabled || !this.overlayLayer || !this.root) return;
|
||||||
|
const GraphicsCtor = this.pixiModule?.Graphics;
|
||||||
|
if (!GraphicsCtor) return;
|
||||||
|
const g = new GraphicsCtor();
|
||||||
|
const vp = this.viewport;
|
||||||
|
const worldLeft = vp.x;
|
||||||
|
const worldTop = vp.y;
|
||||||
|
const worldRight = vp.x + vp.width / vp.scale;
|
||||||
|
const worldBottom = vp.y + vp.height / vp.scale;
|
||||||
|
for (
|
||||||
|
let gx = Math.floor(worldLeft / cfg.size) * cfg.size;
|
||||||
|
gx < worldRight;
|
||||||
|
gx += cfg.size
|
||||||
|
) {
|
||||||
|
g.moveTo(gx, worldTop).lineTo(gx, worldBottom)
|
||||||
|
.stroke({ color: cfg.color, width: 0.5 / vp.scale });
|
||||||
|
}
|
||||||
|
for (
|
||||||
|
let gy = Math.floor(worldTop / cfg.size) * cfg.size;
|
||||||
|
gy < worldBottom;
|
||||||
|
gy += cfg.size
|
||||||
|
) {
|
||||||
|
g.moveTo(worldLeft, gy).lineTo(worldRight, gy)
|
||||||
|
.stroke({ color: cfg.color, width: 0.5 / vp.scale });
|
||||||
|
}
|
||||||
|
this.root.addChild(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawBackground(_img: HTMLImageElement | null, _cfg: BackgroundDrawConfig): void {
|
||||||
|
// Sprite-/Texturintegration folgt in Task C3.
|
||||||
|
}
|
||||||
|
|
||||||
|
drawRuler(_cfg: RulerConfig): void {
|
||||||
|
// Lineal-Rendering folgt in Task C3.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Elemente ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
drawElement(el: CADElement, layer: CADLayer, _style: LayerStyle): void {
|
||||||
|
if (!this.root || !layer.visible || layer.locked) return;
|
||||||
|
this.removeElement(el.id);
|
||||||
|
const container = this.buildGraphicsContainer(el);
|
||||||
|
if (!container) return;
|
||||||
|
this.graphicsCache.set(el.id, container);
|
||||||
|
this.root.addChild(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeElement(id: string): void {
|
||||||
|
const existing = this.graphicsCache.get(id);
|
||||||
|
if (!existing) return;
|
||||||
|
try {
|
||||||
|
existing.destroy({ children: true });
|
||||||
|
} catch {
|
||||||
|
// teardown-safe
|
||||||
|
}
|
||||||
|
this.graphicsCache.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearElements(): void {
|
||||||
|
for (const c of this.graphicsCache.values()) {
|
||||||
|
try {
|
||||||
|
c.destroy({ children: true });
|
||||||
|
} catch {
|
||||||
|
// teardown-safe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.graphicsCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPreview(el: CADElement | null): void {
|
||||||
|
if (!this.overlayLayer) return;
|
||||||
|
for (const child of [...this.overlayLayer.children]) {
|
||||||
|
if ((child as Container).label === '__preview__') {
|
||||||
|
try {
|
||||||
|
child.destroy({ children: true });
|
||||||
|
} catch {
|
||||||
|
// teardown-safe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!el) return;
|
||||||
|
const g = this.buildGraphicsContainer(el);
|
||||||
|
if (g) {
|
||||||
|
g.label = '__preview__';
|
||||||
|
g.alpha = 0.6;
|
||||||
|
this.overlayLayer.addChild(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelection(sel: SelectionState): void {
|
||||||
|
if (!this.overlayLayer) return;
|
||||||
|
// Bestehende Selektions-Grafik entfernen:
|
||||||
|
for (const child of [...this.overlayLayer.children]) {
|
||||||
|
if ((child as Container).label === '__selection__') {
|
||||||
|
try {
|
||||||
|
child.destroy({ children: true });
|
||||||
|
} catch {
|
||||||
|
// teardown-safe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!sel.selectedIds.length || !sel.elements || !this.pixiModule) return;
|
||||||
|
|
||||||
|
const GraphicsCtor = this.pixiModule.Graphics;
|
||||||
|
const g = new GraphicsCtor();
|
||||||
|
const HANDLE = 4; // halbe Kantenlänge der Eck-Handles (in Weltkoordinaten)
|
||||||
|
|
||||||
|
for (const el of sel.elements) {
|
||||||
|
if (!sel.selectedIds.includes(el.id)) continue;
|
||||||
|
// Legacy-Konvention getElementBBox: x/y = Zentrum für rects, sonst BBox
|
||||||
|
let minX: number, minY: number, maxX: number, maxY: number;
|
||||||
|
if (el.type === 'rect' || el.type === 'stage') {
|
||||||
|
minX = el.x - el.width / 2;
|
||||||
|
minY = el.y - el.height / 2;
|
||||||
|
maxX = el.x + el.width / 2;
|
||||||
|
maxY = el.y + el.height / 2;
|
||||||
|
} else if (
|
||||||
|
el.properties &&
|
||||||
|
el.properties.x1 !== undefined && el.properties.y1 !== undefined &&
|
||||||
|
el.properties.x2 !== undefined && el.properties.y2 !== undefined
|
||||||
|
) {
|
||||||
|
minX = Math.min(el.properties.x1 as number, el.properties.x2 as number);
|
||||||
|
minY = Math.min(el.properties.y1 as number, el.properties.y2 as number);
|
||||||
|
maxX = Math.max(el.properties.x1 as number, el.properties.x2 as number);
|
||||||
|
maxY = Math.max(el.properties.y1 as number, el.properties.y2 as number);
|
||||||
|
} else {
|
||||||
|
minX = el.x - el.width / 2;
|
||||||
|
minY = el.y - el.height / 2;
|
||||||
|
maxX = el.x + el.width / 2;
|
||||||
|
maxY = el.y + el.height / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Umrandung (gestrichelt wird via Linien-Segmenten simuliert):
|
||||||
|
const dashLen = 6;
|
||||||
|
const drawDashed = (ax: number, ay: number, bx: number, by: number) => {
|
||||||
|
const len = Math.hypot(bx - ax, by - ay);
|
||||||
|
const n = Math.max(1, Math.floor(len / dashLen));
|
||||||
|
for (let i = 0; i <= n; i += 2) {
|
||||||
|
const t0 = i / n;
|
||||||
|
const t1 = Math.min(1, (i + 1) / n);
|
||||||
|
g.moveTo(ax + (bx - ax) * t0, ay + (by - ay) * t0)
|
||||||
|
.lineTo(ax + (bx - ax) * t1, ay + (by - ay) * t1)
|
||||||
|
.stroke({ color: '#50fa7b', width: 1 / this.viewport.scale });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
drawDashed(minX, minY, maxX, minY);
|
||||||
|
drawDashed(maxX, minY, maxX, maxY);
|
||||||
|
drawDashed(maxX, maxY, minX, maxY);
|
||||||
|
drawDashed(minX, maxY, minX, minY);
|
||||||
|
|
||||||
|
// 4 Eck-Handles (gefüllte Quadrate):
|
||||||
|
const corners = [
|
||||||
|
{ x: minX, y: minY }, { x: maxX, y: minY },
|
||||||
|
{ x: maxX, y: maxY }, { x: minX, y: maxY },
|
||||||
|
];
|
||||||
|
for (const c of corners) {
|
||||||
|
g.rect(c.x - HANDLE, c.y - HANDLE, HANDLE * 2, HANDLE * 2)
|
||||||
|
.fill({ color: '#50fa7b' })
|
||||||
|
.stroke({ color: '#1e1e2e', width: 1 / this.viewport.scale });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.label = '__selection__';
|
||||||
|
this.overlayLayer.addChild(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnapPoints(pts: Array<{ x: number; y: number; type: string }>): void {
|
||||||
|
if (!this.overlayLayer || !this.pixiModule || pts.length === 0) return;
|
||||||
|
const GraphicsCtor = this.pixiModule.Graphics;
|
||||||
|
const g = new GraphicsCtor();
|
||||||
|
g.label = '__snaps__';
|
||||||
|
// Bestehende Snap-Marks entfernen:
|
||||||
|
for (const child of [...this.overlayLayer.children]) {
|
||||||
|
if ((child as Container).label === '__snaps__') {
|
||||||
|
try {
|
||||||
|
child.destroy({ children: true });
|
||||||
|
} catch {
|
||||||
|
// teardown-safe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Snap-Punkt farbig nach Typ (Legacy-Ähnlich):
|
||||||
|
const colorByType: Record<string, string> = {
|
||||||
|
endpoint: '#ff79c6',
|
||||||
|
midpoint: '#8be9fd',
|
||||||
|
center: '#f1fa8c',
|
||||||
|
intersection: '#ffb86c',
|
||||||
|
grid: '#6272a4',
|
||||||
|
};
|
||||||
|
const scaleComp = 1 / this.viewport.scale; // konstante Bildschirmgröße
|
||||||
|
for (const pt of pts) {
|
||||||
|
const color = colorByType[pt.type] ?? '#bd93f9';
|
||||||
|
g.circle(pt.x, pt.y, 4 * scaleComp)
|
||||||
|
.fill({ color })
|
||||||
|
.stroke({ color: '#1e1e2e', width: 1 * scaleComp });
|
||||||
|
}
|
||||||
|
this.overlayLayer.addChild(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay(draw: (g: OverlayPainter) => void): void {
|
||||||
|
if (!this.overlayLayer) return;
|
||||||
|
const GraphicsCtor = this.pixiModule?.Graphics;
|
||||||
|
const TextCtor = this.pixiModule?.Text;
|
||||||
|
if (!GraphicsCtor || !TextCtor) return;
|
||||||
|
const g = new GraphicsCtor();
|
||||||
|
const painter: OverlayPainter = {
|
||||||
|
line: (a: Pt, b: Pt, color: string, width?: number) => {
|
||||||
|
g.moveTo(a.x, a.y).lineTo(b.x, b.y).stroke({ color, width: width ?? 1 });
|
||||||
|
},
|
||||||
|
rect: (x: number, y: number, w: number, h: number, strokeColor: string) => {
|
||||||
|
g.rect(x, y, w, h).stroke({ color: strokeColor });
|
||||||
|
},
|
||||||
|
circle: (center: Pt, radius: number, strokeColor: string) => {
|
||||||
|
g.circle(center.x, center.y, radius).stroke({ color: strokeColor });
|
||||||
|
},
|
||||||
|
text: (text: string, at: Pt, color: string, fontSize?: number) => {
|
||||||
|
const t = new TextCtor({ text, style: { fontSize: fontSize ?? 12, fill: color } });
|
||||||
|
t.position.set(at.x, at.y);
|
||||||
|
g.addChild(t);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
draw(painter);
|
||||||
|
this.overlayLayer.addChild(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
hitTest(_world: Pt, _tolerancePx: number): CADElement | null {
|
||||||
|
// Delegation an SpatialIndex folgt in C3.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task C4: Debug-Info für Playwright-E2E-Assertions.
|
||||||
|
* Ermöglicht Testabfragen wie 'wie viele Elemente sind gerendert?'
|
||||||
|
*/
|
||||||
|
getDebugInfo(): { cachedElements: number; ready: boolean } {
|
||||||
|
return {
|
||||||
|
cachedElements: this.graphicsCache.size,
|
||||||
|
ready: this.initialized,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internes ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
private applyViewportTransform(): void {
|
||||||
|
if (!this.root) return;
|
||||||
|
this.root.position.set(
|
||||||
|
-this.viewport.x * this.viewport.scale,
|
||||||
|
-this.viewport.y * this.viewport.scale,
|
||||||
|
);
|
||||||
|
this.root.scale.set(this.viewport.scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildGraphicsContainer(el: CADElement): Container | null {
|
||||||
|
const prims = elementToPrimitives(el);
|
||||||
|
if (prims.length === 0) return null;
|
||||||
|
const GraphicsCtor = this.pixiModule?.Graphics;
|
||||||
|
const TextCtor = this.pixiModule?.Text;
|
||||||
|
if (!GraphicsCtor || !TextCtor) return null;
|
||||||
|
const ContainerCtor = this.pixiModule!.Container;
|
||||||
|
const container = new ContainerCtor();
|
||||||
|
for (const prim of prims) {
|
||||||
|
const g = new GraphicsCtor();
|
||||||
|
switch (prim.kind) {
|
||||||
|
case 'line':
|
||||||
|
g.moveTo(prim.from.x, prim.from.y)
|
||||||
|
.lineTo(prim.to.x, prim.to.y)
|
||||||
|
.stroke({ color: prim.stroke, width: prim.strokeWidth });
|
||||||
|
break;
|
||||||
|
case 'rect':
|
||||||
|
g.rect(prim.x, prim.y, prim.w, prim.h);
|
||||||
|
if (prim.fill) g.fill({ color: prim.fill });
|
||||||
|
g.stroke({ color: prim.stroke, width: prim.strokeWidth });
|
||||||
|
break;
|
||||||
|
case 'circle':
|
||||||
|
g.circle(prim.center.x, prim.center.y, prim.radius);
|
||||||
|
if (prim.fill) g.fill({ color: prim.fill });
|
||||||
|
g.stroke({ color: prim.stroke, width: prim.strokeWidth });
|
||||||
|
break;
|
||||||
|
case 'polyline': {
|
||||||
|
if (prim.points.length === 0) break;
|
||||||
|
g.moveTo(prim.points[0].x, prim.points[0].y);
|
||||||
|
for (let i = 1; i < prim.points.length; i++) {
|
||||||
|
g.lineTo(prim.points[i].x, prim.points[i].y);
|
||||||
|
}
|
||||||
|
if (prim.close) g.closePath();
|
||||||
|
g.stroke({ color: prim.stroke, width: prim.strokeWidth });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'arc': {
|
||||||
|
const startRad = (prim.startDeg * Math.PI) / 180;
|
||||||
|
const endRad = (prim.endDeg * Math.PI) / 180;
|
||||||
|
g.arc(prim.center.x, prim.center.y, prim.radius, startRad, endRad)
|
||||||
|
.stroke({ color: prim.stroke, width: prim.strokeWidth });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'text': {
|
||||||
|
const t = new TextCtor({ text: prim.text, style: { fontSize: prim.fontSize, fill: prim.color } });
|
||||||
|
t.position.set(prim.at.x, prim.at.y);
|
||||||
|
g.addChild(t);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
container.addChild(g);
|
||||||
|
}
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Task C3: bisher im Pixi-Pfad fehlende Typen ──
|
||||||
|
|
||||||
|
case 'dimension': {
|
||||||
|
// Legacy-Konvention: x1..y2 in properties, BBox-Zentrum als x/y,
|
||||||
|
// Extension lines + Hauptlinie; Pfeilspitzen sind Detail (C3+)
|
||||||
|
const x1 = (props.x1 as number | undefined) ?? el.x - el.width / 2;
|
||||||
|
const y1 = (props.y1 as number | undefined) ?? el.y;
|
||||||
|
const x2 = (props.x2 as number | undefined) ?? el.x + el.width / 2;
|
||||||
|
const y2 = (props.y2 as number | undefined) ?? el.y;
|
||||||
|
const color = (props.color as string | undefined) ?? '#aaaaaa';
|
||||||
|
const dimColor = (props.dimColor as string | undefined) ?? color;
|
||||||
|
const label = String(props.value ?? props.text ?? '');
|
||||||
|
const out: ShapePrimitive[] = [
|
||||||
|
{ kind: 'line', from: { x: x1, y: y1 }, to: { x: x2, y: y2 }, stroke: dimColor, strokeWidth: 1 },
|
||||||
|
];
|
||||||
|
if (label) {
|
||||||
|
out.push({
|
||||||
|
kind: 'text',
|
||||||
|
at: { x: (x1 + x2) / 2, y: (y1 + y2) / 2 - 12 },
|
||||||
|
text: label,
|
||||||
|
fontSize: 10,
|
||||||
|
color: dimColor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'leader': {
|
||||||
|
// Legacy-Konvention: Linie p1→p2 + optionaler Text am Ende
|
||||||
|
const lx1 = (props.x1 as number | undefined) ?? el.x;
|
||||||
|
const ly1 = (props.y1 as number | undefined) ?? el.y;
|
||||||
|
const lx2 = (props.x2 as number | undefined) ?? el.x + el.width;
|
||||||
|
const ly2 = (props.y2 as number | undefined) ?? el.y + el.height;
|
||||||
|
const out: ShapePrimitive[] = [
|
||||||
|
{ kind: 'line', from: { x: lx1, y: ly1 }, to: { x: lx2, y: ly2 }, stroke, strokeWidth: sw },
|
||||||
|
];
|
||||||
|
const label = String(props.text ?? '');
|
||||||
|
if (label) {
|
||||||
|
out.push({
|
||||||
|
kind: 'text',
|
||||||
|
at: { x: lx2 + 4, y: ly2 - 4 },
|
||||||
|
text: label,
|
||||||
|
fontSize: (props.fontSize as number | undefined) ?? 12,
|
||||||
|
color: stroke,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'revcloud': {
|
||||||
|
// Revisionswolke: Punkte-Zug (Bogen-Detail folgt in C3+)
|
||||||
|
const pts = Array.isArray(props.points)
|
||||||
|
? (props.points as unknown[]).map(asPoint)
|
||||||
|
: [];
|
||||||
|
if (pts.length < 2) return [];
|
||||||
|
return [{
|
||||||
|
kind: 'polyline',
|
||||||
|
points: pts,
|
||||||
|
close: true,
|
||||||
|
stroke,
|
||||||
|
strokeWidth: sw,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'chair': {
|
||||||
|
// Stuhl: gefülltes Quadrat in Element-Farbe + Umrandung
|
||||||
|
const fill = (props.fill as string | undefined) ?? '#4a90d9';
|
||||||
|
return [{
|
||||||
|
kind: 'rect',
|
||||||
|
x: el.x - el.width / 2,
|
||||||
|
y: el.y - el.height / 2,
|
||||||
|
w: el.width,
|
||||||
|
h: el.height,
|
||||||
|
fill,
|
||||||
|
stroke: (props.outlineColor as string | undefined) ?? '#2a5a99',
|
||||||
|
strokeWidth: 1,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'table': {
|
||||||
|
// Tisch: Kreis in Element-Größe mit Füllung
|
||||||
|
const fill = (props.fill as string | undefined) ?? '#8b7355';
|
||||||
|
return [{
|
||||||
|
kind: 'circle',
|
||||||
|
center: { x: el.x, y: el.y },
|
||||||
|
radius: Math.max(el.width, el.height) / 2,
|
||||||
|
fill,
|
||||||
|
stroke: '#5a4a35',
|
||||||
|
strokeWidth: 1,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'stage': {
|
||||||
|
// Bühne: Rechteck mit dunkler Füllung
|
||||||
|
return [{
|
||||||
|
kind: 'rect',
|
||||||
|
x: el.x - el.width / 2,
|
||||||
|
y: el.y - el.height / 2,
|
||||||
|
w: el.width,
|
||||||
|
h: el.height,
|
||||||
|
fill: (props.fill as string | undefined) ?? '#3a3a4a',
|
||||||
|
stroke,
|
||||||
|
strokeWidth: 1.5,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* render/types.ts – RenderAdapter-Schnittstelle (Task C1).
|
||||||
|
*
|
||||||
|
* Ziel-Architektur: ALLES Rendering läuft gegen diese schmale API.
|
||||||
|
* Implementierungen:
|
||||||
|
* - render/pixi/PixiRenderer.ts (WebGL, Task C2+)
|
||||||
|
* - tests/helpers/mockAdapter.ts (Headless-Recorder für Unit-Tests)
|
||||||
|
*
|
||||||
|
* Phase D/E-Erweiterungen (Plugins, Custom-Elemente) nutzen denselben Vertrag
|
||||||
|
* über drawOverlay/drawCustom.
|
||||||
|
*/
|
||||||
|
import type { CADElement, CADLayer } from '../types/cad.types';
|
||||||
|
import type { Pt } from '../tools/modification/geometry';
|
||||||
|
|
||||||
|
/** Aktueller Ausschnitt in Weltkoordinaten + Skalierung. */
|
||||||
|
export interface Viewport {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
scale: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeichenraster-Konfiguration. */
|
||||||
|
export interface GridConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hintergrundbild-Konfiguration (Spiegelung der Legacy RenderOptions-Felder). */
|
||||||
|
export interface BackgroundDrawConfig {
|
||||||
|
src?: string;
|
||||||
|
opacity?: number;
|
||||||
|
offsetX?: number;
|
||||||
|
offsetY?: number;
|
||||||
|
scale?: number;
|
||||||
|
rotation?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lineal-Konfiguration. */
|
||||||
|
export interface RulerConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
unit: string;
|
||||||
|
scaleFactor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stil pro CAD-Layer (Farbe/Linientyp/Transparenz). */
|
||||||
|
export interface LayerStyle {
|
||||||
|
color: string;
|
||||||
|
lineType: string;
|
||||||
|
transparency: number;
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Freihand-Zeichner für temporäre Overlays (Marquee, Tool-Hilfslinien). */
|
||||||
|
export interface OverlayPainter {
|
||||||
|
line(a: Pt, b: Pt, color: string, width?: number): void;
|
||||||
|
rect(x: number, y: number, w: number, h: number, strokeColor: string): void;
|
||||||
|
circle(center: Pt, radius: number, strokeColor: string): void;
|
||||||
|
text(text: string, at: Pt, color: string, fontSize?: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selektionszustand für die Anzeige von Handles. */
|
||||||
|
export interface SelectionState {
|
||||||
|
selectedIds: string[];
|
||||||
|
/** Task C3-Finish: Elemente für Handle-Zeichnung (Renderer braucht BBox). */
|
||||||
|
elements?: CADElement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der zentrale Rendervertrag. Implementierungen MÜSSEN alle Methoden bereitstellen,
|
||||||
|
* auch wenn einzelne initial No-Ops sind (z.B. drawBackground ohne Bild).
|
||||||
|
*/
|
||||||
|
export interface RenderAdapter {
|
||||||
|
/** Bindet den Renderer an ein Canvas-Element (einmalig). */
|
||||||
|
init(canvas: HTMLCanvasElement): void;
|
||||||
|
|
||||||
|
/** Reaktion auf Größenänderungen (DPR-bewusst). */
|
||||||
|
resize(width: number, height: number, dpr: number): void;
|
||||||
|
|
||||||
|
/** Setzt den aktuellen Ausschnitt; danach ist ein neuer Frame nötig. */
|
||||||
|
setViewport(vp: Viewport): void;
|
||||||
|
|
||||||
|
/// Gibt WebGL-/DOM-Ressourcen frei. */
|
||||||
|
destroy(): void;
|
||||||
|
|
||||||
|
/** Leert die Zeichenfläche mit einer Grundfarbe. */
|
||||||
|
clear(color: string): void;
|
||||||
|
|
||||||
|
/** Zeichnet das Raster. */
|
||||||
|
drawGrid(cfg: GridConfig): void;
|
||||||
|
|
||||||
|
/** Zeichnet das Hintergrundbild, falls konfiguriert. */
|
||||||
|
drawBackground(img: HTMLImageElement | null, cfg: BackgroundDrawConfig): void;
|
||||||
|
|
||||||
|
/** Zeichnet das Lineal. */
|
||||||
|
drawRuler(cfg: RulerConfig): void;
|
||||||
|
|
||||||
|
/** Zeichnet ein Element im Kontext seines Layers (Stil, Sichtbarkeit). */
|
||||||
|
drawElement(el: CADElement, layer: CADLayer, style: LayerStyle): void;
|
||||||
|
|
||||||
|
/** Entfernt ein Element aus dem Rendering (z.B. nach Löschen in Yjs). */
|
||||||
|
removeElement(id: string): void;
|
||||||
|
|
||||||
|
/** Entfernt ALLE gerenderten Elemente (Reset bei Dokumentwechsel). */
|
||||||
|
clearElements(): void;
|
||||||
|
|
||||||
|
/** Zeichnet ein Element in Transformation / Live-Vorschau. */
|
||||||
|
drawPreview(el: CADElement | null): void;
|
||||||
|
|
||||||
|
/** Synchronisiert die Selektionsanzeige (Handles, Umrandungen). */
|
||||||
|
setSelection(sel: SelectionState): void;
|
||||||
|
|
||||||
|
/** Snap-Markierungen setzen/entfernen. */
|
||||||
|
setSnapPoints(pts: Array<{ x: number; y: number; type: string }>): void;
|
||||||
|
|
||||||
|
/** Öffnet einen Overlay-Pass für freihand-Overlays (Tools). */
|
||||||
|
overlay(draw: (g: OverlayPainter) => void): void;
|
||||||
|
|
||||||
|
/** Treffer abfragen (Delegation an SpatialIndex; kein GPU-Picking). */
|
||||||
|
hitTest(world: Pt, tolerancePx: number): CADElement | null;
|
||||||
|
}
|
||||||
@@ -159,6 +159,13 @@ export const SEATING_TEMPLATES: SeatingTemplate[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let seqCounter = 0;
|
||||||
|
/** Eindeutige, kollisionsfreie ID mit Präfix (Zeit + Sequenz + Zufall). */
|
||||||
|
function nextUid(prefix: string): string {
|
||||||
|
seqCounter += 1;
|
||||||
|
return `${prefix}_${Date.now().toString(36)}_${seqCounter.toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export class SeatingService {
|
export class SeatingService {
|
||||||
private generateId(): string {
|
private generateId(): string {
|
||||||
return `el_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
return `el_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||||
@@ -189,6 +196,7 @@ export class SeatingService {
|
|||||||
createSeatingRow(x: number, y: number, layerId: string, config: Partial<SeatingRowConfig> = {}): CADElement[] {
|
createSeatingRow(x: number, y: number, layerId: string, config: Partial<SeatingRowConfig> = {}): CADElement[] {
|
||||||
const cfg = { ...DEFAULT_ROW, ...config };
|
const cfg = { ...DEFAULT_ROW, ...config };
|
||||||
const elements: CADElement[] = [];
|
const elements: CADElement[] = [];
|
||||||
|
const rowId = nextUid('row');
|
||||||
const totalWidth = (cfg.count - 1) * cfg.spacing + cfg.chairWidth;
|
const totalWidth = (cfg.count - 1) * cfg.spacing + cfg.chairWidth;
|
||||||
const startX = x - totalWidth / 2 + cfg.chairWidth / 2;
|
const startX = x - totalWidth / 2 + cfg.chairWidth / 2;
|
||||||
|
|
||||||
@@ -217,7 +225,7 @@ export class SeatingService {
|
|||||||
outlineColor: '#2a5a99',
|
outlineColor: '#2a5a99',
|
||||||
seatType: 'standard',
|
seatType: 'standard',
|
||||||
rowIndex: i,
|
rowIndex: i,
|
||||||
rowId: `row_${Date.now()}`,
|
rowId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -233,9 +241,10 @@ export class SeatingService {
|
|||||||
const startX = x - totalW / 2 + cfg.chairWidth / 2;
|
const startX = x - totalW / 2 + cfg.chairWidth / 2;
|
||||||
const startY = y - totalH / 2 + cfg.chairHeight / 2;
|
const startY = y - totalH / 2 + cfg.chairHeight / 2;
|
||||||
const rot = cfg.rotation * Math.PI / 180;
|
const rot = cfg.rotation * Math.PI / 180;
|
||||||
const blockId = `block_${Date.now()}`;
|
const blockId = nextUid('block');
|
||||||
|
|
||||||
for (let row = 0; row < cfg.rows; row++) {
|
for (let row = 0; row < cfg.rows; row++) {
|
||||||
|
const rowId = nextUid('row');
|
||||||
const offset = cfg.rowOffset * (row % 2);
|
const offset = cfg.rowOffset * (row % 2);
|
||||||
for (let col = 0; col < cfg.cols; col++) {
|
for (let col = 0; col < cfg.cols; col++) {
|
||||||
const lx = startX + col * cfg.colSpacing + offset;
|
const lx = startX + col * cfg.colSpacing + offset;
|
||||||
@@ -263,6 +272,7 @@ export class SeatingService {
|
|||||||
rowIndex: row,
|
rowIndex: row,
|
||||||
colIndex: col,
|
colIndex: col,
|
||||||
blockId,
|
blockId,
|
||||||
|
rowId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -342,8 +352,18 @@ export class SeatingService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Count seats in a list of elements */
|
/**
|
||||||
countSeats(elements: CADElement[]): { total: number; byRow: Record<string, number>; byBlock: Record<string, number> } {
|
* Zählt Sitzplätze gruppiert nach Reihe und Block.
|
||||||
|
* byRow enthält Reihen aus freien Reihen UND Block-Reihen (seit BUG-1-Fix
|
||||||
|
* tragen Block-Stühle pro Reihe eine eigene rowId).
|
||||||
|
*/
|
||||||
|
countSeats(elements: CADElement[]): {
|
||||||
|
total: number;
|
||||||
|
byRow: Record<string, number>;
|
||||||
|
byBlock: Record<string, number>;
|
||||||
|
/** Platzhalter für künftige Zonen-Gruppierung (aktuell immer leer). */
|
||||||
|
byZone?: Record<string, number>;
|
||||||
|
} {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
const byRow: Record<string, number> = {};
|
const byRow: Record<string, number> = {};
|
||||||
const byBlock: Record<string, number> = {};
|
const byBlock: Record<string, number> = {};
|
||||||
|
|||||||
@@ -163,7 +163,14 @@ export function mirrorElement(el: CADElement, x1: number, y1: number, x2: number
|
|||||||
* Offset element by a distance (creates a parallel copy).
|
* Offset element by a distance (creates a parallel copy).
|
||||||
* For lines: offset perpendicular. For circles/arcs: adjust radius.
|
* For lines: offset perpendicular. For circles/arcs: adjust radius.
|
||||||
*/
|
*/
|
||||||
export function offsetElement(el: CADElement, distance: number): CADElement {
|
/**
|
||||||
|
* Versetzt ein Element senkrecht zur seiner Richtung.
|
||||||
|
* @param distance positiver Betrag des Versatzes
|
||||||
|
* @param side 1 = links der Laufrichtung (Standard), -1 = rechts —
|
||||||
|
* bei circle/arc: 1 = Radius vergrößern, -1 = verkleinern
|
||||||
|
*/
|
||||||
|
export function offsetElement(el: CADElement, distance: number, side?: 1 | -1): CADElement {
|
||||||
|
const signed = distance * (side ?? 1);
|
||||||
const props = { ...el.properties };
|
const props = { ...el.properties };
|
||||||
|
|
||||||
if (el.type === 'line' && props.x1 !== undefined && props.y1 !== undefined && props.x2 !== undefined && props.y2 !== undefined) {
|
if (el.type === 'line' && props.x1 !== undefined && props.y1 !== undefined && props.x2 !== undefined && props.y2 !== undefined) {
|
||||||
@@ -174,8 +181,8 @@ export function offsetElement(el: CADElement, distance: number): CADElement {
|
|||||||
// Perpendicular unit vector
|
// Perpendicular unit vector
|
||||||
const nx = -dy / len;
|
const nx = -dy / len;
|
||||||
const ny = dx / len;
|
const ny = dx / len;
|
||||||
const ox = nx * distance;
|
const ox = nx * signed;
|
||||||
const oy = ny * distance;
|
const oy = ny * signed;
|
||||||
props.x1 += ox;
|
props.x1 += ox;
|
||||||
props.y1 += oy;
|
props.y1 += oy;
|
||||||
props.x2 += ox;
|
props.x2 += ox;
|
||||||
@@ -184,7 +191,7 @@ export function offsetElement(el: CADElement, distance: number): CADElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ((el.type === 'circle' || el.type === 'arc') && props.radius !== undefined) {
|
if ((el.type === 'circle' || el.type === 'arc') && props.radius !== undefined) {
|
||||||
props.radius = Math.abs(props.radius + distance);
|
props.radius = Math.abs(props.radius + signed);
|
||||||
const d = props.radius * 2;
|
const d = props.radius * 2;
|
||||||
return { ...el, width: d, height: d, properties: props };
|
return { ...el, width: d, height: d, properties: props };
|
||||||
}
|
}
|
||||||
@@ -201,7 +208,7 @@ export function offsetElement(el: CADElement, distance: number): CADElement {
|
|||||||
if (len === 0) return p;
|
if (len === 0) return p;
|
||||||
const nx = -dy / len;
|
const nx = -dy / len;
|
||||||
const ny = dx / len;
|
const ny = dx / len;
|
||||||
return { x: p.x + nx * distance, y: p.y + ny * distance };
|
return { x: p.x + nx * signed, y: p.y + ny * signed };
|
||||||
});
|
});
|
||||||
return { ...el, properties: props };
|
return { ...el, properties: props };
|
||||||
}
|
}
|
||||||
@@ -215,7 +222,15 @@ export function offsetElement(el: CADElement, distance: number): CADElement {
|
|||||||
*/
|
*/
|
||||||
export function trimElement(el: CADElement, boundary: CADElement): CADElement | null {
|
export function trimElement(el: CADElement, boundary: CADElement): CADElement | null {
|
||||||
// Simplified: find intersection with boundary, trim line to that point
|
// Simplified: find intersection with boundary, trim line to that point
|
||||||
if (el.type !== 'line' || !el.properties.x1 || !el.properties.x2) return null;
|
// Hinweis: explizit auf undefined prüfen — Koordinate 0 ist gültig (nicht falsy behandeln)
|
||||||
|
const p = el.properties;
|
||||||
|
if (
|
||||||
|
el.type !== 'line' ||
|
||||||
|
p.x1 === undefined || p.y1 === undefined ||
|
||||||
|
p.x2 === undefined || p.y2 === undefined
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const intersect = findIntersection(el, boundary);
|
const intersect = findIntersection(el, boundary);
|
||||||
if (!intersect) return null;
|
if (!intersect) return null;
|
||||||
@@ -247,10 +262,60 @@ export function trimElement(el: CADElement, boundary: CADElement): CADElement |
|
|||||||
* Currently supports extending lines to a boundary intersection.
|
* Currently supports extending lines to a boundary intersection.
|
||||||
*/
|
*/
|
||||||
export function extendElement(el: CADElement, boundary: CADElement): CADElement | null {
|
export function extendElement(el: CADElement, boundary: CADElement): CADElement | null {
|
||||||
if (el.type !== 'line' || !el.properties.x1 || !el.properties.x2) return null;
|
// Explizit auf undefined prüfen — Koordinate 0 ist gültig (nicht falsy behandeln)
|
||||||
|
const p = el.properties;
|
||||||
|
if (
|
||||||
|
el.type !== 'line' ||
|
||||||
|
p.x1 === undefined || p.y1 === undefined ||
|
||||||
|
p.x2 === undefined || p.y2 === undefined
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const intersect = findIntersection(el, boundary);
|
let intersect = findIntersection(el, boundary);
|
||||||
if (!intersect) return null;
|
|
||||||
|
// Echtes CAD-Extend (Fallback): Wenn die Strecke die Kante NICHT berührt,
|
||||||
|
// suche Schnitte auf der VERLÄNGERUNG der Linie in Richtung des nahen Endes.
|
||||||
|
if (!intersect) {
|
||||||
|
const c = { x: boundary.x, y: boundary.y };
|
||||||
|
const r =
|
||||||
|
boundary.type === 'circle' || boundary.type === 'arc'
|
||||||
|
? ((boundary.properties?.radius as number | undefined) ?? boundary.width / 2)
|
||||||
|
: null;
|
||||||
|
if (r === null) return null; // nur Kreis/Bogen-Kanten erweiterbar
|
||||||
|
|
||||||
|
const candidates = lineCircleIntersectionsInfinite(
|
||||||
|
{ x: p.x1!, y: p.y1! },
|
||||||
|
{ x: p.x2!, y: p.y2! },
|
||||||
|
c,
|
||||||
|
r as number,
|
||||||
|
);
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
|
||||||
|
// Wähle den Schnittpunkt, der an der Seite des NÄHEREN Endes liegt:
|
||||||
|
// Erst feststellen, welches Ende näher am Kreiszentrum liegt → dieses wird gestreckt.
|
||||||
|
const d1 = Math.hypot(p.x1! - c.x, p.y1! - c.y);
|
||||||
|
const d2 = Math.hypot(p.x2! - c.x, p.y2! - c.y);
|
||||||
|
const nearerIsStart = d1 <= d2;
|
||||||
|
const anchor = nearerIsStart ? { x: p.x1!, y: p.y1! } : { x: p.x2!, y: p.y2! };
|
||||||
|
intersect = candidates.reduce((best, cur) =>
|
||||||
|
Math.hypot(cur.x - anchor.x, cur.y - anchor.y) < Math.hypot(best.x - anchor.x, best.y - anchor.y) ? cur : best,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Neu berechnete bbox/center auf Basis der Länge nach dem Strekken:
|
||||||
|
const nx1 = nearerIsStart ? intersect.x : p.x1!;
|
||||||
|
const ny1 = nearerIsStart ? intersect.y : p.y1!;
|
||||||
|
const nx2 = nearerIsStart ? p.x2! : intersect.x;
|
||||||
|
const ny2 = nearerIsStart ? p.y2! : intersect.y;
|
||||||
|
return {
|
||||||
|
...el,
|
||||||
|
x: (nx1 + nx2) / 2,
|
||||||
|
y: (ny1 + ny2) / 2,
|
||||||
|
width: Math.abs(nx2 - nx1),
|
||||||
|
height: Math.abs(ny2 - ny1),
|
||||||
|
properties: { ...p, x1: nx1, y1: ny1, x2: nx2, y2: ny2 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const props = { ...el.properties };
|
const props = { ...el.properties };
|
||||||
// Extend the end that is closer to the intersection
|
// Extend the end that is closer to the intersection
|
||||||
@@ -300,22 +365,197 @@ export function filletElements(el1: CADElement, el2: CADElement, radius: number)
|
|||||||
/**
|
/**
|
||||||
* Find intersection point of two elements (lines only for now).
|
* Find intersection point of two elements (lines only for now).
|
||||||
*/
|
*/
|
||||||
function findIntersection(el1: CADElement, el2: CADElement): { x: number; y: number } | null {
|
/** Punkt im 2D-Raum. */
|
||||||
|
export interface Pt {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Schnittpunkte einer Strecke p1→p2 mit einem Kreis (Zentrum c, Radius r).
|
||||||
|
* Liefert nur Punkte innerhalb des Streckensegments (t ∈ [0,1]); [] wenn keine.
|
||||||
|
*/
|
||||||
|
export function lineCircleIntersections(p1: Pt, p2: Pt, c: Pt, r: number): Pt[] {
|
||||||
|
const dx = p2.x - p1.x;
|
||||||
|
const dy = p2.y - p1.y;
|
||||||
|
const fx = p1.x - c.x;
|
||||||
|
const fy = p1.y - c.y;
|
||||||
|
|
||||||
|
const a = dx * dx + dy * dy;
|
||||||
|
const b = 2 * (fx * dx + fy * dy);
|
||||||
|
const cc = fx * fx + fy * fy - r * r;
|
||||||
|
|
||||||
|
let discriminant = b * b - 4 * a * cc;
|
||||||
|
if (discriminant < 0) return []; // keine reale Lösung
|
||||||
|
|
||||||
|
discriminant = Math.sqrt(discriminant);
|
||||||
|
const t1 = (-b - discriminant) / (2 * a);
|
||||||
|
const t2 = (-b + discriminant) / (2 * a);
|
||||||
|
// Tangente: beide Lösungen identisch → nur einen Punkt liefern
|
||||||
|
const ts = Math.abs(t1 - t2) < 1e-7 ? [t1] : [t1, t2];
|
||||||
|
const eps = 1e-9;
|
||||||
|
const results: Pt[] = [];
|
||||||
|
for (const t of ts) {
|
||||||
|
if (t >= -eps && t <= 1 + eps) {
|
||||||
|
results.push({ x: p1.x + t * dx, y: p1.y + t * dy });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schnittpunkte zweier Kreise (Zentren c1/c2, Radien r1/r2).
|
||||||
|
* Liefert 0–2 Punkte; [] wenn getrennt oder identisch.
|
||||||
|
*/
|
||||||
|
export function circleCircleIntersections(c1: Pt, r1: number, c2: Pt, r2: number): Pt[] {
|
||||||
|
const dVec = { x: c2.x - c1.x, y: c2.y - c1.y };
|
||||||
|
const d = Math.hypot(dVec.x, dVec.y);
|
||||||
|
if (d === 0) return []; // identische Zentren
|
||||||
|
if (d > r1 + r2 || d < Math.abs(r1 - r2)) return []; // getrennt / ineinander
|
||||||
|
|
||||||
|
const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d);
|
||||||
|
const hSq = r1 * r1 - a * a;
|
||||||
|
const h = Math.sqrt(Math.max(0, hSq));
|
||||||
|
const baseX = c1.x + (a * dVec.x) / d;
|
||||||
|
const baseY = c1.y + (a * dVec.y) / d;
|
||||||
|
if (h === 0) return [{ x: baseX, y: baseY }]; // tangentiale Berührung
|
||||||
|
return [
|
||||||
|
{ x: baseX + (h * dVec.y) / d, y: baseY - (h * dVec.x) / d },
|
||||||
|
{ x: baseX - (h * dVec.y) / d, y: baseY + (h * dVec.x) / d },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die Arc-Parameter eines CADElements nach der Konvention von
|
||||||
|
* RenderEngine.drawArc: Zentrum = el.x/el.y, radius/startAngle/endAngle aus
|
||||||
|
* properties; Winkel in GRAD, 0° = 3 Uhr, gegen Uhrzeigersinn.
|
||||||
|
*/
|
||||||
|
function readArc(el: CADElement): { c: Pt; r: number; startDeg: number; endDeg: number } | null {
|
||||||
|
const r = (el.properties?.radius as number | undefined) ?? (el as unknown as { width?: number }).width! / 2;
|
||||||
|
if (!Number.isFinite(r) || r <= 0) return null;
|
||||||
|
return {
|
||||||
|
c: { x: el.x, y: el.y },
|
||||||
|
r,
|
||||||
|
startDeg: ((el.properties?.startAngle as number | undefined) ?? 0),
|
||||||
|
endDeg: ((el.properties?.endAngle as number | undefined) ?? 360),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prüft, ob ein Winkel (Grad) innerhalb eines Bogens liegt (CCW von start bis end). */
|
||||||
|
function angleInArcRange(deg: number, startDeg: number, endDeg: number): boolean {
|
||||||
|
const norm = (v: number) => ((v % 360) + 360) % 360;
|
||||||
|
let a = norm(deg);
|
||||||
|
const s = norm(startDeg);
|
||||||
|
const e = norm(endDeg);
|
||||||
|
if (s <= e) return a >= s && a <= e;
|
||||||
|
return a >= s || a <= e; // Bogen über 0°
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie lineCircleIntersections, aber OHNE Segmentbegrenzung (t ∈ ℝ):
|
||||||
|
* findet Schnitte auch auf der VERLÄNGERUNG der Strecke — Grundlage für
|
||||||
|
* echtes CAD-Extend (Task A4.10), bei dem das Ziel die Kante oft noch
|
||||||
|
* gar nicht berührt.
|
||||||
|
*/
|
||||||
|
export function lineCircleIntersectionsInfinite(p1: Pt, p2: Pt, c: Pt, r: number): Pt[] {
|
||||||
|
const dx = p2.x - p1.x;
|
||||||
|
const dy = p2.y - p1.y;
|
||||||
|
const fx = p1.x - c.x;
|
||||||
|
const fy = p1.y - c.y;
|
||||||
|
const a = dx * dx + dy * dy;
|
||||||
|
if (a < 1e-12) return [];
|
||||||
|
const b = 2 * (fx * dx + fy * dy);
|
||||||
|
const cc = fx * fx + fy * fy - r * r;
|
||||||
|
let disc = b * b - 4 * a * cc;
|
||||||
|
if (disc < 0) return [];
|
||||||
|
disc = Math.sqrt(disc);
|
||||||
|
const out: Pt[] = [];
|
||||||
|
for (const t of [(-b - disc) / (2 * a), (-b + disc) / (2 * a)]) {
|
||||||
|
out.push({ x: p1.x + t * dx, y: p1.y + t * dy });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Schnittpunkte einer Strecke p1→p2 mit dem Bogen eines Elements
|
||||||
|
* (Konvention wie RenderEngine.drawArc). Nur Treffer innerhalb des Segments
|
||||||
|
* UND im Winkelbereich des Bogens werden zurückgegeben.
|
||||||
|
*/
|
||||||
|
export function lineArcIntersection(p1: Pt, p2: Pt, el: CADElement): Pt[] {
|
||||||
|
const arc = readArc(el);
|
||||||
|
if (!arc) return [];
|
||||||
|
return lineCircleIntersections(p1, p2, arc.c, arc.r)
|
||||||
|
.filter((pt) => angleInArcRange((Math.atan2(pt.y - arc.c.y, pt.x - arc.c.x) * 180) / Math.PI, arc.startDeg, arc.endDeg));
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineLineIntersections(el1: CADElement, el2: CADElement): Pt[] {
|
||||||
const p1 = el1.properties;
|
const p1 = el1.properties;
|
||||||
const p2 = el2.properties;
|
const p2 = el2.properties;
|
||||||
|
|
||||||
if (p1.x1 === undefined || p1.y1 === undefined || p1.x2 === undefined || p1.y2 === undefined) return null;
|
if (p1.x1 === undefined || p1.y1 === undefined || p1.x2 === undefined || p1.y2 === undefined) return [];
|
||||||
if (p2.x1 === undefined || p2.y1 === undefined || p2.x2 === undefined || p2.y2 === undefined) return null;
|
if (p2.x1 === undefined || p2.y1 === undefined || p2.x2 === undefined || p2.y2 === undefined) return [];
|
||||||
|
|
||||||
// Line-line intersection
|
const denom = (p1.x1! - p1.x2!) * (p2.y1! - p2.y2!) - (p1.y1! - p1.y2!) * (p2.x1! - p2.x2!);
|
||||||
const denom = (p1.x1 - p1.x2) * (p2.y1 - p2.y2) - (p1.y1 - p1.y2) * (p2.x1 - p2.x2);
|
if (Math.abs(denom) < 1e-10) return [];
|
||||||
if (Math.abs(denom) < 1e-10) return null;
|
|
||||||
|
|
||||||
const t = ((p1.x1 - p2.x1) * (p2.y1 - p2.y2) - (p1.y1 - p2.y1) * (p2.x1 - p2.x2)) / denom;
|
const t = ((p1.x1! - p2.x1!) * (p2.y1! - p2.y2!) - (p1.y1! - p2.y1!) * (p2.x1! - p2.x2!)) / denom;
|
||||||
const x = p1.x1 + t * (p1.x2 - p1.x1);
|
const x = p1.x1! + t * (p1.x2! - p1.x1!);
|
||||||
const y = p1.y1 + t * (p1.y2 - p1.y1);
|
const y = p1.y1! + t * (p1.y2! - p1.y1!);
|
||||||
|
return [{ x, y }];
|
||||||
|
}
|
||||||
|
|
||||||
return { x, y };
|
/**
|
||||||
|
* Erster Schnittpunkt zweier CAD-Elemente (line/circle/arc), intern für
|
||||||
|
* trim/extend/fillet. Bei mehreren Treffern wird der zu el1 nächstgelegene
|
||||||
|
* gewählt. null wenn keine Kombination unterstützt oder kein Schnitt.
|
||||||
|
*/
|
||||||
|
function findIntersection(el1: CADElement, el2: CADElement): { x: number; y: number } | null {
|
||||||
|
const types = new Set([el1.type, el2.type]);
|
||||||
|
// Linie immer als erstes Argument normalisieren (reihenfolgeunabhängig)
|
||||||
|
const [lineEl, otherEl] = el1.type === 'line' ? [el1, el2] : [el2, el1];
|
||||||
|
let candidates: Pt[] = [];
|
||||||
|
let refFromFirst = true; // Referenzpunkt von el1 nehmen?
|
||||||
|
|
||||||
|
if (types.has('line') && types.size === 1) {
|
||||||
|
candidates = lineLineIntersections(el1, el2);
|
||||||
|
refFromFirst = false; // beide sind Linien; Referenz unten via el1-Startpunkt
|
||||||
|
} else if (types.has('line') && types.has('circle')) {
|
||||||
|
candidates = lineCircleIntersections(
|
||||||
|
{ x: lineEl.properties.x1!, y: lineEl.properties.y1! },
|
||||||
|
{ x: lineEl.properties.x2!, y: lineEl.properties.y2! },
|
||||||
|
{ x: otherEl.x, y: otherEl.y },
|
||||||
|
(otherEl.properties.radius as number | undefined) ?? otherEl.width / 2,
|
||||||
|
);
|
||||||
|
} else if (types.has('line') && types.has('arc')) {
|
||||||
|
candidates = lineArcIntersection(
|
||||||
|
{ x: lineEl.properties.x1!, y: lineEl.properties.y1! },
|
||||||
|
{ x: lineEl.properties.x2!, y: lineEl.properties.y2! },
|
||||||
|
otherEl,
|
||||||
|
);
|
||||||
|
} else if (types.has('circle') && types.size === 1) {
|
||||||
|
candidates = circleCircleIntersections(
|
||||||
|
{ x: el1.x, y: el1.y },
|
||||||
|
(el1.properties.radius as number | undefined) ?? el1.width / 2,
|
||||||
|
{ x: el2.x, y: el2.y },
|
||||||
|
(el2.properties.radius as number | undefined) ?? el2.width / 2,
|
||||||
|
);
|
||||||
|
refFromFirst = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
// Nächstliegenden Treffer zu el1 wählen (Startpunkt bei Linie, sonst Zentrum)
|
||||||
|
const isFirstLine = el1.type === 'line';
|
||||||
|
const refX = refFromFirst || isFirstLine
|
||||||
|
? (isFirstLine ? el1.properties.x1 : el1.x)
|
||||||
|
: el1.x;
|
||||||
|
const refY = refFromFirst || isFirstLine
|
||||||
|
? (isFirstLine ? el1.properties.y1 : el1.y)
|
||||||
|
: el1.y;
|
||||||
|
let best = candidates[0];
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (Math.hypot(c.x - refX!, c.y - refY!) < Math.hypot(best.x - refX!, best.y - refY!)) best = c;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ export interface RibbonBarProps {
|
|||||||
onCanvasBgColorChange?: (color: string) => void;
|
onCanvasBgColorChange?: (color: string) => void;
|
||||||
onGridColorChange?: (color: string) => void;
|
onGridColorChange?: (color: string) => void;
|
||||||
onToggleRuler?: () => void;
|
onToggleRuler?: () => void;
|
||||||
|
/** Task A4.13: meldet, ob ein V2-Werkzeug-ID aktuell das aktive Tool ist (Highlight). */
|
||||||
|
onIsV2ToolActive?: (toolId: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LeftSidebarProps {
|
export interface LeftSidebarProps {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Minimaler Level-basierter Logger.
|
||||||
|
* Ziel: direktes `console.log` im Produktivcode ersetzen (ROADMAP Teil-4-Regel),
|
||||||
|
* damit sich Logs zentral filtern/abschalten lassen.
|
||||||
|
*
|
||||||
|
* Nutzung:
|
||||||
|
* import { logger } from '../utils/logger';
|
||||||
|
* logger.info('Element erstellt', el.id);
|
||||||
|
*
|
||||||
|
* Level über localStorage['webcad.logLevel'] ('silent'|'error'|'warn'|'info'|'debug',
|
||||||
|
* Default: 'info'). `logger.debug` bleibt auf Default still.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug';
|
||||||
|
|
||||||
|
const LEVEL_ORDER: Record<LogLevel, number> = {
|
||||||
|
silent: 100,
|
||||||
|
error: 40,
|
||||||
|
warn: 30,
|
||||||
|
info: 20,
|
||||||
|
debug: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
function readConfiguredLevel(): LogLevel {
|
||||||
|
try {
|
||||||
|
// Browser und jsdom haben localStorage; in reinen Node-Tests schlägt der Zugriff fehl
|
||||||
|
const raw = globalThis.localStorage?.getItem('webcad.logLevel');
|
||||||
|
if (raw && raw in LEVEL_ORDER) return raw as LogLevel;
|
||||||
|
} catch {
|
||||||
|
/* Umgebung ohne localStorage → Default */
|
||||||
|
}
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentLevel: LogLevel = readConfiguredLevel();
|
||||||
|
|
||||||
|
/** Setzt das Level programmatisch (überschreibt localStorage bis zum Reload). */
|
||||||
|
export function setLogLevel(level: LogLevel): void {
|
||||||
|
currentLevel = level;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aktuell aktives Log-Level. */
|
||||||
|
export function getLogLevel(): LogLevel {
|
||||||
|
return currentLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
/** Fehlerhafte Zustände — melden außer bei silent. */
|
||||||
|
error(...args: unknown[]): void {
|
||||||
|
if (LEVEL_ORDER[currentLevel] <= LEVEL_ORDER.error) console.error('[web-cad]', ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Warnungen. */
|
||||||
|
warn(...args: unknown[]): void {
|
||||||
|
if (LEVEL_ORDER[currentLevel] <= LEVEL_ORDER.warn) console.warn('[web-cad]', ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normale Informationsmeldungen (Default-Level aktiv). */
|
||||||
|
info(...args: unknown[]): void {
|
||||||
|
if (LEVEL_ORDER[currentLevel] <= LEVEL_ORDER.info) console.log('[web-cad]', ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Debug-Ausgaben — nur wenn Level debug konfiguriert ist. */
|
||||||
|
debug(...args: unknown[]): void {
|
||||||
|
if (LEVEL_ORDER[currentLevel] <= LEVEL_ORDER.debug) console.log('[web-cad][debug]', ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = new Logger();
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Regressions-Tests für bekannte Bugs BUG-1, BUG-1b, BUG-2.
|
||||||
|
* Diese Tests sind VOR dem jeweiligen Fix ROT (dokumentiertes Fehlverhalten)
|
||||||
|
* und müssen danach GRÜN sein.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { SeatingService } from '../src/services/seatingService';
|
||||||
|
import { trimElement, offsetElement } from '../src/tools/modification/geometry';
|
||||||
|
import type { CADElement } from '../src/types/cad.types';
|
||||||
|
import { RenderEngine } from '../src/canvas/RenderEngine';
|
||||||
|
import { ZoomPanController } from '../src/canvas/ZoomPanController';
|
||||||
|
import { SpatialIndex } from '../src/canvas/SpatialIndex';
|
||||||
|
import { LayerManager } from '../src/canvas/LayerManager';
|
||||||
|
|
||||||
|
// ── Helfer (Muster aus tests/geometry.test.ts) ─────────────
|
||||||
|
function makeLine(id: string, x1: number, y1: number, x2: number, y2: number): CADElement {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'line',
|
||||||
|
layerId: 'layer-1',
|
||||||
|
x: (x1 + x2) / 2,
|
||||||
|
y: (y1 + y2) / 2,
|
||||||
|
width: Math.abs(x2 - x1),
|
||||||
|
height: Math.abs(y2 - y1),
|
||||||
|
properties: { x1, y1, x2, y2 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCircle(id: string, cx: number, cy: number, r: number): CADElement {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'circle',
|
||||||
|
layerId: 'layer-1',
|
||||||
|
x: cx,
|
||||||
|
y: cy,
|
||||||
|
width: r * 2,
|
||||||
|
height: r * 2,
|
||||||
|
properties: { radius: r },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BUG-1: rowId-Kollision bei schnellem Erzeugen ───────────
|
||||||
|
describe('BUG-1: seating row ids', () => {
|
||||||
|
it('zwei direkt hintereinander erzeugte Reihen haben unterschiedliche rowIds', () => {
|
||||||
|
const svc = new SeatingService();
|
||||||
|
const a = svc.createSeatingRow(0, 0, 'layer-1');
|
||||||
|
const b = svc.createSeatingRow(0, 100, 'layer-1');
|
||||||
|
const ids = new Set([a[0].properties.rowId, b[0].properties.rowId]);
|
||||||
|
expect(ids.size).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── BUG-1b: Block-Reihen ohne eigene rowId ──────────────────
|
||||||
|
describe('BUG-1b: seating block row ids', () => {
|
||||||
|
it('jede Reihe eines Blocks hat eine eigene, definierte rowId', () => {
|
||||||
|
const svc = new SeatingService();
|
||||||
|
const els = svc.createSeatingBlock(0, 0, 'layer-1', { rows: 3, cols: 2 });
|
||||||
|
expect(els.length).toBe(6);
|
||||||
|
const rowIds = new Set(els.map((e) => e.properties.rowId));
|
||||||
|
expect(rowIds.size).toBe(3);
|
||||||
|
for (const rid of rowIds) expect(rid).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── BUG-2: Trim an Kreis scheitert (findIntersection nur Gerade×Gerade) ──
|
||||||
|
describe('BUG-2: trim line at circle', () => {
|
||||||
|
it('trimElement(Linie, Kreis) liefert ein Ergebnis statt null', () => {
|
||||||
|
// Linie von (0,0) nach (200,0) schneidet Kreis (Zentrum 100,0, Radius 40)
|
||||||
|
// an x=60 und x=140 → Schnitt existiert eindeutig.
|
||||||
|
const line = makeLine('l1', 0, 0, 200, 0);
|
||||||
|
const circle = makeCircle('c1', 100, 0, 40);
|
||||||
|
const result = trimElement(line, circle);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── BUG-3: Offset ignorierte die Klickseite ─────────────────
|
||||||
|
describe('BUG-3: offset respects click side', () => {
|
||||||
|
it('side=-1 versetzt Linie entgegen der Normalenrichtung', () => {
|
||||||
|
// Linie (100,100)→(300,100): Laufrichtung +x.
|
||||||
|
// Normale (-dy,dx)=(0,1) zeigt in Canvas-Koordinaten nach UNTEN (+y).
|
||||||
|
const line = makeLine('l1', 100, 100, 300, 100);
|
||||||
|
const result = offsetElement(line, 20, -1);
|
||||||
|
expect(result.properties.y1).toBeCloseTo(80); // nach OBEN versetzt
|
||||||
|
expect(result.properties.y2).toBeCloseTo(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('side=+1 versetzt Linie in Normalenrichtung (Default wie vorher)', () => {
|
||||||
|
const line = makeLine('l1', 100, 100, 300, 100);
|
||||||
|
const result = offsetElement(line, 20, 1);
|
||||||
|
expect(result.properties.y1).toBeCloseTo(120); // nach UNTEN versetzt
|
||||||
|
expect(result.properties.y2).toBeCloseTo(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne side bleibt Verhalten identisch zur alten API (Rueckwaertskompatibilitaet)', () => {
|
||||||
|
const line = makeLine('l1', 100, 100, 300, 100);
|
||||||
|
const result = offsetElement(line, 20);
|
||||||
|
expect(result.properties.y1).toBeCloseTo(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Kreis: side=-1 verkleinert Radius, side=+1 vergroessert', () => {
|
||||||
|
const circle = makeCircle('c1', 0, 0, 50);
|
||||||
|
expect(offsetElement(circle, 10, -1).properties.radius).toBeCloseTo(40);
|
||||||
|
expect(offsetElement(circle, 10, 1).properties.radius).toBeCloseTo(60);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── BUG-4: Hintergrundbild wurde pro Frame neu geladen ──────
|
||||||
|
describe('BUG-4: background image cached', () => {
|
||||||
|
let imageCount: number;
|
||||||
|
let OriginalImage: typeof Image;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
imageCount = 0;
|
||||||
|
OriginalImage = global.Image;
|
||||||
|
// Bild-Stub: zaehlt Konstruktur-Aufrufe; simuliert Browser-Cache-Bild
|
||||||
|
// (complete=true sofort verfuegbar, onload als Mikrotask wie async decode)
|
||||||
|
class FakeImage {
|
||||||
|
complete = true;
|
||||||
|
naturalWidth = 800;
|
||||||
|
width = 800;
|
||||||
|
height = 600;
|
||||||
|
onload: (() => void) | null = null;
|
||||||
|
private internalSrc = '';
|
||||||
|
get src(): string {
|
||||||
|
return this.internalSrc;
|
||||||
|
}
|
||||||
|
set src(value: string) {
|
||||||
|
this.internalSrc = value;
|
||||||
|
queueMicrotask(() => this.onload?.());
|
||||||
|
}
|
||||||
|
constructor() {
|
||||||
|
imageCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(global as { Image: unknown }).Image = FakeImage as unknown as typeof Image;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
(global as { Image: unknown }).Image = OriginalImage;
|
||||||
|
});
|
||||||
|
|
||||||
|
function createEngine(): RenderEngine {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 800;
|
||||||
|
canvas.height = 600;
|
||||||
|
const zoomPan = new ZoomPanController(canvas);
|
||||||
|
const spatialIndex = new SpatialIndex();
|
||||||
|
const layerManager = new LayerManager();
|
||||||
|
return new RenderEngine(canvas, zoomPan, spatialIndex, layerManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lädt das Bild pro Quellwechsel nur EINMAL, weitere Frames nutzen den Cache', async () => {
|
||||||
|
const engine = createEngine();
|
||||||
|
const DATA_URL = 'data:image/png;base64,iVBORw0KGgo=';
|
||||||
|
engine.setOptions({ backgroundSrc: DATA_URL, backgroundOpacity: 0.5 });
|
||||||
|
|
||||||
|
// Frame 1: legt genau ein Image an und cachet es (complete=true)
|
||||||
|
engine.render();
|
||||||
|
expect(imageCount).toBe(1);
|
||||||
|
|
||||||
|
// Weitere Frames mit identischer src: dürfen KEIN neues Image erzeugen
|
||||||
|
engine.render();
|
||||||
|
engine.render();
|
||||||
|
engine.render();
|
||||||
|
expect(imageCount).toBe(1);
|
||||||
|
|
||||||
|
// Ausstehende Mikrotasks (simuliertes onload) abwarten, dann erneut prüfen:
|
||||||
|
await Promise.resolve();
|
||||||
|
engine.render();
|
||||||
|
expect(imageCount).toBe(1); // vorher (Bug): jedes render() erzeugte ein neues Image
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* CADDocument Tests (Task A2b).
|
||||||
|
* Kern-Verhalten: CRUD, Shallow-Merge, Transaktion = 1 Undo-Schritt,
|
||||||
|
* undo/redo-Stacks, deleteElements selektiv.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||||
|
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||||
|
import type { CADElement } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
function makeEl(id: string, props: Record<string, unknown> = {}): CADElement {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'rect',
|
||||||
|
layerId: 'layer-1',
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 30,
|
||||||
|
height: 40,
|
||||||
|
properties: props,
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDoc(): { doc: CADDocument } {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
return { doc: new CADDocument(ydoc) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CADDocument', () => {
|
||||||
|
it('a) addElement → getAllElements enthaelt es', () => {
|
||||||
|
const { doc } = makeDoc();
|
||||||
|
doc.addElement(makeEl('e1'));
|
||||||
|
const all = doc.getAllElements();
|
||||||
|
expect(all.length).toBe(1);
|
||||||
|
expect(all[0].id).toBe('e1');
|
||||||
|
expect(doc.getElement('e1')).toBeDefined();
|
||||||
|
expect(doc.getElement('missing')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('b) transact um zwei addElements = EIN Undo-Schritt entfernt BEIDE', () => {
|
||||||
|
const { doc } = makeDoc();
|
||||||
|
doc.transact(() => {
|
||||||
|
doc.addElement(makeEl('a'));
|
||||||
|
doc.addElement(makeEl('b'));
|
||||||
|
});
|
||||||
|
expect(doc.getAllElements().length).toBe(2);
|
||||||
|
expect(doc.canUndo()).toBe(true);
|
||||||
|
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getAllElements().length).toBe(0); // beide weg mit einem undo!
|
||||||
|
expect(doc.canUndo()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('c) redo stellt beide Elemente wieder her', () => {
|
||||||
|
const { doc } = makeDoc();
|
||||||
|
doc.transact(() => {
|
||||||
|
doc.addElement(makeEl('a'));
|
||||||
|
doc.addElement(makeEl('b'));
|
||||||
|
});
|
||||||
|
doc.undo();
|
||||||
|
expect(doc.getAllElements().length).toBe(0);
|
||||||
|
|
||||||
|
expect(doc.canRedo()).toBe(true);
|
||||||
|
expect(doc.redo()).toBe(true);
|
||||||
|
expect(doc.getAllElements().length).toBe(2);
|
||||||
|
expect(doc.getElement('a')).toBeDefined();
|
||||||
|
expect(doc.getElement('b')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('d) updateElement merged properties SHALLOW (bestehende Keys bleiben)', () => {
|
||||||
|
const { doc } = makeDoc();
|
||||||
|
doc.addElement(makeEl('m', { fill: 'red', rotation: 45, stroke: 'black' }));
|
||||||
|
|
||||||
|
doc.updateElement('m', { properties: { fill: 'blue' } });
|
||||||
|
const el = doc.getElement('m')!;
|
||||||
|
expect(el.properties.fill).toBe('blue'); // ueberschrieben
|
||||||
|
expect(el.properties.rotation).toBe(45); // erhalten
|
||||||
|
expect(el.properties.stroke).toBe('black'); // erhalten
|
||||||
|
});
|
||||||
|
|
||||||
|
it('d2) updateElement kann Top-Level-Felder setzen und wirft bei fehlender ID', () => {
|
||||||
|
const { doc } = makeDoc();
|
||||||
|
doc.addElement(makeEl('m'));
|
||||||
|
doc.updateElement('m', { x: 999 });
|
||||||
|
expect(doc.getElement('m')!.x).toBe(999);
|
||||||
|
expect(() => doc.updateElement('ghost', { x: 1 })).toThrow(/nicht gefunden/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('e) deleteElements entfernt nur die genannten IDs', () => {
|
||||||
|
const { doc } = makeDoc();
|
||||||
|
doc.transact(() => {
|
||||||
|
doc.addElement(makeEl('keep1'));
|
||||||
|
doc.addElement(makeEl('del1'));
|
||||||
|
doc.addElement(makeEl('del2'));
|
||||||
|
doc.addElement(makeEl('keep2'));
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.deleteElements(['del1', 'del2', 'unbekannt']); // unbekannte still ignorieren
|
||||||
|
const ids = doc.getAllElements().map((e) => e.id).sort();
|
||||||
|
expect(ids).toEqual(['keep1', 'keep2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undo nach deleteElements stellt geloeschte Elemente wieder her', () => {
|
||||||
|
const { doc } = makeDoc();
|
||||||
|
doc.addElement(makeEl('x1'));
|
||||||
|
doc.addElement(makeEl('x2'));
|
||||||
|
|
||||||
|
doc.deleteElements(['x1']);
|
||||||
|
expect(doc.getAllElements().length).toBe(1);
|
||||||
|
|
||||||
|
doc.undo(); // Delete rueckgaengig
|
||||||
|
expect(doc.getAllElements().length).toBe(2);
|
||||||
|
expect(doc.getElement('x1')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undo/redo liefern false auf leeren Stacks', () => {
|
||||||
|
const { doc } = makeDoc();
|
||||||
|
expect(doc.undo()).toBe(false);
|
||||||
|
expect(doc.redo()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* InteractionDispatcher Tests (Task A2).
|
||||||
|
* Prüft Event-Routing gegen ein Fake-Werkzeug ohne echte Registry:
|
||||||
|
* Reihenfolge down→move→up, ESC/Rechtsklick cancel, move nur bei aktiver Session,
|
||||||
|
* unbekanntes Tool → kein Routing.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { InteractionDispatcher } from '../src/interaction/dispatcher';
|
||||||
|
import type { ToolExtensionV2 } from '../src/plugins/types';
|
||||||
|
import type { ZoomPanController } from '../src/canvas/ZoomPanController';
|
||||||
|
|
||||||
|
function makeFakeZoomPan(): ZoomPanController {
|
||||||
|
return {
|
||||||
|
screenToWorld: (sx: number, sy: number) => ({ x: sx * 2, y: sy * 3 }),
|
||||||
|
} as unknown as ZoomPanController;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRecordingTool(id = 'fake'): { tool: ToolExtensionV2; log: string[] } {
|
||||||
|
const log: string[] = [];
|
||||||
|
const tool: ToolExtensionV2 = {
|
||||||
|
manifest: { id, label: 'Fake', icon: 'f', ribbonTab: 'tools' },
|
||||||
|
optionsSchema: [],
|
||||||
|
handlers: {
|
||||||
|
down: () => log.push('down'),
|
||||||
|
move: () => log.push('move'),
|
||||||
|
up: () => log.push('up'),
|
||||||
|
key: () => false,
|
||||||
|
cancel: () => log.push('cancel'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { tool, log };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fire(canvas: HTMLCanvasElement, type: string, opts: Partial<PointerEventInit> = {}): PointerEvent {
|
||||||
|
const e = new PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
clientX: 100,
|
||||||
|
clientY: 50,
|
||||||
|
button: 0,
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
if (type === 'pointerdown') canvas.dispatchEvent(e);
|
||||||
|
else window.dispatchEvent(e);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fireKey(key: string): KeyboardEvent {
|
||||||
|
const e = new KeyboardEvent('keydown', { key, bubbles: true });
|
||||||
|
window.dispatchEvent(e);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('InteractionDispatcher', () => {
|
||||||
|
let canvas: HTMLCanvasElement;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
canvas?.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
function setup(tool?: ToolExtensionV2) {
|
||||||
|
canvas = document.createElement('canvas');
|
||||||
|
document.body.appendChild(canvas);
|
||||||
|
const dispatcher = new InteractionDispatcher(canvas, {
|
||||||
|
zoomPan: makeFakeZoomPan(),
|
||||||
|
toolLookup: tool ? () => tool : undefined,
|
||||||
|
});
|
||||||
|
return { dispatcher };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('routet down→move→up in Reihenfolge an das aktive Tool', () => {
|
||||||
|
const { tool, log } = makeRecordingTool();
|
||||||
|
const { dispatcher } = setup(tool);
|
||||||
|
dispatcher.setActive('fake');
|
||||||
|
|
||||||
|
fire(canvas, 'pointerdown');
|
||||||
|
fire(window, 'pointermove');
|
||||||
|
fire(window, 'pointerup');
|
||||||
|
|
||||||
|
expect(log).toEqual(['down', 'move', 'up']);
|
||||||
|
expect(dispatcher.getActive()?.manifest.id).toBe('fake');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignoriert move/up ohne vorheriges down (Session-Guard)', () => {
|
||||||
|
const { tool, log } = makeRecordingTool();
|
||||||
|
const { dispatcher } = setup(tool);
|
||||||
|
dispatcher.setActive('fake');
|
||||||
|
|
||||||
|
fire(window, 'pointermove'); // keine Session aktiv
|
||||||
|
fire(window, 'pointerup');
|
||||||
|
expect(log).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ESC ruft cancel und beendet die Session', () => {
|
||||||
|
const { tool, log } = makeRecordingTool();
|
||||||
|
const { dispatcher } = setup(tool);
|
||||||
|
dispatcher.setActive('fake');
|
||||||
|
|
||||||
|
fire(canvas, 'pointerdown');
|
||||||
|
fireKey('Escape');
|
||||||
|
expect(log).toEqual(['down', 'cancel']);
|
||||||
|
|
||||||
|
fire(window, 'pointermove'); // Session beendet → kein move mehr
|
||||||
|
expect(log).toEqual(['down', 'cancel']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Rechtsklick bricht ab, ohne down durchzureichen', () => {
|
||||||
|
const { tool, log } = makeRecordingTool();
|
||||||
|
const { dispatcher } = setup(tool);
|
||||||
|
dispatcher.setActive('fake');
|
||||||
|
|
||||||
|
fire(canvas, 'pointerdown', { button: 2 });
|
||||||
|
expect(log).toEqual(['cancel']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setActive(null) deaktiviert Routing komplett', () => {
|
||||||
|
const { tool, log } = makeRecordingTool();
|
||||||
|
const { dispatcher } = setup(tool);
|
||||||
|
dispatcher.setActive('fake');
|
||||||
|
dispatcher.setActive(null);
|
||||||
|
fire(canvas, 'pointerdown');
|
||||||
|
fire(window, 'pointermove');
|
||||||
|
expect(log).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liefert Weltkoordinaten aus zoomPan.screenToWorld', () => {
|
||||||
|
let received: { x: number; y: number } | null = null;
|
||||||
|
const tool: ToolExtensionV2 = {
|
||||||
|
manifest: { id: 'geo', label: 'G', icon: 'g', ribbonTab: 't' },
|
||||||
|
optionsSchema: [],
|
||||||
|
handlers: { down: (e) => (received = { ...e.world }) },
|
||||||
|
};
|
||||||
|
const { dispatcher } = setup(tool);
|
||||||
|
dispatcher.setActive('geo');
|
||||||
|
fire(canvas, 'pointerdown', { clientX: 100, clientY: 50 });
|
||||||
|
// fake screenToWorld: x*2, y*3 → (200, 150)
|
||||||
|
expect(received).toEqual({ x: 200, y: 150 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* documentService Tests (Task A4.11).
|
||||||
|
* Prüft Registrierung/Löschung des aktiven Dokuments und die
|
||||||
|
* Undo/Redo-Delegation an CADDocument.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { setActiveDocument, getActiveDocument, undoActive, redoActive } from '../src/kernel/document/documentService';
|
||||||
|
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||||
|
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||||
|
import type { CADElement } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
function makeEl(id: string): CADElement {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'rect',
|
||||||
|
layerId: 'l',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
properties: {},
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setActiveDocument(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('documentService', () => {
|
||||||
|
it('registriert und entregistriert das aktive Dokument', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
setActiveDocument(doc);
|
||||||
|
expect(getActiveDocument()).toBe(doc);
|
||||||
|
setActiveDocument(null);
|
||||||
|
expect(getActiveDocument()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undoActive delegiert an das registrierte Dokument', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
doc.addElement(makeEl('x1'));
|
||||||
|
setActiveDocument(doc);
|
||||||
|
|
||||||
|
expect(undoActive()).toBe(true); // Element wird entfernt
|
||||||
|
expect(doc.getAllElements().length).toBe(0);
|
||||||
|
expect(redoActive()).toBe(true); // wiederhergestellt
|
||||||
|
expect(doc.getAllElements().length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undoActive liefert false ohne aktives Dokument', () => {
|
||||||
|
setActiveDocument(null);
|
||||||
|
expect(undoActive()).toBe(false);
|
||||||
|
expect(redoActive()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* 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([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dimension: Linie + Label aus value/text', () => {
|
||||||
|
const prims = elementToPrimitives(el('d', 'dimension', {
|
||||||
|
x1: 0, y1: 0, x2: 100, y2: 0, value: '250 cm',
|
||||||
|
}, { x: 50, y: 0 }));
|
||||||
|
expect(prims.length).toBe(2);
|
||||||
|
expect(prims[0].kind).toBe('line');
|
||||||
|
const txt = prims[1];
|
||||||
|
if (txt.kind !== 'text') throw new Error('erwartete text');
|
||||||
|
expect(txt.text).toBe('250 cm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leader: Linie + Label am Ende', () => {
|
||||||
|
const prims = elementToPrimitives(el('ld', 'leader', {
|
||||||
|
x1: 10, y1: 10, x2: 80, y2: 60, text: 'A1', fontSize: 14,
|
||||||
|
}));
|
||||||
|
expect(prims.length).toBe(2);
|
||||||
|
expect(prims[0].kind).toBe('line');
|
||||||
|
const txt = prims[1];
|
||||||
|
if (txt.kind !== 'text') throw new Error('erwartete text');
|
||||||
|
expect(txt.text).toBe('A1');
|
||||||
|
expect(txt.fontSize).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revcloud: geschlossener Punktzug', () => {
|
||||||
|
const prims = elementToPrimitives(el('rc', 'revcloud', {
|
||||||
|
points: [{ x: 0, y: 0 }, { x: 50, y: 0 }, { x: 50, y: 30 }],
|
||||||
|
}));
|
||||||
|
expect(prims.length).toBe(1);
|
||||||
|
const p = prims[0];
|
||||||
|
if (p.kind !== 'polyline') throw new Error('erwartete polyline');
|
||||||
|
expect(p.close).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chair: gefuelltes Rechteck mit Fallback-Farbe', () => {
|
||||||
|
const prims = elementToPrimitives(el('ch', 'chair', {}, { x: 50, y: 50, width: 20, height: 20 }));
|
||||||
|
const p = prims[0];
|
||||||
|
if (p.kind !== 'rect') throw new Error('erwartete rect');
|
||||||
|
expect(p.fill).toBe('#4a90d9');
|
||||||
|
expect(p.x).toBe(40);
|
||||||
|
expect(p.y).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('table: Kreis mit Fallback-Farbe und Radius aus width', () => {
|
||||||
|
const prims = elementToPrimitives(el('tb', 'table', {}, { x: 0, y: 0, width: 60, height: 60 }));
|
||||||
|
const p = prims[0];
|
||||||
|
if (p.kind !== 'circle') throw new Error('erwartete circle');
|
||||||
|
expect(p.radius).toBe(30);
|
||||||
|
expect(p.fill).toBe('#8b7355');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stage: dunkles Rechteck', () => {
|
||||||
|
const prims = elementToPrimitives(el('st', 'stage', {}, { x: 0, y: 0, width: 200, height: 100 }));
|
||||||
|
const p = prims[0];
|
||||||
|
if (p.kind !== 'rect') throw new Error('erwartete rect');
|
||||||
|
expect(p.fill).toBe('#3a3a4a');
|
||||||
|
expect(p.w).toBe(200);
|
||||||
|
expect(p.h).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
offsetElement,
|
offsetElement,
|
||||||
getElementBBox,
|
getElementBBox,
|
||||||
distance,
|
distance,
|
||||||
|
lineCircleIntersections,
|
||||||
|
circleCircleIntersections,
|
||||||
|
lineArcIntersection,
|
||||||
angleBetween,
|
angleBetween,
|
||||||
} from '../src/tools/modification/geometry';
|
} from '../src/tools/modification/geometry';
|
||||||
import type { CADElement } from '../src/types/cad.types';
|
import type { CADElement } from '../src/types/cad.types';
|
||||||
@@ -293,3 +296,90 @@ describe('geometry – angleBetween', () => {
|
|||||||
expect(angleBetween({ x: 0, y: 0 }, { x: 0, y: 100 })).toBe(90);
|
expect(angleBetween({ x: 0, y: 0 }, { x: 0, y: 100 })).toBe(90);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('geometry – lineCircleIntersections', () => {
|
||||||
|
const p1 = { x: 0, y: 0 };
|
||||||
|
const p2 = { x: 200, y: 0 };
|
||||||
|
const c = { x: 100, y: 0 };
|
||||||
|
|
||||||
|
it('findet zwei Schnittpunkte bei Sekante', () => {
|
||||||
|
const pts = lineCircleIntersections(p1, p2, c, 40);
|
||||||
|
expect(pts.length).toBe(2);
|
||||||
|
const xs = pts.map((p) => Math.round(p.x)).sort((a, b) => a - b);
|
||||||
|
expect(xs).toEqual([60, 140]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findet genau einen Punkt bei Tangente', () => {
|
||||||
|
// Strecke entlang y=40 tangiert Kreis (100,0,r=40)
|
||||||
|
const pts = lineCircleIntersections({ x: 50, y: 40 }, { x: 150, y: 40 }, { x: 100, y: 0 }, 40);
|
||||||
|
expect(pts.length).toBe(1);
|
||||||
|
expect(pts[0].x).toBeCloseTo(100);
|
||||||
|
expect(pts[0].y).toBeCloseTo(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liefert leeres Array ohne Schnitt (Diskriminante < 0)', () => {
|
||||||
|
expect(lineCircleIntersections(p1, p2, { x: 100, y: 500 }, 10)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respektiert Segmentgrenzen t∈[0,1] (Verlaengerung schneidet nicht)', () => {
|
||||||
|
// Kreis liegt hinter dem Endpunkt der Strecke
|
||||||
|
expect(lineCircleIntersections({ x: 0, y: 0 }, { x: 50, y: 0 }, { x: 100, y: 0 }, 30)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('geometry – circleCircleIntersections', () => {
|
||||||
|
it('findet zwei Punkte bei Ueberlappung', () => {
|
||||||
|
const pts = circleCircleIntersections({ x: 0, y: 0 }, 50, { x: 80, y: 0 }, 50);
|
||||||
|
expect(pts.length).toBe(2);
|
||||||
|
for (const pt of pts) {
|
||||||
|
expect(pt.x).toBeCloseTo(40); // Symmetrieachse
|
||||||
|
expect(Math.abs(pt.y)).toBeCloseTo(30); // Höhe via Satz des Pythagoras
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liefert einen Punkt bei Tangenz', () => {
|
||||||
|
const pts = circleCircleIntersections({ x: 0, y: 0 }, 50, { x: 100, y: 0 }, 50);
|
||||||
|
expect(pts.length).toBe(1);
|
||||||
|
expect(pts[0]).toEqual({ x: 50, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liefert leer bei getrennten Kreisen und identischen Zentren', () => {
|
||||||
|
expect(circleCircleIntersections({ x: 0, y: 0 }, 20, { x: 500, y: 0 }, 20)).toEqual([]);
|
||||||
|
expect(circleCircleIntersections({ x: 0, y: 0 }, 20, { x: 0, y: 0 }, 20)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('geometry – lineArcIntersection', () => {
|
||||||
|
function makeArc(id: string, cx: number, cy: number, r: number, startDeg: number, endDeg: number): CADElement {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'arc',
|
||||||
|
layerId: 'layer-1',
|
||||||
|
x: cx,
|
||||||
|
y: cy,
|
||||||
|
width: r * 2,
|
||||||
|
height: r * 2,
|
||||||
|
properties: { radius: r, startAngle: startDeg, endAngle: endDeg },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('schneidet Strecke mit Halbkreis (0°..180°)', () => {
|
||||||
|
const arc = makeArc('a1', 100, 0, 40, 0, 180); // obere Hälfte (CCW von 3Uhr)
|
||||||
|
const pts = lineArcIntersection({ x: 60, y: 0 }, { x: 140, y: 40 }, arc);
|
||||||
|
// Linie von (60,0) nach (140,40): schneidet Bogen einmal oben
|
||||||
|
expect(pts.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filtert Treffer außerhalb des Winkelbereichs heraus', () => {
|
||||||
|
const arc = makeArc('a2', 100, 0, 40, 180, 360); // untere Hälfte
|
||||||
|
// Strecke verläuft komplett oberhalb der Mittellinie → Schnitte lägen nur
|
||||||
|
// im oberen Halbkreis (Winkel < 180°), der nicht Teil des Bogens ist.
|
||||||
|
const pts = lineArcIntersection({ x: 60, y: 20 }, { x: 140, y: 40 }, arc);
|
||||||
|
expect(pts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liefert leer bei fehlendem Radius (ungueltiges Element)', () => {
|
||||||
|
const bad = { id: 'x', type: 'arc', layerId: 'l', x: 0, y: 0, width: 0, height: 0, properties: {} } as unknown as CADElement;
|
||||||
|
expect(lineArcIntersection({ x: -10, y: -10 }, { x: 10, y: 10 }, bad)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Smoke-Tests für die Test-Helfer aus Task A0.
|
||||||
|
* Stellt sicher, dass Logger, MockRenderEngine und InMemoryDoc funktionieren,
|
||||||
|
* BEVOR spätere Tasks auf ihnen aufbauen.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { logger, setLogLevel } from '../src/utils/logger';
|
||||||
|
import { MockRenderEngine } from './helpers/mockAdapter';
|
||||||
|
import { createInMemoryDoc, makeTestLayer } from './helpers/inMemoryDoc';
|
||||||
|
import type { CADElement } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
describe('A0 helpers – logger', () => {
|
||||||
|
it('schreibt info im Default-Level in die Testumgebung', () => {
|
||||||
|
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
setLogLevel('info');
|
||||||
|
logger.info('smoke test message');
|
||||||
|
expect(spy).toHaveBeenCalledWith('[web-cad]', 'smoke test message');
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debug bleibt bei Level info still und erscheint bei debug', () => {
|
||||||
|
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
setLogLevel('info');
|
||||||
|
logger.debug('hidden');
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
setLogLevel('debug');
|
||||||
|
logger.debug('visible');
|
||||||
|
expect(spy).toHaveBeenCalledWith('[web-cad][debug]', 'visible');
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A0 helpers – MockRenderEngine', () => {
|
||||||
|
it('protokolliert Aufrufe mit Reihenfolge und Argumenten', () => {
|
||||||
|
const mock = new MockRenderEngine();
|
||||||
|
mock.render();
|
||||||
|
mock.hitTest(10, 20, 5);
|
||||||
|
mock.setPreviewElement(null);
|
||||||
|
|
||||||
|
expect(mock.countCalls('render')).toBe(1);
|
||||||
|
expect(mock.countCalls('hitTest')).toBe(1);
|
||||||
|
expect(mock.lastArgsOf('hitTest')).toEqual([10, 20, 5]);
|
||||||
|
expect(mock.calls.map((c) => c.method)).toEqual(['render', 'hitTest', 'setPreviewElement']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liefert konfigurierbare hitTest-Ergebnisse', () => {
|
||||||
|
const mock = new MockRenderEngine();
|
||||||
|
const el = { id: 'e1', type: 'line' } as unknown as CADElement;
|
||||||
|
mock.nextHitTestResult = el;
|
||||||
|
expect(mock.hitTest(0, 0)).toBe(el);
|
||||||
|
mock.reset();
|
||||||
|
expect(mock.hitTest(0, 0)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A0 helpers – createInMemoryDoc', () => {
|
||||||
|
it('erstellt netzwerkfreies Dokument mit Seeding', () => {
|
||||||
|
const { ydoc, addAll } = createInMemoryDoc();
|
||||||
|
ydoc.data.layers.set('layer-1', makeTestLayer() as never);
|
||||||
|
const el = { id: 'el1', type: 'rect', layerId: 'layer-1', x: 0, y: 0, width: 1, height: 1, properties: {} } as unknown as CADElement;
|
||||||
|
addAll([el]);
|
||||||
|
expect(ydoc.getElements().length).toBe(1);
|
||||||
|
expect(ydoc.getLayers().length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clear entfernt alle Elemente', () => {
|
||||||
|
const { ydoc, addAll, clear } = createInMemoryDoc();
|
||||||
|
const el = { id: 'el1', type: 'rect', layerId: 'layer-1', x: 0, y: 0, width: 1, height: 1, properties: {} } as unknown as CADElement;
|
||||||
|
addAll([el]);
|
||||||
|
expect(ydoc.getElements().length).toBe(1);
|
||||||
|
clear();
|
||||||
|
expect(ydoc.getElements().length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* InMemoryDoc-Helper – Netzwerkfreie YjsDocument-Fabrik für Tests.
|
||||||
|
*
|
||||||
|
* YjsDocument läuft bereits komplett In-Memory (Y.Doc ohne Provider);
|
||||||
|
* dieser Wrapper bietet bequemes Seeding und Reset für isolierte Tests.
|
||||||
|
* Genutzt ab A3 (Tool-Workflow-Tests) und A2b (CADDocument-Tests).
|
||||||
|
*/
|
||||||
|
import { YjsDocument } from '../../src/crdt/YjsDocument';
|
||||||
|
import type { CADElement } from '../../src/types/cad.types';
|
||||||
|
|
||||||
|
export interface SeededDoc {
|
||||||
|
ydoc: YjsDocument;
|
||||||
|
/** Fügt Elemente in EINER Transaktion hinzu (1 Undo-Schritt). */
|
||||||
|
addAll(elements: CADElement[]): void;
|
||||||
|
/** Entfernt alle Elemente/Layer-Inhalte (Dokument bleibt verwendbar). */
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erzeugt ein frisches, leeres Test-Dokument. */
|
||||||
|
export function createInMemoryDoc(): SeededDoc {
|
||||||
|
const ydoc = new YjsDocument();
|
||||||
|
return {
|
||||||
|
ydoc,
|
||||||
|
addAll(elements) {
|
||||||
|
ydoc.doc.transact(() => {
|
||||||
|
for (const el of elements) ydoc.data.elements.set(el.id, el);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
ydoc.doc.transact(() => {
|
||||||
|
for (const id of Array.from(ydoc.data.elements.keys())) {
|
||||||
|
ydoc.data.elements.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Basis-CADLayer für Tests. */
|
||||||
|
export function makeTestLayer(id = 'layer-1', name = 'Test-Layer') {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
color: '#ffffff',
|
||||||
|
lineType: 'solid',
|
||||||
|
transparency: 0,
|
||||||
|
sortOrder: 0,
|
||||||
|
parentId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* MockAdapter – Headless-Doppel der RenderEngine für Workflow-Tests.
|
||||||
|
* Zeichnet nichts; protokolliert alle Aufrufe in `calls` (Reihenfolge getreu).
|
||||||
|
*
|
||||||
|
* Methodenumfang = genau das, was InteractionEngine derzeit an RenderEngine
|
||||||
|
* ruft (verifiziert in Task A0): hitTest, render, setActiveSnapPoint,
|
||||||
|
* setOptions, setPreviewElement, setSnapPoints.
|
||||||
|
* Wird in C1 (RenderAdapter-Interface) um die vollen Adapter-Methoden erweitert.
|
||||||
|
*/
|
||||||
|
import type { CADElement } from '../../src/types/cad.types';
|
||||||
|
|
||||||
|
export interface RecordedCall {
|
||||||
|
method: string;
|
||||||
|
args: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MockRenderEngine {
|
||||||
|
/** Alle Aufrufe in Reihenfolge. */
|
||||||
|
readonly calls: RecordedCall[] = [];
|
||||||
|
|
||||||
|
/** Von Tests konfigurierbare hitTest-Rückgabe. */
|
||||||
|
nextHitTestResult: CADElement | null = null;
|
||||||
|
|
||||||
|
// ── Von InteractionEngine genutzte Fläche ────────────────
|
||||||
|
|
||||||
|
hitTest(x: number, y: number, tolerance?: number): CADElement | null {
|
||||||
|
this.calls.push({ method: 'hitTest', args: [x, y, tolerance] });
|
||||||
|
return this.nextHitTestResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): void {
|
||||||
|
this.calls.push({ method: 'render', args: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveSnapPoint(pt: unknown): void {
|
||||||
|
this.calls.push({ method: 'setActiveSnapPoint', args: [pt] });
|
||||||
|
}
|
||||||
|
|
||||||
|
setOptions(opts: Record<string, unknown>): void {
|
||||||
|
this.calls.push({ method: 'setOptions', args: [opts] });
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewElement(el: CADElement | null): void {
|
||||||
|
this.calls.push({ method: 'setPreviewElement', args: [el] });
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnapPoints(pts: unknown[]): void {
|
||||||
|
this.calls.push({ method: 'setSnapPoints', args: [pts] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test-Helfer ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Anzahl Aufrufe einer Methode. */
|
||||||
|
countCalls(method: string): number {
|
||||||
|
return this.calls.filter((c) => c.method === method).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Letzte aufgezeichnete Argumentliste einer Methode (oder undefined). */
|
||||||
|
lastArgsOf(method: string): unknown[] | undefined {
|
||||||
|
const found = this.calls.filter((c) => c.method === method);
|
||||||
|
return found.length ? found[found.length - 1].args : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Protokoll leeren. */
|
||||||
|
reset(): void {
|
||||||
|
this.calls.length = 0;
|
||||||
|
this.nextHitTestResult = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* MockAdapter Smoke-Tests (Task C1).
|
||||||
|
* Headless-Mock erfüllt RenderAdapter-Vertrag und protokolliert Aufrufe —
|
||||||
|
* Grundlage für zukünftige Renderer-/Tool-Unit-Tests.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { MockAdapter } from '../src/render/mock/MockAdapter';
|
||||||
|
import type { RenderAdapter, Viewport } from '../src/render/types';
|
||||||
|
import type { CADElement, CADLayer } from '../src/types/cad.types';
|
||||||
|
import type { Pt } from '../src/tools/modification/geometry';
|
||||||
|
|
||||||
|
function makeLayer(): CADLayer {
|
||||||
|
return {
|
||||||
|
id: 'layer-1', name: 'L1', visible: true, locked: false,
|
||||||
|
color: '#ffffff', lineType: 'solid', transparency: 0,
|
||||||
|
sortOrder: 0,
|
||||||
|
parentId: null,
|
||||||
|
} as unknown as CADLayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEl(id = 'e1'): CADElement {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'line',
|
||||||
|
layerId: 'layer-1',
|
||||||
|
x: 10,
|
||||||
|
y: 10,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
properties: {},
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MockAdapter implements RenderAdapter', () => {
|
||||||
|
it('ist einem RenderAdapter zuweisbar (Compile-proof)', () => {
|
||||||
|
const adapter = new MockAdapter();
|
||||||
|
const asAdapter: RenderAdapter = adapter;
|
||||||
|
expect(asAdapter).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('protokolliert init/resize/clear/drawElement in Reihenfolge', () => {
|
||||||
|
const adapter = new MockAdapter();
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
adapter.init(canvas);
|
||||||
|
adapter.resize(800, 600, 2);
|
||||||
|
const vp: Viewport = { x: 0, y: 0, scale: 1, width: 800, height: 600 };
|
||||||
|
adapter.setViewport(vp);
|
||||||
|
adapter.clear('#101018');
|
||||||
|
adapter.drawElement(makeEl(), makeLayer(), {
|
||||||
|
color: '#fff', lineType: 'solid', transparency: 0, visible: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(adapter.calls.map((c) => c.method)).toEqual([
|
||||||
|
'init', 'resize', 'setViewport', 'clear', 'drawElement',
|
||||||
|
]);
|
||||||
|
expect(adapter.countCalls('drawElement')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hitTest liefert konfigurierbaren Treffer; overlay-Callback wird ausgefuehrt', () => {
|
||||||
|
const adapter = new MockAdapter();
|
||||||
|
const el = makeEl('target');
|
||||||
|
adapter.nextHitTestResult = el;
|
||||||
|
let drewRect = false;
|
||||||
|
adapter.overlay(() => {
|
||||||
|
drewRect = true;
|
||||||
|
});
|
||||||
|
const pt: Pt = { x: 5, y: 5 };
|
||||||
|
const hit = adapter.hitTest(pt, 3);
|
||||||
|
expect(hit?.id).toBe('target');
|
||||||
|
expect(drewRect).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reset leert Protokoll und Treffer-Konfiguration', () => {
|
||||||
|
const adapter = new MockAdapter();
|
||||||
|
adapter.nextHitTestResult = makeEl('tmp');
|
||||||
|
adapter.overlay(() => {});
|
||||||
|
adapter.reset();
|
||||||
|
expect(adapter.calls.length).toBe(0);
|
||||||
|
const pt: Pt = { x: 0, y: 0 };
|
||||||
|
expect(adapter.hitTest(pt, 1)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,924 @@
|
|||||||
|
/**
|
||||||
|
* Pilot V2-Tools Tests (Task A3): line + select.
|
||||||
|
* line: Handler-Sequenz gegen echtes CADDocument (In-Memory), Undo-Verhalten.
|
||||||
|
* select: Bridge-basierte Auswahl mit Shift/Ctrl.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { lineTool, coreDrawingPlugin } from '../src/plugins/builtin/core-drawing';
|
||||||
|
import { selectTool, coreModifyPlugin } from '../src/plugins/builtin/core-modify';
|
||||||
|
import { coreAnnotatePlugin } from '../src/plugins/builtin/core-annotate';
|
||||||
|
import { coreMeasurePlugin } from '../src/plugins/builtin/core-measure';
|
||||||
|
import { eventToolsV2Plugin } from '../src/plugins/builtin/event-tools';
|
||||||
|
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||||
|
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||||
|
import type { ToolPointerEvent, FullToolContext } from '../src/plugins/types';
|
||||||
|
import type { CADElement } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
function pe(x: number, y: number, mods: Partial<Pick<ToolPointerEvent, 'shift' | 'ctrl'>> = {}): ToolPointerEvent {
|
||||||
|
return {
|
||||||
|
world: { x, y },
|
||||||
|
screen: { x: x / 2, y: y / 2 },
|
||||||
|
button: 0,
|
||||||
|
shift: mods.shift ?? false,
|
||||||
|
alt: false,
|
||||||
|
ctrl: mods.ctrl ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx(overrides: Partial<FullToolContext> = {}): FullToolContext & { previews: (CADElement | null)[]; statuses: string[] } {
|
||||||
|
const previews: (CADElement | null)[] = [];
|
||||||
|
const statuses: string[] = [];
|
||||||
|
return {
|
||||||
|
options: {},
|
||||||
|
setStatus: (m) => statuses.push(m),
|
||||||
|
setPreview: (el) => previews.push(el),
|
||||||
|
previews,
|
||||||
|
statuses,
|
||||||
|
...overrides,
|
||||||
|
} as FullToolContext & { previews: (CADElement | null)[]; statuses: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEl(id: string): CADElement {
|
||||||
|
return { id, type: 'rect', layerId: 'l', x: 0, y: 0, width: 1, height: 1, properties: {} } as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('A3 pilot: line tool', () => {
|
||||||
|
it('erzeugt EINE Linie per down→up; ein undo entfernt sie', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const ctx = makeCtx({ doc });
|
||||||
|
|
||||||
|
lineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
lineTool.handlers.move!(pe(100, 50), ctx);
|
||||||
|
expect(ctx.previews.length).toBe(1); // Live-Vorschau war da
|
||||||
|
lineTool.handlers.up!(pe(100, 50), ctx);
|
||||||
|
|
||||||
|
const all = doc.getAllElements();
|
||||||
|
expect(all.length).toBe(1);
|
||||||
|
expect(all[0].type).toBe('line');
|
||||||
|
expect(all[0].properties.x1).toBe(0);
|
||||||
|
expect(all[0].properties.y2).toBe(50);
|
||||||
|
|
||||||
|
// Kern-Akzeptanz aus ROADMAP: Ein Werkzeug-Klick = ein Undo-Schritt
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getAllElements().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel verwirft die laufende Linie ohne Element', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const ctx = makeCtx({ doc });
|
||||||
|
|
||||||
|
lineTool.handlers.down!(pe(5, 5), ctx);
|
||||||
|
lineTool.handlers.cancel!(ctx);
|
||||||
|
lineTool.handlers.up!(pe(9, 9), ctx); // up nach cancel: kein Start mehr
|
||||||
|
|
||||||
|
expect(doc.getAllElements().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Plugin-Manifest ist registrierbar und tagged basic', () => {
|
||||||
|
expect(coreDrawingPlugin.manifest.enabledByDefault).toBe(true);
|
||||||
|
const line = coreDrawingPlugin.tools?.find((t) => t.manifest.id === 'line');
|
||||||
|
expect(line?.manifest.tags).toContain('basic');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4.3 pilot: circle tool', () => {
|
||||||
|
it('erzeugt Kreis Zentrum+Radius; ein undo entfernt ihn', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const ctx = makeCtx({ doc });
|
||||||
|
const circle = coreDrawingPlugin.tools!.find((t) => t.manifest.id === 'circle')!;
|
||||||
|
|
||||||
|
circle.handlers.down!(pe(100, 100), ctx);
|
||||||
|
circle.handlers.move!(pe(200, 100), ctx);
|
||||||
|
expect(ctx.previews.length).toBe(1);
|
||||||
|
circle.handlers.up!(pe(200, 100), ctx);
|
||||||
|
|
||||||
|
const all = doc.getAllElements();
|
||||||
|
expect(all.length).toBe(1);
|
||||||
|
expect(all[0].type).toBe('circle');
|
||||||
|
expect(all[0].x).toBe(100);
|
||||||
|
expect(all[0].properties.radius).toBe(100); // Distanz wie Legacy
|
||||||
|
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getAllElements().length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4.3 pilot: arc tool', () => {
|
||||||
|
it('klick-progressiv: Zentrum→Start→Ende committet beim 3. Klick mit Grad-Winkeln', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const ctx = makeCtx({ doc });
|
||||||
|
const arc = coreDrawingPlugin.tools!.find((t) => t.manifest.id === 'arc')!;
|
||||||
|
|
||||||
|
// Klick 1: Zentrum (up gehört zum Klick, committet NICHT — klick-progressiv)
|
||||||
|
arc.handlers.down!(pe(0, 0), ctx);
|
||||||
|
expect(doc.getAllElements().length).toBe(0); // Phase 1/2 committet nie
|
||||||
|
|
||||||
|
// Klick 2: Startpunkt auf Radius 50 nach Osten (0°)
|
||||||
|
arc.handlers.down!(pe(50, 0), ctx);
|
||||||
|
expect(doc.getAllElements().length).toBe(0);
|
||||||
|
|
||||||
|
// Klick 3: Endpunkt oben (90°) → JETZT committet der dritte down
|
||||||
|
arc.handlers.down!(pe(0, 50), ctx);
|
||||||
|
|
||||||
|
const all = doc.getAllElements();
|
||||||
|
expect(all.length).toBe(1);
|
||||||
|
expect(all[0].type).toBe('arc');
|
||||||
|
expect(all[0].x).toBe(0);
|
||||||
|
expect(all[0].y).toBe(0);
|
||||||
|
expect(all[0].properties.radius).toBe(50);
|
||||||
|
expect(all[0].properties.startAngle).toBeCloseTo(0);
|
||||||
|
expect(all[0].properties.endAngle).toBeCloseTo(90);
|
||||||
|
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getAllElements().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel bricht mehrphasige Sitzung vollstaendig ab', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const ctx = makeCtx({ doc });
|
||||||
|
const arc = coreDrawingPlugin.tools!.find((t) => t.manifest.id === 'arc')!;
|
||||||
|
|
||||||
|
arc.handlers.down!(pe(0, 0), ctx); // phase 1
|
||||||
|
arc.handlers.down!(pe(30, 0), ctx); // phase 2
|
||||||
|
arc.handlers.cancel!(ctx); // reset
|
||||||
|
arc.handlers.down!(pe(99, 99), ctx); // startet neu bei phase 1
|
||||||
|
|
||||||
|
// Kein Commit erfolgt (nur phase 1), nichts im Doc:
|
||||||
|
expect(doc.getAllElements().length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4.3 pilot: polyline/polygon tools', () => {
|
||||||
|
it('polyline sammelt Punkte und committet per ENTER ab 2', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const ctx = makeCtx({ doc });
|
||||||
|
const pl = coreDrawingPlugin.tools!.find((t) => t.manifest.id === 'polyline')!;
|
||||||
|
|
||||||
|
pl.handlers.down!(pe(0, 0), ctx);
|
||||||
|
pl.handlers.move!(pe(10, 10), ctx);
|
||||||
|
expect(ctx.previews.length).toBeGreaterThan(0);
|
||||||
|
pl.handlers.down!(pe(20, 0), ctx);
|
||||||
|
pl.handlers.down!(pe(30, 30), ctx);
|
||||||
|
|
||||||
|
const kev = { key: 'Enter', preventDefault: () => {} } as unknown as KeyboardEvent;
|
||||||
|
const consumed = pl.handlers.key!(kev, ctx);
|
||||||
|
expect(consumed).toBe(true);
|
||||||
|
|
||||||
|
const all = doc.getAllElements();
|
||||||
|
expect(all.length).toBe(1);
|
||||||
|
expect(all[0].type).toBe('polyline');
|
||||||
|
expect((all[0].properties.points as Pt[]).length).toBe(3);
|
||||||
|
expect(all[0].x).toBe(15); // BBox-Zentrum min0 max30
|
||||||
|
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getAllElements().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('polygon braucht mind. 3 Punkte; ENTER davor meldet Hinweis', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const pg = coreDrawingPlugin.tools!.find((t) => t.manifest.id === 'polygon')!;
|
||||||
|
const ctx = makeCtx({ doc });
|
||||||
|
|
||||||
|
pg.handlers.down!(pe(0, 0), ctx);
|
||||||
|
pg.handlers.down!(pe(10, 10), ctx);
|
||||||
|
const kev = { key: 'Enter', preventDefault: () => {} } as unknown as KeyboardEvent;
|
||||||
|
pg.handlers.key!(kev, ctx); // nur 2 Punkte → abgelehnt
|
||||||
|
expect(doc.getAllElements().length).toBe(0);
|
||||||
|
expect(ctx.statuses.some((s) => s.includes('mindestens 3'))).toBe(true);
|
||||||
|
|
||||||
|
pg.handlers.down!(pe(20, -5), ctx);
|
||||||
|
pg.handlers.key!(kev, ctx); // jetzt 3 Punkte → ok
|
||||||
|
const all = doc.getAllElements();
|
||||||
|
expect(all.length).toBe(1);
|
||||||
|
expect(all[0].type).toBe('polygon');
|
||||||
|
expect((all[0].properties.points as Pt[]).length).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4.4 pilot: annotate tools', () => {
|
||||||
|
it('dimension: down→up committet mit x1..y2-Properties; undo entfernt', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const ctx = makeCtx({ doc });
|
||||||
|
const dim = coreAnnotatePlugin.tools!.find((t) => t.manifest.id === 'dimension')!;
|
||||||
|
|
||||||
|
dim.handlers.down!(pe(0, 0), ctx);
|
||||||
|
dim.handlers.move!(pe(60, 0), ctx); // Preview war da
|
||||||
|
expect(ctx.previews.length).toBe(1);
|
||||||
|
dim.handlers.up!(pe(60, 0), ctx);
|
||||||
|
|
||||||
|
const all = doc.getAllElements();
|
||||||
|
expect(all.length).toBe(1);
|
||||||
|
expect(all[0].type).toBe('dimension');
|
||||||
|
expect(all[0].x).toBe(30); // Mitte
|
||||||
|
expect(all[0].properties.x2).toBe(60);
|
||||||
|
expect(all[0].width).toBeCloseTo(60); // dist als Breite wie Legacy
|
||||||
|
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getAllElements().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leader: übernimmt Text/fontSize-Konvention aus Legacy', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const ctx = makeCtx({ doc });
|
||||||
|
const leader = coreAnnotatePlugin.tools!.find((t) => t.manifest.id === 'leader')!;
|
||||||
|
|
||||||
|
leader.handlers.down!(pe(10, 20), ctx);
|
||||||
|
leader.handlers.up!(pe(80, 90), ctx);
|
||||||
|
|
||||||
|
const all = doc.getAllElements();
|
||||||
|
expect(all.length).toBe(1);
|
||||||
|
expect(all[0].type).toBe('leader');
|
||||||
|
expect(all[0].properties.x1).toBe(10);
|
||||||
|
expect(all[0].properties.y2).toBe(90);
|
||||||
|
expect(all[0].properties.text).toBe('');
|
||||||
|
expect(all[0].properties.fontSize).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('text: Einzelklick platziert leeres Textelement mit fontSize-Option', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const ctx = makeCtx({ doc, options: { fontSize: 18 } });
|
||||||
|
const txt = coreAnnotatePlugin.tools!.find((t) => t.manifest.id === 'text')!;
|
||||||
|
|
||||||
|
txt.handlers.down!(pe(40, 70), ctx); // kein up nötig — single-point tool
|
||||||
|
|
||||||
|
const all = doc.getAllElements();
|
||||||
|
expect(all.length).toBe(1);
|
||||||
|
expect(all[0].type).toBe('text');
|
||||||
|
expect(all[0].x).toBe(40);
|
||||||
|
expect(all[0].y).toBe(70);
|
||||||
|
expect(all[0].width).toBe(100); // Legacy-Default-BBox
|
||||||
|
expect(all[0].properties.fontSize).toBe(18); // aus options statt Default 12
|
||||||
|
expect(all[0].properties.text).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4.5 pilot: revcloud tool', () => {
|
||||||
|
it('committet per ENTER ab 3 Punkten mit Legacy-Konvention arcHeight=8', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const ctx = makeCtx({ doc });
|
||||||
|
const cloud = coreDrawingPlugin.tools!.find((t) => t.manifest.id === 'revcloud')!;
|
||||||
|
|
||||||
|
cloud.handlers.down!(pe(0, 0), ctx);
|
||||||
|
cloud.handlers.down!(pe(100, 0), ctx);
|
||||||
|
cloud.handlers.down!(pe(100, 50), ctx);
|
||||||
|
const kev = { key: 'Enter', preventDefault: () => {} } as unknown as KeyboardEvent;
|
||||||
|
const consumed = cloud.handlers.key!(kev, ctx);
|
||||||
|
expect(consumed).toBe(true);
|
||||||
|
|
||||||
|
const all = doc.getAllElements();
|
||||||
|
expect(all.length).toBe(1);
|
||||||
|
expect(all[0].type).toBe('revcloud');
|
||||||
|
expect((all[0].properties.points as Pt[]).length).toBe(3);
|
||||||
|
expect(all[0].properties.arcHeight).toBe(8); // Legacy-Konvention
|
||||||
|
expect(all[0].properties.fill).toBe('none');
|
||||||
|
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getAllElements().length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4.5 pilot: measure tool', () => {
|
||||||
|
it('2 Klicks zeigen Distanz+Winkel im Status; erzeugt KEIN Element', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const ctx = makeCtx({ doc });
|
||||||
|
const meas = coreMeasurePlugin.tools!.find((t) => t.manifest.id === 'measure')!;
|
||||||
|
|
||||||
|
meas.handlers.down!(pe(0, 0), ctx);
|
||||||
|
expect(ctx.statuses.some((s) => s.includes('Endpunkt'))).toBe(true);
|
||||||
|
|
||||||
|
meas.handlers.down!(pe(30, 40), ctx); // dist=50, angle≈53.13°
|
||||||
|
const last = ctx.statuses[ctx.statuses.length - 1];
|
||||||
|
expect(last).toContain('distance:');
|
||||||
|
expect(last).toContain('angle:');
|
||||||
|
expect(last).toContain('53.1'); // atan2(40,30)≈53.13°
|
||||||
|
// KernLegacy-Verhalten: Messung erzeugt kein Element
|
||||||
|
expect(doc.getAllElements().length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4.5 pilot: hatch tool', () => {
|
||||||
|
it('setzt hatch-Properties via updateElement; undo macht rückgängig', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
// Seed-Rect im Doc, das beim hitTest zurückkommt:
|
||||||
|
const rectEl = { id: 'r1', type: 'rect', layerId: 'l', x: 50, y: 50, width: 10, height: 10, properties: {} } as unknown as CADElement;
|
||||||
|
doc.addElement(rectEl);
|
||||||
|
const bridge = {
|
||||||
|
getIds: () => [],
|
||||||
|
setIds: () => {},
|
||||||
|
hitTest: () => rectEl,
|
||||||
|
};
|
||||||
|
const ctx = makeCtx({ doc, selection: bridge, options: { hatchPattern: 'cross', hatchSpacing: 12 } });
|
||||||
|
const hatch = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'hatch')!;
|
||||||
|
|
||||||
|
hatch.handlers.down!(pe(50, 50), ctx);
|
||||||
|
|
||||||
|
const updated = doc.getElement('r1')!;
|
||||||
|
expect(updated.properties.hatch).toBe(true);
|
||||||
|
expect(updated.properties.hatchPattern).toBe('cross'); // aus Options
|
||||||
|
expect(updated.properties.hatchSpacing).toBe(12);
|
||||||
|
|
||||||
|
expect(doc.undo()).toBe(true); // A2b-Update ist selbst Undo-Schritt
|
||||||
|
expect(doc.getElement('r1')!.properties.hatch).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt offene Elementtypen (line) mit Hinweis ab', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const lineEl = { id: 'l1', type: 'line', layerId: 'l', x: 0, y: 0, width: 5, height: 5, properties: { x1: 0, y1: 0, x2: 5, y2: 5 } } as unknown as CADElement;
|
||||||
|
doc.addElement(lineEl);
|
||||||
|
const bridge = { getIds: () => [], setIds: () => {}, hitTest: () => lineEl };
|
||||||
|
const ctx = makeCtx({ doc, selection: bridge });
|
||||||
|
const hatch = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'hatch')!;
|
||||||
|
|
||||||
|
hatch.handlers.down!(pe(2, 2), ctx);
|
||||||
|
expect(doc.getElement('l1')!.properties.hatch).toBeUndefined(); // nichts geändert
|
||||||
|
expect(ctx.statuses.some((s) => s.includes('rect/circle/polygon'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4.9 pilot: event tools', () => {
|
||||||
|
it('chair platziert Einzel-Element per Klick', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const ctx = makeCtx({ doc });
|
||||||
|
const chair = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'chair')!;
|
||||||
|
|
||||||
|
chair.handlers.down!(pe(100, 200), ctx);
|
||||||
|
const all = doc.getAllElements();
|
||||||
|
expect(all.length).toBe(1);
|
||||||
|
expect(all[0].type).toBe('chair');
|
||||||
|
expect(all[0].x).toBe(100);
|
||||||
|
expect(all[0].y).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seating-block erzeugt rows×cols Elemente in EINER Transaktion (ein undo = ganzer Block weg)', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const ctx = makeCtx({ doc });
|
||||||
|
const block = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-block')!;
|
||||||
|
|
||||||
|
block.handlers.down!(pe(0, 0), ctx); // Legacy-Default: rows=3, cols=8
|
||||||
|
const all = doc.getAllElements();
|
||||||
|
expect(all.length).toBeGreaterThan(1); // Multi-Element-Satz
|
||||||
|
expect(all.every((e) => e.type === 'chair')).toBe(true);
|
||||||
|
|
||||||
|
// BUG-1-Regressionsschutz: alle Reihen im Block haben eigene rowIds
|
||||||
|
const rowIds = new Set(all.map((e) => e.properties.rowId));
|
||||||
|
expect(rowIds.size).toBeGreaterThanOrEqual(3);
|
||||||
|
|
||||||
|
// Kern-Akzeptanz: EIN undo entfernt den GANZEN Block
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getAllElements().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seating-row platziert mehrere Stühle mit gemeinsamer rowId; undo entfernt Reihe', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const ctx = makeCtx({ doc });
|
||||||
|
const row = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-row')!;
|
||||||
|
|
||||||
|
row.handlers.down!(pe(0, 0), ctx);
|
||||||
|
const all = doc.getAllElements();
|
||||||
|
expect(all.length).toBeGreaterThanOrEqual(2);
|
||||||
|
const rowIds = new Set(all.map((e) => e.properties.rowId));
|
||||||
|
expect(rowIds.size).toBe(1); // eine Reihe → EINE rowId
|
||||||
|
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getAllElements().length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4.6 pilot: move/copy tools', () => {
|
||||||
|
it('move verschiebt selektierte Elemente in EINER Transaktion (undo entfernt Versatz)', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
doc.addElement(makeEl('m1'));
|
||||||
|
doc.addElement(makeEl('m2'));
|
||||||
|
let selIds = ['m1', 'm2'];
|
||||||
|
const bridge = {
|
||||||
|
getIds: () => selIds,
|
||||||
|
setIds: (ids: string[]) => {
|
||||||
|
selIds = ids;
|
||||||
|
},
|
||||||
|
hitTest: () => null,
|
||||||
|
};
|
||||||
|
const ctx = makeCtx({ doc, selection: bridge });
|
||||||
|
const move = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'move')!;
|
||||||
|
const origM1x = doc.getElement('m1')!.x;
|
||||||
|
const origY = doc.getElement('m1')!.y;
|
||||||
|
|
||||||
|
move.handlers.down!(pe(0, 0), ctx); // Basispunkt
|
||||||
|
move.handlers.down!(pe(50, -20), ctx); // Zielklick → Commit
|
||||||
|
|
||||||
|
expect(doc.getElement('m1')!.x).toBe(origM1x + 50);
|
||||||
|
expect(doc.getElement('m2')!.y).toBe(origY - 20);
|
||||||
|
// EINE Transaktion für beide → ein undo stellt beide wieder her:
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getElement('m1')!.x).toBe(origM1x);
|
||||||
|
expect(doc.getElement('m2')!.y).toBe(origY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copy erzeugt neue Elemente (IDs neu) ohne Original zu veraendern', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
doc.addElement(makeEl('o1'));
|
||||||
|
let selIds = ['o1'];
|
||||||
|
const bridge = {
|
||||||
|
getIds: () => selIds,
|
||||||
|
setIds: (ids: string[]) => {
|
||||||
|
selIds = ids;
|
||||||
|
},
|
||||||
|
hitTest: () => null,
|
||||||
|
};
|
||||||
|
const ctx = makeCtx({ doc, selection: bridge });
|
||||||
|
const copy = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'copy')!;
|
||||||
|
|
||||||
|
copy.handlers.down!(pe(0, 0), ctx);
|
||||||
|
copy.handlers.down!(pe(100, 100), ctx);
|
||||||
|
|
||||||
|
const all = doc.getAllElements();
|
||||||
|
expect(all.length).toBe(2); // Original + Kopie
|
||||||
|
expect(all[0].id).toBe('o1'); // Original unberührt
|
||||||
|
expect(all[0].x).not.toBe(all[1].x); // Kopie versetzt
|
||||||
|
expect(all[1].id).not.toBe('o1'); // neue ID
|
||||||
|
expect(all[1].type).toBe(all[0].type); // gleicher Typ
|
||||||
|
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getAllElements().length).toBe(1); // nur Original bleibt
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Komfort: bei leerer Selektion wird Element unter Klick auto-gewählt', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const target = makeEl('auto');
|
||||||
|
target.x = 10;
|
||||||
|
target.y = 10;
|
||||||
|
doc.addElement(target);
|
||||||
|
let selIds2: string[] = [];
|
||||||
|
const bridge = {
|
||||||
|
getIds: () => selIds2,
|
||||||
|
setIds: (ids: string[]) => {
|
||||||
|
selIds2 = ids;
|
||||||
|
},
|
||||||
|
hitTest: () => target,
|
||||||
|
};
|
||||||
|
const ctx = makeCtx({ doc, selection: bridge });
|
||||||
|
const move = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'move')!;
|
||||||
|
|
||||||
|
move.handlers.down!(pe(10, 10), ctx); // Basis + Auto-Select
|
||||||
|
expect(selIds2).toEqual(['auto']);
|
||||||
|
move.handlers.down!(pe(30, 30), ctx); // Zielklick (Δ=+20)
|
||||||
|
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getAllElements().length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4.10 pilot: geometry tools', () => {
|
||||||
|
function makeLineEl(id: string): CADElement {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'line',
|
||||||
|
layerId: 'l',
|
||||||
|
x: 100,
|
||||||
|
y: 0,
|
||||||
|
width: 200,
|
||||||
|
height: 0,
|
||||||
|
properties: { x1: 0, y1: 0, x2: 200, y2: 0 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCircleEl(id: string): CADElement {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'circle',
|
||||||
|
layerId: 'l',
|
||||||
|
x: 100,
|
||||||
|
y: 0,
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
properties: { radius: 40 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('trim: Kante+Ziel kürzt die Linie am Schnittpunkt; undo stellt her', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const boundary = makeCircleEl('bnd');
|
||||||
|
const target = makeLineEl('tgt');
|
||||||
|
doc.addElement(boundary);
|
||||||
|
doc.addElement(target);
|
||||||
|
|
||||||
|
// Sequenzieller Brücken-HitTest: 1. Klick → boundary, 2. Klick → target
|
||||||
|
const pickQueue = [boundary, target];
|
||||||
|
let pickIdx = 0;
|
||||||
|
const bridge = {
|
||||||
|
getIds: () => [] as string[],
|
||||||
|
setIds: () => {},
|
||||||
|
hitTest: () => pickQueue[Math.min(pickIdx++, pickQueue.length - 1)],
|
||||||
|
};
|
||||||
|
const ctx = makeCtx({ doc, selection: bridge });
|
||||||
|
const trim = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'trim')!;
|
||||||
|
|
||||||
|
trim.handlers.down!(pe(140, 0), ctx); // Kante
|
||||||
|
trim.handlers.down!(pe(180, 0), ctx); // Ziel-Linie rechts vom Kreis
|
||||||
|
|
||||||
|
const trimmed = doc.getElement('tgt')!;
|
||||||
|
expect(trimmed.properties.x2).toBeLessThan(200); // gekürzt
|
||||||
|
// Legacy-Semantik: das NÄHERE Ende wird auf den ERSTEN Schnittpunkt
|
||||||
|
// gesetzt (findIntersection wählt den nächsten zu p1 → x=60):
|
||||||
|
expect(Math.abs(trimmed.properties.x2 as number)).toBeCloseTo(60); // Schnitt bei x=140
|
||||||
|
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getElement('tgt')!.properties.x2).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extend: Linie wird zur Kante hin verlängert', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
// kurze Linie (10→50); Kreis (100,0,r=40) hat Schnitte bei x=60/140
|
||||||
|
// → Extend muss das nahe Ende x2 auf 60 strecken
|
||||||
|
const boundary = makeCircleEl('bnd');
|
||||||
|
const shortLine = {
|
||||||
|
id: 'sl', type: 'line', layerId: 'l', x: 30, y: 0,
|
||||||
|
width: 40, height: 0,
|
||||||
|
properties: { x1: 10, y1: 0, x2: 50, y2: 0 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
doc.addElement(boundary);
|
||||||
|
doc.addElement(shortLine);
|
||||||
|
|
||||||
|
const pickQueue = [boundary, shortLine];
|
||||||
|
let pickIdx = 0;
|
||||||
|
const bridge = {
|
||||||
|
getIds: () => [] as string[],
|
||||||
|
setIds: () => {},
|
||||||
|
hitTest: () => pickQueue[Math.min(pickIdx++, pickQueue.length - 1)],
|
||||||
|
};
|
||||||
|
const ctx = makeCtx({ doc, selection: bridge });
|
||||||
|
const extend = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'extend')!;
|
||||||
|
|
||||||
|
extend.handlers.down!(pe(100, 40), ctx);
|
||||||
|
extend.handlers.down!(pe(45, 0), ctx);
|
||||||
|
|
||||||
|
const ext = doc.getElement('sl')!;
|
||||||
|
// Extend richtet das nahe Ende exakt auf den Schnittpunkt aus:
|
||||||
|
expect(ext.properties.x2).toBeCloseTo(60);
|
||||||
|
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getElement('sl')!.properties.x2).toBe(50); // Original-Länge
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4.7 pilot: transform tools', () => {
|
||||||
|
it('rotate dreht um Pivot des ersten Elements; ein undo stellt zurück', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const rotEl = makeEl('r1');
|
||||||
|
doc.addElement(rotEl);
|
||||||
|
let selIds: string[] = ['r1'];
|
||||||
|
const bridge = {
|
||||||
|
getIds: () => selIds,
|
||||||
|
setIds: (ids: string[]) => { selIds = ids; },
|
||||||
|
hitTest: () => null,
|
||||||
|
};
|
||||||
|
const ctx = makeCtx({ doc, selection: bridge });
|
||||||
|
const rotate = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'rotate')!;
|
||||||
|
|
||||||
|
// Basispunkt rechts vom Zentrum (0°-Richtung), Element x=10
|
||||||
|
rotate.handlers.down!(pe(60, 10), ctx);
|
||||||
|
// Referenzklick oben → Δwinkel ≈ +90° (CCW)
|
||||||
|
rotate.handlers.down!(pe(10, 60), ctx);
|
||||||
|
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getAllElements().length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scale vergrößert width/height um Faktor dist(pivot,zweiteKlick)/dist(pivot,basis)', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const sq = makeEl('s1');
|
||||||
|
sq.width = 40;
|
||||||
|
sq.height = 40;
|
||||||
|
sq.x = 0;
|
||||||
|
sq.y = 0;
|
||||||
|
doc.addElement(sq);
|
||||||
|
let selIds: string[] = ['s1'];
|
||||||
|
const bridge = {
|
||||||
|
getIds: () => selIds,
|
||||||
|
setIds: (ids: string[]) => { selIds = ids; },
|
||||||
|
hitTest: () => null,
|
||||||
|
};
|
||||||
|
const ctx = makeCtx({ doc, selection: bridge });
|
||||||
|
const scale = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'scale')!;
|
||||||
|
|
||||||
|
// Basis bei Distanz 50 vom Pivot, Ziel bei 100 → Faktor 2
|
||||||
|
scale.handlers.down!(pe(50, 0), ctx);
|
||||||
|
scale.handlers.down!(pe(100, 0), ctx);
|
||||||
|
|
||||||
|
expect(doc.getElement('s1')!.width).toBeCloseTo(80); // 40×2
|
||||||
|
expect(doc.getElement('s1')!.height).toBeCloseTo(80);
|
||||||
|
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getElement('s1')!.width).toBeCloseTo(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mirror spiegelt Element an Achse basis→referenz', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const el = makeEl('mi');
|
||||||
|
el.x = 30;
|
||||||
|
el.y = 0;
|
||||||
|
doc.addElement(el);
|
||||||
|
let selIds: string[] = ['mi'];
|
||||||
|
const bridge = {
|
||||||
|
getIds: () => selIds,
|
||||||
|
setIds: (ids: string[]) => { selIds = ids; },
|
||||||
|
hitTest: () => null,
|
||||||
|
};
|
||||||
|
const ctx = makeCtx({ doc, selection: bridge });
|
||||||
|
const mirror = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'mirror')!;
|
||||||
|
|
||||||
|
// Spiegelachse = Y-Achse (basis (0,0)→ref (0,100)): Punkt (30,0)→(−30,0)
|
||||||
|
mirror.handlers.down!(pe(0, 0), ctx);
|
||||||
|
mirror.handlers.down!(pe(0, 100), ctx);
|
||||||
|
|
||||||
|
const mirrored = doc.getElement('mi')!;
|
||||||
|
expect(mirrored.x).toBeLessThan(0); // jenseits der Achse
|
||||||
|
expect(Math.abs(mirrored.x)).toBeGreaterThanOrEqual(29); // ~spiegelsymmetrisch
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4.8 pilot: delete tool', () => {
|
||||||
|
it('löscht alle selektierten Elemente in EINER Transaktion; undo stellt her', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
doc.addElement(makeEl('d1'));
|
||||||
|
doc.addElement(makeEl('d2'));
|
||||||
|
doc.addElement(makeEl('keep'));
|
||||||
|
let selIds: string[] = ['d1', 'd2'];
|
||||||
|
const bridge = {
|
||||||
|
getIds: () => selIds,
|
||||||
|
setIds: (ids: string[]) => { selIds = ids; },
|
||||||
|
hitTest: () => null,
|
||||||
|
};
|
||||||
|
const ctx = makeCtx({ doc, selection: bridge });
|
||||||
|
const del = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'delete')!;
|
||||||
|
|
||||||
|
del.handlers.down!(pe(0, 0), ctx);
|
||||||
|
const remaining = doc.getAllElements().map((e) => e.id);
|
||||||
|
expect(remaining).toEqual(['keep']);
|
||||||
|
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getAllElements().length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Direktklick ohne Selektion löscht getroffenes Element', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const el = makeEl('hitme');
|
||||||
|
el.x = 20;
|
||||||
|
el.y = 20;
|
||||||
|
doc.addElement(el);
|
||||||
|
const bridge = {
|
||||||
|
getIds: () => [] as string[],
|
||||||
|
setIds: () => {},
|
||||||
|
hitTest: () => el,
|
||||||
|
};
|
||||||
|
const ctx = makeCtx({ doc, selection: bridge });
|
||||||
|
const del = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'delete')!;
|
||||||
|
|
||||||
|
del.handlers.down!(pe(20, 20), ctx);
|
||||||
|
expect(doc.getAllElements().length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4.8 pilot: offset tool', () => {
|
||||||
|
it('erzeugt Versatz-Kopie mit Seitenparameter; Original bleibt', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const line = {
|
||||||
|
id: 'src1', type: 'line', layerId: 'l', x: 50, y: 0,
|
||||||
|
width: 100, height: 0,
|
||||||
|
properties: { x1: 0, y1: 0, x2: 100, y2: 0 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
doc.addElement(line);
|
||||||
|
let selIds: string[] = [];
|
||||||
|
const bridge = {
|
||||||
|
getIds: () => selIds,
|
||||||
|
setIds: (ids: string[]) => { selIds = ids; },
|
||||||
|
hitTest: () => line,
|
||||||
|
};
|
||||||
|
const ctx = makeCtx({ doc, selection: bridge });
|
||||||
|
const off = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'offset')!;
|
||||||
|
|
||||||
|
off.handlers.down!(pe(50, 0), ctx); // Klick1: Quelle wählen (Basis)
|
||||||
|
off.handlers.down!(pe(50, -30), ctx); // Klick2: Versatz nach oben (side=-1)
|
||||||
|
|
||||||
|
const all = doc.getAllElements();
|
||||||
|
expect(all.length).toBe(2); // Original + Kopie
|
||||||
|
expect(all[0].id).toBe('src1'); // Original unberührt
|
||||||
|
expect(all[1].id.startsWith('off_')).toBe(true);
|
||||||
|
// side=-1 bei Rechtslaufender Linie → nach OBEN (y kleiner):
|
||||||
|
expect(all[1].properties.y1).toBe(-30);
|
||||||
|
expect(all[1].properties.y2).toBe(-30);
|
||||||
|
expect(all[1].properties.x1).toBe(0); // x bleibt
|
||||||
|
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getAllElements().length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A3 pilot: select tool', () => {
|
||||||
|
function makeBridge(elements: CADElement[]) {
|
||||||
|
let ids: string[] = [];
|
||||||
|
return {
|
||||||
|
bridge: {
|
||||||
|
getIds: () => ids,
|
||||||
|
setIds: (next: string[]) => {
|
||||||
|
ids = next;
|
||||||
|
},
|
||||||
|
hitTest: (wx: number, wy: number, tol: number): CADElement | null =>
|
||||||
|
elements.find((el) => Math.abs(el.x - wx) <= tol && Math.abs(el.y - wy) <= tol) ?? null,
|
||||||
|
},
|
||||||
|
getIds: () => ids,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('wählt getroffenes Element ohne Modifier exklusiv', () => {
|
||||||
|
const els = [makeEl('a'), makeEl('b')];
|
||||||
|
els[0].x = 10; els[0].y = 10;
|
||||||
|
els[1].x = 500; els[1].y = 500;
|
||||||
|
const { bridge, getIds } = makeBridge(els);
|
||||||
|
const ctx = makeCtx({ selection: bridge });
|
||||||
|
|
||||||
|
selectTool.handlers.down!(pe(10, 10), ctx);
|
||||||
|
expect(getIds()).toEqual(['a']);
|
||||||
|
|
||||||
|
// Klick auf b ohne Shift ersetzt Selektion
|
||||||
|
selectTool.handlers.down!(pe(500, 500), ctx);
|
||||||
|
expect(getIds()).toEqual(['b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shift addiert, Ctrl subtrahiert', () => {
|
||||||
|
const els = [makeEl('a'), makeEl('b'), makeEl('c')];
|
||||||
|
els[0].x = 10; els[0].y = 10;
|
||||||
|
els[1].x = 50; els[1].y = 50;
|
||||||
|
els[2].x = 90; els[2].y = 90;
|
||||||
|
const { bridge, getIds } = makeBridge(els);
|
||||||
|
const ctx = makeCtx({ selection: bridge });
|
||||||
|
|
||||||
|
selectTool.handlers.down!(pe(10, 10), ctx);
|
||||||
|
expect(getIds()).toEqual(['a']);
|
||||||
|
|
||||||
|
selectTool.handlers.down!(pe(50, 50, { shift: true }), ctx);
|
||||||
|
expect(getIds().sort()).toEqual(['a', 'b']);
|
||||||
|
|
||||||
|
selectTool.handlers.down!(pe(90, 90, { shift: true }), ctx);
|
||||||
|
expect(getIds().sort()).toEqual(['a', 'b', 'c']);
|
||||||
|
|
||||||
|
selectTool.handlers.down!(pe(50, 50, { ctrl: true }), ctx);
|
||||||
|
expect(getIds().sort()).toEqual(['a', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Leerklick ohne Modifier leert die Selektion; Plugin tagged basic', () => {
|
||||||
|
const els = [makeEl('a')];
|
||||||
|
els[0].x = 10; els[0].y = 10;
|
||||||
|
const { bridge, getIds } = makeBridge(els);
|
||||||
|
const ctx = makeCtx({ selection: bridge });
|
||||||
|
|
||||||
|
selectTool.handlers.down!(pe(10, 10), ctx);
|
||||||
|
expect(getIds()).toEqual(['a']);
|
||||||
|
selectTool.handlers.down!(pe(999, 999), ctx);
|
||||||
|
expect(getIds()).toEqual([]);
|
||||||
|
|
||||||
|
expect(coreModifyPlugin.manifest.enabledByDefault).toBe(true);
|
||||||
|
const sel = coreModifyPlugin.tools?.find((t) => t.manifest.id === 'select');
|
||||||
|
expect(sel?.manifest.tags).toContain('basic');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('D-Ext: array-rect tool', () => {
|
||||||
|
it('vervielfaeltigt Selektion rows×cols in EINER Transaktion', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
doc.addElement(makeEl('a1'));
|
||||||
|
let selIds: string[] = ['a1'];
|
||||||
|
const bridge = {
|
||||||
|
getIds: () => selIds,
|
||||||
|
setIds: (ids: string[]) => { selIds = ids; },
|
||||||
|
hitTest: () => null,
|
||||||
|
};
|
||||||
|
const ctx = makeCtx({ doc, selection: bridge, options: { rows: 2, cols: 2, dx: 60, dy: 60 } });
|
||||||
|
const arr = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'array-rect')!;
|
||||||
|
|
||||||
|
arr.handlers.down!(pe(0, 0), ctx);
|
||||||
|
arr.handlers.down!(pe(0, 0), ctx);
|
||||||
|
|
||||||
|
expect(doc.getAllElements().length).toBe(4);
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getAllElements().length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('D-Ext: array-polar tool', () => {
|
||||||
|
it('verteilt count Kopien gleichmaessig um das Zentrum', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const elem = makeEl('p1');
|
||||||
|
elem.x = 100;
|
||||||
|
elem.y = 0;
|
||||||
|
doc.addElement(elem);
|
||||||
|
let selIds: string[] = ['p1'];
|
||||||
|
const bridge = {
|
||||||
|
getIds: () => selIds,
|
||||||
|
setIds: (ids: string[]) => { selIds = ids; },
|
||||||
|
hitTest: () => null,
|
||||||
|
};
|
||||||
|
const ctx = makeCtx({ doc, selection: bridge, options: { count: 4 } });
|
||||||
|
const polar = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'array-polar')!;
|
||||||
|
|
||||||
|
polar.handlers.down!(pe(0, 0), ctx);
|
||||||
|
polar.handlers.down!(pe(0, 0), ctx);
|
||||||
|
|
||||||
|
const all = doc.getAllElements();
|
||||||
|
expect(all.length).toBe(4);
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getAllElements().length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('C3fin interaktiv: handle-drag on select tool', () => {
|
||||||
|
function makeRect(id: string, x: number, y: number, w: number, h: number): CADElement {
|
||||||
|
return {
|
||||||
|
id, type: 'rect', layerId: 'l',
|
||||||
|
x: x + w / 2, y: y + h / 2,
|
||||||
|
width: w, height: h,
|
||||||
|
properties: { fill: '#abc' },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('down auf Handle startet Drag, move skaliert, up committet', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
// Rect: (0,0) bis (50,50)
|
||||||
|
doc.addElement(makeRect('r1', 0, 0, 50, 50));
|
||||||
|
let selIds: string[] = ['r1'];
|
||||||
|
const bridge = {
|
||||||
|
getIds: () => selIds,
|
||||||
|
setIds: (ids: string[]) => { selIds = ids; },
|
||||||
|
hitTest: () => null,
|
||||||
|
};
|
||||||
|
const ctx = makeCtx({ doc, selection: bridge });
|
||||||
|
const select = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'select')!;
|
||||||
|
|
||||||
|
// down auf Handle der oberen linken Ecke (0,0):
|
||||||
|
select.handlers.down!(pe(0, 0), ctx);
|
||||||
|
// move zu neuem Punkt (-20, -20):
|
||||||
|
select.handlers.move!(pe(-20, -20), ctx);
|
||||||
|
// up → Commit (Skalierung auf neue BBox von (-20,-20) bis (50,50)):
|
||||||
|
select.handlers.up!(pe(-20, -20), ctx);
|
||||||
|
|
||||||
|
const updated = doc.getElement('r1')!;
|
||||||
|
// Neue BBox: 70×70 statt 50×50:
|
||||||
|
expect(updated.width).toBe(70);
|
||||||
|
expect(updated.height).toBe(70);
|
||||||
|
expect(doc.undo()).toBe(true); // Skalierung ist undo-bar
|
||||||
|
expect(doc.getElement('r1')!.width).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('down auf NICHT-Handle startet normale Selektion (kein Drag)', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
doc.addElement(makeRect('r2', 0, 0, 50, 50));
|
||||||
|
doc.addElement(makeRect('r3', 200, 200, 30, 30));
|
||||||
|
let selIds: string[] = ['r2'];
|
||||||
|
const bridge = {
|
||||||
|
getIds: () => selIds,
|
||||||
|
setIds: (ids: string[]) => { selIds = ids; },
|
||||||
|
// Klick bei (210,210) trifft r3:
|
||||||
|
hitTest: () => doc.getElement('r3')!,
|
||||||
|
};
|
||||||
|
const ctx = makeCtx({ doc, selection: bridge });
|
||||||
|
const select = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'select')!;
|
||||||
|
|
||||||
|
// Klick weit weg von Handles von r2 (r2-Ecken sind 0/50):
|
||||||
|
select.handlers.down!(pe(210, 210), ctx);
|
||||||
|
|
||||||
|
// Normal-Selektion: r3 ist jetzt alleine selektiert (kein Drag gestartet):
|
||||||
|
expect(selIds).toEqual(['r3']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Plugin-API v2 Tests (Task A1).
|
||||||
|
* Deckt: registerV2 / enableV2 / disableV2 / isV2Enabled,
|
||||||
|
* getToolsV2 (nur aktive Plugins), getPanels, getLibraryProviders,
|
||||||
|
* doppelte IDs verworfen, initDefaults mit V2.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { pluginRegistry } from '../src/plugins/PluginRegistry';
|
||||||
|
import type {
|
||||||
|
PluginV2,
|
||||||
|
ToolExtensionV2,
|
||||||
|
} from '../src/plugins/types';
|
||||||
|
|
||||||
|
function makeTool(id: string): ToolExtensionV2 {
|
||||||
|
return {
|
||||||
|
manifest: { id, label: id, icon: '□', ribbonTab: 'tools' },
|
||||||
|
optionsSchema: [],
|
||||||
|
handlers: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePlugin(id: string, overrides: Partial<PluginV2> = {}): PluginV2 {
|
||||||
|
return {
|
||||||
|
manifest: {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'test',
|
||||||
|
description: `Testplugin ${id}`,
|
||||||
|
category: 'tools',
|
||||||
|
enabledByDefault: false,
|
||||||
|
},
|
||||||
|
tools: [makeTool(`${id}-tool`)],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('pluginRegistry v2', () => {
|
||||||
|
// Testisolierung: Singleton-Registry nach jedem Test vollständig räumen
|
||||||
|
const registered: string[] = [];
|
||||||
|
function track(plugin: PluginV2): void {
|
||||||
|
registered.push(plugin.manifest.id);
|
||||||
|
pluginRegistry.registerV2(plugin);
|
||||||
|
}
|
||||||
|
afterEach(() => {
|
||||||
|
while (registered.length > 0) {
|
||||||
|
pluginRegistry.unregisterV2(registered.pop()!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registriert und aktiviert ein V2-Plugin; Tools erscheinen in getToolsV2', () => {
|
||||||
|
track(makePlugin('p1'));
|
||||||
|
expect(pluginRegistry.getToolsV2().length).toBe(0); // noch deaktiviert
|
||||||
|
pluginRegistry.enableV2('p1');
|
||||||
|
expect(pluginRegistry.isV2Enabled('p1')).toBe(true);
|
||||||
|
const tools = pluginRegistry.getToolsV2();
|
||||||
|
expect(tools.length).toBe(1);
|
||||||
|
expect(tools[0].manifest.id).toBe('p1-tool');
|
||||||
|
expect(pluginRegistry.getToolV2('p1-tool')?.manifest.id).toBe('p1-tool');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deaktivierte Plugins liefern keine Tools/Panels/Provider', () => {
|
||||||
|
track(
|
||||||
|
makePlugin('p2', {
|
||||||
|
panels: [{ id: 'panel-x', title: 'X', slot: 'left', render: () => document.createElement('div') }],
|
||||||
|
libraryProviders: [
|
||||||
|
{
|
||||||
|
id: 'lib-x',
|
||||||
|
label: 'Lib X',
|
||||||
|
listFolders: async () => [],
|
||||||
|
listBlocks: async () => [],
|
||||||
|
getBlock: async () => ({ id: 'b', folderId: '', name: '' }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
pluginRegistry.enableV2('p2');
|
||||||
|
expect(pluginRegistry.getPanels().length).toBe(1);
|
||||||
|
expect(pluginRegistry.getLibraryProviders().length).toBe(1);
|
||||||
|
pluginRegistry.disableV2('p2');
|
||||||
|
expect(pluginRegistry.isV2Enabled('p2')).toBe(false);
|
||||||
|
expect(pluginRegistry.getPanels().length).toBe(0);
|
||||||
|
expect(pluginRegistry.getLibraryProviders().length).toBe(0);
|
||||||
|
expect(pluginRegistry.getToolsV2().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verwirft doppelte Plugin-IDs ohne Fehler und behält das erste', () => {
|
||||||
|
const first = makePlugin('dup');
|
||||||
|
first.tools = [makeTool('first-tool')];
|
||||||
|
const second = makePlugin('dup');
|
||||||
|
second.tools = [makeTool('second-tool')];
|
||||||
|
track(first);
|
||||||
|
pluginRegistry.registerV2(second); // wird verworfen, muss nicht getrackt werden
|
||||||
|
pluginRegistry.enableV2('dup');
|
||||||
|
const ids = pluginRegistry.getToolsV2().map((t) => t.manifest.id);
|
||||||
|
expect(ids).toEqual(['first-tool']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initDefaults aktiviert nur enabledByDefault-Plugins', () => {
|
||||||
|
track({ ...makePlugin('auto-on'), manifest: { ...makePlugin('auto-on').manifest, enabledByDefault: true } });
|
||||||
|
track(makePlugin('manual-off'));
|
||||||
|
pluginRegistry.initDefaults();
|
||||||
|
expect(pluginRegistry.isV2Enabled('auto-on')).toBe(true);
|
||||||
|
expect(pluginRegistry.isV2Enabled('manual-off')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Seating-Tests — countSeats: Zählung nach Reihe/Block/Gesamt.
|
||||||
|
* Ergänzt Task B5; Grundlage ist der BUG-1-Fix (rowId pro Block-Reihe).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { SeatingService } from '../src/services/seatingService';
|
||||||
|
import type { CADElement } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
function makeChair(id: string, props: Record<string, unknown> = {}): CADElement {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'chair',
|
||||||
|
layerId: 'layer-1',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
properties: props,
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeNonChair(id: string): CADElement {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'rect',
|
||||||
|
layerId: 'layer-1',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
properties: {},
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('countSeats', () => {
|
||||||
|
const svc = new SeatingService();
|
||||||
|
|
||||||
|
it('zählt eine freie Reihe korrekt (5 Stühle, 1 rowId)', () => {
|
||||||
|
const els = svc.createSeatingRow(0, 0, 'layer-1', { count: 5 });
|
||||||
|
const result = svc.countSeats(els);
|
||||||
|
expect(result.total).toBe(5);
|
||||||
|
expect(Object.keys(result.byRow).length).toBe(1);
|
||||||
|
expect(Object.values(result.byRow)[0]).toBe(5);
|
||||||
|
expect(Object.keys(result.byBlock).length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zählt einen Block korrekt (3 Reihen × 4 Stühle = 12)', () => {
|
||||||
|
const els = svc.createSeatingBlock(0, 0, 'layer-1', { rows: 3, cols: 4 });
|
||||||
|
const result = svc.countSeats(els);
|
||||||
|
expect(result.total).toBe(12);
|
||||||
|
expect(Object.keys(result.byBlock).length).toBe(1); // 1 Block
|
||||||
|
expect(Object.values(result.byBlock)[0]).toBe(12);
|
||||||
|
expect(Object.keys(result.byRow).length).toBe(3); // 3 Reihen (BUG-1-Fix!)
|
||||||
|
for (const count of Object.values(result.byRow)) {
|
||||||
|
expect(count).toBe(4); // je Reihe 4 Stühle
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summiert gemischte Zeichnung korrekt (Reihe + Block + Fremdelemente)', () => {
|
||||||
|
const rows = svc.createSeatingRow(0, 0, 'layer-1', { count: 3 });
|
||||||
|
const block = svc.createSeatingBlock(100, 100, 'layer-1', { rows: 2, cols: 2 });
|
||||||
|
const others = [makeNonChair('x1'), makeNonChair('x2')];
|
||||||
|
const all: CADElement[] = [...rows, ...block, ...others];
|
||||||
|
const result = svc.countSeats(all);
|
||||||
|
expect(result.total).toBe(7); // 3 + 4, Nicht-Stühle ignorieren
|
||||||
|
expect(Object.keys(result.byRow).length).toBe(3); // 1 freie Reihe + 2 Block-Reihen
|
||||||
|
expect(Object.keys(result.byBlock).length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liefert leere Summen bei leerer Eingabe', () => {
|
||||||
|
const result = svc.countSeats([]);
|
||||||
|
expect(result.total).toBe(0);
|
||||||
|
expect(result.byRow).toEqual({});
|
||||||
|
expect(result.byBlock).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zählt handgebaute Stühle ohne rowId nur im Gesamt', () => {
|
||||||
|
const els = [makeChair('h1'), makeChair('h2'), makeChair('h3', { rowId: 'row_manual' })];
|
||||||
|
const result = svc.countSeats(els);
|
||||||
|
expect(result.total).toBe(3);
|
||||||
|
expect(result.byRow['row_manual']).toBe(1);
|
||||||
|
expect(Object.keys(result.byRow).length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
+229
@@ -0,0 +1,229 @@
|
|||||||
|
# Umbauplan web-cad v2.0 — Plugin-first, WebGL, Voll-CAD
|
||||||
|
|
||||||
|
> ⚠️ **STATUS: ARCHIVIERTE PLANREFERENZ** — Die operative Umsetzung läuft über **[ROADMAP.md](./ROADMAP.md)**
|
||||||
|
> (Tasks mit exakten Arbeitsschritten, Status, Fortschritts-Log).
|
||||||
|
> Dieses Dokument bleibt als Begründung/Kontext stehen: Warum welche Entscheidungen getroffen wurden,
|
||||||
|
> Marktvergleich, Risikoanalyse. Bei Abweichungen gilt immer ROADMAP.md.
|
||||||
|
|
||||||
|
Stand: 2026-08-26 · Basis: Commit `126277a` (main)
|
||||||
|
|
||||||
|
## Zielbild
|
||||||
|
|
||||||
|
Web-CAD im Umfang einer Standard-2D-CAD-Software mit Event-Fokus:
|
||||||
|
- **Alle Werkzeuge sind Plugins** — auch Kern-Werkzeuge (built-in Plugins haben keinen Sonderstatus mehr)
|
||||||
|
- **WebGL-Renderer** (PixiJS) für große Pläne (50.000+ Elemente bei 60fps)
|
||||||
|
- **Branchenneutrale Basis**: Grundrisse, Gelände, Flächen — nicht nur Veranstaltungen
|
||||||
|
- **Library als First-Class-System**, erweiterbar durch Plugin-Kataloge
|
||||||
|
- **Zwei Bedienmodi**: „Einfach“ für Einsteiger, „Pro“ für CAD-Anwender
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur-Grundsätze
|
||||||
|
|
||||||
|
1. **Core = Kernel only**: Datenmodell (CADElement/Layer), SpatialIndex, ZoomPan, History, Yjs-Sync, CommandRegistry, PluginRegistry. Kein einziges konkretes Werkzeug im Kernel.
|
||||||
|
2. **Werkzeug = Plugin**: Jedes Werkzeug implementiert `ToolExtensionV2` mit eigenem Event-Zustand (onPointerDown/Move/Up/Key/Cancel), eigener Live-Vorschau über RenderAdapter-Overlay und Optionsleiste.
|
||||||
|
3. **InteractionEngine wird Dispatcher**: Sie besitzt nur noch Event-Routing, aktives Tool delegieren, Undo-Transaktionen kapseln. Der aktuelle 1157-Zeilen-switch fällt weg.
|
||||||
|
4. **RenderAdapter als Schnittstelle**: Alles Rendern läuft gegen eine schmale API (`clear/draw*/overlay/hitTest`), Implementierung PixiJS. Canvas2D entfällt (keine produktiven Nutzer → kein Legacy-Pfad).
|
||||||
|
5. **Backend spiegelt Plugin-Lifecycle**: installierte Plugins, Versionen, Aktivierung pro Mandant/User in SQLite; Plugin-Bundles als ZIP mit Frontend-Code + optionalen Backend-Routen (sandboxed, manifest-signiert).
|
||||||
|
6. **Datenmodell offen für Branchen**: `element.type` bleibt string; unbekannte Typen werden vom registrierenden Plugin gerendert (bereits vorhanden, wird zur Pflicht).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase A — Plugin-first Refactoring (Kernel) · ca. 2 Wochen
|
||||||
|
|
||||||
|
### A1 Plugin-API v2 (`frontend/src/plugins/types.ts`)
|
||||||
|
- `ToolExtensionV2`: id, label, icon, ribbonTab, shortcut, cursor, optionsSchema (PropertyFields für Optionsleiste), `handlers: { onPointerDown/Move/Up/KeyDown/Cancel }`, `preview(adapter, state)`, `commit() -> Operation[]` (für Undo als eine Transaktion)
|
||||||
|
- `PanelExtension`: eigene Sidebar-Panels (z.B. Bestuhlungsplaner, Gästeliste)
|
||||||
|
- `LibraryProviderExtension`: liefert Kataloge (siehe Phase F)
|
||||||
|
- `SnapProviderExtension`: zusätzliche Fangpunkte (z.B. Tangente eines Plugin-Elements)
|
||||||
|
- `ImportExportExtension` behalten, um `formatId`, `mime`, `export(doc)`, `import(data)` erweitern
|
||||||
|
- Manifest: `category` erweitert um `'library'`; neue Felder `minAppVersion`, `permissions[]` (canvas, network, storage)
|
||||||
|
|
||||||
|
### A2 InteractionEngine zum Dispatcher umbauen
|
||||||
|
- Neuer Vertrag: `setTool(toolId)` holt Tool aus PluginRegistry statt internem Switch
|
||||||
|
- Undo/Redo: jedes Tool-commit wird eine HistoryManager-Transaktion (heute inkonsistent)
|
||||||
|
- CommandRegistry bleibt; Commands aus Plugins bereits vorhanden
|
||||||
|
- RibbonBar rendert dynamisch aus Registry (`getToolExtensions()` gruppiert nach ribbonTab) — Tabs bleiben konfigurierbar
|
||||||
|
|
||||||
|
### A3 Kern-Tools als built-in Plugins migrieren
|
||||||
|
| Plugin-Paket | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `core-drawing` | line, polyline, rect, circle, arc, polygon, point, xline, ray, ellipse, spline |
|
||||||
|
| `core-modify` | select, move, copy, rotate, scale, mirror, trim, extend, fillet, chamfer, offset, stretch, break, join, explode, align, array-rect, array-polar, array-path |
|
||||||
|
| `core-annotate` | text, mtext, dimension-linear/-angular/-radial, leader, revcloud, hatch |
|
||||||
|
| `core-measure` | measure, distance-info, area-info |
|
||||||
|
| `event-tools` | chair, seating-row, seating-block, table, stage, templates (bestehend, erweitert) |
|
||||||
|
|
||||||
|
Akzeptanz: `interaction/index.ts` < 300 LOC (nur Routing); alle 380 bestehenden Tests grün; Ribbon zeigt identische Werkzeuge wie zuvor.
|
||||||
|
|
||||||
|
### A4 Backend: Plugin-Registrierung
|
||||||
|
- SQLite-Tabelle `plugins(id, version, enabled_default, installed_at)` + `plugin_settings(plugin_id, scope, scope_id, json)`
|
||||||
|
- REST `/api/plugins` (list), `/api/plugins/:id/settings` (GET/PUT), Admin-Upload `/api/plugins/install` (ZIP)
|
||||||
|
- Websocket-Event `pluginStateChanged` → Clients aktivieren/deaktivieren live
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase B — Bugfixes (sofort, unabhängig) · 2–3 Tage
|
||||||
|
|
||||||
|
1. `seatingService.ts`: `rowId`/`blockId` via UUID-Counter statt `Date.now()`; Block-Reihen erhalten eigene `rowId`
|
||||||
|
2. `geometry.ts findIntersection`: Line–Circle, Circle–Circle, Line–Arc ergänzen (trim/extend/fillet funktionieren dann auf Kreisen/Bögen)
|
||||||
|
3. Offset-Tool: Richtungssinn aus zweitem Klick (Vorzeichen der Seitigkeit per Kreuzprodukt)
|
||||||
|
4. `RenderEngine.drawBackground`: Bild cachen (einmal laden), kein `img.onload → render()` pro Frame
|
||||||
|
5. `countSeats`: byRow auch für Blöcke korrekt
|
||||||
|
|
||||||
|
Jeder Fix mit Unit-Test. Diese Phase läuft parallel zu A und ändert Dateien, die B später ohnehin anfasst (Konflikt klein halten durch Reihenfolge: erst B, dann A-Migration je Tool).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase C — WebGL-Renderer (PixiJS) · 3–5 Wochen
|
||||||
|
|
||||||
|
### C1 RenderAdapter-Schnittstelle (`frontend/src/render/types.ts`)
|
||||||
|
```
|
||||||
|
interface RenderAdapter {
|
||||||
|
init(canvas), resize(w,h,dpr), setViewport(vp), destroy()
|
||||||
|
beginFrame(), endFrame()
|
||||||
|
drawGrid(cfg), drawBackground(img,cfg), drawRuler(cfg)
|
||||||
|
drawElement(el, layerStyle, scale)
|
||||||
|
drawPreview(el), drawSelection(el, handles), drawSnapPoints(pts)
|
||||||
|
drawOverlay(fn) // Freihand-Overlays von Tools (Gummibänder, Arc-Row-Vorschau)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### C2 PixiJS-Implementierung
|
||||||
|
- Layer-Konzept: Graphics pro CAD-Layer, statische Elemente in Container mit Cache-as-Texture, dynamisches (Selection/Snap/Preview) in Overlay-Layer
|
||||||
|
- Text: PIXI.Text/BitmapText; Dimensionen als Graphics + Label
|
||||||
|
- Chair/Table/Stage: Texture-Atlas (einmal generiert, tausendfach instanziert) — der große Performance-Hebel für Bestuhlung
|
||||||
|
- Grid/Ruler als getrennte Ticker-Layer, DPR-korrekt
|
||||||
|
- Hintergrundbild als Sprite (löst gleichzeitig Bug #4 endgültig)
|
||||||
|
|
||||||
|
### C3 Kompatibilität Plugin-Element-Renderer
|
||||||
|
- `ElementTypeExtension.render(ctx)` erhält Adapter-Wrapper: zeichnet in Offscreen-Canvas → `Texture.from(canvas)` → Sprite. Bestehende Event-Elemente (curtain, spotlight, barrier) laufen unverändert weiter; später können Plugins nativen Pixi-Renderer liefern (`renderPixi?`).
|
||||||
|
|
||||||
|
### C4 Tests & Benchmarks
|
||||||
|
- 29 RenderEngine-Tests auf Adapter-Mock umschreiben (ca. 1 Tag Aufwand)
|
||||||
|
- PerformanceBenchmark neu: 10k / 25k / 50k Chairs, Zielschwelle 60fps (Playwright + trace)
|
||||||
|
- Feature-Flag `renderer=webgl` während Migration, danach Flag entfernen
|
||||||
|
|
||||||
|
Akzeptanz: visuelle Parität zu Canvas2D (Screenshot-Vergleichstests), 50k Chairs @60fps auf Referenz-Hardware, alle Tests grün.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase D — CAD-Werkzeuge komplettieren (alles Plugins) · 3–4 Wochen
|
||||||
|
|
||||||
|
### D1 core-drawing Ergänzungen
|
||||||
|
- Ellipse + Ellipsenbogen (Achsen-, Rotations-Eingabe)
|
||||||
|
- Spline: Bézier (CV-Kontrolle) + Glättungs-Polyline-Interpolation; Speicherung als Kontrollpunkte
|
||||||
|
- Punkt, Konstruktionslinien (XLine/Ray, eigene Layer-Klasse „construction“)
|
||||||
|
- MTEXT: mehrzeilig, Inline-Formatierung (fett/kursiv/Größe), Autowrap-Box
|
||||||
|
|
||||||
|
### D2 core-modify Ergänzungen
|
||||||
|
- Stretch (Fenster über Polygonecken, Endpunkte verschieben)
|
||||||
|
- Break (Element am Punkt teilen), Join (kollineare Segmente/Polylines fusionieren)
|
||||||
|
- Chamfer (wie Fillet, gerade Kante), Explode (Block→Einzelelemente, Polyline→Segmente)
|
||||||
|
- Align (2-Punkt-Ausrichtung inkl. Skalierung optional)
|
||||||
|
- Lengthen (DElta/Total/Dynamisch)
|
||||||
|
- Polyline-Editor: Segment hinzufügen/löschen, Kurven glätten, Breite
|
||||||
|
- Grip-Editing: Ankerpunkte (Endpunkte/Mitte/Centre) direkt ziehen — großer Bedienkomfort-Gewinn
|
||||||
|
|
||||||
|
### D3 Arrays
|
||||||
|
- Rechteckig (Zeilen×Spalten×Abstände), Polar (Zentrum, Anzahl, Winkel, rotieren ja/nein), Pfad (entlang Polyline/Arc) — für Bestuhlung kritisch
|
||||||
|
|
||||||
|
### D4 Snaps erweitern (SnapEngine + SnapProviderExtension)
|
||||||
|
- Tangente, Senkrecht, Parallel, Quadrant, Center, Knoten, From-Offset (Basispunkt+Versatz)
|
||||||
|
|
||||||
|
### D5 Block-Attribute
|
||||||
|
- Datenmodell: Blockdefinition mit `attrDefs[]`, Instanz mit `attrValues[]`
|
||||||
|
- Editor-UI (Attribut-Formular beim Einfügen), Anzeige im RenderAdapter
|
||||||
|
- DXF-Interop: ATTDEF/ATTR lesen/schreiben (Phase F nutzt es)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase E — Event-Domain ausbauen · 2 Wochen
|
||||||
|
|
||||||
|
1. Interaktives Bestuhlungs-Werkzeug (im `event-tools` Plugin): Klick-Drag definiert Reihe/Block, Live-Vorschau via Overlay, Parameter (Anzahl, Abstand, Kurvenradius) in Optionsleiste
|
||||||
|
2. **Arc-Rows**: Reihen entlang Kreisbogen um Bühnenpunkt (Radius, Winkelspread, Versatz)
|
||||||
|
3. Sitzplatzzählung robust: byRow/byBlock/byZone, Summen in StatusBar, Klick auf Zahl highlightet Reihe
|
||||||
|
4. Vorlagenkatalog: Kino, Bankett-Rundtische, Stehtische, Podeste, Parlamentarisch, U-Form
|
||||||
|
5. Sitzplatzliste export (CSV/XLSX: Reihe, Platz, Zone, Gast optional)
|
||||||
|
6. Optional (Backend): `guests`-Tabelle + Zuordnung seatId↔guest, CSV-Import — Vorbereitung Social-Tables-ähnlicher Funktion
|
||||||
|
7. Event-Elemente veredeln: Bühne (mit Höhen-Angabe), Vorhang, Scheinwerfer (Farbe/Winkel), Barrieren (Kettenstücke), Fluchtweg-Signaturen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase F — Library-System & Import/Export · 2–3 Wochen
|
||||||
|
|
||||||
|
### F1 Library-Kern
|
||||||
|
- Backend-Schema erweitern: `library_folders` (Baum, Mandant-scope), `library_blocks` (+ tags, thumbnail, attrDefs, source: builtin/user/plugin)
|
||||||
|
- Suche (Name/Tags), Favoriten pro User, Drag-Drop ins Canvas, „Als Block speichern“ aus Auswahl
|
||||||
|
- Thumbnail-Generierung client-seitig beim Speichern (kleines PNG dataURL)
|
||||||
|
|
||||||
|
### F2 Library-Plugins
|
||||||
|
- `LibraryProviderExtension`: `id`, `label`, `listFolders()`, `listBlocks(folder)`, `getBlock(id)`
|
||||||
|
- Built-in Provider: `builtin-catalog` (mitgelieferte Kataloge: event, architecture, landscape), `user-library`, `global-library`
|
||||||
|
- Plugin-Katalog-Pakete liefern Provider + Blöcke (Beispiel: „messe-plugin“ mit Standwand-Katalog)
|
||||||
|
|
||||||
|
### F3 Import
|
||||||
|
- DXF: Parser erweitern um ELLIPSE, SPLINE, MTEXT, POINT, HATCH (Basis), ATTDEF/ATTR; INSERT→Blockdefinition + Instanz; Layer-Mapping
|
||||||
|
- SVG→Block (Vektor in Elemente konvertieren oder als referenzierter Render-Typ)
|
||||||
|
- Bibliotheks-Pakete `.wcadlib` (ZIP: manifest.json + blocks.json + thumbnails) importieren
|
||||||
|
- PNG/JPG als Hintergrund (vorhanden) + PDF-Unterlage beibehalten
|
||||||
|
|
||||||
|
### F4 Export
|
||||||
|
- `.wcadlib` Paket-Export (eigene Bibliothek teilen)
|
||||||
|
- DXF-Export mit BLOCKS/INSERTS (Austausch mit AutoCAD/LibreCAD)
|
||||||
|
- Bestehende Formate behalten: SVG, PDF, JSON
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase G — Layouts & Plot (Standard-CAD-Niveau) · 2 Wochen
|
||||||
|
|
||||||
|
1. Paperspace/Layouts: mehrere Layouts pro Zeichnung, Viewports mit Maßstab 1:1…1:500, Rahmen/Titelblock als Block mit Attributen (Projekt, Datum, Maßstab, Bearbeiter)
|
||||||
|
2. PDF-Export pro Layout in echtem Ausgabemaßstab (pdf-lib vorhanden)
|
||||||
|
3. Plot-Stil: Linienstärken nach Farbe/Layer, monochrom-Option
|
||||||
|
4. Drucken über Browser-Print (CSS @page) zusätzlich
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase H — Branchenneutralität & Einfachheit · 1–2 Wochen
|
||||||
|
|
||||||
|
1. **Modus „Einfach/Pro“**: User-Setting; Simple-Mode blendet Ribbon nach `tool.tags:['basic']` (Auswahl, Stühle, Vorlagen, Text, Maß), versteckt CommandLine/Layer-Tiefe; Pro zeigt alles. Gleiche Engine, gleiche Daten.
|
||||||
|
2. **Projekt-Templates**: Halle, Open Air, Saal, Messehalle, Bürogrundriss, Garten/Gelände — Template aktiviert passende Bibliotheks-Ordner + Plugin-Set + Rastermaßstab (m/cm/ft)
|
||||||
|
3. **Rollen**: Library-Admin (global bearbeiten) vs. Nutzer (lesen, eigene Ordner) — an bestehendes 4-Rollen-Auth andocken
|
||||||
|
4. i18n-Vorbereitung: Strings zentral (de/en), UI-Texte aus Bundle
|
||||||
|
5. Gelände-Tauglichkeit: Polygon-Flächen mit Flächenberechnung (m²), Hang-/Höhen-Annotation als Text/Leader reicht v1; GIS-Anbindung bewusst out-of-scope
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase I — Qualität, CI, Release · 1 Woche
|
||||||
|
|
||||||
|
- CI (Forgejo Actions): lint + tsc + vitest (FE 400+/BE 255+) + Playwright-Smoke + Performance-Gate (50k@60fps)
|
||||||
|
- Test-Deckung Tools: jede ToolExtension braucht Workflow-Test (ziehen, snappen, committen, undo)
|
||||||
|
- Docker/Coolify unverändert; Migrations-Scripte für SQLite-Schema (plugins, library, guests, layouts)
|
||||||
|
- Docs: Plugin-Entwickler-Guide (API v2), Library-Paket-Spec, Changelog 2.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Meilensteine & Timeline
|
||||||
|
|
||||||
|
| Milestone | Inhalt | ca. Woche |
|
||||||
|
|---|---|---|
|
||||||
|
| M1 | Phase B Bugfixes + A Plugin-first Kernel, Ribbon dynamisch | 2 |
|
||||||
|
| M2 | C WebGL live hinter Flag, Parität + Benchmarks grün | 6 |
|
||||||
|
| M3 | D volle Werkzeugpalette (Ellipse/Spline/Array/Grip/Attribute) | 10 |
|
||||||
|
| M4 | E Event-Domain + F Library/Import-Export fertig | 13 |
|
||||||
|
| M5 | G Layouts/PDF + H Modi/Templates + I Release 2.0 | 16–17 |
|
||||||
|
|
||||||
|
Einzelarbeit ~4 Monate. Parallelisierung (z.B. B+C ein Track, D+F zweiter Track) verkürzt auf ~3 Monate.
|
||||||
|
|
||||||
|
## Risiken
|
||||||
|
|
||||||
|
1. **PixiJS-Textqualität bei Dimensionen** → früh Screenshot-Paritätstest, ggf. DOM-Overlay für Labels
|
||||||
|
2. **Plugin-Sandboxing** (fremder Code im Browser) → v2 nur Admin-installierte, signierte Bundles; CSP; später Worker-Isolation
|
||||||
|
3. **DXF-Sonderfälle** (SPLINE-Grad, HATCH-Grenzen) → Grenzen dokumentieren, Fehlertoleranz-Importer mit Report
|
||||||
|
4. **Yjs + Layouts** → Layouts als Yjs-Map pro Projekt, Konfliktfreiheit gegeben
|
||||||
|
|
||||||
|
## Offene Entscheidungen
|
||||||
|
|
||||||
|
- PixiJS vs. Three.js-2D-Mode → Empfehlung PixiJS (Szene-Graph, Texture-Atlasten, kleiner)
|
||||||
|
- Fremd-Plugins sofort erlaubt? → Empfehlung nein (nur first-party bis M5)
|
||||||
|
- Gästemodul (E6) in 2.0 oder 2.1? → Empfehlung 2.1
|
||||||
Reference in New Issue
Block a user