diff --git a/frontend/src/plugins/builtin/core-modify/index.ts b/frontend/src/plugins/builtin/core-modify/index.ts index 7e98762..dfedb8e 100644 --- a/frontend/src/plugins/builtin/core-modify/index.ts +++ b/frontend/src/plugins/builtin/core-modify/index.ts @@ -82,6 +82,109 @@ function hitHandle( return null; } +// ───────────────────────────────────────────────────────────── +// Task D9: GripEditing — Vertex-/Center-Grips (reine Funktion). +// ───────────────────────────────────────────────────────────── + +export interface GripPoint { + kind: 'vertex' | 'center' | 'corner'; + point: Pt; + index: number; +} + +/** + * Element-spezifische Griff-Punkte (Task D9): + * line = beide Endpunkte, polyline/polygon = alle Vertices, + * circle/arc = Zentrum, rect/default = 4 BBox-Ecken (C3fin-Skalierung). + */ +export function getGripPoints(el: CADElement): GripPoint[] { + const props = el.properties as Record; + const type = String(el.type); + if ( + type === 'line' && + props.x1 !== undefined && props.y1 !== undefined && + props.x2 !== undefined && props.y2 !== undefined + ) { + return [ + { kind: 'vertex', point: { x: props.x1 as number, y: props.y1 as number }, index: 0 }, + { kind: 'vertex', point: { x: props.x2 as number, y: props.y2 as number }, index: 1 }, + ]; + } + if (type === 'polyline' || type === 'polygon') { + const pts = (props.points as Pt[] | undefined) ?? []; + return pts.map((p, i) => ({ kind: 'vertex' as const, point: { x: p.x, y: p.y }, index: i })); + } + if (type === 'circle' || type === 'arc') { + return [{ kind: 'center', point: { x: el.x, y: el.y }, index: 0 }]; + } + // rect und alle anderen: BBox-Ecken (Corner-Drag/Skalierung, C3fin) + const minX = el.x - el.width / 2; + const minY = el.y - el.height / 2; + const maxX = el.x + el.width / 2; + const maxY = el.y + el.height / 2; + return [ + { kind: 'corner', point: { x: minX, y: minY }, index: 0 }, + { kind: 'corner', point: { x: maxX, y: minY }, index: 1 }, + { kind: 'corner', point: { x: maxX, y: maxY }, index: 2 }, + { kind: 'corner', point: { x: minX, y: maxY }, index: 3 }, + ]; +} + +/** + * Wendet eine Vertex-/Center-Drag-Position an (Task D9) und liefert + * das aktualisierte Element (reine Funktion — für Preview UND Commit). + */ +function applyVertexDrag( + el: CADElement, + drag: { el: CADElement; gripKind: 'vertex' | 'center'; index: number }, + wx: number, + wy: number, +): CADElement { + const props = { ...(el.properties as Record) }; + const type = String(el.type); + if (drag.gripKind === 'center') { + return { ...el, x: wx, y: wy, properties: props as CADElement['properties'] } as CADElement; + } + if (type === 'line') { + if (drag.index === 0) { props.x1 = wx; props.y1 = wy; } + else { props.x2 = wx; props.y2 = wy; } + // BBox aktualisieren + const x1 = props.x1 as number, y1 = props.y1 as number, x2 = props.x2 as number, y2 = props.y2 as number; + return { + ...el, + x: (x1 + x2) / 2, + y: (y1 + y2) / 2, + width: Math.abs(x2 - x1), + height: Math.abs(y2 - y1), + properties: props as CADElement['properties'], + } as CADElement; + } + if (type === 'polyline' || type === 'polygon') { + const pts = [...((props.points as Pt[] | undefined) ?? [])]; + if (drag.index < pts.length) pts[drag.index] = { x: wx, y: wy }; + props.points = pts; + const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y); + const minX = Math.min(...xs), minY = Math.min(...ys); + const maxX = Math.max(...xs), maxY = Math.max(...ys); + return { + ...el, + x: minX + (maxX - minX) / 2, + y: minY + (maxY - minY) / 2, + width: maxX - minX, + height: maxY - minY, + properties: props as CADElement['properties'], + } as CADElement; + } + return el; +} + +/** Vertex-/Center-Drag-Sitzung (Task D9). */ +let vertexDrag: { + el: CADElement; + gripKind: 'vertex' | 'center'; + index: number; +} | null = null; + export const selectTool: ToolExtensionV2 = { manifest: { id: 'select', @@ -101,6 +204,28 @@ export const selectTool: ToolExtensionV2 = { const subtractive = e.ctrl; const hit = bridge.hitTest(e.world.x, e.world.y, 5); + // ── Task D9: Vertex-/Center-Grip-Prüfung VOR BBox-Corners ── + // (spezifischere Grips gewinnen gegenüber Corner-Skalierung) + if (!additive && !subtractive && c.doc) { + const GRIP_TOL = 8; + for (const id of bridge.getIds()) { + const el = c.doc.getElement(id); + if (!el) continue; + for (const g of getGripPoints(el)) { + if (g.kind === 'corner') continue; // Corner unten (C3fin) + if ( + Math.abs(e.world.x - g.point.x) <= GRIP_TOL && + Math.abs(e.world.y - g.point.y) <= GRIP_TOL + ) { + vertexDrag = { el, gripKind: g.kind, index: g.index }; + const what = g.kind === 'center' ? 'Zentrum' : `Punkt ${g.index + 1}`; + ctx.setStatus(`${what} gegriffen — ziehen zum Verschieben`); + return; + } + } + } + } + // ── Handle-Drag-Start (Task C3fin interaktiv) ── // Wenn bereits Elemente selektiert sind und der Klick auf eine Ecke // eines davon trifft → Handle-Drag starten statt neu zu wählen. @@ -133,6 +258,15 @@ export const selectTool: ToolExtensionV2 = { }, move(e, ctx) { + // ── Task D9: Vertex-Drag-Preview (nur der Punkt aktualisiert) ── + if (vertexDrag) { + const c = ctx as FullToolContext; + const el = c.doc?.getElement(vertexDrag.el.id); + if (!el) return; + const updated = applyVertexDrag(el, vertexDrag, e.world.x, e.world.y); + c.setPreview?.(updated); + return; + } // Live-Skalierung während Handle-Drag: if (!handleDrag) return; const c = ctx as FullToolContext; @@ -174,6 +308,26 @@ export const selectTool: ToolExtensionV2 = { }, up(e, ctx) { + // ── Task D9: Vertex-Commit (nur der Punkt, 1 Undo-Schritt) ── + if (vertexDrag) { + const c = ctx as FullToolContext; + const el = c.doc?.getElement(vertexDrag.el.id); + if (el && c.doc) { + const updated = applyVertexDrag(el, vertexDrag, e.world.x, e.world.y); + c.doc.transact(() => { + c.doc!.updateElement(el.id, { + x: updated.x, + y: updated.y, + properties: updated.properties as CADElement['properties'], + }); + }); + const what = vertexDrag.gripKind === 'center' ? 'Zentrum' : `Punkt ${vertexDrag.index + 1}`; + ctx.setStatus(`${what} verschoben`); + } + vertexDrag = null; + ctx.setPreview?.(null); + return; + } if (!handleDrag) return; const c = ctx as FullToolContext; if (!c.doc) { handleDrag = null; return; } @@ -224,6 +378,7 @@ export const selectTool: ToolExtensionV2 = { cancel(ctx) { handleDrag = null; + vertexDrag = null; ctx.setPreview?.(null); }, }, diff --git a/frontend/tests/gripEditing.test.ts b/frontend/tests/gripEditing.test.ts new file mode 100644 index 0000000..9692ccd --- /dev/null +++ b/frontend/tests/gripEditing.test.ts @@ -0,0 +1,244 @@ +/** + * Task D9 – GripEditing: Vertex-/Center-Grips im select-Tool. + * + * getGripPoints: element-spezifische Griff-Punkte (line=Endpunkte, + * polyline=Vertices, circle/arc=Zentrum, rect=BBox-Ecken). + * Drag eines Vertex-Grips verschiebt NUR diesen Punkt in einer + * Transaktion (undo-fähig); BBox-Skalierung (C3fin) bleibt parallel + * für Elemente ohne Vertex-Grips (rect u.a.). + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { selectTool, getGripPoints } 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() { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const selectedIds = new Set(); + 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), + 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) => { + // Einfacher Hit-Test: trifft wenn ein Element-BBox den Punkt enthaelt + for (const el of doc.getAllElements()) { + const p = el.properties as Record; + if (p.x1 !== undefined) { + const minX = Math.min(p.x1 as number, p.x2 as number); + const maxX = Math.max(p.x1 as number, p.x2 as number); + const minY = Math.min(p.y1 as number, p.y2 as number); + const maxY = Math.max(p.y1 as number, p.y2 as number); + if (x >= minX - tol && x <= maxX + tol && y >= minY - tol && y <= maxY + tol) return el; + } + if (x >= el.x - el.width / 2 - tol && x <= el.x + el.width / 2 + tol && + y >= el.y - el.height / 2 - tol && y <= el.y + el.height / 2 + tol) return el; + } + return null; + }, + }, + }; + return { ctx, doc, selectedIds, previews, statuses }; +} + +function pe(x: number, y: number, mods: { shift?: boolean; ctrl?: boolean } = {}): { world: Pt; shift: boolean; ctrl: boolean } { + return { world: { x, y }, shift: mods.shift ?? false, ctrl: mods.ctrl ?? false } as never; +} + +beforeEach(() => { + selectTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); +}); + +// ─── getGripPoints (reine Funktion) ──────────────────────── + +describe('D9: getGripPoints', () => { + it('line: beide Endpunkte als Grips', () => { + const el = { + id: 'l1', type: 'line', layerId: 'l', x: 50, y: 50, width: 100, height: 0, + properties: { x1: 0, y1: 50, x2: 100, y2: 50 }, + } as unknown as CADElement; + const grips = getGripPoints(el); + expect(grips.map((g) => g.kind)).toEqual(['vertex', 'vertex']); + expect(grips[0].point).toEqual({ x: 0, y: 50 }); + expect(grips[1].point).toEqual({ x: 100, y: 50 }); + }); + + it('polyline: alle Vertices als Grips', () => { + const el = { + id: 'p1', type: 'polyline', layerId: 'l', x: 50, y: 0, width: 100, height: 100, + properties: { points: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }] }, + } as unknown as CADElement; + const grips = getGripPoints(el); + expect(grips.length).toBe(3); + expect(grips.every((g) => g.kind === 'vertex')).toBe(true); + expect(grips[2].point).toEqual({ x: 100, y: 100 }); + }); + + it('circle: Zentrum als Center-Grip', () => { + const el = { + id: 'c1', type: 'circle', layerId: 'l', x: 30, y: 40, width: 20, height: 20, + properties: { radius: 10 }, + } as unknown as CADElement; + const grips = getGripPoints(el); + expect(grips).toHaveLength(1); + expect(grips[0].kind).toBe('center'); + expect(grips[0].point).toEqual({ x: 30, y: 40 }); + }); + + it('rect: BBox-Ecken als Corner-Grips (C3fin bleibt)', () => { + const el = { + id: 'r1', type: 'rect', layerId: 'l', x: 50, y: 25, width: 100, height: 50, + properties: {}, + } as unknown as CADElement; + const grips = getGripPoints(el); + expect(grips.length).toBe(4); + expect(grips.every((g) => g.kind === 'corner')).toBe(true); + expect(grips[0].point).toEqual({ x: 0, y: 0 }); + expect(grips[2].point).toEqual({ x: 100, y: 50 }); + }); + + it('Grips tragen index fuer Drag-Zuordnung', () => { + const el = { + id: 'p1', type: 'polyline', layerId: 'l', x: 0, y: 0, width: 10, height: 10, + properties: { points: [{ x: 0, y: 0 }, { x: 10, y: 10 }] }, + } as unknown as CADElement; + const grips = getGripPoints(el); + expect(grips[0].index).toBe(0); + expect(grips[1].index).toBe(1); + }); +}); + +// ─── Drag-Interaktion: Vertex-Zug (undo-faehig) ───────────── + +describe('D9: Vertex-Drag im selectTool', () => { + it('Linien-Endpunkt ziehen verschiebt NUR diesen Punkt (1 Undo)', () => { + const { ctx, doc, selectedIds } = mkCtx(); + const line = { + id: 'l1', 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); + selectedIds.add('l1'); + + // Klick auf Endpunkt (100,0) startet Vertex-Drag + selectTool.handlers.down!(pe(100, 0), ctx); + selectTool.handlers.up!(pe(120, 30), ctx); + + const updated = doc.getElement('l1')!; + expect(updated.properties.x2).toBe(120); + expect(updated.properties.y2).toBe(30); + // Anderer Endpunkt unveraendert + expect(updated.properties.x1).toBe(0); + expect(updated.properties.y1).toBe(0); + // Undo stellt her + doc.undo(); + const restored = doc.getElement('l1')!; + expect(restored.properties.x2).toBe(100); + expect(restored.properties.y2).toBe(0); + }); + + it('Polyline-Vertex ziehen aktualisiert nur diesen Vertex', () => { + const { ctx, doc, selectedIds } = mkCtx(); + const poly = { + id: 'p1', type: 'polyline', layerId: 'l', x: 50, y: 50, width: 100, height: 100, + properties: { points: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }] }, + } as unknown as CADElement; + doc.addElement(poly); + selectedIds.add('p1'); + + // Mittleren Vertex (100,0) greifen und auf (100,50) ziehen + selectTool.handlers.down!(pe(100, 0), ctx); + selectTool.handlers.up!(pe(100, 50), ctx); + + const pts = doc.getElement('p1')!.properties.points as Pt[]; + expect(pts[1]).toEqual({ x: 100, y: 50 }); + expect(pts[0]).toEqual({ x: 0, y: 0 }); + expect(pts[2]).toEqual({ x: 100, y: 100 }); + }); + + it('Kreis-Zentrum ziehen verschiebt x/y (Radius bleibt)', () => { + const { ctx, doc, selectedIds } = mkCtx(); + const circle = { + id: 'c1', type: 'circle', layerId: 'l', x: 30, y: 40, width: 20, height: 20, + properties: { radius: 10 }, + } as unknown as CADElement; + doc.addElement(circle); + selectedIds.add('c1'); + + selectTool.handlers.down!(pe(30, 40), ctx); + selectTool.handlers.up!(pe(60, 80), ctx); + + const updated = doc.getElement('c1')!; + expect(updated.x).toBe(60); + expect(updated.y).toBe(80); + expect(updated.properties.radius).toBe(10); + }); + + it('BBox-Corner-Drag bei rect funktioniert weiterhin (Skalierung)', () => { + const { ctx, doc, selectedIds } = mkCtx(); + const rect = { + id: 'r1', type: 'rect', layerId: 'l', x: 50, y: 25, width: 100, height: 50, + properties: {}, + } as unknown as CADElement; + doc.addElement(rect); + selectedIds.add('r1'); + + // Ecke unten-rechts (100,50) greifen und auf (150,75) ziehen + selectTool.handlers.down!(pe(100, 50), ctx); + selectTool.handlers.up!(pe(150, 75), ctx); + + const updated = doc.getElement('r1')!; + expect(updated.width).toBeCloseTo(150, 0); + expect(updated.height).toBeCloseTo(75, 0); + }); + + it('Vertex-Grip gewinnt gegen BBox-Corner bei Ueberlappung (line-Endpunkt am BBox-Rand)', () => { + // line (0,0)-(100,0): Endpunkt (0,0) ist AUCH BBox-Ecke. + // Der Drag muss als Vertex (Punkt verschieben) wirken, nicht als Scale. + const { ctx, doc, selectedIds } = mkCtx(); + const line = { + id: 'l1', 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); + selectedIds.add('l1'); + + selectTool.handlers.down!(pe(0, 0), ctx); + selectTool.handlers.up!(pe(-30, -20), ctx); + + const updated = doc.getElement('l1')!; + // x1/y1 verschoben, x2 unveraendert → Vertex-Drag, kein Scale + expect(updated.properties.x1).toBe(-30); + expect(updated.properties.y1).toBe(-20); + expect(updated.properties.x2).toBe(100); + }); + + it('Preview bei Vertex-Drag zeigt aktualisiertes Element', () => { + const { ctx, doc, selectedIds, previews } = mkCtx(); + const line = { + id: 'l1', 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); + selectedIds.add('l1'); + + selectTool.handlers.down!(pe(100, 0), ctx); + selectTool.handlers.move!(pe(90, 40), ctx); + expect(previews.length).toBeGreaterThanOrEqual(1); + const last = previews[previews.length - 1]; + expect(last).not.toBeNull(); + const lastProps = last!.properties as Record; + expect(lastProps.x2).toBe(90); + expect(lastProps.y2).toBe(40); + }); +});