Compare commits

...

63 Commits

Author SHA1 Message Date
Agent Zero b9a4bce2ff docs: roadmap log D-ext3 2026-08-27 20:03:14 +02:00
Agent Zero b470ac3346 task(D-ext3): join/explode/align tools for v2 2026-08-27 20:03:14 +02:00
Agent Zero 9aed0e2163 docs: roadmap log C3fin-int interactive handles 2026-08-27 20:01:04 +02:00
Agent Zero 5605993462 task(C3fin-int): interactive handle drag for select tool (scale by corner) 2026-08-27 20:00:33 +02:00
Agent Zero 8223d159f8 docs: roadmap log D-ext2 chamfer/lengthen 2026-08-27 18:57:34 +02:00
Agent Zero eb1b8692a8 task(D-ext2): chamfer and lengthen tools for v2 2026-08-27 18:56:26 +02:00
Agent Zero 6a1d0db7a1 docs: roadmap log C3fin2 integration 2026-08-27 18:52:53 +02:00
Agent Zero e8aac3baa6 docs: roadmap log C3fin2 2026-08-27 18:51:27 +02:00
Agent Zero 042c546ebe task(C3fin2): snap marks and selection handles wired to canvas events 2026-08-27 18:51:27 +02:00
Agent Zero cefc508736 docs: roadmap log D-ext arrays + session summary 2026-08-27 18:42:59 +02:00
Agent Zero cdb04baff7 docs: roadmap log D-ext arrays 2026-08-27 18:40:59 +02:00
Agent Zero d5b6395f4f task(D-ext): array-rect and array-polar tools for v2 2026-08-27 18:40:58 +02:00
Agent Zero 9b658f31fb docs: roadmap log C3fin 2026-08-27 18:37:09 +02:00
Agent Zero 129ddcc61f task(C3fin): selection handles and snap marks in pixi renderer 2026-08-27 18:37:09 +02:00
Agent Zero 0abfc3e0be docs: roadmap log C4 2026-08-27 12:58:36 +02:00
Agent Zero eb8d6231c5 task(C4): use_pixi_renderer flag on by default with e2e proof 2026-08-27 12:58:36 +02:00
Agent Zero 738e4e8373 docs: roadmap log C3 2026-08-27 10:07:35 +02:00
Agent Zero 124b924457 task(C3): extend pixi element types - dimension/leader/revcloud/chair/table/stage 2026-08-27 10:07:35 +02:00
Agent Zero 8eb5a06816 docs: roadmap log C2int pixi integration 2026-08-27 10:02:54 +02:00
Agent Zero 8b1cca0324 task(C2int): integrate pixi renderer as overlay with lazy import 2026-08-27 10:02:05 +02:00
Agent Zero 8cde5d7f35 docs: roadmap log C2 pixi renderer 2026-08-27 08:54:26 +02:00
Agent Zero dde5077bf9 task(C2): pixi renderer with incremental element rendering behind adapter 2026-08-27 08:53:51 +02:00
Agent Zero b412a65c33 task(C2a): pure shape-primitive factory for all core drawing types 2026-08-27 08:49:18 +02:00
Agent Zero 98db99dc87 docs: roadmap log C1 render adapter 2026-08-27 08:45:18 +02:00
Agent Zero c962e755ec task(C1): render adapter interface with headless mock adapter 2026-08-27 08:43:19 +02:00
Agent Zero ebe8cd32d4 docs: roadmap log VERIF + A4.14 - v2 path is now the default 2026-08-27 08:38:44 +02:00
Agent Zero 69141d604a task(A4.14): make v2 tool path the default (verified e2e without flag) 2026-08-27 08:36:30 +02:00
Agent Zero fbf72e0b6d docs: roadmap log A4.11-A4.13 - phase A functional completion 2026-08-27 07:22:22 +02:00
Agent Zero e370552055 task(A4.13): dynamic v2 tool section in ribbon behind flag 2026-08-27 07:21:19 +02:00
Agent Zero 216bf6360a task(A4.12): v2 history panel reading undo manager behind flag 2026-08-27 07:17:46 +02:00
Agent Zero 25e721d3d3 task(A4.11): documentService singleton + global v2 undo/redo shortcuts 2026-08-27 07:14:41 +02:00
Agent Zero e76b3efe20 docs: roadmap log A4.10 - tool migration complete 2026-08-27 01:22:41 +02:00
Agent Zero 19f838fbf8 task(A4.10): migrate trim/extend/fillet to v2 + real CAD extend fallback 2026-08-27 01:21:44 +02:00
Agent Zero ebafcf0c65 docs: roadmap log A4.8/A4.9 + restore A4.7 line after patch slip 2026-08-27 01:14:36 +02:00
Agent Zero 76f305fc6f task(A4.9): migrate event domain tools (chair/row/block/table/stage) to v2 2026-08-27 01:12:52 +02:00
Agent Zero cf67635732 task(A4.8): migrate delete/offset tools to v2 2026-08-27 01:09:51 +02:00
Agent Zero 7565016200 docs: roadmap log A4.7 transforms 2026-08-27 01:05:40 +02:00
Agent Zero 1d598daba1 task(A4.7): migrate rotate/scale/mirror tools to v2 (selection-based transforms) 2026-08-27 01:04:48 +02:00
Agent Zero 1d3d9bd191 docs: roadmap log A4.6 2026-08-27 00:54:09 +02:00
Agent Zero ca1c3c08f3 task(A4.6): migrate move/copy tools to v2 with selection-based workflow 2026-08-27 00:53:16 +02:00
Agent Zero 0c852d18ee docs: roadmap log A4.5 2026-08-27 00:49:15 +02:00
Agent Zero 464ddb91cc task(A4.5): migrate revcloud/hatch/measure tools to v2 2026-08-27 00:48:28 +02:00
Agent Zero af46f0028a docs: roadmap log A4.4 annotate plugin 2026-08-27 00:43:51 +02:00
Agent Zero 239257c8b0 task(A4.4): migrate text/dimension/leader tools to v2 annotate plugin 2026-08-27 00:43:20 +02:00
Agent Zero 4850f0584b docs: roadmap log A4.3 drawing tools migrated 2026-08-27 00:40:03 +02:00
Agent Zero 0dfe3b5870 task(A4.3): migrate circle/arc/polyline/polygon tools to v2 2026-08-27 00:39:30 +02:00
Agent Zero 9216a9051e docs: roadmap log A4.2 rect migration pattern established 2026-08-26 23:14:59 +02:00
Agent Zero 8204de81c4 task(A4.2): migrate rect tool to v2 plugin 2026-08-26 23:14:26 +02:00
Agent Zero 4b31f49781 docs: roadmap log A4.1 wiring complete 2026-08-26 23:11:24 +02:00
Agent Zero d54a67662b task(A4.1): wire dispatcher to canvas - doc bridge, selection bridge, activeTool sync 2026-08-26 23:10:20 +02:00
Agent Zero 09a120d800 task(A3): pilot v2 tools select+line 2026-08-26 21:30:54 +02:00
Agent Zero ddfeadac58 task(A2b): CADDocument facade with transactions and undo 2026-08-26 21:23:22 +02:00
Agent Zero 4a7a8f912f task(A2): tool dispatcher behind flag 2026-08-26 21:14:46 +02:00
Agent Zero 5d7fa23825 task(A1): plugin api v2 types and registry (additive) 2026-08-26 21:11:01 +02:00
Agent Zero cba9358235 task(A0): logger and test helpers 2026-08-26 21:04:17 +02:00
Agent Zero 725879562e docs: clarify plan roles - ROADMAP.md is authoritative, umbauplan-v2.md archived reference 2026-08-26 20:58:37 +02:00
Agent Zero 3b34181c54 docs: roadmap progress phase B complete (B0-B5), log updated 2026-08-26 20:56:59 +02:00
Agent Zero 7f4768c205 task(B5): seat counting by row and block 2026-08-26 20:53:42 +02:00
Agent Zero d93ee5308c task(B4): cache background image (fixes BUG-4) 2026-08-26 20:51:43 +02:00
Agent Zero d8f29e42e3 task(B3): offset respects click side (fixes BUG-3) 2026-08-26 13:51:02 +02:00
Agent Zero 95b14cf86b task(B2): line/circle/arc intersections (fixes BUG-2) 2026-08-26 13:47:25 +02:00
Agent Zero 1bbd653d34 task(B1): unique row/block ids for seating (fixes BUG-1) 2026-08-26 13:14:34 +02:00
Agent Zero b9eb69fc81 task(B0): regression tests for known bugs (red) 2026-08-26 12:53:07 +02:00
46 changed files with 8241 additions and 31 deletions
+1428
View File
File diff suppressed because it is too large Load Diff
+86
View File
@@ -10,6 +10,7 @@
"dependencies": {
"dxf-parser": "^1.1.2",
"pdf-lib": "^1.17.1",
"pixi.js": "^8.20.1",
"rbush": "^4.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -661,6 +662,11 @@
"pako": "^1.0.10"
}
},
"node_modules/@pixi/colord": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz",
"integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA=="
},
"node_modules/@playwright/test": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
@@ -1094,6 +1100,11 @@
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true
},
"node_modules/@types/earcut": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz",
"integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ=="
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1257,6 +1268,19 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.72",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz",
"integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw=="
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.15",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
"integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/abstract-leveldown": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz",
@@ -1559,6 +1583,11 @@
"loglevel": "^1.7.1"
}
},
"node_modules/earcut": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q=="
},
"node_modules/electron-to-chromium": {
"version": "1.5.378",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz",
@@ -1629,6 +1658,11 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -1678,6 +1712,14 @@
"node": ">=6.9.0"
}
},
"node_modules/gifuct-js": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz",
"integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==",
"dependencies": {
"js-binary-schema-parser": "^2.0.3"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
@@ -1737,6 +1779,11 @@
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true
},
"node_modules/ismobilejs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
"integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw=="
},
"node_modules/isomorphic.js": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
@@ -1746,6 +1793,11 @@
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/js-binary-schema-parser": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz",
"integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg=="
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2377,6 +2429,11 @@
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
},
"node_modules/parse-svg-path": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz",
"integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg=="
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
@@ -2424,6 +2481,27 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pixi.js": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.20.1.tgz",
"integrity": "sha512-akLVBMLvQbaEViqsmVraK0Njer1q4Q4lm4iNKuzvBiFrV8q+6/+HluJN3sWIwO663IBeQtUUS/CaXFJZs6ffXQ==",
"dependencies": {
"@pixi/colord": "^2.9.6",
"@types/earcut": "^3.0.0",
"@webgpu/types": "^0.1.69",
"@xmldom/xmldom": "^0.8.15",
"earcut": "^3.0.2",
"eventemitter3": "^5.0.1",
"gifuct-js": "^2.1.2",
"ismobilejs": "^1.1.1",
"parse-svg-path": "^0.2.0",
"tiny-lru": "^11.4.7"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/pixijs"
}
},
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
@@ -2754,6 +2832,14 @@
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true
},
"node_modules/tiny-lru": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
"integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==",
"engines": {
"node": ">=12"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+1
View File
@@ -14,6 +14,7 @@
"dependencies": {
"dxf-parser": "^1.1.2",
"pdf-lib": "^1.17.1",
"pixi.js": "^8.20.1",
"rbush": "^4.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -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
View File
@@ -18,6 +18,8 @@ import StatusBar from './components/StatusBar';
import MobileDrawers from './components/MobileDrawers';
import BackgroundImport from './components/BackgroundImport';
import HistoryPanel from './components/HistoryPanel';
import V2HistoryPanel from './components/V2HistoryPanel';
import { V2_TOOLS_ENABLED } from './kernel/featureFlags';
import SettingsModal from './components/SettingsModal';
import InlineTextEditor from './components/InlineTextEditor';
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 === '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 ───
if (action === 'background-import') { setBgImportOpen(true); return; }
@@ -1563,6 +1574,11 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
activeTab={activeRibbonTab}
onTabChange={setActiveRibbonTab}
onAction={handleRibbonAction}
onIsV2ToolActive={(toolId: string) =>
V2_TOOLS_ENABLED &&
activeTool === toolId &&
!!pluginRegistry.getToolV2(toolId)
}
canvasBgColor={canvasBgColor}
gridColor={gridColor}
rulerEnabled={rulerEnabled}
@@ -1728,7 +1744,11 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
onApply={handleBgApply}
backgroundService={bgServiceRef.current}
/>
{historyPanelOpen && (
{historyPanelOpen && (V2_TOOLS_ENABLED ? (
<V2HistoryPanel
onClose={() => setHistoryPanelOpen(false)}
/>
) : (
<HistoryPanel
entries={historyEntries}
onJumpTo={(entryId: string) => {
@@ -1741,7 +1761,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
}}
onClose={() => setHistoryPanelOpen(false)}
/>
)}
))}
{exportFormatOpen && (
<div className="export-format-overlay" onClick={() => setExportFormatOpen(false)}>
<div className="export-format-modal" onClick={(e) => e.stopPropagation()}>
+25 -4
View File
@@ -50,6 +50,10 @@ export class RenderEngine {
private snapPoints: SnapPoint[] = [];
private activeSnapPoint: SnapPoint | 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;
constructor(
@@ -379,13 +383,30 @@ export class RenderEngine {
}
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();
img.src = this.options.backgroundSrc!;
if (!img.complete) {
img.onload = () => this.render();
img.onload = () => {
this.bgImage = img;
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;
}
// Gecachtes Bild zeichnen
const img = this.bgImage;
this.ctx.save();
this.ctx.globalAlpha = this.options.backgroundOpacity;
const s = this.zoomPan.getScale();
+183
View File
@@ -12,6 +12,14 @@ import { SpatialIndex } from '../canvas/SpatialIndex';
import { LayerManager } from '../canvas/LayerManager';
import { GroupManager } from '../tools/modification/GroupTool';
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> = ({
cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled,
@@ -26,6 +34,13 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
const zoomPanRef = useRef<ZoomPanController | null>(null);
const renderEngineRef = useRef<RenderEngine | 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 layerManagerRef = useRef<LayerManager | null>(null);
const selectionEngineRef = useRef<SelectionEngine | null>(null);
@@ -44,6 +59,128 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
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;
renderEngineRef.current = renderEngine;
interactionRef.current = interaction;
@@ -66,6 +203,11 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
return () => {
interaction.detach();
dispatcherRef.current?.destroy();
dispatcherRef.current = null;
pixiRendererRef.current?.destroy(); // Task C2: WebGL-Kontext freigeben
pixiRendererRef.current = null;
setActiveDocument(null);
zoomPanRef.current = null;
renderEngineRef.current = null;
interactionRef.current = null;
@@ -229,9 +371,34 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
const interaction = interactionRef.current;
if (!interaction) return;
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
}, [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
useEffect(() => {
const interaction = interactionRef.current;
@@ -338,6 +505,22 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
<span style={{ color: 'var(--color-text-faint)' }}>{unit}</span>
</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
ref={canvasRef}
className="canvas-svg"
+35
View File
@@ -1,5 +1,7 @@
import React from 'react';
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 }> = [
{ 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,
canvasBgColor = '#1e1e2e', gridColor = '#3a3a4a', rulerEnabled = true,
onCanvasBgColorChange, onGridColorChange, onToggleRuler,
onIsV2ToolActive,
}) => {
return (
<nav className="ribbon" role="navigation" aria-label="Hauptwerkzeuge">
@@ -36,6 +39,38 @@ const RibbonBar: React.FC<RibbonBarProps> = ({
</div>
<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 ===== */}
{activeTab === 'start' && (
<>
+164
View File
@@ -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;
+157
View File
@@ -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();
}
}
+17 -1
View File
@@ -761,7 +761,23 @@ export class InteractionEngine {
if (this.modifySelected.length === 1) {
const first = this.state.points[0];
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)
this.onElementCreated?.({ ...offsetted, id: this.generateId() });
this.resetModify();
+53
View File
@@ -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 */
}
}
}
+34
View File
@@ -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';
}
}
+137
View File
@@ -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;
}
+23
View File
@@ -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';
+105
View File
@@ -9,14 +9,29 @@ import type {
ToolExtension,
CommandExtension,
ImportExportExtension,
// API v2 (Task A1):
PluginV2,
ToolExtensionV2,
PanelExtension,
LibraryProviderExtension,
} from './types';
/** Interner Zustand eines registrierten V2-Plugins. */
interface PluginStateV2 {
plugin: PluginV2;
enabled: boolean;
loaded: boolean;
}
class PluginRegistryClass {
private plugins = new Map<string, Plugin>();
private states = new Map<string, PluginState>();
private context: PluginContext | null = null;
private listeners = new Set<() => void>();
// ── API v2 (Task A1) ─────────────────────────────────────
private v2Plugins = new Map<string, PluginStateV2>();
/** Set the plugin context (called once on app init) */
setContext(ctx: PluginContext) {
this.context = ctx;
@@ -164,6 +179,96 @@ class PluginRegistryClass {
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],
};
+27
View File
@@ -12,15 +12,42 @@ export type {
CommandExtension,
ImportExportExtension,
PropertyField,
// API v2 (Task A1):
PluginV2,
ToolExtensionV2,
ToolManifestPart,
ToolPointerEvent,
ToolContext,
PanelExtension,
LibraryProviderExtension,
LibFolder,
LibBlock,
PluginCategoryV2,
} from './types';
// Built-in plugins
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 { 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 */
export function registerBuiltinPlugins() {
pluginRegistry.register(eventToolsPlugin);
// API v2 (Task A3+):
pluginRegistry.registerV2(coreDrawingPlugin);
pluginRegistry.registerV2(coreModifyPlugin);
pluginRegistry.registerV2(coreAnnotatePlugin);
pluginRegistry.registerV2(coreMeasurePlugin);
pluginRegistry.registerV2(eventToolsV2Plugin);
}
+128
View File
@@ -116,3 +116,131 @@ export interface PluginState {
enabled: 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;
}
+123
View File
@@ -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;
}
}
+436
View File
@@ -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;
}
}
+242
View File
@@ -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 [];
}
}
+123
View File
@@ -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;
}
+24 -4
View File
@@ -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 {
private generateId(): string {
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[] {
const cfg = { ...DEFAULT_ROW, ...config };
const elements: CADElement[] = [];
const rowId = nextUid('row');
const totalWidth = (cfg.count - 1) * cfg.spacing + cfg.chairWidth;
const startX = x - totalWidth / 2 + cfg.chairWidth / 2;
@@ -217,7 +225,7 @@ export class SeatingService {
outlineColor: '#2a5a99',
seatType: 'standard',
rowIndex: i,
rowId: `row_${Date.now()}`,
rowId,
},
});
}
@@ -233,9 +241,10 @@ export class SeatingService {
const startX = x - totalW / 2 + cfg.chairWidth / 2;
const startY = y - totalH / 2 + cfg.chairHeight / 2;
const rot = cfg.rotation * Math.PI / 180;
const blockId = `block_${Date.now()}`;
const blockId = nextUid('block');
for (let row = 0; row < cfg.rows; row++) {
const rowId = nextUid('row');
const offset = cfg.rowOffset * (row % 2);
for (let col = 0; col < cfg.cols; col++) {
const lx = startX + col * cfg.colSpacing + offset;
@@ -263,6 +272,7 @@ export class SeatingService {
rowIndex: row,
colIndex: col,
blockId,
rowId,
},
});
}
@@ -342,8 +352,18 @@ export class SeatingService {
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;
const byRow: Record<string, number> = {};
const byBlock: Record<string, number> = {};
+259 -19
View File
@@ -163,7 +163,14 @@ export function mirrorElement(el: CADElement, x1: number, y1: number, x2: number
* Offset element by a distance (creates a parallel copy).
* 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 };
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
const nx = -dy / len;
const ny = dx / len;
const ox = nx * distance;
const oy = ny * distance;
const ox = nx * signed;
const oy = ny * signed;
props.x1 += ox;
props.y1 += oy;
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) {
props.radius = Math.abs(props.radius + distance);
props.radius = Math.abs(props.radius + signed);
const d = props.radius * 2;
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;
const nx = -dy / 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 };
}
@@ -215,7 +222,15 @@ export function offsetElement(el: CADElement, distance: number): CADElement {
*/
export function trimElement(el: CADElement, boundary: CADElement): CADElement | null {
// 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);
if (!intersect) return null;
@@ -247,10 +262,60 @@ export function trimElement(el: CADElement, boundary: CADElement): CADElement |
* Currently supports extending lines to a boundary intersection.
*/
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);
if (!intersect) return null;
let intersect = findIntersection(el, boundary);
// 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 };
// 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).
*/
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 02 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 p2 = el2.properties;
if (p1.x1 === undefined || p1.y1 === undefined || p1.x2 === undefined || p1.y2 === undefined) return null;
if (p2.x1 === undefined || p2.y1 === undefined || p2.x2 === undefined || p2.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 [];
// Line-line intersection
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 null;
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 [];
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 y = p1.y1 + t * (p1.y2 - p1.y1);
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 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;
}
/**
+2
View File
@@ -94,6 +94,8 @@ export interface RibbonBarProps {
onCanvasBgColorChange?: (color: string) => void;
onGridColorChange?: (color: string) => 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 {
+69
View File
@@ -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();
+173
View File
@@ -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
});
});
+121
View File
@@ -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);
});
});
+138
View File
@@ -0,0 +1,138 @@
/**
* InteractionDispatcher Tests (Task A2).
* Prüft Event-Routing gegen ein Fake-Werkzeug ohne echte Registry:
* Reihenfolge downmoveup, 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 });
});
});
+56
View File
@@ -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);
});
});
+174
View File
@@ -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);
});
});
+90
View File
@@ -10,6 +10,9 @@ import {
offsetElement,
getElementBBox,
distance,
lineCircleIntersections,
circleCircleIntersections,
lineArcIntersection,
angleBetween,
} from '../src/tools/modification/geometry';
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);
});
});
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([]);
});
});
+74
View File
@@ -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);
});
});
+52
View File
@@ -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,
};
}
+69
View File
@@ -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;
}
}
+82
View File
@@ -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();
});
});
+924
View File
@@ -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']);
});
});
+106
View File
@@ -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);
});
});
+84
View File
@@ -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
View File
@@ -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) · 23 Tage
1. `seatingService.ts`: `rowId`/`blockId` via UUID-Counter statt `Date.now()`; Block-Reihen erhalten eigene `rowId`
2. `geometry.ts findIntersection`: LineCircle, CircleCircle, LineArc 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) · 35 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) · 34 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 · 23 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 · 12 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 | 1617 |
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