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))}
/>