diff --git a/frontend/src/plugins/builtin/event-tools/index.ts b/frontend/src/plugins/builtin/event-tools/index.ts index f2d9466..4f04d9d 100644 --- a/frontend/src/plugins/builtin/event-tools/index.ts +++ b/frontend/src/plugins/builtin/event-tools/index.ts @@ -511,6 +511,7 @@ function makeTrussElement( rotationDeg: number, layerId: string, trussWidth = 29, + chordCount = 2, ): CADElement { const rad = (rotationDeg * Math.PI) / 180; const w = Math.abs(length * Math.cos(rad)) + Math.abs(trussWidth * Math.sin(rad)); @@ -524,7 +525,7 @@ function makeTrussElement( width: Math.max(w, trussWidth), height: Math.max(h, trussWidth), properties: { - length, rotation: rotationDeg, trussWidth, truss: true, + length, rotation: rotationDeg, trussWidth, chordCount, truss: true, stroke: '#9aa0a6', strokeWidth: 2, fill: '#6b7280', }, } as unknown as CADElement; @@ -542,13 +543,9 @@ export const trussTool: ToolExtensionV2 = { description: 'Traverse platzieren: Drag = Richtung, dockt magnetisch an bestehenden Traversen an (CAD-7)', }, 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: '300', label: '300 cm' }, - ] }, + { key: 'length', label: 'Länge (cm)', type: 'number', min: 10, max: 1000 }, { key: 'trussWidth', label: 'Breite (cm)', type: 'number', min: 10, max: 60 }, + { key: 'chordCount', label: 'Gurtrohre', type: 'number', min: 1, max: 4 }, ], handlers: { down(e, ctx) { @@ -558,22 +555,23 @@ export const trussTool: ToolExtensionV2 = { move(e, ctx) { const c = ctx as FullToolContext; if (!trussDragStart) return; - const length = Number((c.options.length as string | undefined) ?? 200) || 200; + const length = Number((c.options.length as number | string | undefined) ?? 200) || 200; const trussWidth = (c.options.trussWidth as number | undefined) ?? 29; + const chordCount = Math.max(1, Math.min(4, (c.options.chordCount as number | undefined) ?? 2)); // 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' || String(el.type) === 'boxcorner', ) : []; const dock = snapTrussToDock(existing, trussDragStart, length, DOCK_TOLERANCE); if (dock) { - ctx.setPreview?.(makeTrussElement(dock.center, length, dock.rotationDeg, 'layer-0', trussWidth)); + ctx.setPreview?.(makeTrussElement(dock.center, length, dock.rotationDeg, 'layer-0', trussWidth, chordCount)); return; } // Frei: Richtung aus Drag const ang = (Math.atan2(e.world.y - trussDragStart.y, e.world.x - trussDragStart.x) * 180) / Math.PI; const cx = (trussDragStart.x + e.world.x) / 2; const cy = (trussDragStart.y + e.world.y) / 2; - ctx.setPreview?.(makeTrussElement({ x: cx, y: cy }, length, ang, 'layer-0')); + ctx.setPreview?.(makeTrussElement({ x: cx, y: cy }, length, ang, 'layer-0', trussWidth, chordCount)); }, up(e, ctx) { const c = ctx as FullToolContext; @@ -583,6 +581,7 @@ export const trussTool: ToolExtensionV2 = { 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 chordCount = Math.max(1, Math.min(4, (c.options.chordCount as number | undefined) ?? 2)); const layerId = (c.options.layerId as string | undefined) ?? 'layer-0'; // MAGNETISCH: Dock-Pruefung am Drag-Startpunkt (Traversen UND Ecken) const existing = (c.doc.getAllElements() as CADElement[]).filter( @@ -591,13 +590,13 @@ export const trussTool: ToolExtensionV2 = { const dock = snapTrussToDock(existing, start, length, DOCK_TOLERANCE); let el: CADElement; if (dock) { - el = makeTrussElement(dock.center, length, dock.rotationDeg, layerId, trussWidth); + el = makeTrussElement(dock.center, length, dock.rotationDeg, layerId, trussWidth, chordCount); 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, trussWidth); + el = makeTrussElement({ x: cx, y: cy }, length, ang, layerId, trussWidth, chordCount); ctx.setStatus(`Traverse platziert (${length}cm, Breite ${trussWidth}cm)`); } c.doc.transact(() => { c.doc!.addElement(el); }); @@ -621,8 +620,9 @@ export const trussTool: ToolExtensionV2 = { 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 angle = (el.properties.angle as number | undefined) ?? 90; const rot = (rotDeg * Math.PI) / 180; - const rot2 = ((rotDeg + 90) * Math.PI) / 180; + const rot2 = ((rotDeg + angle) * 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 }, @@ -674,6 +674,7 @@ function makeBoxcornerElement( vertex: Pt, armLength: number, rotationDeg: number, + angleDeg: number, trussWidth: number, layerId: string, ): CADElement { @@ -686,7 +687,7 @@ function makeBoxcornerElement( width: armLength + trussWidth, height: armLength + trussWidth, properties: { - armLength, trussWidth, rotation: rotationDeg, boxcorner: true, + armLength, trussWidth, rotation: rotationDeg, angle: angleDeg, boxcorner: true, stroke: '#9aa0a6', strokeWidth: 2, fill: '#6b7280', }, } as unknown as CADElement; @@ -705,6 +706,7 @@ export const boxCornerTool: ToolExtensionV2 = { }, optionsSchema: [ { key: 'armLength', label: 'Armlänge (cm)', type: 'number', min: 20, max: 150 }, + { key: 'angle', label: 'Winkel (°)', type: 'number', min: 15, max: 165 }, { key: 'trussWidth', label: 'Breite (cm)', type: 'number', min: 10, max: 60 }, ], handlers: { @@ -716,17 +718,18 @@ export const boxCornerTool: ToolExtensionV2 = { const c = ctx as FullToolContext; if (!cornerDragStart) return; const armLength = (c.options.armLength as number | undefined) ?? 50; + const cornerAngle = (c.options.angle as number | undefined) ?? 90; 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')); + ctx.setPreview?.(makeBoxcornerElement(dock.vertex, armLength, dock.rotationDeg, cornerAngle, 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')); + ctx.setPreview?.(makeBoxcornerElement(cornerDragStart, armLength, ang, cornerAngle, trussWidth, 'layer-0')); }, up(e, ctx) { const c = ctx as FullToolContext; @@ -735,6 +738,7 @@ export const boxCornerTool: ToolExtensionV2 = { ctx.setPreview?.(null); if (!start || !c.doc) return; const armLength = (c.options.armLength as number | undefined) ?? 50; + const cornerAngle = (c.options.angle as number | undefined) ?? 90; 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( @@ -743,11 +747,11 @@ export const boxCornerTool: ToolExtensionV2 = { const dock = cornerDockToDock(existing, start, DOCK_TOLERANCE); let el: CADElement; if (dock) { - el = makeBoxcornerElement(dock.vertex, armLength, dock.rotationDeg, trussWidth, layerId); + el = makeBoxcornerElement(dock.vertex, armLength, dock.rotationDeg, cornerAngle, 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); + el = makeBoxcornerElement(start, armLength, ang, cornerAngle, trussWidth, layerId); ctx.setStatus(`Eck-Traverse platziert (${armLength}cm Arme, Breite ${trussWidth}cm)`); } c.doc.transact(() => { c.doc!.addElement(el); }); @@ -760,6 +764,58 @@ export const boxCornerTool: ToolExtensionV2 = { }, }; +// Task CAD-11: Kreis-Traverse +function makeCircleTrussElement( + center: Pt, + diameter: number, + trussWidth: number, + chordCount: number, + layerId: string, +): CADElement { + return { + id: `circletruss_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`, + type: 'circletruss', + layerId, + x: center.x, + y: center.y, + width: diameter, + height: diameter, + properties: { + diameter, trussWidth, chordCount, circletruss: true, + stroke: '#9aa0a6', strokeWidth: 2, fill: 'none', + }, + } as unknown as CADElement; +} + +export const circleTrussTool: ToolExtensionV2 = { + manifest: { + id: 'circle-truss', + label: 'Kreis-Traverse', + icon: '○', + ribbonTab: 'insert', + tags: ['basic'], + description: 'Kreis-Traverse platzieren (CAD-11)', + }, + optionsSchema: [ + { key: 'diameter', label: 'Durchmesser (cm)', type: 'number', min: 50, max: 1500 }, + { key: 'trussWidth', label: 'Breite (cm)', type: 'number', min: 10, max: 60 }, + { key: 'chordCount', label: 'Gurtrohre', type: 'number', min: 1, max: 4 }, + ], + handlers: { + down(e, ctx) { + const c = ctx as FullToolContext; + if (!c.doc) return; + const diameter = Math.max(50, (c.options.diameter as number | undefined) ?? 200); + const trussWidth = (c.options.trussWidth as number | undefined) ?? 29; + const chordCount = Math.max(1, Math.min(4, (c.options.chordCount as number | undefined) ?? 2)); + const layerId = (c.options.layerId as string | undefined) ?? 'layer-0'; + const el = makeCircleTrussElement(e.world, diameter, trussWidth, chordCount, layerId); + c.doc.transact(() => { c.doc!.addElement(el); }); + ctx.setStatus('Kreis-Traverse platziert'); + }, + }, +}; + export const eventToolsV2Plugin: PluginV2 = { manifest: { id: 'event-tools', @@ -770,5 +826,5 @@ export const eventToolsV2Plugin: PluginV2 = { category: 'elements', enabledByDefault: true, }, - tools: [chairTool, seatingRowToolDrag, seatingBlockToolDrag, tableTool, stageTool, arcRowTool, kinoTemplateTool, banquetTemplateTool, standingTableTemplateTool, podiumTemplateTool, curtainTool, spotlightTool, barrierTool, trussTool, boxCornerTool], + tools: [chairTool, seatingRowToolDrag, seatingBlockToolDrag, tableTool, stageTool, arcRowTool, kinoTemplateTool, banquetTemplateTool, standingTableTemplateTool, podiumTemplateTool, curtainTool, spotlightTool, barrierTool, trussTool, boxCornerTool, circleTrussTool], }; diff --git a/frontend/src/render/pixi/elementDrawers.ts b/frontend/src/render/pixi/elementDrawers.ts index 90e6aba..0ca4a93 100644 --- a/frontend/src/render/pixi/elementDrawers.ts +++ b/frontend/src/render/pixi/elementDrawers.ts @@ -400,14 +400,16 @@ export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: stri } case 'truss': { - // Traverse (CAD-7/8): ROTIERTES Rechteck-Polygon + Gurtlinien - // entlang der Richtung (Fachwerk-Profil), Breite = trussWidth. + // Traverse (CAD-7/8/11): ROTIERTES Rechteck-Polygon + Gurtlinien + // entlang der Richtung (Fachwerk-Profil), Breite = trussWidth, + // chordCount = Anzahl Gurtrohre (CAD-11). const length = (props.length as number | undefined) ?? el.width; const tWidth = (props.trussWidth as number | undefined) ?? el.height ?? 29; + const chordCount = Math.max(1, Math.min(4, (props.chordCount as number | undefined) ?? 2)); const rotDeg = (props.rotation as number | undefined) ?? 0; const rad = (rotDeg * Math.PI) / 180; - const ux = Math.cos(rad), uy = Math.sin(rad); // Laengsrichtung - const nx = -Math.sin(rad), ny = Math.cos(rad); // Normalenrichtung + const ux = Math.cos(rad), uy = Math.sin(rad); + const nx = -Math.sin(rad), ny = Math.cos(rad); const hl = length / 2, hw = tWidth / 2; const c = { x: el.x, y: el.y }; const corner = (su: number, sv: number): { x: number; y: number } => ({ @@ -424,45 +426,58 @@ export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: stri stroke: strokeCol, strokeWidth: 2, }, - // Gurtlinien oben/unten - { - kind: 'line', - from: corner(-1, -1), - to: corner(1, -1), - stroke: '#4b5563', strokeWidth: 1, - }, - { - kind: 'line', - from: corner(-1, 1), - to: corner(1, 1), - stroke: '#4b5563', strokeWidth: 1, - }, ]; + // Gurtrohre: chordCount Linien gleichmaessig ueber die Breite verteilt + // chordCount=1: Mittellinie, chordCount=2: bei ±hw/2, chordCount=4: bei ±hw/2 und ±hw/6 + for (let ci = 0; ci < chordCount; ci++) { + let sv: number; + if (chordCount === 1) sv = 0; + else if (chordCount === 2) sv = ci === 0 ? -0.5 : 0.5; + else if (chordCount === 3) sv = ci === 0 ? -0.67 : ci === 1 ? 0 : 0.67; + else sv = -0.75 + ci * 0.5; // chordCount=4: -0.75, -0.25, 0.25, 0.75 + const chordPt = (su: number): { x: number; y: number } => ({ + x: c.x + ux * (hl * su) + nx * (hw * sv), + y: c.y + uy * (hl * su) + ny * (hw * sv), + }); + const from = chordPt(-1); + const to = chordPt(1); + out.push({ kind: 'line', from, to, 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. + // Eck-Traverse (CAD-8/11): L-foermiges Polygon (6 Punkte) aus zwei + // Armen (rot und rot+angle) um den Eckpunkt, Breite = trussWidth, + // Winkel = angle (variabel, CAD-11, Default 90). 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 angleDeg = (props.angle as number | undefined) ?? 90; const rad = (rotDeg * Math.PI) / 180; + const rad2 = ((rotDeg + angleDeg) * 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 vx = { 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, + x: vx.x + ux * a + nx * b, + y: vx.y + uy * a + ny * b, }); - // L-Geometrie: Arm1 entlang +u, Arm2 entlang +n (rot+90) + const ax2 = Math.cos(rad2), ay2 = Math.sin(rad2); + const pxa = (a: number, b: number): { x: number; y: number } => ({ + x: vx.x + ax2 * a - ay2 * b, + y: vx.y + ay2 * a + ax2 * b, + }); + // L-Geometrie: Arm1 entlang +u (rot), Arm2 entlang rot+angle const pts = [ px(0, 0), px(armLength, 0), px(armLength, bWidth), px(bWidth, bWidth), - px(bWidth, armLength), - px(0, armLength), + pxa(bWidth, 0), + pxa(armLength, 0), + pxa(0, 0), + px(0, bWidth), ]; const strokeCol = (props.stroke as string | undefined) ?? '#9aa0a6'; return [{ @@ -474,6 +489,35 @@ export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: stri }]; } + case 'circletruss': { + // Kreis-Traverse (CAD-11): Aussenkreis + Innenkreis + + // chordCount Gurtrohr-Kreise gleichmaessig verteilt. + const diameter = (props.diameter as number | undefined) ?? el.width; + const tWidth = (props.trussWidth as number | undefined) ?? 29; + const chordCount = Math.max(1, Math.min(4, (props.chordCount as number | undefined) ?? 2)); + const rOuter = diameter / 2; + const rInner = rOuter - tWidth; + const strokeCol = (props.stroke as string | undefined) ?? '#9aa0a6'; + const out: ShapePrimitive[] = [ + { kind: 'circle', center: { x: el.x, y: el.y }, radius: rOuter, fill: null, stroke: strokeCol, strokeWidth: 2 }, + { kind: 'circle', center: { x: el.x, y: el.y }, radius: rInner, fill: null, stroke: strokeCol, strokeWidth: 1 }, + ]; + // Gurtrohr-Kreise: gleichmaessig zwischen Aussen- und Innenkreis + const rMid = (rOuter + rInner) / 2; + for (let ci = 0; ci < chordCount; ci++) { + const ang = (2 * Math.PI * ci) / chordCount; + out.push({ + kind: 'circle', + center: { x: el.x + Math.cos(ang) * rMid, y: el.y + Math.sin(ang) * rMid }, + radius: tWidth * 0.15, + fill: null, + stroke: '#4b5563', + strokeWidth: 1, + }); + } + return out; + } + case 'barrier': { // Absperrung (Task E7): Kette aus Kreis-Paaren (properties.circles). const circles = Array.isArray(props.circles) diff --git a/frontend/src/services/bomService.ts b/frontend/src/services/bomService.ts index 703a9ba..a2a5234 100644 --- a/frontend/src/services/bomService.ts +++ b/frontend/src/services/bomService.ts @@ -10,16 +10,20 @@ import type { CADElement } from '../types/cad.types'; export interface BOMRow { pos: number; - type: 'Traverse' | 'Eck-Traverse'; - /** Laenge in cm (Traverse: length; Eck-Traverse: armLength). */ + type: 'Traverse' | 'Eck-Traverse' | 'Kreis-Traverse'; + /** Laenge in cm (Traverse: length; Eck-Traverse: armLength; Kreis: Durchmesser). */ length: number; /** Breite in cm (trussWidth). */ width: number; count: number; - /** Gesamtlaenge dieser Position in cm (count * length, Ecken * 2). */ + /** Gesamtlaenge dieser Position in cm. */ totalCm: number; - /** Bezeichnung, z.B. "Traverse 200 x 29". */ + /** Bezeichnung. */ label: string; + /** Gurtrohr-Anzahl (optional, CAD-11). */ + chordCount?: number; + /** Eck-Winkel in Grad (optional, CAD-11). */ + angle?: number; } export interface BOMResult { @@ -29,9 +33,11 @@ export interface BOMResult { } interface GroupKey { - type: 'Traverse' | 'Eck-Traverse'; + type: 'Traverse' | 'Eck-Traverse' | 'Kreis-Traverse'; length: number; width: number; + chordCount?: number; + angle?: number; } /** @@ -46,16 +52,27 @@ export function buildBOM(elements: CADElement[]): BOMResult { if (String(el.type) === 'truss' && el.properties.truss === true) { const length = (el.properties.length as number | undefined) ?? el.width ?? 0; const width = (el.properties.trussWidth as number | undefined) ?? el.height ?? 29; - const key: GroupKey = { type: 'Traverse', length, width }; - const id = `T|${length}|${width}`; + const chordCount = (el.properties.chordCount as number | undefined) ?? 2; + const key: GroupKey = { type: 'Traverse', length, width, chordCount }; + const id = `T|${length}|${width}|${chordCount}`; const g = groups.get(id) ?? { key, count: 0 }; g.count++; groups.set(id, g); } else if (String(el.type) === 'boxcorner' && el.properties.boxcorner === true) { const length = (el.properties.armLength as number | undefined) ?? 50; const width = (el.properties.trussWidth as number | undefined) ?? 29; - const key: GroupKey = { type: 'Eck-Traverse', length, width }; - const id = `C|${length}|${width}`; + const angle = (el.properties.angle as number | undefined) ?? 90; + const key: GroupKey = { type: 'Eck-Traverse', length, width, angle }; + const id = `C|${length}|${width}|${angle}`; + const g = groups.get(id) ?? { key, count: 0 }; + g.count++; + groups.set(id, g); + } else if (String(el.type) === 'circletruss' && el.properties.circletruss === true) { + const diameter = (el.properties.diameter as number | undefined) ?? el.width; + const width = (el.properties.trussWidth as number | undefined) ?? 29; + const chordCount = (el.properties.chordCount as number | undefined) ?? 2; + const key: GroupKey = { type: 'Kreis-Traverse', length: diameter, width, chordCount }; + const id = `K|${diameter}|${width}|${chordCount}`; const g = groups.get(id) ?? { key, count: 0 }; g.count++; groups.set(id, g); @@ -73,8 +90,15 @@ export function buildBOM(elements: CADElement[]): BOMResult { const rows: BOMRow[] = entries.map((g, i) => { // Ecken haben 2 Arme: Gesamtlaenge = count * length * 2 + // Kreis-Traversen: Umfang = Pi * Durchmesser + const isCircle = g.key.type === 'Kreis-Traverse'; const armFactor = g.key.type === 'Eck-Traverse' ? 2 : 1; - const totalCm = g.count * g.key.length * armFactor; + const perPieceCm = isCircle ? Math.PI * g.key.length : g.key.length * armFactor; + const totalCm = g.count * perPieceCm; + const detailParts: string[] = []; + if (g.key.chordCount !== undefined && g.key.chordCount > 0) detailParts.push(`${g.key.chordCount}-rohr`); + if (g.key.angle !== undefined && g.key.angle !== 90) detailParts.push(`${g.key.angle}°`); + const detailSuffix = detailParts.length > 0 ? ` (${detailParts.join(', ')})` : ''; return { pos: i + 1, type: g.key.type, @@ -82,7 +106,9 @@ export function buildBOM(elements: CADElement[]): BOMResult { width: g.key.width, count: g.count, totalCm, - label: `${g.key.type} ${g.key.length} \u00d7 ${g.key.width}`, + label: `${g.key.type} ${isCircle ? '⌀' : ''}${g.key.length} × ${g.key.width}${detailSuffix}`, + chordCount: g.key.chordCount, + angle: g.key.angle, }; }); diff --git a/frontend/src/types/cad.types.ts b/frontend/src/types/cad.types.ts index 6731afb..1277cbf 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' | 'boxcorner'; + | 'curtain' | 'spotlight' | 'barrier' | 'truss' | 'boxcorner' | 'circletruss'; export type LineType = 'solid' | 'dashed' | 'dotted'; diff --git a/frontend/tests/trussPro.test.ts b/frontend/tests/trussPro.test.ts new file mode 100644 index 0000000..0e2c750 --- /dev/null +++ b/frontend/tests/trussPro.test.ts @@ -0,0 +1,254 @@ +/** + * Task CAD-11 - Truss Professional Upgrade: + * 1. chordCount (Anzahl Gurtrohre, 1-4) einstellbar + * 2. Laenge als freier Zahleneintrag (nicht nur Presets) + * 3. Corner mit variablem Winkel (nicht nur 90 Grad) + * 4. Kreis-Traverse als eigenes Tool + * 5. BOM beruecksichtigt chordCount und Kreistraversen + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + trussTool, + boxCornerTool, + circleTrussTool, + trussEndpoints, + boxcornerEndpoints, +} from '../src/plugins/builtin/event-tools'; +import { buildBOM } from '../src/services/bomService'; +import { elementToPrimitives } from '../src/render/pixi/elementDrawers'; +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 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, rotation: number, width = 29, chordCount = 2): CADElement { + return { + id, type: 'truss', layerId: 'l', x, y, width: length, height: width, + properties: { length, rotation, trussWidth: width, chordCount, truss: true }, + } as unknown as CADElement; +} + +function makeCorner(id: string, x: number, y: number, armLength = 50, rotation = 0, angle = 90, width = 29): CADElement { + return { + id, type: 'boxcorner', layerId: 'l', x, y, width: 80, height: 80, + properties: { armLength, rotation, angle, trussWidth: width, boxcorner: true }, + } as unknown as CADElement; +} + +function makeCircleTruss(id: string, x: number, y: number, diameter: number, width = 29, chordCount = 2): CADElement { + return { + id, type: 'circletruss', layerId: 'l', x, y, width: diameter, height: diameter, + properties: { diameter, trussWidth: width, chordCount, circletruss: true }, + } as unknown as CADElement; +} + +beforeEach(() => { + for (const t of [trussTool, boxCornerTool, circleTrussTool]) { + t.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); + } +}); + +// ─── 1: chordCount (Gurtrohre) ────────────────────────────── + +describe('CAD-11: chordCount', () => { + it('trussTool hat chordCount-Option (number, 1-4, Default 2)', () => { + const field = trussTool.optionsSchema?.find((f) => f.key === 'chordCount'); + expect(field).toBeDefined(); + expect(field!.type).toBe('number'); + }); + + it('platzierte Traverse: chordCount=2 Standard', () => { + const { ctx, doc } = mkCtx(); + trussTool.handlers.down!(pe(100, 100), ctx); + trussTool.handlers.up!(pe(300, 100), ctx); + expect(doc.getAllElements()[0].properties.chordCount).toBe(2); + }); + + it('chordCount=3 wird uebernommen', () => { + const { ctx, doc } = mkCtx([], { chordCount: 3 }); + trussTool.handlers.down!(pe(100, 100), ctx); + trussTool.handlers.up!(pe(300, 100), ctx); + expect(doc.getAllElements()[0].properties.chordCount).toBe(3); + }); + + it('Rendering: chordCount=2 zeigt 2 Gurtlinien, chordCount=4 zeigt 4', () => { + const t2 = makeTruss('t2', 100, 100, 200, 0, 29, 2); + const t4 = makeTruss('t4', 100, 100, 200, 0, 29, 4); + const prims2 = elementToPrimitives(t2).filter((p) => p.kind === 'line' && p.stroke === '#4b5563'); + const prims4 = elementToPrimitives(t4).filter((p) => p.kind === 'line' && p.stroke === '#4b5563'); + expect(prims2.length).toBe(2); + expect(prims4.length).toBe(4); + }); + + it('chordCount=1: eine Mittellinie', () => { + const t1 = makeTruss('t1', 100, 100, 200, 0, 29, 1); + const lines = elementToPrimitives(t1).filter((p) => p.kind === 'line' && p.stroke === '#4b5563'); + expect(lines.length).toBe(1); + const line = lines[0] as { from: Pt; to: Pt }; + expect(line.from.y).toBeCloseTo(100, 0); // Mittellinie auf y=100 + }); +}); + +// ─── 2: Freie Laengeneingabe ───────────────────────────────── + +describe('CAD-11: Laenge als freier Zahleneintrag', () => { + it('length ist number (nicht select) — Presets als separate Quick-Buttons', () => { + const field = trussTool.optionsSchema?.find((f) => f.key === 'length'); + expect(field).toBeDefined(); + expect(field!.type).toBe('number'); + }); + + it('Custom-Laenge 175 wird uebernommen', () => { + const { ctx, doc } = mkCtx([], { length: 175 }); + trussTool.handlers.down!(pe(100, 100), ctx); + trussTool.handlers.up!(pe(300, 100), ctx); + expect(doc.getAllElements()[0].properties.length).toBe(175); + }); +}); + +// ─── 3: Corner mit variablem Winkel ───────────────────────── + +describe('CAD-11: Corner variablem Winkel', () => { + it('boxCornerTool hat angle-Option (number, Default 90)', () => { + const field = boxCornerTool.optionsSchema?.find((f) => f.key === 'angle'); + expect(field).toBeDefined(); + expect(field!.type).toBe('number'); + }); + + it('boxcornerEndpoints: 90-Grad-Ecke = Arm2 bei rot+90', () => { + const c = makeCorner('c1', 100, 100, 50, 0, 90); + const [a1, a2] = boxcornerEndpoints(c); + expect(a1.x).toBeCloseTo(150, 0); + expect(a1.y).toBeCloseTo(100, 0); + expect(a2.x).toBeCloseTo(100, 0); + expect(a2.y).toBeCloseTo(150, 0); + }); + + it('boxcornerEndpoints: 45-Grad-Ecke = Arm2 bei rot+45', () => { + const c = makeCorner('c1', 100, 100, 50, 0, 45); + const [a1, a2] = boxcornerEndpoints(c); + expect(a1.x).toBeCloseTo(150, 0); + expect(a1.y).toBeCloseTo(100, 0); + const halfDiag = 50 * Math.cos(Math.PI / 4); + expect(a2.x).toBeCloseTo(100 + halfDiag, 0); + expect(a2.y).toBeCloseTo(100 + halfDiag, 0); + }); + + it('platzierte 30-Grad-Ecke: angle=30 in properties', () => { + const { ctx, doc } = mkCtx([], { angle: 30 }); + boxCornerTool.handlers.down!(pe(100, 100), ctx); + boxCornerTool.handlers.up!(pe(200, 100), ctx); + expect(doc.getAllElements()[0].properties.angle).toBe(30); + }); + + it('Default-Winkel ist 90 (abwaertskompatibel)', () => { + const { ctx, doc } = mkCtx(); + boxCornerTool.handlers.down!(pe(100, 100), ctx); + boxCornerTool.handlers.up!(pe(200, 100), ctx); + expect(doc.getAllElements()[0].properties.angle).toBe(90); + }); + + it('Rendering: 90-Grad = L-Form, 45-Grad = spitzeres V', () => { + const c90 = makeCorner('c90', 100, 100, 50, 0, 90, 29); + const c45 = makeCorner('c45', 100, 100, 50, 0, 45, 29); + const p90 = elementToPrimitives(c90).find((p) => p.kind === 'polyline') as { points: Pt[] }; + const p45 = elementToPrimitives(c45).find((p) => p.kind === 'polyline') as { points: Pt[] }; + // Beide sind Polygone mit >= 6 Punkten + expect(p90.points.length).toBeGreaterThanOrEqual(6); + expect(p45.points.length).toBeGreaterThanOrEqual(6); + // 45-Grad-Ecke: zweiter Arm diagonal → Y-Ausdehnung kleiner als bei 90° + const maxY90 = Math.max(...p90.points.map((p) => p.y)); + const maxY45 = Math.max(...p45.points.map((p) => p.y)); + expect(maxY45).toBeLessThan(maxY90); + }); +}); + +// ─── 4: Kreis-Traverse ────────────────────────────────────── + +describe('CAD-11: Kreis-Traverse', () => { + it('circleTrussTool existiert mit Manifest', () => { + expect(circleTrussTool.manifest.id).toBe('circle-truss'); + const keys = (circleTrussTool.optionsSchema ?? []).map((f) => f.key); + expect(keys).toContain('diameter'); + expect(keys).toContain('trussWidth'); + }); + + it('Klick platziert Kreistraverse mit diameter/trussWidth/chordCount', () => { + const { ctx, doc } = mkCtx([], { diameter: 300, trussWidth: 29, chordCount: 2 }); + circleTrussTool.handlers.down!(pe(200, 200), ctx); + const els = doc.getAllElements(); + expect(els).toHaveLength(1); + expect(els[0].type).toBe('circletruss'); + expect(els[0].properties.diameter).toBe(300); + expect(els[0].properties.trussWidth).toBe(29); + expect(els[0].properties.chordCount).toBe(2); + }); + + it('Kreis-Traverse: Rendering zeigt Aussen- und Innenkreis + Gurtrohr-Kreise', () => { + const ct = makeCircleTruss('ct1', 200, 200, 300, 29, 2); + const prims = elementToPrimitives(ct); + const circles = prims.filter((p) => p.kind === 'circle'); + // Aussen + Innen + chordCount-Kreise = mindestens 4 + expect(circles.length).toBeGreaterThanOrEqual(4); + }); + + it('1 Undo entfernt die Kreistraverse', () => { + const { ctx, doc } = mkCtx(); + circleTrussTool.handlers.down!(pe(200, 200), ctx); + expect(doc.getAllElements()).toHaveLength(1); + doc.undo(); + expect(doc.getAllElements()).toHaveLength(0); + }); + + it('diameter-Default ist 200', () => { + const { ctx, doc } = mkCtx(); + circleTrussTool.handlers.down!(pe(200, 200), ctx); + expect(doc.getAllElements()[0].properties.diameter).toBe(200); + }); +}); + +// ─── 5: BOM-Integration ───────────────────────────────────── + +describe('CAD-11: BOM mit chordCount + Kreistraversen', () => { + it('BOM gruppiert nach chordCount (2-rohr vs 4-rohr getrennt)', () => { + const els = [ + makeTruss('t1', 0, 0, 200, 0, 29, 2), + makeTruss('t2', 0, 0, 200, 0, 29, 4), + ]; + const bom = buildBOM(els); + expect(bom.rows.filter((r) => r.type === 'Traverse')).toHaveLength(2); + }); + + it('BOM enthaelt Kreistraversen mit Durchmesser', () => { + const els = [makeCircleTruss('ct1', 200, 200, 300, 29, 2)]; + const bom = buildBOM(els); + expect(bom.rows).toHaveLength(1); + expect(bom.rows[0].label).toContain('Kreis'); + expect(bom.rows[0].label).toContain('300'); + }); + + it('Kreistraverse-Umfang zaehlt in Gesamtlaenge (Pi * d)', () => { + const els = [makeCircleTruss('ct1', 200, 200, 300, 29, 2)]; + const bom = buildBOM(els); + const expected = (Math.PI * 300) / 100; // ~9.42m + expect(bom.totalLengthM).toBeCloseTo(expected, 1); + }); +}); diff --git a/frontend/tests/trussUpgrade.test.ts b/frontend/tests/trussUpgrade.test.ts index 1475992..65625f2 100644 --- a/frontend/tests/trussUpgrade.test.ts +++ b/frontend/tests/trussUpgrade.test.ts @@ -67,11 +67,12 @@ beforeEach(() => { // ─── 1: Laengen-Presets + Breite ────────────────────────────── describe('CAD-8: Laengen-Presets + Breite', () => { - it('Laengen-Select: genau 50/100/200/300 cm', () => { + it('Laengen-Select: freie Eingabe mit 50-300 Bereich (CAD-11: number)', () => { 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']); + expect(lenField!.type).toBe('number'); + expect(lenField!.min).toBe(10); + expect(lenField!.max).toBe(1000); }); it('Breiten-Option trussWidth (number) vorhanden', () => { @@ -296,7 +297,7 @@ describe('CAD-8: Boxcorner-Rendering (L-Form)', () => { 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 + expect(poly.points).toHaveLength(8); // CAD-11: variable-angle Geometrie hat 8 Punkte const minDist = Math.min(...poly.points.map((p) => Math.hypot(p.x - 100, p.y - 100))); expect(minDist).toBeLessThanOrEqual(29); });