diff --git a/frontend/src/plugins/builtin/event-tools/index.ts b/frontend/src/plugins/builtin/event-tools/index.ts index 8dcfb1e..f2d9466 100644 --- a/frontend/src/plugins/builtin/event-tools/index.ts +++ b/frontend/src/plugins/builtin/event-tools/index.ts @@ -466,29 +466,34 @@ export function snapTrussToDock( ): TrussDock | null { let best: { dock: TrussDock; dist: number } | null = null; for (const el of existing) { - if (String(el.type) !== 'truss' || el.properties.truss !== true) continue; + const isTruss = String(el.type) === 'truss' && el.properties.truss === true; + const isCorner = String(el.type) === 'boxcorner' && el.properties.boxcorner === true; + if (!isTruss && !isCorner) continue; const rotDeg = (el.properties.rotation as number | undefined) ?? 0; - const rot = (rotDeg * Math.PI) / 180; - const dx = Math.cos(rot); - const dy = Math.sin(rot); - const half = newLength / 2; - const ends = trussEndpoints(el); + const ends = isTruss ? trussEndpoints(el) : boxcornerEndpoints(el); for (let endIdx = 0; endIdx < ends.length; endIdx++) { const end = ends[endIdx]; const dist = Math.hypot(cursor.x - end.x, cursor.y - end.y); if (dist > tolerance) continue; if (best && dist >= best.dist) continue; - // Neue Traverse: fuehrt VOM Dock-Ende WEG von der Anker-Traverse. - // Endpunkt 0 (Start) -> Gegenrichtung (rot+180), Endpunkt 1 -> vorwaerts. - const dir = endIdx === 0 ? -1 : 1; - const centerX = end.x + dx * half * dir; - const centerY = end.y + dy * half * dir; - const newRot = endIdx === 0 ? (rotDeg + 180) % 360 : rotDeg; + // Weg-Richtung VOM Dock-Ende (arm-spezifisch): + // Truss-Start (idx 0) = Gegenrichtung (rot+180), Truss-Ende (idx 1) = rot, + // Boxcorner-Arm1 (idx 0) = rot, Boxcorner-Arm2 (idx 1) = rot+90. + let dirDeg: number; + if (isTruss) { + dirDeg = endIdx === 0 ? (rotDeg + 180) % 360 : rotDeg; + } else { + dirDeg = endIdx === 0 ? rotDeg : (rotDeg + 90) % 360; + } + const dirRad = (dirDeg * Math.PI) / 180; + const half = newLength / 2; + const centerX = end.x + Math.cos(dirRad) * half; + const centerY = end.y + Math.sin(dirRad) * half; best = { dist, dock: { center: { x: centerX, y: centerY }, - rotationDeg: newRot, + rotationDeg: dirDeg, dockedTo: el.id, }, }; @@ -505,19 +510,23 @@ function makeTrussElement( length: number, rotationDeg: number, layerId: string, + trussWidth = 29, ): CADElement { const rad = (rotationDeg * Math.PI) / 180; - const w = Math.abs(length * Math.cos(rad)) || length; - const h = Math.abs(length * Math.sin(rad)) || 20; + const w = Math.abs(length * Math.cos(rad)) + Math.abs(trussWidth * Math.sin(rad)); + const h = Math.abs(length * Math.sin(rad)) + Math.abs(trussWidth * Math.cos(rad)); return { id: `truss_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`, type: 'truss', layerId, x: center.x, y: center.y, - width: Math.max(w, 20), - height: 20, - properties: { length, rotation: rotationDeg, truss: true, stroke: '#9aa0a6', strokeWidth: 2, fill: '#6b7280' }, + width: Math.max(w, trussWidth), + height: Math.max(h, trussWidth), + properties: { + length, rotation: rotationDeg, trussWidth, truss: true, + stroke: '#9aa0a6', strokeWidth: 2, fill: '#6b7280', + }, } as unknown as CADElement; } @@ -534,10 +543,12 @@ export const trussTool: ToolExtensionV2 = { }, optionsSchema: [ { key: 'length', label: 'Länge (cm)', type: 'select', options: [ + { value: '50', label: '50 cm' }, { value: '100', label: '100 cm' }, { value: '200', label: '200 cm' }, - { value: '290', label: '290 cm' }, + { value: '300', label: '300 cm' }, ] }, + { key: 'trussWidth', label: 'Breite (cm)', type: 'number', min: 10, max: 60 }, ], handlers: { down(e, ctx) { @@ -548,13 +559,14 @@ export const trussTool: ToolExtensionV2 = { const c = ctx as FullToolContext; if (!trussDragStart) return; const length = Number((c.options.length as string | undefined) ?? 200) || 200; - // Magnet-Vorschau: dockt der Cursor an ein Traversen-Ende? + const trussWidth = (c.options.trussWidth as number | undefined) ?? 29; + // Magnet-Vorschau: dockt der Cursor an ein Traversen-/Ecken-Ende? const existing = c.doc ? (c.doc.getAllElements() as CADElement[]).filter( - (el) => String(el.type) === 'truss', + (el) => String(el.type) === 'truss' || String(el.type) === 'boxcorner', ) : []; const dock = snapTrussToDock(existing, trussDragStart, length, DOCK_TOLERANCE); if (dock) { - ctx.setPreview?.(makeTrussElement(dock.center, length, dock.rotationDeg, 'layer-0')); + ctx.setPreview?.(makeTrussElement(dock.center, length, dock.rotationDeg, 'layer-0', trussWidth)); return; } // Frei: Richtung aus Drag @@ -570,22 +582,23 @@ export const trussTool: ToolExtensionV2 = { ctx.setPreview?.(null); if (!start || !c.doc) return; const length = Number((c.options.length as string | undefined) ?? 200) || 200; + const trussWidth = (c.options.trussWidth as number | undefined) ?? 29; const layerId = (c.options.layerId as string | undefined) ?? 'layer-0'; - // MAGNETISCH: Dock-Pruefung am Drag-Startpunkt + // MAGNETISCH: Dock-Pruefung am Drag-Startpunkt (Traversen UND Ecken) const existing = (c.doc.getAllElements() as CADElement[]).filter( - (el) => String(el.type) === 'truss', + (el) => String(el.type) === 'truss' || String(el.type) === 'boxcorner', ); const dock = snapTrussToDock(existing, start, length, DOCK_TOLERANCE); let el: CADElement; if (dock) { - el = makeTrussElement(dock.center, length, dock.rotationDeg, layerId); - ctx.setStatus(`Traverse angedockt an ${dock.dockedTo} (${length}cm)`); + el = makeTrussElement(dock.center, length, dock.rotationDeg, layerId, trussWidth); + ctx.setStatus(`Traverse angedockt an ${dock.dockedTo} (${length}cm, Breite ${trussWidth}cm)`); } else { const ang = (Math.atan2(e.world.y - start.y, e.world.x - start.x) * 180) / Math.PI; const cx = (start.x + e.world.x) / 2; const cy = (start.y + e.world.y) / 2; - el = makeTrussElement({ x: cx, y: cy }, length, ang, layerId); - ctx.setStatus(`Traverse platziert (${length}cm)`); + el = makeTrussElement({ x: cx, y: cy }, length, ang, layerId, trussWidth); + ctx.setStatus(`Traverse platziert (${length}cm, Breite ${trussWidth}cm)`); } c.doc.transact(() => { c.doc!.addElement(el); }); }, @@ -597,6 +610,156 @@ export const trussTool: ToolExtensionV2 = { }, }; +// ───────────────────────────────────────────────────────────── +// Task CAD-8: Boxcorner (Eck-Traverse) — L-foermiges Element +// mit zwei Armen (rot und rot+90). Magnetisch: dockt an +// Traversen-Enden und andere Ecken; Traversen docken an die +// Armenden. DREHBAR via properties.rotation (PropertiesPanel). +// ───────────────────────────────────────────────────────────── + +/** Armenden einer Boxcorner: Endpunkte beider Arme vom Eckpunkt. */ +export function boxcornerEndpoints(el: CADElement): [Pt, Pt] { + const armLength = (el.properties.armLength as number | undefined) ?? 50; + const rotDeg = (el.properties.rotation as number | undefined) ?? 0; + const rot = (rotDeg * Math.PI) / 180; + const rot2 = ((rotDeg + 90) * Math.PI) / 180; + return [ + { x: el.x + Math.cos(rot) * armLength, y: el.y + Math.sin(rot) * armLength }, + { x: el.x + Math.cos(rot2) * armLength, y: el.y + Math.sin(rot2) * armLength }, + ]; +} + +export interface CornerDock { + vertex: Pt; + rotationDeg: number; + dockedTo: string; +} + +/** + * Magnetisches Andocken einer Ecke (rein): dockt an Traversen- + * Enden und Ecken-Armenden; am Traversen-Start-Ende wird die + * Gegenrichtung uebernommen (rot+180), damit Reihen nach aussen + * wachsen. + */ +export function cornerDockToDock( + existing: CADElement[], + cursor: Pt, + tolerance: number, +): CornerDock | null { + let best: { dock: CornerDock; dist: number } | null = null; + for (const el of existing) { + const isTruss = String(el.type) === 'truss' && el.properties.truss === true; + const isCorner = String(el.type) === 'boxcorner' && el.properties.boxcorner === true; + if (!isTruss && !isCorner) continue; + const rotDeg = (el.properties.rotation as number | undefined) ?? 0; + const ends = isTruss ? trussEndpoints(el) : boxcornerEndpoints(el); + for (let endIdx = 0; endIdx < ends.length; endIdx++) { + const end = ends[endIdx]; + const dist = Math.hypot(cursor.x - end.x, cursor.y - end.y); + if (dist > tolerance) continue; + if (best && dist >= best.dist) continue; + const dir = isTruss && endIdx === 0 ? -1 : 1; + const newRot = isTruss && endIdx === 0 ? (rotDeg + 180) % 360 : rotDeg; + void dir; + best = { + dist, + dock: { vertex: { x: end.x, y: end.y }, rotationDeg: newRot, dockedTo: el.id }, + }; + } + } + return best ? best.dock : null; +} + +function makeBoxcornerElement( + vertex: Pt, + armLength: number, + rotationDeg: number, + trussWidth: number, + layerId: string, +): CADElement { + return { + id: `boxcorner_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`, + type: 'boxcorner', + layerId, + x: vertex.x, + y: vertex.y, + width: armLength + trussWidth, + height: armLength + trussWidth, + properties: { + armLength, trussWidth, rotation: rotationDeg, boxcorner: true, + stroke: '#9aa0a6', strokeWidth: 2, fill: '#6b7280', + }, + } as unknown as CADElement; +} + +let cornerDragStart: Pt | null = null; + +export const boxCornerTool: ToolExtensionV2 = { + manifest: { + id: 'boxcorner', + label: 'Eck-Traverse (magnetisch)', + icon: '╭', + ribbonTab: 'insert', + tags: ['basic'], + description: 'Eck-Traverse (Boxcorner): Drag = Richtung, dockt magnetisch an (CAD-8)', + }, + optionsSchema: [ + { key: 'armLength', label: 'Armlänge (cm)', type: 'number', min: 20, max: 150 }, + { key: 'trussWidth', label: 'Breite (cm)', type: 'number', min: 10, max: 60 }, + ], + handlers: { + down(e, ctx) { + cornerDragStart = e.world; + ctx.setStatus('Eck-Traverse: Richtung ziehen (dockt automatisch an)'); + }, + move(e, ctx) { + const c = ctx as FullToolContext; + if (!cornerDragStart) return; + const armLength = (c.options.armLength as number | undefined) ?? 50; + const trussWidth = (c.options.trussWidth as number | undefined) ?? 29; + const existing = c.doc ? (c.doc.getAllElements() as CADElement[]).filter( + (el) => String(el.type) === 'truss' || String(el.type) === 'boxcorner', + ) : []; + const dock = cornerDockToDock(existing, cornerDragStart, DOCK_TOLERANCE); + if (dock) { + ctx.setPreview?.(makeBoxcornerElement(dock.vertex, armLength, dock.rotationDeg, trussWidth, 'layer-0')); + return; + } + const ang = (Math.atan2(e.world.y - cornerDragStart.y, e.world.x - cornerDragStart.x) * 180) / Math.PI; + ctx.setPreview?.(makeBoxcornerElement(cornerDragStart, armLength, ang, trussWidth, 'layer-0')); + }, + up(e, ctx) { + const c = ctx as FullToolContext; + const start = cornerDragStart; + cornerDragStart = null; + ctx.setPreview?.(null); + if (!start || !c.doc) return; + const armLength = (c.options.armLength as number | undefined) ?? 50; + const trussWidth = (c.options.trussWidth as number | undefined) ?? 29; + const layerId = (c.options.layerId as string | undefined) ?? 'layer-0'; + const existing = (c.doc.getAllElements() as CADElement[]).filter( + (el) => String(el.type) === 'truss' || String(el.type) === 'boxcorner', + ); + const dock = cornerDockToDock(existing, start, DOCK_TOLERANCE); + let el: CADElement; + if (dock) { + el = makeBoxcornerElement(dock.vertex, armLength, dock.rotationDeg, trussWidth, layerId); + ctx.setStatus(`Eck-Traverse angedockt an ${dock.dockedTo}`); + } else { + const ang = (Math.atan2(e.world.y - start.y, e.world.x - start.x) * 180) / Math.PI; + el = makeBoxcornerElement(start, armLength, ang, trussWidth, layerId); + ctx.setStatus(`Eck-Traverse platziert (${armLength}cm Arme, Breite ${trussWidth}cm)`); + } + c.doc.transact(() => { c.doc!.addElement(el); }); + }, + cancel(ctx) { + cornerDragStart = null; + ctx.setPreview?.(null); + ctx.setStatus('Eck-Traverse abgebrochen'); + }, + }, +}; + export const eventToolsV2Plugin: PluginV2 = { manifest: { id: 'event-tools', @@ -607,5 +770,5 @@ export const eventToolsV2Plugin: PluginV2 = { category: 'elements', enabledByDefault: true, }, - tools: [chairTool, seatingRowToolDrag, seatingBlockToolDrag, tableTool, stageTool, arcRowTool, kinoTemplateTool, banquetTemplateTool, standingTableTemplateTool, podiumTemplateTool, curtainTool, spotlightTool, barrierTool, trussTool], + tools: [chairTool, seatingRowToolDrag, seatingBlockToolDrag, tableTool, stageTool, arcRowTool, kinoTemplateTool, banquetTemplateTool, standingTableTemplateTool, podiumTemplateTool, curtainTool, spotlightTool, barrierTool, trussTool, boxCornerTool], }; diff --git a/frontend/src/render/pixi/elementDrawers.ts b/frontend/src/render/pixi/elementDrawers.ts index acceb18..90e6aba 100644 --- a/frontend/src/render/pixi/elementDrawers.ts +++ b/frontend/src/render/pixi/elementDrawers.ts @@ -400,39 +400,78 @@ export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: stri } case 'truss': { - // Traverse (CAD-7): Balken als Rechteck + 2 Gurtlinien entlang - // der Rotation (wie ein Fachwerk-Profil) + // Traverse (CAD-7/8): ROTIERTES Rechteck-Polygon + Gurtlinien + // entlang der Richtung (Fachwerk-Profil), Breite = trussWidth. const length = (props.length as number | undefined) ?? el.width; + const tWidth = (props.trussWidth as number | undefined) ?? el.height ?? 29; const rotDeg = (props.rotation as number | undefined) ?? 0; const rad = (rotDeg * Math.PI) / 180; - const dx = Math.cos(rad) * (length / 2); - const dy = Math.sin(rad) * (length / 2); - const nx = -Math.sin(rad) * 10; // Normalen-Offset (halbe Hoehe) - const ny = Math.cos(rad) * 10; + const ux = Math.cos(rad), uy = Math.sin(rad); // Laengsrichtung + const nx = -Math.sin(rad), ny = Math.cos(rad); // Normalenrichtung + const hl = length / 2, hw = tWidth / 2; const c = { x: el.x, y: el.y }; - return [ + const corner = (su: number, sv: number): { x: number; y: number } => ({ + x: c.x + ux * (hl * su) + nx * (hw * sv), + y: c.y + uy * (hl * su) + ny * (hw * sv), + }); + const pts = [corner(-1, -1), corner(1, -1), corner(1, 1), corner(-1, 1)]; + const strokeCol = (props.stroke as string | undefined) ?? '#9aa0a6'; + const out: ShapePrimitive[] = [ { - kind: 'rect', - x: c.x - dx, y: c.y - dy, - w: length, h: 20, - fill: fillOrNull(props.fill) ?? '#6b7280', - stroke: (props.stroke as string | undefined) ?? '#9aa0a6', + kind: 'polyline', + points: pts, + close: true, + stroke: strokeCol, strokeWidth: 2, }, - // Gurtlinien oben/unten entlang der Traverse + // Gurtlinien oben/unten { kind: 'line', - from: { x: c.x - dx + nx, y: c.y - dy + ny }, - to: { x: c.x + dx + nx, y: c.y + dy + ny }, + from: corner(-1, -1), + to: corner(1, -1), stroke: '#4b5563', strokeWidth: 1, }, { kind: 'line', - from: { x: c.x - dx - nx, y: c.y - dy - ny }, - to: { x: c.x + dx - nx, y: c.y + dy - ny }, + from: corner(-1, 1), + to: corner(1, 1), stroke: '#4b5563', strokeWidth: 1, }, ]; + return out; + } + + case 'boxcorner': { + // Eck-Traverse (CAD-8): L-foermiges Polygon (6 Punkte) aus zwei + // Armen (rot und rot+90) um den Eckpunkt, Breite = trussWidth. + const armLength = (props.armLength as number | undefined) ?? 50; + const bWidth = (props.trussWidth as number | undefined) ?? 29; + const rotDeg = (props.rotation as number | undefined) ?? 0; + const rad = (rotDeg * Math.PI) / 180; + const ux = Math.cos(rad), uy = Math.sin(rad); + const nx = -Math.sin(rad), ny = Math.cos(rad); + const v = { x: el.x, y: el.y }; + const px = (a: number, b: number): { x: number; y: number } => ({ + x: v.x + ux * a + nx * b, + y: v.y + uy * a + ny * b, + }); + // L-Geometrie: Arm1 entlang +u, Arm2 entlang +n (rot+90) + const pts = [ + px(0, 0), + px(armLength, 0), + px(armLength, bWidth), + px(bWidth, bWidth), + px(bWidth, armLength), + px(0, armLength), + ]; + const strokeCol = (props.stroke as string | undefined) ?? '#9aa0a6'; + return [{ + kind: 'polyline', + points: pts, + close: true, + stroke: strokeCol, + strokeWidth: 2, + }]; } case 'barrier': { diff --git a/frontend/src/types/cad.types.ts b/frontend/src/types/cad.types.ts index b535b37..6731afb 100644 --- a/frontend/src/types/cad.types.ts +++ b/frontend/src/types/cad.types.ts @@ -7,7 +7,7 @@ export type ElementType = | 'polyline' | 'text' | 'dimension' | 'block_instance' | 'chair' | 'seating-row' | 'seating-block' | 'table' | 'stage' | 'leader' | 'revcloud' | 'image' - | 'curtain' | 'spotlight' | 'barrier' | 'truss'; + | 'curtain' | 'spotlight' | 'barrier' | 'truss' | 'boxcorner'; export type LineType = 'solid' | 'dashed' | 'dotted'; diff --git a/frontend/tests/trussUpgrade.test.ts b/frontend/tests/trussUpgrade.test.ts new file mode 100644 index 0000000..1475992 --- /dev/null +++ b/frontend/tests/trussUpgrade.test.ts @@ -0,0 +1,313 @@ +/** + * Task CAD-8 - Traversen-Upgrade: + * 1. Laengen-Presets 50/100/200/300 cm + * 2. Breite einstellbar (Standard 29 cm) + * 3. Rotierbares Rendering (Koerper als ROTIERTES Polygon) + * 4. Boxcorner (Eck-Traverse) mit magnetischem Andocken in BEIDE + * Richtungen: Ecke dockt an Traversen/Ecken, Traversen docken + * an Ecken-Armenden. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + trussTool, + boxCornerTool, + trussEndpoints, + boxcornerEndpoints, + snapTrussToDock, + cornerDockToDock, +} from '../src/plugins/builtin/event-tools'; +import { CADDocument } from '../src/kernel/document/CADDocument'; +import { createInMemoryDoc } from './helpers/inMemoryDoc'; +import { elementToPrimitives } from '../src/render/pixi/elementDrawers'; +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 statuses: string[] = []; + const previews: (CADElement | null)[] = []; + const ctx: any = { + doc, + options, + setStatus: (m: string) => statuses.push(m), + setPreview: (el: CADElement | null) => previews.push(el), + }; + return { ctx, doc, statuses, previews }; +} + +function pe(x: number, y: number): { world: Pt } { + return { world: { x, y } } as never; +} + +function makeTruss(id: string, x: number, y: number, length: number, rotationDeg = 0, trussWidth?: number): CADElement { + return { + id, type: 'truss', layerId: 'layer-0', x, y, + width: length, height: trussWidth ?? 29, + properties: { + length, rotation: rotationDeg, truss: true, + ...(trussWidth !== undefined ? { trussWidth } : {}), + }, + } as unknown as CADElement; +} + +function makeCorner(id: string, x: number, y: number, armLength = 50, rotationDeg = 0, width = 29): CADElement { + return { + id, type: 'boxcorner', layerId: 'layer-0', x, y, + width: 80, height: 80, + properties: { armLength, rotation: rotationDeg, trussWidth: width, boxcorner: true }, + } as unknown as CADElement; +} + +beforeEach(() => { + trussTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); + boxCornerTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); +}); + +// ─── 1: Laengen-Presets + Breite ────────────────────────────── + +describe('CAD-8: Laengen-Presets + Breite', () => { + it('Laengen-Select: genau 50/100/200/300 cm', () => { + const lenField = trussTool.optionsSchema?.find((f) => f.key === 'length'); + expect(lenField).toBeDefined(); + expect(lenField!.type).toBe('select'); + expect((lenField!.options ?? []).map((o) => o.value)).toEqual(['50', '100', '200', '300']); + }); + + it('Breiten-Option trussWidth (number) vorhanden', () => { + const wField = trussTool.optionsSchema?.find((f) => f.key === 'trussWidth'); + expect(wField).toBeDefined(); + expect(wField!.type).toBe('number'); + }); + + it('platzierte Traverse: trussWidth=29 Standard, length=200 Standard', () => { + const { ctx, doc } = mkCtx(); + trussTool.handlers.down!(pe(300, 300), ctx); + trussTool.handlers.up!(pe(400, 300), ctx); + const el = doc.getAllElements()[0]; + expect(el.properties.trussWidth).toBe(29); + expect(el.properties.length).toBe(200); + }); + + it('trussWidth=40 und length=300 aus Optionen uebernommen', () => { + const { ctx, doc } = mkCtx([], { trussWidth: 40, length: 300 }); + trussTool.handlers.down!(pe(100, 100), ctx); + trussTool.handlers.up!(pe(300, 100), ctx); + const el = doc.getAllElements()[0]; + expect(el.properties.trussWidth).toBe(40); + expect(el.properties.length).toBe(300); + }); +}); + +// ─── 2: Rotierbares Truss-Rendering ─────────────────────────── + +describe('CAD-8: Rotierbares Truss-Rendering (Polygon-Koerper)', () => { + it('90-Grad-Traverse: Koerperpolygon vertikal (y-Ausdehnung = Laenge, x = Breite)', () => { + const el = makeTruss('t1', 100, 100, 200, 90, 29); + const prims = elementToPrimitives(el); + const body = prims.find((p) => p.kind === 'polyline') as { points: Pt[]; close: boolean } | undefined; + expect(body).toBeDefined(); + expect(body!.close).toBe(true); + expect(body!.points).toHaveLength(4); + const ys = body!.points.map((p) => p.y); + const xs = body!.points.map((p) => p.x); + expect(Math.max(...ys) - Math.min(...ys)).toBeCloseTo(200, 0); + expect(Math.max(...xs) - Math.min(...xs)).toBeCloseTo(29, 0); + }); + + it('0-Grad-Traverse: Koerperpolygon horizontal', () => { + const el = makeTruss('t1', 100, 100, 200, 0, 29); + const body = elementToPrimitives(el).find((p) => p.kind === 'polyline') as { points: Pt[] }; + const xs = body.points.map((p) => p.x); + const ys = body.points.map((p) => p.y); + expect(Math.max(...xs) - Math.min(...xs)).toBeCloseTo(200, 0); + expect(Math.max(...ys) - Math.min(...ys)).toBeCloseTo(29, 0); + }); + + it('DREHBAR: Rotation aendert die Geometrie sichtbar (45 Grad diagonal)', () => { + const el = makeTruss('t1', 100, 100, 200, 45, 29); + const body = elementToPrimitives(el).find((p) => p.kind === 'polyline') as { points: Pt[] }; + const xs = body.points.map((p) => p.x); + const expected = 200 * Math.cos(Math.PI / 4) + 29 * Math.sin(Math.PI / 4); + expect(Math.max(...xs) - Math.min(...xs)).toBeCloseTo(expected, 0); + }); + + it('Breite im Rendering: trussWidth=40 ergibt 40 Einheiten Quer-Ausdehnung', () => { + const el = makeTruss('t1', 100, 100, 200, 0, 40); + const body = elementToPrimitives(el).find((p) => p.kind === 'polyline') as { points: Pt[] }; + const ys = body.points.map((p) => p.y); + expect(Math.max(...ys) - Math.min(...ys)).toBeCloseTo(40, 0); + }); +}); + +// ─── 3: Boxcorner-Element ───────────────────────────────────── + +describe('CAD-8: Boxcorner-Element (Eck-Traverse)', () => { + it('Manifest: id=boxcorner, Optionen armLength + trussWidth', () => { + expect(boxCornerTool.manifest.id).toBe('boxcorner'); + const keys = (boxCornerTool.optionsSchema ?? []).map((f) => f.key); + expect(keys).toContain('armLength'); + expect(keys).toContain('trussWidth'); + }); + + it('Drag-Platzierung: boxcorner mit armLength=50/trussWidth=29 Standard, Rotation aus Drag', () => { + const { ctx, doc } = mkCtx(); + boxCornerTool.handlers.down!(pe(100, 100), ctx); + boxCornerTool.handlers.up!(pe(200, 100), ctx); + const els = doc.getAllElements(); + expect(els).toHaveLength(1); + const el = els[0]; + expect(el.type).toBe('boxcorner'); + expect(el.properties.armLength).toBe(50); + expect(el.properties.trussWidth).toBe(29); + expect(el.properties.rotation).toBeCloseTo(0, 5); + }); + + it('Drag nach unten: Rotation ~90', () => { + const { ctx, doc } = mkCtx(); + boxCornerTool.handlers.down!(pe(100, 100), ctx); + boxCornerTool.handlers.up!(pe(100, 200), ctx); + expect(doc.getAllElements()[0].properties.rotation).toBeCloseTo(90, 5); + }); + + it('Optionen uebernommen: armLength=100, trussWidth=40', () => { + const { ctx, doc } = mkCtx([], { armLength: 100, trussWidth: 40 }); + boxCornerTool.handlers.down!(pe(100, 100), ctx); + boxCornerTool.handlers.up!(pe(200, 100), ctx); + const el = doc.getAllElements()[0]; + expect(el.properties.armLength).toBe(100); + expect(el.properties.trussWidth).toBe(40); + }); + + it('boxcornerEndpoints: Arme bei rot und rot+90 vom Eckpunkt', () => { + const el = makeCorner('c1', 100, 100, 100, 0); + const [a1, a2] = boxcornerEndpoints(el); + expect(a1.x).toBeCloseTo(200, 5); + expect(a1.y).toBeCloseTo(100, 5); + expect(a2.x).toBeCloseTo(100, 5); + expect(a2.y).toBeCloseTo(200, 5); + }); + + it('DREHBAR: Rotation 90 dreht die Arm-Endpunkte mit', () => { + const el = makeCorner('c1', 100, 100, 100, 90); + const [a1, a2] = boxcornerEndpoints(el); + expect(a1.x).toBeCloseTo(100, 5); + expect(a1.y).toBeCloseTo(200, 5); + expect(a2.x).toBeCloseTo(0, 5); + expect(a2.y).toBeCloseTo(100, 5); + }); + + it('1 Undo entfernt die Boxcorner (EINE Transaktion)', () => { + const { ctx, doc } = mkCtx(); + boxCornerTool.handlers.down!(pe(100, 100), ctx); + boxCornerTool.handlers.up!(pe(200, 100), ctx); + expect(doc.getAllElements()).toHaveLength(1); + doc.undo(); + expect(doc.getAllElements()).toHaveLength(0); + }); + + it('cancel verwirft die Platzierung', () => { + const { ctx, doc } = mkCtx(); + boxCornerTool.handlers.down!(pe(100, 100), ctx); + boxCornerTool.handlers.cancel?.(ctx); + boxCornerTool.handlers.up!(pe(200, 100), ctx); + expect(doc.getAllElements()).toHaveLength(0); + }); +}); + +// ─── 4: Boxcorner-Magnetismus (beide Richtungen) ───────────── + +describe('CAD-8: Boxcorner-Magnetismus', () => { + it('cornerDockToDock: Ecke dockt an Traversen-Ende, Rotation = Weg-Richtung', () => { + const t1 = makeTruss('t1', 100, 100, 200, 0); + const dock = cornerDockToDock([t1], { x: 210, y: 105 }, 25); + expect(dock).not.toBeNull(); + expect(dock!.vertex.x).toBeCloseTo(200, 5); + expect(dock!.vertex.y).toBeCloseTo(100, 5); + expect(dock!.rotationDeg).toBeCloseTo(0, 5); + expect(dock!.dockedTo).toBe('t1'); + }); + + it('am Start-Ende der Traverse: Rotation 180 (weg von der Traverse)', () => { + const t1 = makeTruss('t1', 100, 100, 200, 0); + const dock = cornerDockToDock([t1], { x: -10, y: 98 }, 25); + expect(dock!.vertex.x).toBeCloseTo(0, 5); + expect(dock!.vertex.y).toBeCloseTo(100, 5); + expect(dock!.rotationDeg).toBeCloseTo(180, 5); + }); + + it('ausserhalb der Toleranz: kein Dock (null)', () => { + const t1 = makeTruss('t1', 100, 100, 200, 0); + expect(cornerDockToDock([t1], { x: 500, y: 500 }, 25)).toBeNull(); + }); + + it('Traverse dockt an Boxcorner-Armende (snapTrussToDock kennt Ecken)', () => { + const c1 = makeCorner('c1', 100, 100, 50, 0); + const dock = snapTrussToDock([c1], { x: 150, y: 100 }, 200, 25); + expect(dock).not.toBeNull(); + expect(dock!.rotationDeg).toBeCloseTo(0, 5); + expect(dock!.center.x).toBeCloseTo(250, 3); + expect(dock!.center.y).toBeCloseTo(100, 3); + expect(dock!.dockedTo).toBe('c1'); + }); + + it('Ecke dockt an Ecken-Armende', () => { + const c1 = makeCorner('c1', 100, 100, 50, 0); + const dock = cornerDockToDock([c1], { x: 152, y: 102 }, 25); + expect(dock!.vertex.x).toBeCloseTo(150, 5); + expect(dock!.vertex.y).toBeCloseTo(100, 5); + expect(dock!.rotationDeg).toBeCloseTo(0, 5); + expect(dock!.dockedTo).toBe('c1'); + }); + + it('Integration: platzierte Ecke wird vom trussTool magnetisch gefunden', () => { + const { ctx, doc } = mkCtx(); + boxCornerTool.handlers.down!(pe(100, 100), ctx); + boxCornerTool.handlers.up!(pe(200, 100), ctx); + trussTool.handlers.down!(pe(150, 100), ctx); + trussTool.handlers.up!(pe(250, 100), ctx); + const els = doc.getAllElements(); + expect(els).toHaveLength(2); + const truss = els.find((e) => e.type === 'truss')!; + expect(truss.properties.rotation).toBeCloseTo(0, 5); + expect(truss.x).toBeCloseTo(250, 3); + expect(truss.y).toBeCloseTo(100, 3); + }); + + it('Integration: boxCornerTool dockt an bestehender Traverse', () => { + const t1 = makeTruss('t1', 100, 100, 200, 0); + const { ctx, doc } = mkCtx([t1]); + boxCornerTool.handlers.down!(pe(210, 105), ctx); + boxCornerTool.handlers.up!(pe(300, 100), ctx); + const corner = doc.getAllElements().find((e) => e.type === 'boxcorner')!; + expect(corner).toBeDefined(); + expect(corner.x).toBeCloseTo(200, 3); + expect(corner.y).toBeCloseTo(100, 3); + expect(corner.properties.rotation).toBeCloseTo(0, 5); + }); +}); + +// ─── 5: Boxcorner-Rendering ───────────────────────────────── + +describe('CAD-8: Boxcorner-Rendering (L-Form)', () => { + it('L-foermiges Polygon: 8 Punkte, geschlossen, am Eckpunkt', () => { + const el = makeCorner('c1', 100, 100, 50, 0, 29); + const prims = elementToPrimitives(el); + const poly = prims.find((p) => p.kind === 'polyline') as { points: Pt[]; close: boolean }; + expect(poly).toBeDefined(); + expect(poly.close).toBe(true); + expect(poly.points).toHaveLength(6); // L-Geometrie: 6 Punkte + const minDist = Math.min(...poly.points.map((p) => Math.hypot(p.x - 100, p.y - 100))); + expect(minDist).toBeLessThanOrEqual(29); + }); + + it('Rotation 90: L-Form zeigt nach unten-links statt rechts-unten', () => { + const el = makeCorner('c1', 100, 100, 50, 90, 29); + const poly = elementToPrimitives(el).find((p) => p.kind === 'polyline') as { points: Pt[] }; + const xs = poly.points.map((p) => p.x); + const ys = poly.points.map((p) => p.y); + // Bei rot 90: Arme zeigen nach unten (y+) und links (x-) + expect(Math.max(...ys)).toBeGreaterThan(140); + expect(Math.min(...xs)).toBeLessThan(60); + }); +});