From 9176d7c9a4b27fd6990296ec923f38d16790008c Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sat, 29 Aug 2026 02:36:20 +0200 Subject: [PATCH] task(E7): event elements polish - curtain (wavy polyline), spotlight (cone polygon with angle/color), barrier (chain circles), stage height attribute --- .../src/plugins/builtin/event-tools/index.ts | 147 +++++++++++++++++- frontend/src/render/pixi/elementDrawers.ts | 60 +++++++ frontend/src/services/seatingService.ts | 1 + frontend/src/types/cad.types.ts | 3 +- frontend/tests/eventPolish.test.ts | 106 +++++++++++++ 5 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 frontend/tests/eventPolish.test.ts diff --git a/frontend/src/plugins/builtin/event-tools/index.ts b/frontend/src/plugins/builtin/event-tools/index.ts index 004edd4..a5f27f5 100644 --- a/frontend/src/plugins/builtin/event-tools/index.ts +++ b/frontend/src/plugins/builtin/event-tools/index.ts @@ -280,15 +280,156 @@ export const arcRowTool: ToolExtensionV2 = { }; /** V2-Plugin: Event-Domain (Tasks A4.9 + E1). */ +// ───────────────────────────────────────────────────────────── +// Task E7: Event-Elemente-Polish — curtain, spotlight, barrier +// ───────────────────────────────────────────────────────────── + +/** Vorhang: wellige Polyline (Standardbreite 400) über der Bühne. */ +export const curtainTool: ToolExtensionV2 = { + manifest: { + id: 'curtain', + label: 'Vorhang', + icon: '╭', + ribbonTab: 'insert', + tags: ['basic'], + description: 'Vorhang entlang der Bühnen-Oberkante platzieren', + }, + optionsSchema: [ + { key: 'stageHeight', label: 'Höhe (cm)', type: 'number' }, + ], + handlers: { + down(e, ctx) { + const c = ctx as FullToolContext; + if (!c.doc) return; + const { layerId } = optsOf(ctx); + const stageHeight = (ctx.options.stageHeight as number | undefined) ?? 120; + const width = 400; + // Wellenform: 24 Punkte, Sinus gedämpft an den Enden + const pts: Pt[] = []; + const SEG = 24; + for (let i = 0; i <= SEG; i++) { + const t = i / SEG; + const x = e.world.x - width / 2 + width * t; + const y = e.world.y + Math.sin(t * Math.PI) * (stageHeight / 4); + pts.push({ x, y }); + } + const el: CADElement = { + id: `curtain-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + type: 'curtain', + layerId, + x: e.world.x, + y: e.world.y, + width, + height: stageHeight, + properties: { points: pts, stageHeight, stroke: '#7a4a8f', strokeWidth: 2 }, + }; + c.doc.transact(() => { c.doc!.addElement(el); }); + ctx.setStatus(`Vorhang platziert (${width} breit, ${stageHeight} cm hoch)`); + }, + }, +}; + +/** Scheinwerfer: Lichtkegel in angle-Richtung mit Farbe. */ +export const spotlightTool: ToolExtensionV2 = { + manifest: { + id: 'spotlight', + label: 'Scheinwerfer', + icon: '▶', + ribbonTab: 'insert', + tags: ['basic'], + description: 'Scheinwerfer-Kegel platzieren (Winkel + Farbe aus Optionen)', + }, + optionsSchema: [ + { key: 'angle', label: 'Winkel (°)', type: 'number' }, + { key: 'color', label: 'Lichtfarbe', type: 'color' }, + ], + handlers: { + down(e, ctx) { + const c = ctx as FullToolContext; + if (!c.doc) return; + const { layerId } = optsOf(ctx); + const angle = (ctx.options.angle as number | undefined) ?? 0; + const color = (ctx.options.color as string | undefined) ?? '#ffcc00'; + const length = 300; + const halfAngle = 20; + const rad = (angle * Math.PI) / 180; + const radHalf = (halfAngle * Math.PI) / 180; + const p1 = { x: e.world.x + length * Math.cos(rad - radHalf), y: e.world.y + length * Math.sin(rad - radHalf) }; + const p2 = { x: e.world.x + length * Math.cos(rad + radHalf), y: e.world.y + length * Math.sin(rad + radHalf) }; + const pts: Pt[] = [ + { x: e.world.x, y: e.world.y }, + p1, + p2, + { x: e.world.x, y: e.world.y }, + ]; + const el: CADElement = { + id: `spotlight-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + type: 'spotlight', + layerId, + x: e.world.x, + y: e.world.y, + width: length, + height: length, + properties: { points: pts, angle, color }, + }; + c.doc.transact(() => { c.doc!.addElement(el); }); + ctx.setStatus(`Scheinwerfer platziert (${angle}°, ${color})`); + }, + }, +}; + +/** Absperrung: Kette aus Kreis-Paaren entlang Standardlänge 200. */ +export const barrierTool: ToolExtensionV2 = { + manifest: { + id: 'barrier', + label: 'Absperrung', + icon: '⛴', + ribbonTab: 'insert', + tags: ['basic'], + description: 'Absperr-Kette (Kreis-Paare) platzieren', + }, + optionsSchema: [ + { key: 'length', label: 'Länge', type: 'number' }, + ], + handlers: { + down(e, ctx) { + const c = ctx as FullToolContext; + if (!c.doc) return; + const { layerId } = optsOf(ctx); + const length = (ctx.options.length as number | undefined) ?? 200; + const circles: Array<{ x: number; y: number; r: number }> = []; + const pairs = 6; + for (let i = 0; i < pairs; i++) { + const t = (i + 0.5) / pairs; + const cx = e.world.x - length / 2 + length * t; + circles.push({ x: cx, y: e.world.y - 10, r: 8 }); + circles.push({ x: cx, y: e.world.y + 10, r: 8 }); + } + const el: CADElement = { + id: `barrier-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + type: 'barrier', + layerId, + x: e.world.x, + y: e.world.y, + width: length, + height: 20, + properties: { circles, color: '#c0392b' }, + }; + c.doc.transact(() => { c.doc!.addElement(el); }); + ctx.setStatus(`Absperrung platziert (${length} lang, ${pairs} Kettenglieder)`); + }, + }, +}; + export const eventToolsV2Plugin: PluginV2 = { manifest: { id: 'event-tools', name: 'Event-Bestuhlung', - version: '2.1.0', + version: '2.2.0', author: 'web-cad team', - description: 'Stühle, Reihen (Drag), Blöcke (Drag), Tische, Bühne (V2).', + description: 'Stühle, Reihen (Drag), Blöcke (Drag), Tische, Bühne, Vorhang, Scheinwerfer, Absperrung (V2).', category: 'elements', enabledByDefault: true, }, - tools: [chairTool, seatingRowToolDrag, seatingBlockToolDrag, tableTool, stageTool, arcRowTool, kinoTemplateTool, banquetTemplateTool, standingTableTemplateTool, podiumTemplateTool], + tools: [chairTool, seatingRowToolDrag, seatingBlockToolDrag, tableTool, stageTool, arcRowTool, kinoTemplateTool, banquetTemplateTool, standingTableTemplateTool, podiumTemplateTool, curtainTool, spotlightTool, barrierTool], }; diff --git a/frontend/src/render/pixi/elementDrawers.ts b/frontend/src/render/pixi/elementDrawers.ts index 04b0682..7e42cfd 100644 --- a/frontend/src/render/pixi/elementDrawers.ts +++ b/frontend/src/render/pixi/elementDrawers.ts @@ -236,6 +236,66 @@ export function elementToPrimitives(el: CADElement): ShapePrimitive[] { }]; } + case 'curtain': { + // Vorhang (Task E7): wellige Polyline entlang der Breite. + const pts = Array.isArray(props.points) + ? (props.points as unknown[]).map(asPoint) + : []; + if (pts.length >= 2) { + return [{ kind: 'polyline', points: pts, close: false, stroke, strokeWidth: sw }]; + } + return [{ + kind: 'line', + from: { x: el.x - el.width / 2, y: el.y }, + to: { x: el.x + el.width / 2, y: el.y }, + stroke, + strokeWidth: sw, + }]; + } + + case 'spotlight': { + // Scheinwerfer (Task E7): Kegel-Polygon in properties.color. + const pts = Array.isArray(props.points) + ? (props.points as unknown[]).map(asPoint) + : []; + if (pts.length >= 3) { + const col = (props.color as string | undefined) ?? '#ffcc00'; + return [{ kind: 'polyline', points: pts, close: true, stroke: col, strokeWidth: sw }]; + } + return []; + } + + case 'barrier': { + // Absperrung (Task E7): Kette aus Kreis-Paaren (properties.circles). + const circles = Array.isArray(props.circles) + ? (props.circles as Array<{ x: number; y: number; r: number }>) + : []; + const col = (props.color as string | undefined) ?? '#c0392b'; + if (circles.length > 0) { + return circles.map((c) => ({ + kind: 'circle' as const, + center: { x: c.x, y: c.y }, + radius: c.r, + fill: null, + stroke: col, + strokeWidth: 1, + })); + } + const out: ShapePrimitive[] = []; + const N = 5; + for (let i = 0; i < N; i++) { + out.push({ + kind: 'circle', + center: { x: el.x - el.width / 2 + (el.width * (i + 0.5)) / N, y: el.y }, + radius: Math.min(6, el.width / (N * 2.5)), + fill: null, + stroke: col, + strokeWidth: 1, + }); + } + return out; + } + default: return []; } diff --git a/frontend/src/services/seatingService.ts b/frontend/src/services/seatingService.ts index a9b8824..ac7f80f 100644 --- a/frontend/src/services/seatingService.ts +++ b/frontend/src/services/seatingService.ts @@ -359,6 +359,7 @@ export class SeatingService { stroke: '#1a2e3f', strokeWidth: 2, label: cfg.label, + height: cfg.height, // Bühnenhöhe in cm (Task E7: stage height attr) }, }; } diff --git a/frontend/src/types/cad.types.ts b/frontend/src/types/cad.types.ts index 5f024c3..ebe8706 100644 --- a/frontend/src/types/cad.types.ts +++ b/frontend/src/types/cad.types.ts @@ -6,7 +6,8 @@ export type ElementType = | 'line' | 'circle' | 'arc' | 'rect' | 'polygon' | 'polyline' | 'text' | 'dimension' | 'block_instance' | 'chair' | 'seating-row' | 'seating-block' | 'table' | 'stage' - | 'leader' | 'revcloud' | 'image'; + | 'leader' | 'revcloud' | 'image' + | 'curtain' | 'spotlight' | 'barrier'; export type LineType = 'solid' | 'dashed' | 'dotted'; diff --git a/frontend/tests/eventPolish.test.ts b/frontend/tests/eventPolish.test.ts new file mode 100644 index 0000000..85f00d7 --- /dev/null +++ b/frontend/tests/eventPolish.test.ts @@ -0,0 +1,106 @@ +/** + * Task E7 – Event-Elemente-Polish: curtain, spotlight, barrier, + * stage-height-Attribut. + * + * Prüft die Tool-Platzierung (Element-Form + Options), die + * Pixi-Drawer-Primitives und die height-Durchreichung für stage. + */ +import { describe, it, expect } from 'vitest'; +import { + curtainTool, spotlightTool, barrierTool, +} from '../src/plugins/builtin/event-tools'; +import { createInMemoryDoc } from './helpers/inMemoryDoc'; +import { CADDocument } from '../src/kernel/document/CADDocument'; +import { elementToPrimitives } from '../src/render/pixi/elementDrawers'; +import { SeatingService } from '../src/services/seatingService'; +import type { CADElement, Pt } from '../src/types/cad.types'; + +function fireDown(tool: any, world: Pt, options: Record = {}) { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const ctx = { + doc, + options, + setStatus: () => {}, + setPreview: () => {}, + } as any; + tool.handlers.down({ world } as any, ctx); + return { added: doc.getAllElements(), doc }; +} + +describe('E7: curtain', () => { + it('curtainTool platziert curtain-Element mit Wellen-Polyline und stageHeight', () => { + const { added } = fireDown(curtainTool, { x: 100, y: 50 }, { stageHeight: 120 }); + expect(added.length).toBe(1); + const el = added[0]; + expect(el.type).toBe('curtain'); + expect(el.width).toBe(400); // Standard-Breite + expect(el.properties.stageHeight).toBe(120); + const pts = el.properties.points as Pt[]; + expect(Array.isArray(pts)).toBe(true); + expect(pts.length).toBeGreaterThan(20); // Wellenpunkte + }); + + it('Drawer: curtain → polyline-Primitive (geschlossen=false)', () => { + const { added } = fireDown(curtainTool, { x: 100, y: 50 }, { stageHeight: 120 }); + const prims = elementToPrimitives(added[0]); + expect(prims.length).toBe(1); + expect(prims[0].kind).toBe('polyline'); + expect((prims[0] as any).close).toBe(false); + }); +}); + +describe('E7: spotlight', () => { + it('spotlightTool platziert Lichtkegel-Polygon mit angle und color', () => { + const { added } = fireDown(spotlightTool, { x: 0, y: 0 }, { angle: 45, color: '#ffcc00' }); + expect(added.length).toBe(1); + const el = added[0]; + expect(el.type).toBe('spotlight'); + expect(el.properties.angle).toBe(45); + expect(el.properties.color).toBe('#ffcc00'); + const pts = el.properties.points as Pt[]; + expect(pts.length).toBe(4); // Kegel: Ursprung + zwei Basis-Ecken + zurück + }); + + it('Drawer: spotlight → Polygon mit Füllung aus color', () => { + const { added } = fireDown(spotlightTool, { x: 0, y: 0 }, { angle: 45, color: '#ffcc00' }); + const prims = elementToPrimitives(added[0]); + expect(prims.length).toBe(1); + expect(prims[0].kind).toBe('polyline'); + expect((prims[0] as any).close).toBe(true); + expect((prims[0] as any).stroke.toLowerCase()).toContain('#ffcc00'); + }); + + it('Kegel-Ausrichtung: angle=0 zeigt nach rechts (Richtung +x)', () => { + const { added } = fireDown(spotlightTool, { x: 0, y: 0 }, { angle: 0, color: '#fff' }); + const pts = added[0].properties.points as Pt[]; + expect(pts[1].x).toBeGreaterThan(100); // Basis-Ecke weiter rechts als Ursprung + expect(pts[2].x).toBeGreaterThan(100); + }); +}); + +describe('E7: barrier', () => { + it('barrierTool platziert Kette aus Kreis-Paaren entlang Standardlänge', () => { + const { added } = fireDown(barrierTool, { x: 0, y: 0 }, {}); + expect(added.length).toBe(1); + const el = added[0]; + expect(el.type).toBe('barrier'); + expect(el.width).toBe(200); // Standard-Länge + }); + + it('Drawer: barrier → mehrere circle-Primitives (Kettenglieder)', () => { + const { added } = fireDown(barrierTool, { x: 0, y: 0 }, {}); + const prims = elementToPrimitives(added[0]); + expect(prims.length).toBeGreaterThan(4); + expect(prims.every((p) => p.kind === 'circle')).toBe(true); + }); +}); + +describe('E7: stage height', () => { + it('createStage nimmt height-Option auf (properties.height)', () => { + const stage = new SeatingService().createStage(100, 100, 'layer-0', { width: 400, height: 60 }); + expect(stage.type).toBe('stage'); + expect(stage.properties.height).toBe(60); + expect(stage.width).toBe(400); + }); +});