diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c06db20..a43e119 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,10 +26,11 @@ import { getCommandRegistry } from './services/commandRegistry'; import { importFile, type ImportResult } from './services/importService'; import { exportProject, downloadBlob, type ExportFormat } from './services/exportService'; import type { ProjectData } from './types/cad.types'; -import { loadProjectDataTyped, createElementTyped, updateElement, deleteElement as apiDeleteElement, createLayerTyped, updateLayer, deleteLayer as apiDeleteLayer, createBlockTyped, updateBlock, deleteBlock as apiDeleteBlock, aiChat } from './services/api'; +import { loadProjectDataTyped, createElementTyped, updateElement, deleteElement as apiDeleteElement, createLayerTyped, updateLayer, deleteLayer as apiDeleteLayer, createBlockTyped, updateBlock, deleteBlock as apiDeleteBlock, aiChat, getSetting, setSetting } from './services/api'; import { useYjsBinding } from './crdt'; import { registerBuiltinPlugins, pluginRegistry } from './plugins'; import type { PluginContext } from './plugins'; +import type { UnitType } from './utils/format'; import './styles.css'; import './styles/auth.css'; import { useAuth } from './contexts/AuthContext'; @@ -99,6 +100,11 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack const [snapEnabled, setSnapEnabled] = useState(true); const [polarEnabled, setPolarEnabled] = useState(false); + // Units + const [unit, setUnit] = useState('mm'); + const [gridSize, setGridSize] = useState(20); + const [scaleFactor, setScaleFactor] = useState(1); + // Right sidebar const [activeRightPanel, setActiveRightPanel] = useState('tool'); const [selectedElement, setSelectedElement] = useState(null); @@ -328,6 +334,53 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId, token]); + // Load unit settings from backend on mount + React.useEffect(() => { + if (!token) return; + let cancelled = false; + (async () => { + try { + const [unitSetting, gridSetting, scaleSetting] = await Promise.all([ + getSetting(token, 'cad.unit').catch(() => null), + getSetting(token, 'cad.gridSize').catch(() => null), + getSetting(token, 'cad.scaleFactor').catch(() => null), + ]); + if (cancelled) return; + if (unitSetting && (unitSetting.value === 'mm' || unitSetting.value === 'cm' || unitSetting.value === 'm')) { + setUnit(unitSetting.value as UnitType); + } + if (gridSetting) { + const gs = parseFloat(gridSetting.value); + if (!isNaN(gs) && gs > 0) setGridSize(gs); + } + if (scaleSetting) { + const sf = parseFloat(scaleSetting.value); + if (!isNaN(sf) && sf > 0) setScaleFactor(sf); + } + } catch { + // Settings not yet stored — use defaults + } + })(); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + // Save unit settings to backend when they change + const handleUnitChange = useCallback((newUnit: UnitType) => { + setUnit(newUnit); + if (token) setSetting(token, 'cad.unit', newUnit).catch(() => {}); + }, [token]); + + const handleGridSizeChange = useCallback((newSize: number) => { + setGridSize(newSize); + if (token) setSetting(token, 'cad.gridSize', String(newSize)).catch(() => {}); + }, [token]); + + const handleScaleFactorChange = useCallback((newFactor: number) => { + setScaleFactor(newFactor); + if (token) setSetting(token, 'cad.scaleFactor', String(newFactor)).catch(() => {}); + }, [token]); + // Sync remote → local: when Yjs data changes from other users, update local state React.useEffect(() => { if (collab.status !== 'connected') return; @@ -1471,6 +1524,8 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack onOpenSettings={() => setSettingsOpen(true)} onOpenLeftDrawer={() => setMobileLeftOpen(true)} onOpenRightDrawer={() => setMobileRightOpen(true)} + unit={unit} + onUnitChange={handleUnitChange} /> = ({ projectId, token, onNavigateBack bgConfig={bgConfig} remoteCursors={collab.cursors} groups={groups} + unit={unit} + gridSize={gridSize} + scaleFactor={scaleFactor} /> = ({ projectId, token, onNavigateBack groups={groups} onSelectElement={handleSelectElement} token={token} + unit={unit} + scaleFactor={scaleFactor} /> = ({ projectId, token, onNavigateBack onTogglePolar={handleTogglePolar} onToggleGrid={handleToggleGrid} seatCount={seatCount} + unit={unit} + scaleFactor={scaleFactor} + onUnitChange={handleUnitChange} /> = ({ projectId, token, onNavigateBack onCloseRight={() => setMobileRightOpen(false)} onRightTabChange={setActiveDrawerTab} /> - setSettingsOpen(false)} /> + setSettingsOpen(false)} + unit={unit} + gridSize={gridSize} + scaleFactor={scaleFactor} + onUnitChange={handleUnitChange} + onGridSizeChange={handleGridSizeChange} + onScaleFactorChange={handleScaleFactorChange} + /> setBgImportOpen(false)} diff --git a/frontend/src/canvas/RenderEngine.ts b/frontend/src/canvas/RenderEngine.ts index d8c0164..3c4a2b7 100644 --- a/frontend/src/canvas/RenderEngine.ts +++ b/frontend/src/canvas/RenderEngine.ts @@ -18,6 +18,9 @@ export interface RenderOptions { backgroundOffsetY: number; backgroundRotation: number; backgroundOpacity: number; + unit?: 'mm' | 'cm' | 'm'; + scaleFactor?: number; + showGridLabels?: boolean; } export interface SelectionState { @@ -216,6 +219,56 @@ export class RenderEngine { this.ctx.lineTo(w, y); } this.ctx.stroke(); + + // Draw axis labels with unit at each major grid line + if (this.options.showGridLabels !== false) { + const unit = this.options.unit || 'mm'; + const scaleFactor = this.options.scaleFactor || 1; + const transform = this.zoomPan.getTransform(); + const scale = this.zoomPan.getScale(); + const worldGridSize = this.options.gridSize * 5; // world units per major line + + this.ctx.fillStyle = '#666680'; + this.ctx.font = '10px sans-serif'; + this.ctx.textBaseline = 'top'; + + // X-axis labels (top edge) + const xStartWorld = Math.floor((-transform.e / scale) / worldGridSize) * worldGridSize; + for (let i = 0; i * majorSize + majOffX < w; i++) { + const screenX = majOffX + i * majorSize; + const worldX = xStartWorld + i * worldGridSize; + if (screenX < 30) continue; // skip too-close-to-edge labels + const label = this.formatGridLabel(worldX, unit, scaleFactor); + this.ctx.fillText(label, screenX + 2, 2); + } + + // Y-axis labels (left edge) + this.ctx.textAlign = 'left'; + this.ctx.textBaseline = 'middle'; + const yStartWorld = Math.floor((-transform.f / scale) / worldGridSize) * worldGridSize; + for (let i = 0; i * majorSize + majOffY < h; i++) { + const screenY = majOffY + i * majorSize; + const worldY = yStartWorld + i * worldGridSize; + if (screenY < 15) continue; + const label = this.formatGridLabel(worldY, unit, scaleFactor); + this.ctx.fillText(label, 2, screenY - 6); + } + + this.ctx.textAlign = 'start'; + this.ctx.textBaseline = 'alphabetic'; + } + } + } + + private formatGridLabel(worldVal: number, unit: 'mm' | 'cm' | 'm', scaleFactor: number): string { + const mm = worldVal * scaleFactor; + switch (unit) { + case 'mm': + return `${mm.toFixed(0)}mm`; + case 'cm': + return `${(mm / 10).toFixed(0)}cm`; + case 'm': + return `${(mm / 1000).toFixed(2)}m`; } } diff --git a/frontend/src/components/CanvasArea.tsx b/frontend/src/components/CanvasArea.tsx index 19e1f7e..ae0c252 100644 --- a/frontend/src/components/CanvasArea.tsx +++ b/frontend/src/components/CanvasArea.tsx @@ -11,6 +11,7 @@ import { SelectionEngine } from '../canvas/SelectionEngine'; import { SpatialIndex } from '../canvas/SpatialIndex'; import { LayerManager } from '../canvas/LayerManager'; import { GroupManager } from '../tools/modification/GroupTool'; +import { formatCoordinate } from '../utils/format'; const CanvasArea: React.FC = ({ cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled, @@ -18,6 +19,7 @@ const CanvasArea: React.FC = ({ activeTool, elements, layers, activeLayerId, onElementCreated, onElementsDeleted, onElementsModified, onCursorMoved, onToolStateChanged, onToggleGrid, onToggleOrtho, onToggleSnap, onZoomIn, onZoomOut, onZoomFit, onTextEdit, onCommandTrigger, blocks, onBlockDrop, onSelectionChange, externalSelectionIds, selectedTemplate, bgConfig, remoteCursors, zoomCommand, groups, + unit = 'mm', gridSize = 20, scaleFactor = 1, }) => { const canvasRef = useRef(null); const zoomPanRef = useRef(null); @@ -210,12 +212,15 @@ const CanvasArea: React.FC = ({ renderEngine.setOptions({ showGrid: gridEnabled }); renderEngine.setOptions({ showSnapPoints: snapEnabled }); renderEngine.setOptions({ showOrtho: orthoEnabled }); + renderEngine.setOptions({ gridSize, unit, scaleFactor, showGridLabels: true }); interaction.setSnapEnabled(snapEnabled); interaction.setOrthoEnabled(orthoEnabled); interaction.setPolarEnabled(polarEnabled); + interaction.setUnits(unit, scaleFactor); + interaction.setGridSize(gridSize); renderEngine.render(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [gridEnabled, snapEnabled, orthoEnabled, polarEnabled]); + }, [gridEnabled, snapEnabled, orthoEnabled, polarEnabled, unit, gridSize, scaleFactor]); // Sync active tool useEffect(() => { @@ -326,9 +331,9 @@ const CanvasArea: React.FC = ({ return (
- X{cursorPos.x.toFixed(3)} - Y{cursorPos.y.toFixed(3)} - m + X{formatCoordinate(cursorPos.x, unit, scaleFactor)} + Y{formatCoordinate(cursorPos.y, unit, scaleFactor)} + {unit}
`${v.toFixed(3)} m`; -const formatDim = (v: number) => `${v.toFixed(2)} m`; const parseNum = (s: string) => parseFloat(s.replace(/[^0-9.\-]/g, '')) || 0; -const PropertiesPanel: React.FC = ({ selectedElement, layers, onUpdateProperty, onDelete }) => { +const PropertiesPanel: React.FC = ({ + selectedElement, + layers, + onUpdateProperty, + onDelete, + unit = 'mm', + scaleFactor = 1, +}) => { const el = selectedElement; - const x = el ? formatPos(el.x) : '0.000 m'; - const y = el ? formatPos(el.y) : '0.000 m'; - const w = el ? formatDim(el.width) : '0.50 m'; - const h = el ? formatDim(el.height) : '0.50 m'; - const rot = el ? String(el.properties.rotation ?? 0) : '0'; + + // Local input state — allows user to type freely, synced when element or unit changes + const [xInput, setXInput] = useState(''); + const [yInput, setYInput] = useState(''); + const [wInput, setWInput] = useState(''); + const [hInput, setHInput] = useState(''); + const [rotInput, setRotInput] = useState(''); + const [radiusInput, setRadiusInput] = useState(''); + + // Sync displayed values when element changes or unit changes + useEffect(() => { + if (el) { + setXInput(formatInputValue(el.x, unit, scaleFactor)); + setYInput(formatInputValue(el.y, unit, scaleFactor)); + setWInput(formatInputValue(el.width, unit, scaleFactor)); + setHInput(formatInputValue(el.height, unit, scaleFactor)); + setRotInput(String(el.properties.rotation ?? 0)); + const radius = el.properties.radius as number | undefined; + setRadiusInput(radius !== undefined ? formatInputValue(radius, unit, scaleFactor) : ''); + } + }, [el, unit, scaleFactor]); + const color = (el?.properties.stroke as string) || '#3b82f6'; - const blockName = el?.properties.blockId || 'Stuhl-Standard'; - const desc = el?.properties.text as string || 'Standard-Konferenzstuhl 0.5×0.5m'; + const blockName = el?.properties.blockId || ''; + const desc = (el?.properties.text as string) || ''; + + const handleXChange = (val: string) => { + setXInput(val); + const world = parseDistance(val, unit, scaleFactor); + onUpdateProperty('x', world); + }; + const handleYChange = (val: string) => { + setYInput(val); + const world = parseDistance(val, unit, scaleFactor); + onUpdateProperty('y', world); + }; + const handleWChange = (val: string) => { + setWInput(val); + const world = parseDistance(val, unit, scaleFactor); + onUpdateProperty('width', world); + }; + const handleHChange = (val: string) => { + setHInput(val); + const world = parseDistance(val, unit, scaleFactor); + onUpdateProperty('height', world); + }; + const handleRadiusChange = (val: string) => { + setRadiusInput(val); + const world = parseDistance(val, unit, scaleFactor); + onUpdateProperty('radius', world); + }; return ( <>
- Auswahl · 1 Stuhl + Auswahl {el ? `· ${el.type}` : ''}
{onDelete && el && (
Linientyp
Stärke - onUpdateProperty('strokeWidth', parseNum(e.target.value))}> + + + +
@@ -97,28 +152,25 @@ const PropertiesPanel: React.FC = ({ selectedElement, laye {layers.length > 0 ? layers.map((l) => ( )) : ( - <> - - - - - + )}
-
-
Block
-
- Block-Name - onUpdateProperty('blockId', e.target.value)} /> + {el?.type === 'block_instance' && ( +
+
Block
+
+ Block-Name + onUpdateProperty('blockId', e.target.value)} /> +
+
+ Beschreibung + onUpdateProperty('text', e.target.value)} /> +
-
- Beschreibung - onUpdateProperty('text', e.target.value)} /> -
-
+ )} ); }; diff --git a/frontend/src/components/RightSidebar.tsx b/frontend/src/components/RightSidebar.tsx index 918ec67..75c2f71 100644 --- a/frontend/src/components/RightSidebar.tsx +++ b/frontend/src/components/RightSidebar.tsx @@ -31,6 +31,8 @@ const RightSidebar: React.FC = ({ collapsed, onToggleCollapse, token, + unit = 'mm', + scaleFactor = 1, }) => { return (