feat: units system — mm/cm/m switchable, configurable grid, real measurements, formatted display
This commit is contained in:
+74
-2
@@ -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<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
const [snapEnabled, setSnapEnabled] = useState(true);
|
||||
const [polarEnabled, setPolarEnabled] = useState(false);
|
||||
|
||||
// Units
|
||||
const [unit, setUnit] = useState<UnitType>('mm');
|
||||
const [gridSize, setGridSize] = useState<number>(20);
|
||||
const [scaleFactor, setScaleFactor] = useState<number>(1);
|
||||
|
||||
// Right sidebar
|
||||
const [activeRightPanel, setActiveRightPanel] = useState<RightPanel>('tool');
|
||||
const [selectedElement, setSelectedElement] = useState<CADElement | null>(null);
|
||||
@@ -328,6 +334,53 @@ const CADEditor: React.FC<CADEditorProps> = ({ 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<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onOpenLeftDrawer={() => setMobileLeftOpen(true)}
|
||||
onOpenRightDrawer={() => setMobileRightOpen(true)}
|
||||
unit={unit}
|
||||
onUnitChange={handleUnitChange}
|
||||
/>
|
||||
<RibbonBar
|
||||
activeTab={activeRibbonTab}
|
||||
@@ -1532,6 +1587,9 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
bgConfig={bgConfig}
|
||||
remoteCursors={collab.cursors}
|
||||
groups={groups}
|
||||
unit={unit}
|
||||
gridSize={gridSize}
|
||||
scaleFactor={scaleFactor}
|
||||
/>
|
||||
<RightSidebar
|
||||
activePanel={activeRightPanel}
|
||||
@@ -1574,6 +1632,8 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
groups={groups}
|
||||
onSelectElement={handleSelectElement}
|
||||
token={token}
|
||||
unit={unit}
|
||||
scaleFactor={scaleFactor}
|
||||
/>
|
||||
</div>
|
||||
<CommandLine
|
||||
@@ -1595,6 +1655,9 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
onTogglePolar={handleTogglePolar}
|
||||
onToggleGrid={handleToggleGrid}
|
||||
seatCount={seatCount}
|
||||
unit={unit}
|
||||
scaleFactor={scaleFactor}
|
||||
onUnitChange={handleUnitChange}
|
||||
/>
|
||||
<MobileDrawers
|
||||
leftOpen={mobileLeftOpen}
|
||||
@@ -1604,7 +1667,16 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
onCloseRight={() => setMobileRightOpen(false)}
|
||||
onRightTabChange={setActiveDrawerTab}
|
||||
/>
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
<SettingsModal
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
unit={unit}
|
||||
gridSize={gridSize}
|
||||
scaleFactor={scaleFactor}
|
||||
onUnitChange={handleUnitChange}
|
||||
onGridSizeChange={handleGridSizeChange}
|
||||
onScaleFactorChange={handleScaleFactorChange}
|
||||
/>
|
||||
<BackgroundImport
|
||||
open={bgImportOpen}
|
||||
onClose={() => setBgImportOpen(false)}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<CanvasAreaProps> = ({
|
||||
cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled,
|
||||
@@ -18,6 +19,7 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
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<HTMLCanvasElement>(null);
|
||||
const zoomPanRef = useRef<ZoomPanController | null>(null);
|
||||
@@ -210,12 +212,15 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
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<CanvasAreaProps> = ({
|
||||
return (
|
||||
<section className="canvas-area" id="canvas-area" aria-label="CAD Zeichenfläche">
|
||||
<div className="canvas-coords" role="status" aria-live="polite">
|
||||
<span><span className="canvas-coords-label">X</span><span className="canvas-coords-val" id="coord-x">{cursorPos.x.toFixed(3)}</span></span>
|
||||
<span><span className="canvas-coords-label">Y</span><span className="canvas-coords-val" id="coord-y">{cursorPos.y.toFixed(3)}</span></span>
|
||||
<span style={{ color: 'var(--color-text-faint)' }}>m</span>
|
||||
<span><span className="canvas-coords-label">X</span><span className="canvas-coords-val" id="coord-x">{formatCoordinate(cursorPos.x, unit, scaleFactor)}</span></span>
|
||||
<span><span className="canvas-coords-label">Y</span><span className="canvas-coords-val" id="coord-y">{formatCoordinate(cursorPos.y, unit, scaleFactor)}</span></span>
|
||||
<span style={{ color: 'var(--color-text-faint)' }}>{unit}</span>
|
||||
</div>
|
||||
|
||||
<canvas
|
||||
|
||||
@@ -1,26 +1,75 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import type { PropertiesPanelProps } from '../types/ui.types';
|
||||
import { formatInputValue, parseDistance, type UnitType } from '../utils/format';
|
||||
|
||||
const formatPos = (v: number) => `${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<PropertiesPanelProps> = ({ selectedElement, layers, onUpdateProperty, onDelete }) => {
|
||||
const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||
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 (
|
||||
<>
|
||||
<div className="panel-section">
|
||||
<div className="panel-section-title">
|
||||
<span>Auswahl · 1 Stuhl</span>
|
||||
<span>Auswahl {el ? `· ${el.type}` : ''}</span>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
{onDelete && el && (
|
||||
<button
|
||||
@@ -42,24 +91,30 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({ selectedElement, laye
|
||||
<div className="panel-section">
|
||||
<div className="panel-section-title"><span>Geometrie</span></div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">Position X</span>
|
||||
<input className="prop-input" type="text" value={x} aria-label="Position X" onChange={(e) => onUpdateProperty('x', e.target.value)} />
|
||||
<span className="prop-label">Position X ({unit})</span>
|
||||
<input className="prop-input" type="text" value={xInput} aria-label="Position X" onChange={(e) => handleXChange(e.target.value)} />
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">Position Y</span>
|
||||
<input className="prop-input" type="text" value={y} aria-label="Position Y" onChange={(e) => onUpdateProperty('y', e.target.value)} />
|
||||
<span className="prop-label">Position Y ({unit})</span>
|
||||
<input className="prop-input" type="text" value={yInput} aria-label="Position Y" onChange={(e) => handleYChange(e.target.value)} />
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">Breite</span>
|
||||
<input className="prop-input" type="text" value={w} aria-label="Breite" onChange={(e) => onUpdateProperty('width', e.target.value)} />
|
||||
<span className="prop-label">Breite ({unit})</span>
|
||||
<input className="prop-input" type="text" value={wInput} aria-label="Breite" onChange={(e) => handleWChange(e.target.value)} />
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">Tiefe</span>
|
||||
<input className="prop-input" type="text" value={h} aria-label="Tiefe" onChange={(e) => onUpdateProperty('height', e.target.value)} />
|
||||
<span className="prop-label">Tiefe ({unit})</span>
|
||||
<input className="prop-input" type="text" value={hInput} aria-label="Tiefe" onChange={(e) => handleHChange(e.target.value)} />
|
||||
</div>
|
||||
{el?.type === 'circle' && (
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">Drehung</span>
|
||||
<input className="prop-input" type="text" value={rot} aria-label="Drehung" onChange={(e) => onUpdateProperty('rotation', e.target.value)} />
|
||||
<span className="prop-label">Radius ({unit})</span>
|
||||
<input className="prop-input" type="text" value={radiusInput} aria-label="Radius" onChange={(e) => handleRadiusChange(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">Drehung (°)</span>
|
||||
<input className="prop-input" type="text" value={rotInput} aria-label="Drehung" onChange={(e) => { setRotInput(e.target.value); onUpdateProperty('rotation', parseNum(e.target.value)); }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,26 +124,26 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({ selectedElement, laye
|
||||
<span className="prop-label">Farbe</span>
|
||||
<div className="prop-color-row">
|
||||
<div className="prop-color-swatch" style={{ background: color }} aria-hidden="true"></div>
|
||||
<input className="prop-swatch-input" type="color" value={color} aria-label="Stuhlfarbe" onChange={(e) => onUpdateProperty('stroke', e.target.value)} />
|
||||
<input className="prop-swatch-input" type="color" value={color} aria-label="Elementfarbe" onChange={(e) => onUpdateProperty('stroke', e.target.value)} />
|
||||
<input className="prop-input" type="text" value={color.toUpperCase()} aria-label="Farb-Hex" onChange={(e) => onUpdateProperty('stroke', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">Linientyp</span>
|
||||
<select className="prop-select" aria-label="Linientyp" value={el?.properties.lineType || 'solid'} onChange={(e) => onUpdateProperty('lineType', e.target.value)}>
|
||||
<option>Solid (durchgehend)</option>
|
||||
<option>Dashed (gestrichelt)</option>
|
||||
<option>Dotted (gepunktet)</option>
|
||||
<option>Dash-Dot</option>
|
||||
<option value="solid">Solid (durchgehend)</option>
|
||||
<option value="dashed">Dashed (gestrichelt)</option>
|
||||
<option value="dotted">Dotted (gepunktet)</option>
|
||||
<option value="dash-dot">Dash-Dot</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">Stärke</span>
|
||||
<select className="prop-select" aria-label="Linienstärke" value={String(el?.properties.strokeWidth ?? '0.18')} onChange={(e) => onUpdateProperty('strokeWidth', e.target.value)}>
|
||||
<option>0.18 mm · Normal</option>
|
||||
<option>0.25 mm · Dick</option>
|
||||
<option>0.35 mm · Extra</option>
|
||||
<option>0.50 mm · Max</option>
|
||||
<select className="prop-select" aria-label="Linienstärke" value={String(el?.properties.strokeWidth ?? '1')} onChange={(e) => onUpdateProperty('strokeWidth', parseNum(e.target.value))}>
|
||||
<option value="0.18">0.18 mm · Normal</option>
|
||||
<option value="0.25">0.25 mm · Dick</option>
|
||||
<option value="0.35">0.35 mm · Extra</option>
|
||||
<option value="0.50">0.50 mm · Max</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
@@ -97,17 +152,13 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({ selectedElement, laye
|
||||
{layers.length > 0 ? layers.map((l) => (
|
||||
<option key={l.id} value={l.id}>{l.name}</option>
|
||||
)) : (
|
||||
<>
|
||||
<option>Bestuhlung · Standard</option>
|
||||
<option>Bestuhlung · VIP</option>
|
||||
<option>Möbel · Tische</option>
|
||||
<option>Bühne</option>
|
||||
</>
|
||||
<option value="">Kein Layer</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{el?.type === 'block_instance' && (
|
||||
<div className="panel-section">
|
||||
<div className="panel-section-title"><span>Block</span></div>
|
||||
<div className="prop-row">
|
||||
@@ -119,6 +170,7 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({ selectedElement, laye
|
||||
<input className="prop-input" type="text" value={desc} aria-label="Beschreibung" onChange={(e) => onUpdateProperty('text', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,6 +31,8 @@ const RightSidebar: React.FC<RightSidebarProps> = ({
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
token,
|
||||
unit = 'mm',
|
||||
scaleFactor = 1,
|
||||
}) => {
|
||||
return (
|
||||
<aside className={`rightbar${className ? ' ' + className : ''}${collapsed ? ' rightbar-collapsed' : ''}`} aria-label="Eigenschaften-Panels">
|
||||
@@ -61,7 +63,7 @@ const RightSidebar: React.FC<RightSidebarProps> = ({
|
||||
|
||||
<div className="rightbar-content">
|
||||
<div className={`rightbar-panel${activePanel === 'tool' ? ' active' : ''}`} data-panel-content="tool">
|
||||
{activePanel === 'tool' && <PropertiesPanel selectedElement={selectedElement} layers={layers} onDelete={selectedElement && onElementsDeleted ? () => onElementsDeleted([selectedElement.id]) : undefined} onUpdateProperty={(key, value) => {
|
||||
{activePanel === 'tool' && <PropertiesPanel selectedElement={selectedElement} layers={layers} unit={unit} scaleFactor={scaleFactor} onDelete={selectedElement && onElementsDeleted ? () => onElementsDeleted([selectedElement.id]) : undefined} onUpdateProperty={(key, value) => {
|
||||
if (!selectedElement || !onUpdateElement) return;
|
||||
const updated = { ...selectedElement };
|
||||
if (key === 'x' || key === 'y' || key === 'width' || key === 'height') {
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import PluginManager from './PluginManager';
|
||||
import type { UnitType } from '../utils/format';
|
||||
|
||||
export interface SettingsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
unit?: UnitType;
|
||||
gridSize?: number;
|
||||
scaleFactor?: number;
|
||||
onUnitChange?: (unit: UnitType) => void;
|
||||
onGridSizeChange?: (size: number) => void;
|
||||
onScaleFactorChange?: (factor: number) => void;
|
||||
}
|
||||
|
||||
type SettingsTab = 'personal' | 'password' | 'language' | 'theme' | 'users' | 'plugins' | 'ai';
|
||||
type SettingsTab = 'personal' | 'password' | 'language' | 'theme' | 'units' | 'users' | 'plugins' | 'ai';
|
||||
|
||||
const SettingsModal: React.FC<SettingsModalProps> = ({ open, onClose }) => {
|
||||
const SettingsModal: React.FC<SettingsModalProps> = ({
|
||||
open, onClose,
|
||||
unit = 'mm', gridSize = 20, scaleFactor = 1,
|
||||
onUnitChange, onGridSizeChange, onScaleFactorChange,
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('personal');
|
||||
const [displayName, setDisplayName] = useState(user?.name || '');
|
||||
@@ -24,6 +35,20 @@ const SettingsModal: React.FC<SettingsModalProps> = ({ open, onClose }) => {
|
||||
const [aiApiKey, setAiApiKey] = useState('');
|
||||
const [pwMessage, setPwMessage] = useState('');
|
||||
|
||||
// Local state for units tab — syncs with props on open
|
||||
const [localUnit, setLocalUnit] = useState<UnitType>(unit);
|
||||
const [localGridSize, setLocalGridSize] = useState<number>(gridSize);
|
||||
const [localScaleFactor, setLocalScaleFactor] = useState<number>(scaleFactor);
|
||||
|
||||
// Sync local state when modal opens or props change
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setLocalUnit(unit);
|
||||
setLocalGridSize(gridSize);
|
||||
setLocalScaleFactor(scaleFactor);
|
||||
}
|
||||
}, [open, unit, gridSize, scaleFactor]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const isAdmin = user?.role === 'admin';
|
||||
@@ -34,6 +59,7 @@ const SettingsModal: React.FC<SettingsModalProps> = ({ open, onClose }) => {
|
||||
{ id: 'password', label: 'Passwort' },
|
||||
{ id: 'language', label: 'Sprache' },
|
||||
{ id: 'theme', label: 'Theme' },
|
||||
{ id: 'units', label: 'Einheiten' },
|
||||
{ id: 'users', label: 'Benutzer', adminOnly: true },
|
||||
{ id: 'plugins', label: 'Plugins', adminOnly: true },
|
||||
{ id: 'ai', label: 'KI' },
|
||||
@@ -60,6 +86,12 @@ const SettingsModal: React.FC<SettingsModalProps> = ({ open, onClose }) => {
|
||||
setConfirmPassword('');
|
||||
};
|
||||
|
||||
const handleUnitApply = () => {
|
||||
onUnitChange?.(localUnit);
|
||||
onGridSizeChange?.(localGridSize);
|
||||
onScaleFactorChange?.(localScaleFactor);
|
||||
};
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'personal':
|
||||
@@ -111,6 +143,49 @@ const SettingsModal: React.FC<SettingsModalProps> = ({ open, onClose }) => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'units':
|
||||
return (
|
||||
<div className="settings-tab-content">
|
||||
<label className="settings-label">Maßeinheit</label>
|
||||
<select
|
||||
className="settings-input"
|
||||
value={localUnit}
|
||||
onChange={(e) => setLocalUnit(e.target.value as UnitType)}
|
||||
>
|
||||
<option value="mm">Millimeter (mm)</option>
|
||||
<option value="cm">Zentimeter (cm)</option>
|
||||
<option value="m">Meter (m)</option>
|
||||
</select>
|
||||
|
||||
<label className="settings-label">Rastergröße (Welt-Einheiten)</label>
|
||||
<input
|
||||
className="settings-input"
|
||||
type="number"
|
||||
min={1}
|
||||
value={localGridSize}
|
||||
onChange={(e) => setLocalGridSize(parseFloat(e.target.value) || 20)}
|
||||
/>
|
||||
<p style={{ fontSize: '11px', color: 'var(--color-text-muted)', margin: '2px 0 12px' }}>
|
||||
Rasterabstand in Welt-Einheiten. Bei mm mit Scale-Faktor 1 entspricht 20 = 20mm.
|
||||
</p>
|
||||
|
||||
<label className="settings-label">Scale-Faktor (mm pro Welt-Einheit)</label>
|
||||
<input
|
||||
className="settings-input"
|
||||
type="number"
|
||||
min={0.001}
|
||||
step={0.001}
|
||||
value={localScaleFactor}
|
||||
onChange={(e) => setLocalScaleFactor(parseFloat(e.target.value) || 1)}
|
||||
/>
|
||||
<p style={{ fontSize: '11px', color: 'var(--color-text-muted)', margin: '2px 0 12px' }}>
|
||||
1 Welt-Einheit = {localScaleFactor} mm. Standard: 1 (1 Welt-Einheit = 1mm).
|
||||
Für echte Meter-Koordinaten: 1000 (1 Welt-Einheit = 1m).
|
||||
</p>
|
||||
|
||||
<button className="settings-btn" onClick={handleUnitApply}>Anwenden</button>
|
||||
</div>
|
||||
);
|
||||
case 'users':
|
||||
return (
|
||||
<div className="settings-tab-content">
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import React from 'react';
|
||||
import type { StatusBarProps } from '../types/ui.types';
|
||||
import { formatCoordinate, type UnitType } from '../utils/format';
|
||||
|
||||
const StatusBar: React.FC<StatusBarProps> = ({
|
||||
snapEnabled, orthoEnabled, polarEnabled, gridEnabled,
|
||||
cursorX, cursorY, activeLayer, activeTool, onlineCount,
|
||||
onToggleSnap, onToggleOrtho, onTogglePolar, onToggleGrid, seatCount,
|
||||
unit = 'mm', scaleFactor = 1, onUnitChange,
|
||||
}) => {
|
||||
const xDisplay = formatCoordinate(cursorX, unit, scaleFactor);
|
||||
const yDisplay = formatCoordinate(cursorY, unit, scaleFactor);
|
||||
|
||||
return (
|
||||
<footer className="statusbar" role="status">
|
||||
<div className={`status-item${snapEnabled ? ' active' : ''}`} title="Snap-Modus (F3)" aria-pressed={snapEnabled} onClick={onToggleSnap}>
|
||||
@@ -25,10 +30,10 @@ const StatusBar: React.FC<StatusBarProps> = ({
|
||||
<span>GRID</span>
|
||||
</div>
|
||||
<div className="status-item" style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
<span>X: {cursorX.toFixed(3)} m</span>
|
||||
<span>X: {xDisplay} {unit}</span>
|
||||
</div>
|
||||
<div className="status-item" style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
<span>Y: {cursorY.toFixed(3)} m</span>
|
||||
<span>Y: {yDisplay} {unit}</span>
|
||||
</div>
|
||||
<div className="status-item hide-mobile" title="Layer">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/></svg>
|
||||
@@ -44,6 +49,28 @@ const StatusBar: React.FC<StatusBarProps> = ({
|
||||
<span>{seatCount} Stühle</span>
|
||||
</div>
|
||||
)}
|
||||
{onUnitChange && (
|
||||
<div className="status-item" title="Maßeinheit">
|
||||
<select
|
||||
value={unit}
|
||||
onChange={(e) => onUnitChange(e.target.value as UnitType)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '3px',
|
||||
padding: '1px 4px',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
aria-label="Maßeinheit auswählen"
|
||||
>
|
||||
<option value="mm">mm</option>
|
||||
<option value="cm">cm</option>
|
||||
<option value="m">m</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="status-item" title={`${onlineCount} anderer Nutzer online`}>
|
||||
<span className="status-dot"></span>
|
||||
<span>Online · {onlineCount} weiterer</span>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import React from 'react';
|
||||
import type { TopbarProps } from '../types/ui.types';
|
||||
import type { UnitType } from '../utils/format';
|
||||
|
||||
const Topbar: React.FC<TopbarProps> = ({ projectName, savedStatus, onUndo, onRedo, onThemeToggle, theme, onOpenSettings, onOpenLeftDrawer, onOpenRightDrawer }) => {
|
||||
const Topbar: React.FC<TopbarProps> = ({
|
||||
projectName, savedStatus, onUndo, onRedo, onThemeToggle, theme,
|
||||
onOpenSettings, onOpenLeftDrawer, onOpenRightDrawer,
|
||||
unit = 'mm', onUnitChange,
|
||||
}) => {
|
||||
return (
|
||||
<header className="topbar" role="banner">
|
||||
<div className="topbar-left">
|
||||
@@ -28,6 +33,29 @@ const Topbar: React.FC<TopbarProps> = ({ projectName, savedStatus, onUndo, onRed
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 15-6.7L21 13"/></svg>
|
||||
</button>
|
||||
<span style={{ width: '1px', height: '20px', background: 'var(--color-border)', margin: '0 4px' }}></span>
|
||||
{onUnitChange && (
|
||||
<select
|
||||
className="unit-select"
|
||||
aria-label="Maßeinheit"
|
||||
title="Maßeinheit"
|
||||
value={unit}
|
||||
onChange={(e) => onUnitChange(e.target.value as UnitType)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 6px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
>
|
||||
<option value="mm">mm</option>
|
||||
<option value="cm">cm</option>
|
||||
<option value="m">m</option>
|
||||
</select>
|
||||
)}
|
||||
<button className="icon-btn-top" aria-label="Versionen" title="Versionsverlauf">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
</button>
|
||||
@@ -47,7 +75,7 @@ const Topbar: React.FC<TopbarProps> = ({ projectName, savedStatus, onUndo, onRed
|
||||
<option value="en">EN</option>
|
||||
</select>
|
||||
<button className="icon-btn-top mobile-drawer-toggle" aria-label="Panel öffnen" title="Panel öffnen" onClick={onOpenRightDrawer}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
|
||||
</button>
|
||||
<button className="icon-btn-top" aria-label="Benachrichtigungen" title="Benachrichtigungen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { GroupManager } from '../tools/modification/GroupTool';
|
||||
import { moveElement, rotateElement, scaleElement, mirrorElement, offsetElement, trimElement, extendElement, filletElements, distance, angleBetween } from '../tools/modification/geometry';
|
||||
import { SeatingService, SEATING_TEMPLATES } from '../services/seatingService';
|
||||
import { DimensionService } from '../services/dimensionService';
|
||||
import { formatDistance, type UnitType } from '../utils/format';
|
||||
|
||||
export type ToolPhase = 'idle' | 'drawing' | 'modifying' | 'panning' | 'selecting';
|
||||
|
||||
@@ -61,6 +62,8 @@ export class InteractionEngine {
|
||||
private boundMouseMove: EventListener;
|
||||
private boundMouseUp: EventListener;
|
||||
private boundPointerCancel: EventListener;
|
||||
private unit: UnitType = 'mm';
|
||||
private scaleFactor: number = 1;
|
||||
|
||||
constructor(
|
||||
canvas: HTMLCanvasElement,
|
||||
@@ -118,6 +121,15 @@ export class InteractionEngine {
|
||||
this.state.ortho = enabled;
|
||||
}
|
||||
|
||||
setUnits(unit: UnitType, scaleFactor: number): void {
|
||||
this.unit = unit;
|
||||
this.scaleFactor = scaleFactor;
|
||||
}
|
||||
|
||||
setGridSize(gridSize: number): void {
|
||||
this.snapEngine.setConfig({ gridSpacing: gridSize });
|
||||
}
|
||||
|
||||
setPolarEnabled(enabled: boolean): void {
|
||||
this.snapEngine.setConfig({ polarEnabled: enabled });
|
||||
}
|
||||
@@ -684,9 +696,10 @@ export class InteractionEngine {
|
||||
this.onCommandTrigger?.('measure: click end point');
|
||||
} else {
|
||||
const first = this.state.points[0];
|
||||
const dist = Math.sqrt((pt.x - first.x) ** 2 + (pt.y - first.y) ** 2);
|
||||
const rawDist = Math.sqrt((pt.x - first.x) ** 2 + (pt.y - first.y) ** 2);
|
||||
const angle = (Math.atan2(pt.y - first.y, pt.x - first.x) * 180) / Math.PI;
|
||||
this.onCommandTrigger?.(`distance: ${dist.toFixed(2)} angle: ${angle.toFixed(1)}°`);
|
||||
const distStr = formatDistance(rawDist, this.unit, this.scaleFactor);
|
||||
this.onCommandTrigger?.(`distance: ${distStr} angle: ${angle.toFixed(1)}°`);
|
||||
this.state.points = [];
|
||||
this.state.phase = 'idle';
|
||||
this.requestRender();
|
||||
|
||||
@@ -608,6 +608,33 @@ export async function deleteGlobalBlock(token: string, id: string): Promise<void
|
||||
|
||||
export { API_BASE };
|
||||
|
||||
// ─── Settings (Key/Value) ────────────────────────────────
|
||||
export interface Setting {
|
||||
key: string;
|
||||
value: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export async function getSetting(token: string, key: string): Promise<Setting | null> {
|
||||
const res = await fetch(`${API_BASE}/api/settings/${encodeURIComponent(key)}`, {
|
||||
method: 'GET',
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`Failed to fetch setting: ${key}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function setSetting(token: string, key: string, value: string): Promise<Setting> {
|
||||
const res = await fetch(`${API_BASE}/api/settings/${encodeURIComponent(key)}`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify({ value }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to save setting: ${key}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── AI Copilot ─────────────────────────────────────────
|
||||
export interface AIChatMessage {
|
||||
role: string;
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { CADElement, CADLayer, BlockDefinition, ToolType } from './cad.type
|
||||
import type { ToolState } from '../interaction';
|
||||
import type { BackgroundConfig } from '../services/backgroundService';
|
||||
import type { UserCursor } from '../crdt';
|
||||
import type { UnitType } from '../utils/format';
|
||||
|
||||
export type ViewMode = '2d' | 'iso' | 'front' | 'top';
|
||||
export type RibbonTab = 'start' | 'insert' | 'format' | 'view' | 'tools' | 'ki';
|
||||
@@ -67,11 +68,19 @@ export interface TopbarProps {
|
||||
onOpenSettings?: () => void;
|
||||
onOpenLeftDrawer?: () => void;
|
||||
onOpenRightDrawer?: () => void;
|
||||
unit?: UnitType;
|
||||
onUnitChange?: (unit: UnitType) => void;
|
||||
}
|
||||
|
||||
export interface SettingsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
unit?: UnitType;
|
||||
gridSize?: number;
|
||||
scaleFactor?: number;
|
||||
onUnitChange?: (unit: UnitType) => void;
|
||||
onGridSizeChange?: (size: number) => void;
|
||||
onScaleFactorChange?: (factor: number) => void;
|
||||
}
|
||||
|
||||
export interface RibbonBarProps {
|
||||
@@ -137,6 +146,9 @@ export interface CanvasAreaProps {
|
||||
bgConfig?: BackgroundConfig | null;
|
||||
remoteCursors?: UserCursor[];
|
||||
groups?: Array<{ id: string; name: string; elementIds: string[]; parentGroupId: string | null }>;
|
||||
unit?: UnitType;
|
||||
gridSize?: number;
|
||||
scaleFactor?: number;
|
||||
}
|
||||
|
||||
export interface RightSidebarProps {
|
||||
@@ -183,6 +195,8 @@ export interface RightSidebarProps {
|
||||
onCollapse?: () => void;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
unit?: UnitType;
|
||||
scaleFactor?: number;
|
||||
}
|
||||
|
||||
export interface PropertiesPanelProps {
|
||||
@@ -190,6 +204,8 @@ export interface PropertiesPanelProps {
|
||||
layers: CADLayer[];
|
||||
onUpdateProperty: (key: string, value: unknown) => void;
|
||||
onDelete?: () => void;
|
||||
unit?: UnitType;
|
||||
scaleFactor?: number;
|
||||
}
|
||||
|
||||
export interface LayerPanelProps {
|
||||
@@ -255,6 +271,9 @@ export interface StatusBarProps {
|
||||
onTogglePolar: () => void;
|
||||
onToggleGrid: () => void;
|
||||
seatCount?: number;
|
||||
unit?: UnitType;
|
||||
scaleFactor?: number;
|
||||
onUnitChange?: (unit: UnitType) => void;
|
||||
}
|
||||
|
||||
export interface TreeViewProps {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatDistance, parseDistance, formatCoordinate, formatInputValue } from '../format';
|
||||
|
||||
describe('formatDistance', () => {
|
||||
it('formats mm correctly with default scaleFactor', () => {
|
||||
expect(formatDistance(100, 'mm')).toBe('100 mm');
|
||||
expect(formatDistance(0, 'mm')).toBe('0 mm');
|
||||
expect(formatDistance(1234.5, 'mm')).toBe('1235 mm');
|
||||
});
|
||||
|
||||
it('formats cm correctly with default scaleFactor', () => {
|
||||
expect(formatDistance(100, 'cm')).toBe('10.0 cm');
|
||||
expect(formatDistance(0, 'cm')).toBe('0.0 cm');
|
||||
expect(formatDistance(55, 'cm')).toBe('5.5 cm');
|
||||
});
|
||||
|
||||
it('formats m correctly with default scaleFactor', () => {
|
||||
expect(formatDistance(1000, 'm')).toBe('1.00 m');
|
||||
expect(formatDistance(0, 'm')).toBe('0.00 m');
|
||||
expect(formatDistance(1500, 'm')).toBe('1.50 m');
|
||||
});
|
||||
|
||||
it('applies scaleFactor correctly', () => {
|
||||
expect(formatDistance(100, 'mm', 10)).toBe('1000 mm');
|
||||
expect(formatDistance(100, 'cm', 10)).toBe('100.0 cm');
|
||||
expect(formatDistance(100, 'm', 10)).toBe('1.00 m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDistance', () => {
|
||||
it('parses mm input correctly', () => {
|
||||
expect(parseDistance('500', 'mm')).toBe(500);
|
||||
expect(parseDistance('0', 'mm')).toBe(0);
|
||||
});
|
||||
|
||||
it('parses cm input correctly', () => {
|
||||
expect(parseDistance('10', 'cm')).toBe(100);
|
||||
expect(parseDistance('5.5', 'cm')).toBe(55);
|
||||
});
|
||||
|
||||
it('parses m input correctly', () => {
|
||||
expect(parseDistance('1', 'm')).toBe(1000);
|
||||
expect(parseDistance('0.5', 'm')).toBe(500);
|
||||
});
|
||||
|
||||
it('applies scaleFactor correctly', () => {
|
||||
expect(parseDistance('500', 'mm', 10)).toBe(50);
|
||||
expect(parseDistance('10', 'cm', 10)).toBe(10);
|
||||
expect(parseDistance('1', 'm', 10)).toBe(100);
|
||||
});
|
||||
|
||||
it('returns 0 for invalid input', () => {
|
||||
expect(parseDistance('abc', 'mm')).toBe(0);
|
||||
expect(parseDistance('', 'cm')).toBe(0);
|
||||
expect(parseDistance('NaN', 'm')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCoordinate', () => {
|
||||
it('formats mm coordinate', () => {
|
||||
expect(formatCoordinate(123.7, 'mm')).toBe('124');
|
||||
});
|
||||
|
||||
it('formats cm coordinate', () => {
|
||||
expect(formatCoordinate(123.7, 'cm')).toBe('12.4');
|
||||
});
|
||||
|
||||
it('formats m coordinate with 3 decimal places', () => {
|
||||
expect(formatCoordinate(1234.5, 'm')).toBe('1.235');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatInputValue', () => {
|
||||
it('formats mm input value without unit suffix', () => {
|
||||
expect(formatInputValue(100, 'mm')).toBe('100');
|
||||
});
|
||||
|
||||
it('formats cm input value', () => {
|
||||
expect(formatInputValue(100, 'cm')).toBe('10.0');
|
||||
});
|
||||
|
||||
it('formats m input value', () => {
|
||||
expect(formatInputValue(1500, 'm')).toBe('1.500');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip: parseDistance to formatDistance', () => {
|
||||
it('round-trips mm values correctly', () => {
|
||||
const input = '500';
|
||||
const world = parseDistance(input, 'mm');
|
||||
const formatted = formatDistance(world, 'mm');
|
||||
expect(formatted).toBe('500 mm');
|
||||
});
|
||||
|
||||
it('round-trips cm values correctly', () => {
|
||||
const input = '10.5';
|
||||
const world = parseDistance(input, 'cm');
|
||||
const formatted = formatDistance(world, 'cm');
|
||||
expect(formatted).toBe('10.5 cm');
|
||||
});
|
||||
|
||||
it('round-trips m values correctly', () => {
|
||||
const input = '2.50';
|
||||
const world = parseDistance(input, 'm');
|
||||
const formatted = formatDistance(world, 'm');
|
||||
expect(formatted).toBe('2.50 m');
|
||||
});
|
||||
|
||||
it('round-trips with scaleFactor', () => {
|
||||
const input = '500';
|
||||
const world = parseDistance(input, 'mm', 10);
|
||||
const formatted = formatDistance(world, 'mm', 10);
|
||||
expect(formatted).toBe('500 mm');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Unit formatting and parsing utilities for CAD distance values.
|
||||
* All internal CAD coordinates are stored in world units.
|
||||
* scaleFactor converts world units to millimeters (1 world unit = scaleFactor mm).
|
||||
*/
|
||||
|
||||
export type UnitType = 'mm' | 'cm' | 'm';
|
||||
|
||||
/**
|
||||
* Format a raw world-unit distance into a human-readable string with the given unit.
|
||||
* @param raw - distance in world units
|
||||
* @param unit - target display unit ('mm' | 'cm' | 'm')
|
||||
* @param scaleFactor - millimeters per world unit (default 1)
|
||||
* @returns formatted string like "500 mm", "5.0 cm", "0.05 m"
|
||||
*/
|
||||
export function formatDistance(
|
||||
raw: number,
|
||||
unit: UnitType,
|
||||
scaleFactor: number = 1,
|
||||
): string {
|
||||
const mm = raw * scaleFactor;
|
||||
switch (unit) {
|
||||
case 'mm':
|
||||
return `${mm.toFixed(0)} mm`;
|
||||
case 'cm':
|
||||
return `${(mm / 10).toFixed(1)} cm`;
|
||||
case 'm':
|
||||
return `${(mm / 1000).toFixed(2)} m`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a user-entered distance string back into world units.
|
||||
* @param input - numeric string (e.g. "500", "5.5", "0.05")
|
||||
* @param unit - the unit the user is entering values in
|
||||
* @param scaleFactor - millimeters per world unit (default 1)
|
||||
* @returns world-unit distance, or 0 if input is invalid
|
||||
*/
|
||||
export function parseDistance(
|
||||
input: string,
|
||||
unit: UnitType,
|
||||
scaleFactor: number = 1,
|
||||
): number {
|
||||
const value = parseFloat(input);
|
||||
if (isNaN(value)) return 0;
|
||||
switch (unit) {
|
||||
case 'mm':
|
||||
return value / scaleFactor;
|
||||
case 'cm':
|
||||
return (value * 10) / scaleFactor;
|
||||
case 'm':
|
||||
return (value * 1000) / scaleFactor;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a raw world-unit coordinate value for compact display (e.g. status bar).
|
||||
* Uses fewer decimal places than formatDistance.
|
||||
*/
|
||||
export function formatCoordinate(
|
||||
raw: number,
|
||||
unit: UnitType,
|
||||
scaleFactor: number = 1,
|
||||
): string {
|
||||
const mm = raw * scaleFactor;
|
||||
switch (unit) {
|
||||
case 'mm':
|
||||
return `${mm.toFixed(0)}`;
|
||||
case 'cm':
|
||||
return `${(mm / 10).toFixed(1)}`;
|
||||
case 'm':
|
||||
return `${(mm / 1000).toFixed(3)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a raw world-unit value for an input field (without unit suffix).
|
||||
* This is what the user sees in text inputs so they can edit the numeric value.
|
||||
*/
|
||||
export function formatInputValue(
|
||||
raw: number,
|
||||
unit: UnitType,
|
||||
scaleFactor: number = 1,
|
||||
): string {
|
||||
const mm = raw * scaleFactor;
|
||||
switch (unit) {
|
||||
case 'mm':
|
||||
return `${mm.toFixed(0)}`;
|
||||
case 'cm':
|
||||
return `${(mm / 10).toFixed(1)}`;
|
||||
case 'm':
|
||||
return `${(mm / 1000).toFixed(3)}`;
|
||||
}
|
||||
}
|
||||
@@ -136,8 +136,9 @@ describe('StatusBar', () => {
|
||||
|
||||
it('should render cursor coordinates', () => {
|
||||
render(<StatusBar {...defaultProps} />);
|
||||
// Default unit is mm, scaleFactor=1: 123.456 → '123', 78.9 → '79'
|
||||
expect(screen.getByText(/123/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/78/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/79/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render active layer and tool', () => {
|
||||
@@ -352,29 +353,31 @@ describe('PropertiesPanel', () => {
|
||||
const layers = [makeLayer({ id: 'layer-1', name: 'Layer 1' })];
|
||||
const element = makeElement({ id: 'elem-1', type: 'rect', x: 10, y: 20, width: 100, height: 50 });
|
||||
|
||||
it('should show default values when no element selected', () => {
|
||||
it('should show empty inputs when no element selected', () => {
|
||||
render(<PropertiesPanel selectedElement={null} layers={layers} onUpdateProperty={() => {}} />);
|
||||
// Default values are in inputs with aria-labels
|
||||
expect(screen.getByLabelText('Position X')).toHaveDisplayValue('0.000 m');
|
||||
expect(screen.getByLabelText('Position Y')).toHaveDisplayValue('0.000 m');
|
||||
// When no element is selected, input fields are empty
|
||||
expect(screen.getByLabelText('Position X')).toHaveDisplayValue('');
|
||||
expect(screen.getByLabelText('Position Y')).toHaveDisplayValue('');
|
||||
});
|
||||
|
||||
it('should display element coordinates when element is selected', () => {
|
||||
it('should display element coordinates in mm by default', () => {
|
||||
render(<PropertiesPanel selectedElement={element} layers={layers} onUpdateProperty={() => {}} />);
|
||||
expect(screen.getByLabelText('Position X')).toHaveDisplayValue('10.000 m');
|
||||
expect(screen.getByLabelText('Position Y')).toHaveDisplayValue('20.000 m');
|
||||
// Default unit is mm with scaleFactor=1, so world units are displayed as mm
|
||||
expect(screen.getByLabelText('Position X')).toHaveDisplayValue('10');
|
||||
expect(screen.getByLabelText('Position Y')).toHaveDisplayValue('20');
|
||||
});
|
||||
|
||||
it('should display element dimensions', () => {
|
||||
it('should display element dimensions in mm by default', () => {
|
||||
render(<PropertiesPanel selectedElement={element} layers={layers} onUpdateProperty={() => {}} />);
|
||||
expect(screen.getByLabelText('Breite')).toHaveDisplayValue('100.00 m');
|
||||
expect(screen.getByLabelText('Tiefe')).toHaveDisplayValue('50.00 m');
|
||||
expect(screen.getByLabelText('Breite')).toHaveDisplayValue('100');
|
||||
expect(screen.getByLabelText('Tiefe')).toHaveDisplayValue('50');
|
||||
});
|
||||
|
||||
it('should call onUpdateProperty when changing a property input', () => {
|
||||
it('should call onUpdateProperty with world-unit number when changing a property input', () => {
|
||||
const onUpdateProperty = vi.fn();
|
||||
render(<PropertiesPanel selectedElement={element} layers={layers} onUpdateProperty={onUpdateProperty} />);
|
||||
fireEvent.change(screen.getByLabelText('Position X'), { target: { value: '999' } });
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('x', '999');
|
||||
// In mm mode with scaleFactor=1, input '999' → 999 world units
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('x', 999);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user