diff --git a/frontend/src/kernel/document/CADDocument.ts b/frontend/src/kernel/document/CADDocument.ts index d04ba2c..e6e4dc7 100644 --- a/frontend/src/kernel/document/CADDocument.ts +++ b/frontend/src/kernel/document/CADDocument.ts @@ -112,6 +112,11 @@ export class CADDocument { } + /** Fügt einen Layer hinzu (überschreibt gleiche ID, kein Undo-Schritt). */ + addLayer(layer: CADLayer): void { + this.ydoc.data.layers.set(layer.id, layer); + } + /** * Ersetzt das komplette Layer-Set (Task H2: Projekt-Templates). * Bestehende Layer werden entfernt, Template-Layer übernommen. diff --git a/frontend/src/plugins/builtin/core-drawing/index.ts b/frontend/src/plugins/builtin/core-drawing/index.ts index 62677ab..a15009a 100644 --- a/frontend/src/plugins/builtin/core-drawing/index.ts +++ b/frontend/src/plugins/builtin/core-drawing/index.ts @@ -662,15 +662,244 @@ export const splineTool: ToolExtensionV2 = { }, }; +// ───────────────────────────────────────────────────────────── +// Task D3: Point/XLine/Ray — Konstruktions-Elemente auf defpoints. +// ───────────────────────────────────────────────────────────── + +/** Auto-Anlegen des defpoints-Layers (Task D3) falls fehlt. */ +function ensureDefpointsLayer(ctx: FullToolContext): void { + const doc = ctx.doc as unknown as { + getLayer(id: string): unknown; + addLayer(l: unknown): void; + } | undefined; + if (!doc || doc.getLayer('defpoints')) return; + doc.addLayer({ + id: 'defpoints', + name: 'defpoints', + visible: true, + locked: false, + color: '#8a8f98', + lineType: 'dotted', + transparency: 0.5, + sortOrder: 999, + parentId: null, + }); +} + +/** Konstruktions-Element-Basis-Props (construction-Tags, Task D3). */ +function constructionProps(): Record { + return { stroke: '#8a8f98', strokeWidth: 1, construction: true }; +} + +export const pointTool: ToolExtensionV2 = { + manifest: { + id: 'point', + label: 'Punkt', + icon: '\u2022', + ribbonTab: 'canvas', + tags: ['basic'], + description: 'Konstruktionspunkt platzieren (defpoints-Layer)', + }, + optionsSchema: [], + handlers: { + down(e, ctx) { + const c = ctx as FullToolContext; + if (!c.doc) return; + ensureDefpointsLayer(c); + const r = 1.5; + const el: CADElement = { + id: uid('point'), + type: 'circle', + layerId: 'defpoints', + x: e.world.x, + y: e.world.y, + width: r * 2, + height: r * 2, + properties: { ...constructionProps(), radius: r, point: true }, + } as unknown as CADElement; + c.doc.transact(() => c.doc!.addElement(el)); + ctx.setStatus(`Punkt platziert (${el.id})`); + }, + }, +}; + +/** XLine: sehr lange Linie in BEIDE Richtungen um den Basispunkt. */ +let xlineBase: Pt | null = null; + +export const xlineTool: ToolExtensionV2 = { + manifest: { + id: 'xline', + label: 'Konstruktionslinie', + icon: '\u2194', + ribbonTab: 'canvas', + tags: ['basic'], + description: 'Unendliche Konstruktionslinie: Basispunkt + Richtung klicken', + }, + optionsSchema: [], + handlers: { + down(e, ctx) { + const c = ctx as FullToolContext; + if (!c.doc) return; + if (!xlineBase) { + xlineBase = e.world; + ctx.setStatus('Konstruktionslinie: Basis gesetzt – Richtung klicken'); + return; + } + ensureDefpointsLayer(c); + const dx = e.world.x - xlineBase.x; + const dy = e.world.y - xlineBase.y; + const len = Math.hypot(dx, dy); + if (len < 0.001) { xlineBase = null; return; } + const ux = dx / len, uy = dy / len; + const L = 100000; + const el: CADElement = { + id: uid('xline'), + type: 'line', + layerId: 'defpoints', + x: xlineBase.x, + y: xlineBase.y, + width: 0, + height: 0, + properties: { + ...constructionProps(), + x1: xlineBase.x - ux * L, + y1: xlineBase.y - uy * L, + x2: xlineBase.x + ux * L, + y2: xlineBase.y + uy * L, + }, + } as unknown as CADElement; + c.doc.transact(() => c.doc!.addElement(el)); + xlineBase = null; + ctx.setPreview?.(null); + ctx.setStatus(`Konstruktionslinie erstellt (${el.id})`); + }, + move(e, ctx) { + const c = ctx as FullToolContext; + if (!xlineBase) return; + const dx = e.world.x - xlineBase.x; + const dy = e.world.y - xlineBase.y; + const len = Math.hypot(dx, dy); + if (len < 0.001) return; + const ux = dx / len, uy = dy / len; + const L = 100000; + ctx.setPreview?.({ + id: '__preview_xline__', + type: 'line', + layerId: 'defpoints', + x: xlineBase.x, + y: xlineBase.y, + width: 0, + height: 0, + properties: { + ...constructionProps(), + x1: xlineBase.x - ux * L, + y1: xlineBase.y - uy * L, + x2: xlineBase.x + ux * L, + y2: xlineBase.y + uy * L, + }, + } as unknown as CADElement); + }, + cancel(ctx) { + xlineBase = null; + ctx.setPreview?.(null); + ctx.setStatus('Konstruktionslinie abgebrochen'); + }, + }, +}; + +/** Ray: Linie ab Basispunkt NUR in Klickrichtung. */ +let rayBase: Pt | null = null; + +export const rayTool: ToolExtensionV2 = { + manifest: { + id: 'ray', + label: 'Strahl', + icon: '\u2192', + ribbonTab: 'canvas', + tags: ['basic'], + description: 'Strahl ab Basispunkt in Klickrichtung', + }, + optionsSchema: [], + handlers: { + down(e, ctx) { + const c = ctx as FullToolContext; + if (!c.doc) return; + if (!rayBase) { + rayBase = e.world; + ctx.setStatus('Strahl: Basis gesetzt – Richtung klicken'); + return; + } + ensureDefpointsLayer(c); + const dx = e.world.x - rayBase.x; + const dy = e.world.y - rayBase.y; + const len = Math.hypot(dx, dy); + if (len < 0.001) { rayBase = null; return; } + const ux = dx / len, uy = dy / len; + const L = 100000; + const el: CADElement = { + id: uid('ray'), + type: 'line', + layerId: 'defpoints', + x: rayBase.x, + y: rayBase.y, + width: 0, + height: 0, + properties: { + ...constructionProps(), + x1: rayBase.x, + y1: rayBase.y, + x2: rayBase.x + ux * L, + y2: rayBase.y + uy * L, + }, + } as unknown as CADElement; + c.doc.transact(() => c.doc!.addElement(el)); + rayBase = null; + ctx.setPreview?.(null); + ctx.setStatus(`Strahl erstellt (${el.id})`); + }, + move(e, ctx) { + const c = ctx as FullToolContext; + if (!rayBase) return; + const dx = e.world.x - rayBase.x; + const dy = e.world.y - rayBase.y; + const len = Math.hypot(dx, dy); + if (len < 0.001) return; + const ux = dx / len, uy = dy / len; + const L = 100000; + ctx.setPreview?.({ + id: '__preview_ray__', + type: 'line', + layerId: 'defpoints', + x: rayBase.x, + y: rayBase.y, + width: 0, + height: 0, + properties: { + ...constructionProps(), + x1: rayBase.x, + y1: rayBase.y, + x2: rayBase.x + ux * L, + y2: rayBase.y + uy * L, + }, + } as unknown as CADElement); + }, + cancel(ctx) { + rayBase = null; + ctx.setPreview?.(null); + ctx.setStatus('Strahl abgebrochen'); + }, + }, +}; + export const coreDrawingPlugin: PluginV2 = { manifest: { id: 'core-drawing', name: 'Zeichnen (Kern)', - version: '1.4.0', + version: '1.5.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, ellipseTool, splineTool], + tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool, splineTool, pointTool, xlineTool, rayTool], }; diff --git a/frontend/tests/constructionTools.test.ts b/frontend/tests/constructionTools.test.ts new file mode 100644 index 0000000..80e4fce --- /dev/null +++ b/frontend/tests/constructionTools.test.ts @@ -0,0 +1,147 @@ +/** + * Task D3 – Point/XLine/Ray: Konstruktions-Elemente. + * + * pointTool: Klick-Platzierer (kleiner Kreis mit point:true-Marker, + * r=1.5 wie DXF-POINT-Import). xlineTool: 2 Klicks (Basis+Richtung), + * sehr lange Linie in BEIDE Richtungen. rayTool: halblang (nur eine + * Richtung). Alle mit construction:true und auf defpoints-Layer + * (auto-angelegt falls fehlt). + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { pointTool, xlineTool, rayTool } from '../src/plugins/builtin/core-drawing'; +import { CADDocument } from '../src/kernel/document/CADDocument'; +import { createInMemoryDoc } from './helpers/inMemoryDoc'; +import type { CADElement, Pt } from '../src/types/cad.types'; + +function mkCtx() { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const previews: (CADElement | null)[] = []; + const statuses: string[] = []; + const ctx: any = { + doc, + options: {}, + setStatus: (m: string) => statuses.push(m), + setPreview: (el: CADElement | null) => previews.push(el), + }; + return { ctx, doc, previews, statuses }; +} + +function pe(x: number, y: number): { world: Pt } { + return { world: { x, y } } as never; +} + +beforeEach(() => { + pointTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); + xlineTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); + rayTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); +}); + +describe('D3: pointTool', () => { + it('Klick platziert Kreis mit point-Marker (r=1.5, wie DXF POINT)', () => { + const { ctx, doc } = mkCtx(); + pointTool.handlers.down!(pe(55, 66), ctx); + const els = doc.getAllElements(); + expect(els).toHaveLength(1); + const el = els[0]; + expect(el.type).toBe('circle'); + expect(el.x).toBe(55); + expect(el.y).toBe(66); + expect(el.properties.radius).toBe(1.5); + expect(el.properties.point).toBe(true); + }); + + it('defpoints-Layer wird automatisch angelegt falls fehlt', () => { + const { ctx, doc } = mkCtx(); + expect(doc.getLayer('defpoints')).toBeUndefined(); + pointTool.handlers.down!(pe(0, 0), ctx); + expect(doc.getLayer('defpoints')).toBeDefined(); + expect(doc.getLayer('defpoints')!.name).toBe('defpoints'); + }); + + it('Element landet auf defpoints-Layer mit construction:true', () => { + const { ctx, doc } = mkCtx(); + pointTool.handlers.down!(pe(10, 10), ctx); + const el = doc.getAllElements()[0]; + expect(el.layerId).toBe('defpoints'); + expect(el.properties.construction).toBe(true); + }); +}); + +describe('D3: xlineTool (Konstruktionslinie, beide Richtungen)', () => { + it('2 Klicks erzeugen sehr lange Linie durch beide Punkte in BEIDE Richtungen', () => { + const { ctx, doc } = mkCtx(); + xlineTool.handlers.down!(pe(0, 0), ctx); + xlineTool.handlers.down!(pe(10, 10), ctx); + const els = doc.getAllElements(); + expect(els).toHaveLength(1); + const el = els[0]; + expect(el.type).toBe('line'); + const dx = (el.properties.x2 as number) - (el.properties.x1 as number); + const dy = (el.properties.y2 as number) - (el.properties.y1 as number); + // Sehr lange: Länge >> Distanz der beiden Klicks (14.14) + expect(Math.hypot(dx, dy)).toBeGreaterThan(100000); + // Mittelpunkt der Linie = Basispunkt + expect((el.properties.x1 as number) + dx / 2).toBeCloseTo(0, 3); + expect((el.properties.y1 as number) + dy / 2).toBeCloseTo(0, 3); + }); + + it('Element auf defpoints-Layer mit construction:true', () => { + const { ctx, doc } = mkCtx(); + xlineTool.handlers.down!(pe(0, 0), ctx); + xlineTool.handlers.down!(pe(5, 0), ctx); + const el = doc.getAllElements()[0]; + expect(el.layerId).toBe('defpoints'); + expect(el.properties.construction).toBe(true); + }); + + it('Preview bei move nach erstem Klick', () => { + const { ctx, previews } = mkCtx(); + xlineTool.handlers.down!(pe(0, 0), ctx); + xlineTool.handlers.move!(pe(50, 50), ctx); + expect(previews.length).toBeGreaterThanOrEqual(1); + }); + + it('Erster Klick ohne zweiten erzeugt NICHTS (Session-State)', () => { + const { ctx, doc } = mkCtx(); + xlineTool.handlers.down!(pe(0, 0), ctx); + expect(doc.getAllElements()).toHaveLength(0); + }); +}); + +describe('D3: rayTool (Strahl, nur EINE Richtung)', () => { + it('2 Klicks erzeugen Linie ab Basispunkt NUR in Zugrichtung', () => { + const { ctx, doc } = mkCtx(); + rayTool.handlers.down!(pe(0, 0), ctx); + rayTool.handlers.down!(pe(10, 0), ctx); + const el = doc.getAllElements()[0]; + expect(el.type).toBe('line'); + expect(el.properties.x1).toBe(0); + expect(el.properties.y1).toBe(0); + // Endpunkt weit in +x-Richtung + expect((el.properties.x2 as number)).toBeGreaterThanOrEqual(100000); + // NICHT in Gegenrichtung: x1 ist exakt der Basispunkt + expect(el.properties.x1).toBe(0); + }); + + it('construction:true + defpoints-Layer', () => { + const { ctx, doc } = mkCtx(); + rayTool.handlers.down!(pe(0, 0), ctx); + rayTool.handlers.down!(pe(0, 10), ctx); + const el = doc.getAllElements()[0]; + expect(el.layerId).toBe('defpoints'); + expect(el.properties.construction).toBe(true); + }); +}); + +describe('D3: Manifeste', () => { + it('ids/ribbonTab/tags korrekt', () => { + expect(pointTool.manifest.id).toBe('point'); + expect(xlineTool.manifest.id).toBe('xline'); + expect(rayTool.manifest.id).toBe('ray'); + for (const t of [pointTool, xlineTool, rayTool]) { + expect(t.manifest.ribbonTab).toBe('canvas'); + expect((t.manifest.tags ?? [])).toContain('basic'); + } + }); +});