From 099f65414f05f7d5999e7ec8f5212093408443e2 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 30 Aug 2026 14:11:07 +0200 Subject: [PATCH] task(CAD-3): box selection - window/crossing by drag direction, preview rectangle, shift-additive, defensive state hygiene --- .../src/plugins/builtin/core-modify/index.ts | 133 +++++++++++- frontend/tests/boxSelect.test.ts | 194 ++++++++++++++++++ frontend/tests/pilotTools.test.ts | 2 + 3 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 frontend/tests/boxSelect.test.ts diff --git a/frontend/src/plugins/builtin/core-modify/index.ts b/frontend/src/plugins/builtin/core-modify/index.ts index 30392b0..ef2afba 100644 --- a/frontend/src/plugins/builtin/core-modify/index.ts +++ b/frontend/src/plugins/builtin/core-modify/index.ts @@ -178,6 +178,71 @@ function applyVertexDrag( return el; } +// ───────────────────────────────────────────────────────────── +// Task CAD-3: Fenster-/Kreuz-Selektion (Box-Select). +// Links-nach-rechts ziehen = 'window' (Element muss VOLLSTAENDIG +// in der Box liegen), rechts-nach-links = 'crossing' (Beruehrung +// reicht). Leerer down startet Box-Drag statt Selektion zu leeren. +// ───────────────────────────────────────────────────────────── + +/** Richtung bestimmt den Modus: to.x >= from.x → window, sonst crossing. */ +export function boxModeFor(from: Pt, to: Pt): 'window' | 'crossing' { + return to.x >= from.x ? 'window' : 'crossing'; +} + +/** + * Selektiert alle Elemente, die die Box erfuellen. + * window: BBox des Elements VOLLSTAENDIG in der Box. + * crossing: BBox des Elements ueberschneidet/beruehrt die Box. + */ +export function boxSelectElements( + elements: CADElement[], + box: { x1: number; y1: number; x2: number; y2: number }, + mode: 'window' | 'crossing', +): string[] { + const minX = Math.min(box.x1, box.x2); + const maxX = Math.max(box.x1, box.x2); + const minY = Math.min(box.y1, box.y2); + const maxY = Math.max(box.y1, box.y2); + const hits: string[] = []; + for (const el of elements) { + const b = elementBBox(el); + if (!b) continue; + if (mode === 'window') { + if (b.minX >= minX && b.maxX <= maxX && b.minY >= minY && b.maxY <= maxY) { + hits.push(el.id); + } + } else { + // crossing: Ueberschneidung (Beruehrung inklusive) + if (b.minX <= maxX && b.maxX >= minX && b.minY <= maxY && b.maxY >= minY) { + hits.push(el.id); + } + } + } + return hits; +} + +/** BBox eines Elements (line aus Koordinaten, sonst x/y±w/2). */ +function elementBBox(el: CADElement): { minX: number; minY: number; maxX: number; maxY: number } | null { + const props = el.properties as Record; + if ( + props.x1 !== undefined && props.y1 !== undefined && + props.x2 !== undefined && props.y2 !== undefined + ) { + const x1 = props.x1 as number, y1 = props.y1 as number; + const x2 = props.x2 as number, y2 = props.y2 as number; + return { minX: Math.min(x1, x2), minY: Math.min(y1, y2), maxX: Math.max(x1, x2), maxY: Math.max(y1, y2) }; + } + const w = el.width ?? 0, h = el.height ?? 0; + return { minX: el.x - w / 2, minY: el.y - h / 2, maxX: el.x + w / 2, maxY: el.y + h / 2 }; +} + +/** Box-Drag-Sitzung (Task CAD-3). */ +let boxDrag: { start: Pt; shift: boolean } | null = null; + +/** Mindestgroesse (Welt-Einheiten) ab der ein Zug als Box gilt. */ +const BOX_MIN_SIZE = 3; + /** Vertex-/Center-Drag-Sitzung (Task D9). */ let vertexDrag: { el: CADElement; @@ -200,6 +265,9 @@ export const selectTool: ToolExtensionV2 = { const bridge = (ctx as FullToolContext).selection; const c = ctx as FullToolContext; if (!bridge) return; + // CAD-3: verlorene Box-Sitzungen abräumen (defensive State-Hygiene, + // schützt auch vor verlorenen up-Events); wird unten bei Leerraum neu gesetzt. + boxDrag = null; const additive = e.shift; const subtractive = e.ctrl; const hit = bridge.hitTest(e.world.x, e.world.y, 5); @@ -243,7 +311,10 @@ export const selectTool: ToolExtensionV2 = { } if (!hit) { - if (!additive && !subtractive) bridge.setIds([]); + // ── Task CAD-3: Leerraum-Klick startet Box-Select-Drag ── + // (statt sofort zu leeren; das Leeren passiert nur bei Klick OHNE Zug) + boxDrag = { start: e.world, shift: e.shift }; + ctx.setStatus('Box-Auswahl: ziehen (rechts=fenster, links=kreuzend)'); return; } let ids = new Set(bridge.getIds()); @@ -258,6 +329,31 @@ export const selectTool: ToolExtensionV2 = { }, move(e, ctx) { + // ── Task CAD-3: Box-Select-Preview ── + if (boxDrag) { + const c = ctx as FullToolContext; + const x = Math.min(boxDrag.start.x, e.world.x); + const y = Math.min(boxDrag.start.y, e.world.y); + const w = Math.abs(e.world.x - boxDrag.start.x); + const h = Math.abs(e.world.y - boxDrag.start.y); + const mode = boxModeFor(boxDrag.start, e.world); + c.setPreview?.({ + id: '__preview_boxselect__', + type: 'rect', + layerId: 'layer-0', + x: x + w / 2, + y: y + h / 2, + width: w, + height: h, + properties: { + stroke: mode === 'window' ? '#4a90d9' : '#22c55e', + strokeWidth: 1, + fill: mode === 'window' ? 'rgba(74,144,217,0.08)' : 'rgba(34,197,94,0.08)', + boxSelectMode: mode, + }, + } as unknown as CADElement); + return; + } // ── Task D9: Vertex-Drag-Preview (nur der Punkt aktualisiert) ── if (vertexDrag) { const c = ctx as FullToolContext; @@ -308,6 +404,40 @@ export const selectTool: ToolExtensionV2 = { }, up(e, ctx) { + // ── Task CAD-3: Box-Select-Abschluss ── + if (boxDrag) { + const c = ctx as FullToolContext; + const start = boxDrag.start; + const shift = boxDrag.shift || e.shift; + boxDrag = null; + ctx.setPreview?.(null); + const w = Math.abs(e.world.x - start.x); + const h = Math.abs(e.world.y - start.y); + // Winzige Box = Klick ohne Zug → Legacy: Selektion leeren + if (w < BOX_MIN_SIZE && h < BOX_MIN_SIZE) { + if (!shift) (c.selection ?? { setIds: () => {} }).setIds([]); + ctx.setStatus('Selektion geleert'); + return; + } + const mode = boxModeFor(start, e.world); + const all = c.doc ? c.doc.getAllElements() : []; + const hits = boxSelectElements(all, { + x1: start.x, y1: start.y, x2: e.world.x, y2: e.world.y, + }, mode); + const bridge = (ctx as FullToolContext).selection; + if (!bridge) return; + if (shift) { + const merged = new Set([...bridge.getIds(), ...hits]); + bridge.setIds(Array.from(merged)); + } else { + bridge.setIds(hits); + } + const count = shift + ? new Set([...(bridge.getIds())]).size + : hits.length; + ctx.setStatus(`Box-Auswahl (${mode === 'window' ? 'Fenster' : 'Kreuzend'}): ${count} Element(e)`); + return; + } // ── Task D9: Vertex-Commit (nur der Punkt, 1 Undo-Schritt) ── if (vertexDrag) { const c = ctx as FullToolContext; @@ -379,6 +509,7 @@ export const selectTool: ToolExtensionV2 = { cancel(ctx) { handleDrag = null; vertexDrag = null; + boxDrag = null; ctx.setPreview?.(null); }, }, diff --git a/frontend/tests/boxSelect.test.ts b/frontend/tests/boxSelect.test.ts new file mode 100644 index 0000000..ada7152 --- /dev/null +++ b/frontend/tests/boxSelect.test.ts @@ -0,0 +1,194 @@ +/** + * Task CAD-3 - Fenster-/Kreuz-Selektion (Box-Select). + * + * boxSelectElements: reine Funktion. Links-nach-rechts ziehen = + * 'window' (Element muss VOLLSTAENDIG in der Box liegen), + * rechts-nach-links = 'crossing' (Element darf die Box beruehren). + * Im selectTool: Leerer down startet Box-Drag, up selektiert. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { boxSelectElements, boxModeFor } from '../src/plugins/builtin/core-modify'; +import { selectTool } 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[]) { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + for (const el of elements) doc.addElement(el); + 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) => { + for (const el of elements) { + 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 - 5 && x <= maxX + 5 && y >= minY - 5 && y <= maxY + 5) return el; + } + 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, previews, statuses }; +} + +function pe(x: number, y: number, mods: { shift?: boolean } = {}): { world: Pt; shift: boolean; ctrl: boolean } { + return { world: { x, y }, shift: mods.shift ?? false, ctrl: false } as never; +} + +function rect(id: string, x: number, y: number, w: number, h: number): CADElement { + return { id, type: 'rect', layerId: 'l', x, y, width: w, height: h, properties: {} } as unknown as CADElement; +} +function line(id: string, x1: number, y1: number, x2: number, y2: number): CADElement { + return { + id, type: 'line', layerId: 'l', + 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; +} + +beforeEach(() => { + selectTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); +}); + +// ─── boxModeFor: Richtung bestimmt Modus ────────────────────── + +describe('CAD-3: boxModeFor', () => { + it('Links-nach-rechts = window', () => { + expect(boxModeFor({ x: 0, y: 0 }, { x: 100, y: 100 })).toBe('window'); + }); + it('Rechts-nach-links = crossing', () => { + expect(boxModeFor({ x: 100, y: 0 }, { x: 0, y: 100 })).toBe('crossing'); + }); +}); + +// ─── boxSelectElements: window (vollstaendig enthalten) ───── + +describe('CAD-3: window-Selektion (vollstaendig enthalten)', () => { + it('Element komplett in Box wird selektiert', () => { + const els = [rect('r1', 50, 50, 20, 20)]; + const box = { x1: 0, y1: 0, x2: 100, y2: 100 }; + const hit = boxSelectElements(els, box, 'window'); + expect(hit).toContain('r1'); + }); + + it('Element, das die Box-Kante durchstoesst, wird NICHT selektiert (window)', () => { + // rect 90..110, Box 0..100: ragt raus + const els = [rect('r1', 100, 50, 20, 20)]; + const box = { x1: 0, y1: 0, x2: 100, y2: 100 }; + const hit = boxSelectElements(els, box, 'window'); + expect(hit).not.toContain('r1'); + }); + + it('Line mit beiden Endpunkten in Box wird selektiert', () => { + const els = [line('l1', 10, 10, 80, 80)]; + const box = { x1: 0, y1: 0, x2: 100, y2: 100 }; + expect(boxSelectElements(els, box, 'window')).toContain('l1'); + }); +}); + +// ─── boxSelectElements: crossing (Beruehrung reicht) ───────── + +describe('CAD-3: crossing-Selektion (Beruehrung reicht)', () => { + it('Element, das die Box nur teilweise durchstoesst, WIRD selektiert', () => { + const els = [rect('r1', 100, 50, 20, 20)]; // 90..110 + const box = { x1: 0, y1: 0, x2: 100, y2: 100 }; + const hit = boxSelectElements(els, box, 'crossing'); + expect(hit).toContain('r1'); + }); + + it('Element ausserhalb der Box wird nicht selektiert', () => { + const els = [rect('r1', 500, 500, 20, 20)]; + const box = { x1: 0, y1: 0, x2: 100, y2: 100 }; + expect(boxSelectElements(els, box, 'crossing')).not.toContain('r1'); + }); + + it('Element, das die Box nur beruehrt (Kante = Kante), wird selektiert', () => { + // rect endet exakt bei x=100 (Kante = Box-Kante) + const els = [rect('r1', 95, 50, 10, 20)]; // 90..100 + const box = { x1: 0, y1: 0, x2: 100, y2: 100 }; + expect(boxSelectElements(els, box, 'crossing')).toContain('r1'); + }); +}); + +// ─── Tool-Integration: Box-Drag im selectTool ──────────────── + +describe('CAD-3: selectTool Box-Drag', () => { + it('Leerer down + move + up fuehrt Box-Selektion aus (window: links nach rechts)', () => { + const inside = rect('r1', 50, 50, 20, 20); + const outside = rect('r2', 500, 500, 20, 20); + const { ctx, selectedIds, previews } = mkCtx([inside, outside]); + + // down auf Leerraum (kein Element getroffen) + selectTool.handlers.down!(pe(0, 0), ctx); + // move zieht Box auf + selectTool.handlers.move!(pe(100, 100), ctx); + expect(previews.length).toBeGreaterThanOrEqual(1); // Box-Preview + // up schliesst ab + selectTool.handlers.up!(pe(100, 100), ctx); + + expect(selectedIds.has('r1')).toBe(true); + expect(selectedIds.has('r2')).toBe(false); + }); + + it('Klick OHNE Zug (winzige Box) leert die Selektion (Legacy-Verhalten bleibt)', () => { + const inside = rect('r1', 50, 50, 20, 20); + const { ctx, selectedIds } = mkCtx([inside]); + selectedIds.add('r1'); + // down + up am selben Punkt = kein Zug + selectTool.handlers.down!(pe(300, 300), ctx); + selectTool.handlers.up!(pe(300, 300), ctx); + expect(Array.from(selectedIds)).toHaveLength(0); + }); + + it('Shift + Box-Select addiert zur bestehenden Selektion', () => { + const a = rect('a', 50, 50, 20, 20); + const b = rect('b', 200, 50, 20, 20); + const { ctx, selectedIds } = mkCtx([a, b]); + selectedIds.add('a'); // schon selektiert + // Shift-Box um b (Shift an down UND up, wie der echte Dispatcher) + selectTool.handlers.down!(pe(150, 0, { shift: true }), ctx); + selectTool.handlers.up!(pe(250, 100, { shift: true }), ctx); + expect(selectedIds.has('a')).toBe(true); + expect(selectedIds.has('b')).toBe(true); + }); + + it('Status meldet Anzahl und Modus', () => { + const els = [rect('r1', 50, 50, 20, 20), rect('r2', 60, 60, 20, 20)]; + const { ctx, statuses } = mkCtx(els); + selectTool.handlers.down!(pe(0, 0), ctx); + selectTool.handlers.up!(pe(100, 100), ctx); + const last = statuses[statuses.length - 1]; + expect(last).toContain('2'); + }); + + it('Box-Drag startet NICHT wenn ein Element getroffen wurde', () => { + const el = rect('r1', 50, 50, 20, 20); + const { ctx, selectedIds } = mkCtx([el]); + // down AUF das Element: normale Einzelselektion, kein Box-Start + selectTool.handlers.down!(pe(50, 50), ctx); + expect(selectedIds.has('r1')).toBe(true); + }); +}); diff --git a/frontend/tests/pilotTools.test.ts b/frontend/tests/pilotTools.test.ts index fcaba7d..0ba8d77 100644 --- a/frontend/tests/pilotTools.test.ts +++ b/frontend/tests/pilotTools.test.ts @@ -807,6 +807,8 @@ describe('A3 pilot: select tool', () => { selectTool.handlers.down!(pe(10, 10), ctx); expect(getIds()).toEqual(['a']); selectTool.handlers.down!(pe(999, 999), ctx); + // CAD-3: Leerklick leert erst bei up (Klick ohne Zug) — Box-Select-Follow-up + selectTool.handlers.up!(pe(999, 999), ctx); expect(getIds()).toEqual([]); expect(coreModifyPlugin.manifest.enabledByDefault).toBe(true);