diff --git a/frontend/src/plugins/builtin/core-modify/index.ts b/frontend/src/plugins/builtin/core-modify/index.ts index dfedb8e..a77b2fb 100644 --- a/frontend/src/plugins/builtin/core-modify/index.ts +++ b/frontend/src/plugins/builtin/core-modify/index.ts @@ -1077,6 +1077,110 @@ function makeArrayTool(mode: 'array-rect' | 'array-polar'): ToolExtensionV2 { export const arrayRectTool: ToolExtensionV2 = makeArrayTool('array-rect'); export const arrayPolarTool: ToolExtensionV2 = makeArrayTool('array-polar'); +// ───────────────────────────────────────────────────────────── +// Task D10-Rest: array-path — Elemente entlang einer Polyline +// (Pfad) verteilen. Klick1: Quelle (Auto-HitSelect), Klick2: auf +// die Pfad-Polyline klicken. count Kopien gleichmäßig über die +// Gesamtlänge (s = i/(count-1)·total), ALLES in 1 Transaktion. +// ───────────────────────────────────────────────────────────── + +let arrayPathBase: Pt | null = null; + +/** Punkt auf einer Polyline bei kumulierter Länge s (0..total). */ +function pointAlongPolyline(pts: Pt[], s: number): Pt { + if (pts.length === 0) return { x: 0, y: 0 }; + if (pts.length === 1 || s <= 0) return { ...pts[0] }; + let remaining = s; + for (let i = 0; i < pts.length - 1; i++) { + const dx = pts[i + 1].x - pts[i].x; + const dy = pts[i + 1].y - pts[i].y; + const segLen = Math.hypot(dx, dy); + if (segLen <= 0) continue; + if (remaining <= segLen) { + const t = remaining / segLen; + return { x: pts[i].x + dx * t, y: pts[i].y + dy * t }; + } + remaining -= segLen; + } + return { ...pts[pts.length - 1] }; +} + +export const arrayPathTool: ToolExtensionV2 = { + manifest: { + id: 'array-path', + label: 'Array Pfad', + icon: '∿', + ribbonTab: 'canvas', + tags: ['pro'], + description: 'Array entlang Pfad: Auswahl treffen, dann auf Pfad-Polyline klicken (Task D10)', + }, + optionsSchema: [ + { key: 'count', label: 'Anzahl', type: 'number', min: 2, max: 100 }, + ], + handlers: { + down(e, ctx) { + const c = ctx as FullToolContext; + if (!c.doc || !c.selection) return; + if (!arrayPathBase) { + if (c.selection.getIds().length === 0) { + const hit = c.selection.hitTest(e.world.x, e.world.y, 5); + if (!hit) { + ctx.setStatus('Array Pfad: zuerst Element(e) auswählen'); + return; + } + c.selection.setIds([hit.id]); + } + arrayPathBase = e.world; + ctx.setStatus(`Array Pfad: auf Pfad-Polyline klicken (${c.selection.getIds().length} Element(e))`); + return; + } + // Zweiter Klick: muss eine Polyline als Pfad treffen + const pathHit = c.selection.hitTest(e.world.x, e.world.y, 8); + if (!pathHit || (pathHit.type !== 'polyline' && pathHit.type !== 'polygon')) { + ctx.setStatus('Array Pfad: zweiter Klick muss eine Polyline als Pfad treffen'); + arrayPathBase = null; + return; + } + const pathPts = (pathHit.properties as Record).points as Pt[] | undefined; + if (!pathPts || pathPts.length < 2) { + ctx.setStatus('Array Pfad: Pfad-Polyline hat zu wenige Punkte'); + arrayPathBase = null; + return; + } + const count = Math.max(2, (c.options.count as number | undefined) ?? 5); + const ids = c.selection.getIds().filter((id) => id !== pathHit.id); + // Gesamtlänge des Pfads + let total = 0; + for (let i = 0; i < pathPts.length - 1; i++) { + total += Math.hypot(pathPts[i + 1].x - pathPts[i].x, pathPts[i + 1].y - pathPts[i].y); + } + c.doc.transact(() => { + for (const id of ids) { + const el = c.doc!.getElement(id); + if (!el) continue; + for (let i = 0; i < count; i++) { + const s = total * (i / Math.max(1, count - 1)); + const pos = pointAlongPolyline(pathPts, s); + c.doc!.addElement({ + ...el, + id: uid('arrp'), + x: pos.x, + y: pos.y, + } as CADElement); + } + } + }); + arrayPathBase = null; + ctx.setStatus(`Array Pfad: ${ids.length * count} Kopien entlang Pfad verteilt`); + }, + cancel(ctx) { + arrayPathBase = null; + ctx.setPreview?.(null); + ctx.setStatus('Array Pfad abgebrochen'); + }, + }, +}; + // ───────────────────────────────────────────────────────────── // Chamfer (Task D-Ext): schneidet zwei sich schneidende Linien an einer // festen Distanz vom Schnittpunkt und verbindet die Enden mit einer @@ -1483,5 +1587,5 @@ export const coreModifyPlugin: PluginV2 = { category: 'tools', enabledByDefault: true, }, - tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool, chamferTool, lengthenTool, joinTool, explodeTool, alignTool, polylineEditTool, stretchTool, breakTool], + tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool, chamferTool, lengthenTool, joinTool, explodeTool, alignTool, polylineEditTool, stretchTool, breakTool, arrayPathTool], }; diff --git a/frontend/tests/arrayPath.test.ts b/frontend/tests/arrayPath.test.ts new file mode 100644 index 0000000..106a45c --- /dev/null +++ b/frontend/tests/arrayPath.test.ts @@ -0,0 +1,170 @@ +/** + * Task D10-Rest – array-path: Elemente entlang einer Polyline verteilen. + * + * Klick1 = Quelle (Auto-HitSelect bei leerer Selektion), Klick2 auf + * Pfad-Polyline → Element-Kopien gleichmaessig entlang der Pfad- + * laenge verteilt (count aus Options, Default 5), Original bleibt, + * ALLES in EINER Transaktion = 1 Undo-Schritt. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { arrayPathTool } from '../src/plugins/builtin/core-modify'; +import { CADDocument } from '../src/kernel/document/CADDocument'; +import { createInMemoryDoc } from './helpers/inMemoryDoc'; +import type { CADElement, Pt } from '../src/types/cad.types'; + +function mkCtx(elements: CADElement[], options: Record = {}) { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + for (const el of elements) doc.addElement(el); + const selectedIds = new Set(); + const statuses: string[] = []; + const ctx: any = { + doc, + options, + setStatus: (m: string) => statuses.push(m), + setPreview: (_el: CADElement | null) => {}, + selection: { + getIds: () => Array.from(selectedIds), + setIds: (ids: string[]) => { + selectedIds.clear(); + for (const id of ids) selectedIds.add(id); + }, + hitTest: (x: number, y: number, _tol: number) => { + // trifft das Element, dessen BBox den Punkt enthaelt (erstes Gewinnt) + for (const el of elements) { + if ( + x >= el.x - el.width / 2 && x <= el.x + el.width / 2 && + y >= el.y - el.height / 2 && y <= el.y + el.height / 2 + ) return el; + } + return null; + }, + }, + }; + return { ctx, doc, selectedIds, statuses }; +} + +function pe(x: number, y: number): { world: Pt } { + return { world: { x, y } } as never; +} + +const CHAIR = { + id: 'chair-1', type: 'chair', layerId: 'layer-0', + x: 0, y: 0, width: 40, height: 40, properties: {}, +} as unknown as CADElement; + +const PATH = { + id: 'path-1', type: 'polyline', layerId: 'layer-0', + x: 250, y: 0, width: 500, height: 0, + properties: { points: [{ x: 0, y: 0 }, { x: 500, y: 0 }] }, +} as unknown as CADElement; + +beforeEach(() => { + arrayPathTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); +}); + +describe('D10: arrayPathTool', () => { + it('verteilt count Kopien gleichmaessig entlang der Pfadlaenge (Original bleibt)', () => { + const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH]); + selectedIds.add('chair-1'); + // Klick1: Quelle ist schon selektiert -> Basis direkt setzen + arrayPathTool.handlers.down!(pe(0, 0), ctx); + // Klick2: auf den Pfad klicken (Pfad-Mitte 250,0) + arrayPathTool.handlers.down!(pe(250, 0), ctx); + + const els = doc.getAllElements(); + // Original + Pfad + 5 Kopien + const chairs = els.filter((e) => e.type === 'chair'); + expect(chairs.length).toBe(1 + 5); + // Kopien getrennt vom Original (Original bleibt bei x=0 unangetastet): + const copies = chairs.filter((c) => c.id !== 'chair-1'); + expect(copies).toHaveLength(5); + const copyXs = copies.map((c) => c.x).sort((a, b) => a - b); + // count=5 gleichmaessig ueber Pfadlaenge 500: s = i/(count-1)*total + expect(copyXs[0]).toBeCloseTo(0, 0); + expect(copyXs[1]).toBeCloseTo(125, 0); + expect(copyXs[2]).toBeCloseTo(250, 0); + expect(copyXs[3]).toBeCloseTo(375, 0); + expect(copyXs[4]).toBeCloseTo(500, 0); + }); + + it('Auto-HitSelect bei leerer Selektion (Klick1 auf Element)', () => { + const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH]); + // Selektion ist leer: Klick auf den Stuhl waehlt ihn automatisch + arrayPathTool.handlers.down!(pe(0, 0), ctx); + expect(selectedIds.has('chair-1')).toBe(true); + // Klick2 auf Pfad -> Commit + arrayPathTool.handlers.down!(pe(100, 0), ctx); + expect(doc.getAllElements().filter((e) => e.type === 'chair').length).toBe(6); + }); + + it('Klick2 trifft KEINEN Pfad -> kein Commit, Status erklaert', () => { + const { ctx, doc, selectedIds, statuses } = mkCtx([CHAIR, PATH]); + selectedIds.add('chair-1'); + arrayPathTool.handlers.down!(pe(0, 0), ctx); + // Klick weit weg vom Pfad (kein Element getroffen) + arrayPathTool.handlers.down!(pe(9999, 9999), ctx); + expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(1); + expect(statuses[statuses.length - 1]).toContain('Pfad'); + }); + + it('Kopien in EINER Transaktion = 1 Undo entfernt alle', () => { + const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH]); + selectedIds.add('chair-1'); + arrayPathTool.handlers.down!(pe(0, 0), ctx); + arrayPathTool.handlers.down!(pe(250, 0), ctx); + expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(6); + doc.undo(); + expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(1); + }); + + it('count-Option steuert die Anzahl (3 statt 5)', () => { + const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH], { count: 3 }); + selectedIds.add('chair-1'); + arrayPathTool.handlers.down!(pe(0, 0), ctx); + arrayPathTool.handlers.down!(pe(250, 0), ctx); + expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(4); // Original + 3 + // Kopien getrennt vom Original: count=3 -> s = 0, 250, 500 + const copies3 = doc.getAllElements().filter((e) => e.type === 'chair' && e.id !== 'chair-1'); + const copyXs3 = copies3.map((c) => c.x).sort((a, b) => a - b); + expect(copyXs3[0]).toBeCloseTo(0, 0); + expect(copyXs3[1]).toBeCloseTo(250, 0); + expect(copyXs3[2]).toBeCloseTo(500, 0); + }); + + it('gebogener Pfad (3 Punkte): Kopien folgen der Geometrie', () => { + const cornerPath = { + id: 'path-2', type: 'polyline', layerId: 'layer-0', + x: 100, y: 100, width: 200, height: 200, + properties: { points: [{ x: 0, y: 0 }, { x: 200, y: 0 }, { x: 200, y: 200 }] }, + } as unknown as CADElement; + const { ctx, doc, selectedIds } = mkCtx([CHAIR, cornerPath], { count: 4 }); + selectedIds.add('chair-1'); + arrayPathTool.handlers.down!(pe(0, 0), ctx); + arrayPathTool.handlers.down!(pe(100, 10), ctx); + const chairs = doc.getAllElements().filter((e) => e.type === 'chair'); + expect(chairs.length).toBe(5); // Original + 4 + // Kopien muessen auf dem L-foermigen Pfad liegen: alle x<=200, y>=0 + for (const c of chairs.slice(1)) { + expect(c.x).toBeLessThanOrEqual(200.5); + expect(c.y).toBeGreaterThanOrEqual(-0.5); + } + }); + + it('cancel resettet die Sitzung', () => { + const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH]); + selectedIds.add('chair-1'); + arrayPathTool.handlers.down!(pe(0, 0), ctx); + arrayPathTool.handlers.cancel?.(ctx); + // Nach cancel: Klick startet frisch (Auto-HitSelect-Pfad wieder aktiv) + arrayPathTool.handlers.down!(pe(9999, 9999), ctx); + expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(1); + }); + + it('Manifest: id=array-path, tags pro', () => { + expect(arrayPathTool.manifest.id).toBe('array-path'); + expect((arrayPathTool.manifest.tags ?? [])).toContain('pro'); + const keys = (arrayPathTool.optionsSchema ?? []).map((f) => f.key); + expect(keys).toContain('count'); + }); +});