diff --git a/bun.lock b/bun.lock index f402fa78..304552a1 100644 --- a/bun.lock +++ b/bun.lock @@ -131,6 +131,7 @@ "@dnd-kit/utilities": "^3.2.2", "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", + "@pascal-app/lingo": "^0.1.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", @@ -220,6 +221,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "@pascal-app/lingo": "^0.1.0", "zod": "^4.3.5", }, "devDependencies": { @@ -714,6 +716,8 @@ "@pascal-app/ifc-converter": ["@pascal-app/ifc-converter@workspace:packages/ifc-converter"], + "@pascal-app/lingo": ["@pascal-app/lingo@0.1.0", "", { "peerDependencies": { "react": "^19" }, "optionalPeers": ["react"] }, "sha512-j4cN9DSc3QD4LGG003TCIO5+jMYySFpJO6467V0Q3IPumVkwq7/BpEAw8JGY6zTZFmFETIeupuPEi4drnA9m+g=="], + "@pascal-app/mcp": ["@pascal-app/mcp@workspace:packages/mcp"], "@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"], diff --git a/packages/editor/package.json b/packages/editor/package.json index 02ead606..481ab352 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -26,6 +26,7 @@ "@dnd-kit/utilities": "^3.2.2", "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", + "@pascal-app/lingo": "^0.1.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", diff --git a/packages/editor/src/components/ui/controls/metric-control.tsx b/packages/editor/src/components/ui/controls/metric-control.tsx index 1ce1f5fc..9742c813 100644 --- a/packages/editor/src/components/ui/controls/metric-control.tsx +++ b/packages/editor/src/components/ui/controls/metric-control.tsx @@ -1,13 +1,13 @@ 'use client' import { useScene } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' import { - getLinearUnitLabel, - linearUnitToMeters, - metersToLinearUnit, -} from '../../../lib/measurements' + lingoUnitSpec, + measurementHint, + parseMeasurement, +} from '../../../lib/measurement-parser' +import { useLinearDisplay } from '../../../lib/use-linear-display' import { cn } from '../../../lib/utils' interface MetricControlProps { @@ -37,19 +37,13 @@ export function MetricControl({ unit = '', restoreOnCommit = true, }: MetricControlProps) { - const viewerUnit = useViewer((state) => state.unit) - const isImperial = viewerUnit === 'imperial' && unit === 'm' - const displayUnit = isImperial ? getLinearUnitLabel('imperial') : unit + const { + isImperial, + displayUnit, + toDisplay: toDisplayValue, + toStored: toStoredValue, + } = useLinearDisplay(unit, precision) - const toDisplayValue = useCallback( - (storedValue: number) => (isImperial ? metersToLinearUnit(storedValue, 'imperial') : storedValue), - [isImperial], - ) - const toStoredValue = useCallback( - (displayValue: number) => - isImperial ? linearUnitToMeters(displayValue, 'imperial') : displayValue, - [isImperial], - ) const clamp = useCallback( (val: number) => { return Math.min(Math.max(val, min), max) @@ -229,14 +223,46 @@ export function MetricControl({ }, []) const submitValue = useCallback(() => { - const numValue = Number.parseFloat(inputValue) - if (Number.isNaN(numValue)) { + const spec = lingoUnitSpec(unit) + let stored = spec + ? parseMeasurement(inputValue, spec, { + bareUnit: isImperial ? 'ft' : spec.unitId, + system: isImperial ? 'us' : 'metric', + }) + : null + if (stored === null) { + const numValue = Number.parseFloat(inputValue) + stored = Number.isFinite(numValue) ? toStoredValue(numValue) : null + } + if (stored === null) { setInputValue(toDisplayValue(value).toFixed(precision)) } else { - applyCommittedValue(clamp(toStoredValue(numValue))) + applyCommittedValue(clamp(stored)) } setIsEditing(false) - }, [inputValue, applyCommittedValue, clamp, toStoredValue, value, precision, toDisplayValue]) + }, [ + inputValue, + unit, + isImperial, + applyCommittedValue, + clamp, + toStoredValue, + value, + precision, + toDisplayValue, + ]) + + const spec = lingoUnitSpec(unit) + const hint = + isEditing && spec + ? measurementHint(inputValue, spec, { + bareUnit: isImperial ? 'ft' : spec.unitId, + system: isImperial ? 'us' : 'metric', + displayUnit: isImperial ? 'ft' : spec.unitId, + precision, + clamp, + }) + : null const handleInputBlur = useCallback(() => { submitValue() @@ -290,6 +316,11 @@ export function MetricControl({
{isEditing ? (
+ {hint && ( + + {hint} + + )} Math.min(Math.max(val, min), max), [min, max]) + // Apply a signed display-unit delta to a stored value, rounding in the + // display unit and clamping in the stored unit. + const applyDisplayDelta = useCallback( + (storedValue: number, displayDelta: number, displayStep: number) => + clamp( + toStored( + Number.parseFloat((toDisplay(storedValue) + displayDelta).toFixed(stepPrecision(displayStep))), + ), + ), + [clamp, toDisplay, toStored], + ) useEffect(() => { if (!isEditing) { - setInputValue(value.toFixed(precision)) + setInputValue(toDisplay(value).toFixed(precision)) } - }, [value, precision, isEditing]) + }, [value, precision, isEditing, toDisplay]) // Wheel support on the label useEffect(() => { @@ -96,14 +120,13 @@ export function SliderControl({ e.preventDefault() const direction = e.deltaY < 0 ? 1 : -1 const s = getAdjustedStep(step, e) - const newValue = clamp(valueRef.current + direction * s) - const final = Number.parseFloat(newValue.toFixed(stepPrecision(s))) + const final = applyDisplayDelta(valueRef.current, direction * s, s) if (final !== valueRef.current) onChange(final) onCommit?.(final) } el.addEventListener('wheel', handleWheel, { passive: false }) return () => el.removeEventListener('wheel', handleWheel) - }, [isEditing, step, clamp, onChange, onCommit]) + }, [isEditing, step, applyDisplayDelta, onChange, onCommit]) // Arrow key support while hovered useEffect(() => { @@ -115,15 +138,14 @@ export function SliderControl({ if (direction !== 0) { e.preventDefault() const s = getAdjustedStep(step, e) - const newValue = clamp(valueRef.current + direction * s) - const final = Number.parseFloat(newValue.toFixed(stepPrecision(s))) + const final = applyDisplayDelta(valueRef.current, direction * s, s) if (final !== valueRef.current) onChange(final) onCommit?.(final) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [isHovered, isEditing, step, clamp, onChange, onCommit]) + }, [isHovered, isEditing, step, applyDisplayDelta, onChange, onCommit]) const handleLabelPointerDown = useCallback( (e: React.PointerEvent) => { @@ -160,15 +182,13 @@ export function SliderControl({ const dx = e.clientX - anchorX const s = step * multiplier // 4 px per step at default sensitivity - const newValue = clamp( - Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))), - ) + const newValue = applyDisplayDelta(anchorValue, (dx / 4) * s, s) if (newValue !== valueRef.current) { valueRef.current = newValue onChange(newValue) } }, - [step, clamp, onChange], + [step, applyDisplayDelta, onChange], ) const handleLabelPointerUp = useCallback( @@ -195,49 +215,65 @@ export function SliderControl({ const handleValueClick = useCallback(() => { setIsEditing(true) - setInputValue(value.toFixed(precision)) - }, [value, precision]) + setInputValue(toDisplay(value).toFixed(precision)) + }, [value, precision, toDisplay]) const submitValue = useCallback(() => { - const numValue = Number.parseFloat(inputValue) - if (Number.isNaN(numValue)) { - setInputValue(value.toFixed(precision)) + const spec = lingoUnitSpec(unit) + let stored = spec + ? parseMeasurement(inputValue, spec, { + bareUnit: isImperial ? 'ft' : spec.unitId, + system: isImperial ? 'us' : 'metric', + }) + : null + if (stored === null) { + // Fallback: a bare number typed in the DISPLAY unit → convert to stored. + const numValue = Number.parseFloat(inputValue) + stored = Number.isFinite(numValue) ? toStored(numValue) : null + } + if (stored === null) { + setInputValue(toDisplay(value).toFixed(precision)) } else { - const nextValue = clamp(Number.parseFloat(numValue.toFixed(precision))) + const nextValue = clamp(toStored(Number.parseFloat(toDisplay(stored).toFixed(precision)))) onChange(nextValue) onCommit?.(nextValue) } setIsEditing(false) - }, [inputValue, onChange, onCommit, clamp, precision, value]) + }, [inputValue, unit, isImperial, onChange, onCommit, clamp, precision, value, toDisplay, toStored]) + + const spec = lingoUnitSpec(unit) + const hint = + isEditing && spec + ? measurementHint(inputValue, spec, { + bareUnit: isImperial ? 'ft' : spec.unitId, + system: isImperial ? 'us' : 'metric', + displayUnit: isImperial ? 'ft' : spec.unitId, + precision, + clamp, + }) + : null const handleInputKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter') { submitValue() } else if (e.key === 'Escape') { - setInputValue(value.toFixed(precision)) + setInputValue(toDisplay(value).toFixed(precision)) setIsEditing(false) - } else if (e.key === 'ArrowUp') { + } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault() + const direction = e.key === 'ArrowUp' ? 1 : -1 const adjustedStep = getAdjustedStep(step, e) - const newV = clamp( - Number.parseFloat((value + adjustedStep).toFixed(stepPrecision(adjustedStep))), - ) + const newV = applyDisplayDelta(value, direction * adjustedStep, adjustedStep) onChange(newV) - setInputValue(newV.toFixed(precision)) - } else if (e.key === 'ArrowDown') { - e.preventDefault() - const adjustedStep = getAdjustedStep(step, e) - const newV = clamp( - Number.parseFloat((value - adjustedStep).toFixed(stepPrecision(adjustedStep))), - ) - onChange(newV) - setInputValue(newV.toFixed(precision)) + setInputValue(toDisplay(newV).toFixed(precision)) } }, - [submitValue, value, precision, step, clamp, onChange], + [submitValue, value, precision, step, applyDisplayDelta, onChange, toDisplay], ) + const displayValue = toDisplay(value) + return (
{isEditing ? ( <> + {hint && ( + + {hint} + + )} - {unit && {unit}} + {displayUnit && {displayUnit}} ) : (
- {Number(value.toFixed(precision)).toFixed(precision)} + {Number(displayValue.toFixed(precision)).toFixed(precision)} - {unit && {unit}} + {displayUnit && {displayUnit}}
)}
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index bb5209eb..4ce869b6 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -38,6 +38,11 @@ import { } from './../../../../../lib/level-duplication' import { getDefaultLevelName } from '@pascal-app/core' import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection' +import { + getLinearUnitLabel, + linearUnitToMeters, + metersToLinearUnit, +} from './../../../../../lib/measurements' import { createLocalGuideImage } from './../../../../../lib/local-guide-image' import { cn } from './../../../../../lib/utils' import useEditor from './../../../../../store/use-editor' @@ -93,6 +98,7 @@ const PropertyLineSection = memo(function PropertyLineSection() { const updateNode = useScene((state) => state.updateNode) const mode = useEditor((state) => state.mode) const setMode = useEditor((state) => state.setMode) + const viewerUnit = useViewer((state) => state.unit) if (!siteNode) return null @@ -101,6 +107,14 @@ const PropertyLineSection = memo(function PropertyLineSection() { const perimeter = calculatePerimeter(points) const isEditing = mode === 'edit' + // Property-line coordinates and readouts follow the metric/imperial toggle. + const isImperial = viewerUnit === 'imperial' + const linearLabel = getLinearUnitLabel(viewerUnit) + const toDisplayLinear = (meters: number) => metersToLinearUnit(meters, viewerUnit) + const toStoredLinear = (display: number) => linearUnitToMeters(display, viewerUnit) + const displayArea = isImperial ? area * 10.763_910_417 : area + const displayPerimeter = toDisplayLinear(perimeter) + const handleToggleEdit = () => { setMode(isEditing ? 'select' : 'edit') } @@ -166,10 +180,16 @@ const PropertyLineSection = memo(function PropertyLineSection() { {/* Measurements */}
- Area: {area.toFixed(1)} m² + Area:{' '} + + {displayArea.toFixed(1)} {isImperial ? 'ft²' : 'm²'} +
- Perimeter: {perimeter.toFixed(1)} m + Perimeter:{' '} + + {displayPerimeter.toFixed(1)} {linearLabel} +
@@ -184,21 +204,21 @@ const PropertyLineSection = memo(function PropertyLineSection() { - handlePointChange(index, 0, Number.parseFloat(e.target.value) || 0) + handlePointChange(index, 0, toStoredLinear(Number.parseFloat(e.target.value) || 0)) } step={0.5} type="number" - value={point[0]} + value={Number(toDisplayLinear(point[0]).toFixed(2))} /> - handlePointChange(index, 1, Number.parseFloat(e.target.value) || 0) + handlePointChange(index, 1, toStoredLinear(Number.parseFloat(e.target.value) || 0)) } step={0.5} type="number" - value={point[1]} + value={Number(toDisplayLinear(point[1]).toFixed(2))} />