diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8c78ecf..d914fa2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1732,6 +1732,7 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack = ({ cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled, @@ -169,6 +170,9 @@ const CanvasArea: React.FC = ({ }, setPreview: (el) => renderEngine.setPreviewElement(el as CADElement | null), getDoc: () => v2Doc, + // CAD-14 Phase 4a: Werkzeug-Optionen aus dem zentralen Store + // (Laenge/Breite/chordCount/Winkel... erreichen die V2-Werkzeuge). + getOptions: (toolId: string) => getToolOptions(toolId), getSelectionBridge: () => ({ getIds: () => Array.from(selectionEngine.getSelectedIds()), setIds: (ids) => selectionEngine.selectByIds(ids, false), @@ -223,6 +227,14 @@ const CanvasArea: React.FC = ({ pixi!.setSelection({ selectedIds: ids, elements: selected }); }; } + // CAD-14 Phase 4a: Defaults aus den optionsSchemas aller V2-Werkzeuge + // in den toolOptionsStore fuellen (Quelle fuer OptionsPanel + Dispatcher). + for (const tool of pluginRegistry.getToolsV2()) { + if (tool.optionsSchema?.length) { + setToolOptionsDefaults(tool.manifest.id, defaultsFromSchema(tool.optionsSchema as never)); + } + } + logger.info('InteractionDispatcher v2 aktiviert (VITE_TOOL_DISPATCHER=v2)'); } diff --git a/frontend/src/components/RightSidebar.tsx b/frontend/src/components/RightSidebar.tsx index 3d71060..9245976 100644 --- a/frontend/src/components/RightSidebar.tsx +++ b/frontend/src/components/RightSidebar.tsx @@ -2,6 +2,7 @@ import React, { useCallback } from 'react'; import type { RightSidebarProps, RightPanel } from '../types/ui.types'; import type { LibraryProviderExtension } from '../plugins/types'; import PropertiesPanel from './PropertiesPanel'; +import ToolOptionsPanel from './ToolOptionsPanel'; import LayerPanel from './LayerPanel'; import BlockLibrary from './BlockLibrary'; import BlockLibraryTree from './BlockLibraryTree'; @@ -23,6 +24,7 @@ const tabs: Array<{ id: RightPanel; label: string; svg: React.ReactNode; badge?: const RightSidebar: React.FC = ({ activePanel, onPanelChange, selectedElement, layers, blocks, + activeToolId, className, activeLayerId, onSelectLayer, onAddLayer, onToggleLayer, onDeleteLayer, onRenameLayer, onDuplicateLayer, onToggleLock, @@ -116,6 +118,7 @@ const RightSidebar: React.FC = ({
+ {activePanel === 'tool' && selectedElement && onUpdateElement && ( onElementsDeleted([selectedElement.id]) : undefined} onUpdateProperty={(key, value) => { if (!selectedElement || !onUpdateElement) return; diff --git a/frontend/src/components/ToolOptionsPanel.tsx b/frontend/src/components/ToolOptionsPanel.tsx new file mode 100644 index 0000000..01dabd3 --- /dev/null +++ b/frontend/src/components/ToolOptionsPanel.tsx @@ -0,0 +1,86 @@ +/** + * CAD-14 Phase 4a - Werkzeug-Optionen-Panel. + * + * Rendert das optionsSchema des aktiven V2-Werkzeugs und bindet + * die Werte an den toolOptionsService (Quelle fuer den Dispatcher). + */ +import React, { useState, useEffect, useMemo } from 'react'; +import { pluginRegistry } from '../plugins'; +import { getToolOption, setToolOption, subscribeToolOptions } from '../services/toolOptionsService'; + +interface ToolOptionsPanelProps { + activeToolId: string | null | undefined; +} + +const ToolOptionsPanel: React.FC = ({ activeToolId }) => { + const [, forceRender] = useState(0); + + // Store-Abonnement: Panel bei Optionsaenderung neu rendern + React.useEffect(() => { + return subscribeToolOptions(() => forceRender((n) => n + 1)); + }, []); + + const tool = useMemo(() => { + if (!activeToolId) return null; + return pluginRegistry.getToolV2(activeToolId) ?? null; + }, [activeToolId]); + + const schema = tool?.optionsSchema ?? []; + if (!tool || schema.length === 0) return null; + + const renderField = (field: { key: string; label: string; type: string; min?: number; max?: number; options?: Array<{ value: string; label: string }> }) => { + const value = getToolOption(tool.manifest.id, field.key); + const onChange = (v: unknown) => setToolOption(tool.manifest.id, field.key, v); + if (field.type === 'number') { + return ( + + ); + } + if (field.type === 'color') { + return ( + + ); + } + if (field.type === 'select' && field.options) { + return ( + + ); + } + if (field.type === 'boolean') { + return ( + + ); + } + return null; + }; + + return ( +
+
Werkzeug-Optionen: {tool.manifest.label}
+ {schema.map((f) => renderField(f as never))} +
+ ); +}; + +export default ToolOptionsPanel; diff --git a/frontend/src/services/toolOptionsService.ts b/frontend/src/services/toolOptionsService.ts new file mode 100644 index 0000000..724937d --- /dev/null +++ b/frontend/src/services/toolOptionsService.ts @@ -0,0 +1,72 @@ +/** + * CAD-14 Phase 4a - toolOptionsService. + * + * Zentraler Store fuer Werkzeug-Optionen (Laenge, Breite, Gurtrohre, + * Winkel, Durchmesser...). Der InteractionDispatcher liest darueber + * getOptions; die UI (OptionsPanel) schreibt hier hinein. Defaults + * stammen aus dem optionsSchema der V2-Werkzeuge. + */ + +type Listener = () => void; + +/** Per-Tool gesetzte Werte (override Defaults). */ +const values = new Map>(); +/** Per-Tool Defaults (aus optionsSchema gefuellt von der Registry/UI). */ +const defaults = new Map>(); +/** Abonnenten (UI-Update bei Aenderung). */ +const listeners = new Set(); + +function notify(): void { + for (const l of Array.from(listeners)) l(); +} + +/** + * Leitet sinnvolle Defaults aus einem optionsSchema ab: + * number -> default ?? min (geclamped), color -> '#9aa0a6', sonst undefined. + */ +export function defaultsFromSchema(schema: Array<{ key: string; type: string; default?: unknown; min?: number }>): Record { + const out: Record = {}; + for (const f of schema) { + if (f.default !== undefined) { out[f.key] = f.default; continue; } + if (f.type === 'number') { + out[f.key] = typeof f.min === 'number' ? Math.max(f.min, f.min === 0 ? 0 : Math.round(f.min)) : 0; + } else if (f.type === 'color') { + out[f.key] = '#9aa0a6'; + } + } + return out; +} + +/** Registriert Defaults fuer ein Werkzeug (aus optionsSchema). */ +export function setToolOptionsDefaults(toolId: string, defs: Record): void { + defaults.set(toolId, { ...(defaults.get(toolId) ?? {}), ...defs }); +} + +export function getToolOption(toolId: string, key: string, fallback?: T): T { + const v = values.get(toolId)?.[key]; + if (v !== undefined) return v as T; + const d = defaults.get(toolId)?.[key]; + if (d !== undefined) return d as T; + return fallback as T; +} + +export function setToolOption(toolId: string, key: string, value: unknown): void { + const cur = values.get(toolId) ?? {}; + cur[key] = value; + values.set(toolId, cur); + notify(); +} + +export function getToolOptions(toolId: string): Record { + return { ...(defaults.get(toolId) ?? {}), ...(values.get(toolId) ?? {}) }; +} + +export function subscribeToolOptions(fn: Listener): () => void { + listeners.add(fn); + return () => { listeners.delete(fn); }; +} + +export function resetToolOptions(): void { + values.clear(); + defaults.clear(); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index e959c55..6e30deb 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -3125,3 +3125,15 @@ body.drawer-open { overflow: hidden; } .ribbon-v2-btn.active .ribbon-v2-label { color: #4a90d9; } + +/* CAD-14 Phase 4a: Werkzeug-Optionen */ +.tool-options-panel { padding: 8px 4px; } +.toolopt-row { + display: flex; align-items: center; justify-content: space-between; + gap: 8px; padding: 4px 0; font-size: 12px; color: var(--color-text, #d0d0d0); +} +.toolopt-row input[type=number], .toolopt-row select { + width: 90px; padding: 3px 6px; background: rgba(255,255,255,0.06); + color: #d0d0d0; border: 1px solid rgba(255,255,255,0.15); border-radius: 4px; +} +.toolopt-row input[type=color] { width: 36px; height: 24px; border: none; background: transparent; cursor: pointer; } diff --git a/frontend/src/types/ui.types.ts b/frontend/src/types/ui.types.ts index 80daf50..bedcca3 100644 --- a/frontend/src/types/ui.types.ts +++ b/frontend/src/types/ui.types.ts @@ -172,6 +172,8 @@ export interface RightSidebarProps { layers: CADLayer[]; elements?: CADElement[]; blocks: BlockDefinition[]; + /** CAD-14 Phase 4a: Aktives V2-Werkzeug (fuer Werkzeug-Optionen-Panel). */ + activeToolId?: string | null; activeLayerId?: string; onSelectLayer?: (id: string) => void; onAddLayer?: () => void; diff --git a/frontend/tests/toolOptionsService.test.ts b/frontend/tests/toolOptionsService.test.ts new file mode 100644 index 0000000..ae4a376 --- /dev/null +++ b/frontend/tests/toolOptionsService.test.ts @@ -0,0 +1,66 @@ +/** + * CAD-14 Phase 4a: toolOptionsService — zentraler Store fuer + * Werkzeug-Optionen (Laenge, Breite, chordCount, Winkel...). + * Quelle der Defaults: optionsSchema der V2-Werkzeuge. + * Der InteractionDispatcher liest darueber getOptions. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + getToolOption, + setToolOption, + getToolOptions, + resetToolOptions, + subscribeToolOptions, + defaultsFromSchema, + setToolOptionsDefaults, +} from '../src/services/toolOptionsService'; + +describe('CAD-14 Phase 4a: toolOptionsService', () => { + beforeEach(() => { + resetToolOptions(); + }); + + it('defaultsFromSchema: leitet Defaults aus optionsSchema ab', () => { + const defaults = defaultsFromSchema([ + { key: 'length', label: 'Länge', type: 'number', min: 10, max: 1000 }, + { key: 'chordCount', label: 'Gurtrohre', type: 'number', min: 1, max: 4 }, + ] as never); + // number ohne expliziten default: min clamped auf sinnvollen Start + expect(defaults.length).toBeDefined(); + expect(defaults.chordCount).toBeDefined(); + }); + + it('set/get: Wert wird gespeichert und gelesen', () => { + setToolOption('truss', 'length', 175); + expect(getToolOption('truss', 'length')).toBe(175); + }); + + it('getToolOption ohne Set: default aus Schema (defaultsFromSchema)', () => { + setToolOptionsDefaults('truss', { length: 200, chordCount: 2, trussWidth: 29 }); + expect(getToolOption('truss', 'length')).toBe(200); + expect(getToolOption('truss', 'chordCount')).toBe(2); + }); + + it('getToolOptions liefert Gesamtsatz (defaults + gesetzte Werte)', () => { + setToolOptionsDefaults('truss', { length: 200, trussWidth: 29 }); + setToolOption('truss', 'length', 300); + const all = getToolOptions('truss'); + expect(all.length).toBe(300); + expect(all.trussWidth).toBe(29); + }); + + it('subscribe: Benachrichtigung bei Änderung (UI-Update)', () => { + let notified = 0; + const unsub = subscribeToolOptions(() => { notified++; }); + setToolOption('truss', 'length', 250); + unsub(); + setToolOption('truss', 'length', 260); + expect(notified).toBe(1); + }); + + it('resetToolOptions leert alles', () => { + setToolOptionsDefaults('truss', { length: 200 }); + resetToolOptions(); + expect(getToolOption('truss', 'length', 99)).toBe(99); + }); +});