Merge pull request #472 from pascalorg/feat/lingo-natural-inputs
feat: natural-language measurement inputs (UI + MCP tools) via @pascal-app/lingo
This commit is contained in:
@@ -131,6 +131,7 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@iconify/react": "^6.0.2",
|
"@iconify/react": "^6.0.2",
|
||||||
"@number-flow/react": "^0.6.0",
|
"@number-flow/react": "^0.6.0",
|
||||||
|
"@pascal-app/lingo": "^0.1.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@@ -220,6 +221,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"@pascal-app/lingo": "^0.1.0",
|
||||||
"zod": "^4.3.5",
|
"zod": "^4.3.5",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -714,6 +716,8 @@
|
|||||||
|
|
||||||
"@pascal-app/ifc-converter": ["@pascal-app/ifc-converter@workspace:packages/ifc-converter"],
|
"@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/mcp": ["@pascal-app/mcp@workspace:packages/mcp"],
|
||||||
|
|
||||||
"@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"],
|
"@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"],
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@iconify/react": "^6.0.2",
|
"@iconify/react": "^6.0.2",
|
||||||
"@number-flow/react": "^0.6.0",
|
"@number-flow/react": "^0.6.0",
|
||||||
|
"@pascal-app/lingo": "^0.1.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useScene } from '@pascal-app/core'
|
import { useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
getLinearUnitLabel,
|
lingoUnitSpec,
|
||||||
linearUnitToMeters,
|
measurementHint,
|
||||||
metersToLinearUnit,
|
parseMeasurement,
|
||||||
} from '../../../lib/measurements'
|
} from '../../../lib/measurement-parser'
|
||||||
|
import { useLinearDisplay } from '../../../lib/use-linear-display'
|
||||||
import { cn } from '../../../lib/utils'
|
import { cn } from '../../../lib/utils'
|
||||||
|
|
||||||
interface MetricControlProps {
|
interface MetricControlProps {
|
||||||
@@ -37,19 +37,13 @@ export function MetricControl({
|
|||||||
unit = '',
|
unit = '',
|
||||||
restoreOnCommit = true,
|
restoreOnCommit = true,
|
||||||
}: MetricControlProps) {
|
}: MetricControlProps) {
|
||||||
const viewerUnit = useViewer((state) => state.unit)
|
const {
|
||||||
const isImperial = viewerUnit === 'imperial' && unit === 'm'
|
isImperial,
|
||||||
const displayUnit = isImperial ? getLinearUnitLabel('imperial') : unit
|
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(
|
const clamp = useCallback(
|
||||||
(val: number) => {
|
(val: number) => {
|
||||||
return Math.min(Math.max(val, min), max)
|
return Math.min(Math.max(val, min), max)
|
||||||
@@ -229,14 +223,46 @@ export function MetricControl({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const submitValue = useCallback(() => {
|
const submitValue = useCallback(() => {
|
||||||
const numValue = Number.parseFloat(inputValue)
|
const spec = lingoUnitSpec(unit)
|
||||||
if (Number.isNaN(numValue)) {
|
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))
|
setInputValue(toDisplayValue(value).toFixed(precision))
|
||||||
} else {
|
} else {
|
||||||
applyCommittedValue(clamp(toStoredValue(numValue)))
|
applyCommittedValue(clamp(stored))
|
||||||
}
|
}
|
||||||
setIsEditing(false)
|
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(() => {
|
const handleInputBlur = useCallback(() => {
|
||||||
submitValue()
|
submitValue()
|
||||||
@@ -290,6 +316,11 @@ export function MetricControl({
|
|||||||
<div className="flex shrink-0 justify-end">
|
<div className="flex shrink-0 justify-end">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
{hint && (
|
||||||
|
<span className="mr-1.5 shrink-0 whitespace-nowrap text-[11px] text-muted-foreground/50 tabular-nums">
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
|
className="w-full bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
import { useScene } from '@pascal-app/core'
|
import { useScene } from '@pascal-app/core'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
lingoUnitSpec,
|
||||||
|
measurementHint,
|
||||||
|
parseMeasurement,
|
||||||
|
} from '../../../lib/measurement-parser'
|
||||||
|
import { useLinearDisplay } from '../../../lib/use-linear-display'
|
||||||
import { cn } from '../../../lib/utils'
|
import { cn } from '../../../lib/utils'
|
||||||
|
|
||||||
interface SliderControlProps {
|
interface SliderControlProps {
|
||||||
@@ -59,10 +65,17 @@ export function SliderControl({
|
|||||||
unit = '',
|
unit = '',
|
||||||
restoreOnCommit = true,
|
restoreOnCommit = true,
|
||||||
}: SliderControlProps) {
|
}: SliderControlProps) {
|
||||||
|
// Display/storage conversion so the value honors the metric/imperial toggle.
|
||||||
|
// `value`, `onChange`, `onCommit`, `min`/`max`/`clamp` are always in the
|
||||||
|
// stored unit (meters for `unit === 'm'`); the step, drag deltas, text field
|
||||||
|
// and rendered number are in the DISPLAY unit (feet when imperial). For
|
||||||
|
// metric and non-length units these conversions are the identity.
|
||||||
|
const { isImperial, displayUnit, toDisplay, toStored } = useLinearDisplay(unit, precision)
|
||||||
|
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
const [inputValue, setInputValue] = useState(value.toFixed(precision))
|
const [inputValue, setInputValue] = useState(toDisplay(value).toFixed(precision))
|
||||||
|
|
||||||
const dragRef = useRef<{
|
const dragRef = useRef<{
|
||||||
// Original value at drag start — preserved across modifier re-anchors so
|
// Original value at drag start — preserved across modifier re-anchors so
|
||||||
@@ -80,12 +93,23 @@ export function SliderControl({
|
|||||||
valueRef.current = value
|
valueRef.current = value
|
||||||
|
|
||||||
const clamp = useCallback((val: number) => Math.min(Math.max(val, min), max), [min, max])
|
const clamp = useCallback((val: number) => 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(() => {
|
useEffect(() => {
|
||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
setInputValue(value.toFixed(precision))
|
setInputValue(toDisplay(value).toFixed(precision))
|
||||||
}
|
}
|
||||||
}, [value, precision, isEditing])
|
}, [value, precision, isEditing, toDisplay])
|
||||||
|
|
||||||
// Wheel support on the label
|
// Wheel support on the label
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -96,14 +120,13 @@ export function SliderControl({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const direction = e.deltaY < 0 ? 1 : -1
|
const direction = e.deltaY < 0 ? 1 : -1
|
||||||
const s = getAdjustedStep(step, e)
|
const s = getAdjustedStep(step, e)
|
||||||
const newValue = clamp(valueRef.current + direction * s)
|
const final = applyDisplayDelta(valueRef.current, direction * s, s)
|
||||||
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
|
|
||||||
if (final !== valueRef.current) onChange(final)
|
if (final !== valueRef.current) onChange(final)
|
||||||
onCommit?.(final)
|
onCommit?.(final)
|
||||||
}
|
}
|
||||||
el.addEventListener('wheel', handleWheel, { passive: false })
|
el.addEventListener('wheel', handleWheel, { passive: false })
|
||||||
return () => el.removeEventListener('wheel', handleWheel)
|
return () => el.removeEventListener('wheel', handleWheel)
|
||||||
}, [isEditing, step, clamp, onChange, onCommit])
|
}, [isEditing, step, applyDisplayDelta, onChange, onCommit])
|
||||||
|
|
||||||
// Arrow key support while hovered
|
// Arrow key support while hovered
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -115,15 +138,14 @@ export function SliderControl({
|
|||||||
if (direction !== 0) {
|
if (direction !== 0) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const s = getAdjustedStep(step, e)
|
const s = getAdjustedStep(step, e)
|
||||||
const newValue = clamp(valueRef.current + direction * s)
|
const final = applyDisplayDelta(valueRef.current, direction * s, s)
|
||||||
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
|
|
||||||
if (final !== valueRef.current) onChange(final)
|
if (final !== valueRef.current) onChange(final)
|
||||||
onCommit?.(final)
|
onCommit?.(final)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [isHovered, isEditing, step, clamp, onChange, onCommit])
|
}, [isHovered, isEditing, step, applyDisplayDelta, onChange, onCommit])
|
||||||
|
|
||||||
const handleLabelPointerDown = useCallback(
|
const handleLabelPointerDown = useCallback(
|
||||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
@@ -160,15 +182,13 @@ export function SliderControl({
|
|||||||
const dx = e.clientX - anchorX
|
const dx = e.clientX - anchorX
|
||||||
const s = step * multiplier
|
const s = step * multiplier
|
||||||
// 4 px per step at default sensitivity
|
// 4 px per step at default sensitivity
|
||||||
const newValue = clamp(
|
const newValue = applyDisplayDelta(anchorValue, (dx / 4) * s, s)
|
||||||
Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))),
|
|
||||||
)
|
|
||||||
if (newValue !== valueRef.current) {
|
if (newValue !== valueRef.current) {
|
||||||
valueRef.current = newValue
|
valueRef.current = newValue
|
||||||
onChange(newValue)
|
onChange(newValue)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[step, clamp, onChange],
|
[step, applyDisplayDelta, onChange],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleLabelPointerUp = useCallback(
|
const handleLabelPointerUp = useCallback(
|
||||||
@@ -195,49 +215,65 @@ export function SliderControl({
|
|||||||
|
|
||||||
const handleValueClick = useCallback(() => {
|
const handleValueClick = useCallback(() => {
|
||||||
setIsEditing(true)
|
setIsEditing(true)
|
||||||
setInputValue(value.toFixed(precision))
|
setInputValue(toDisplay(value).toFixed(precision))
|
||||||
}, [value, precision])
|
}, [value, precision, toDisplay])
|
||||||
|
|
||||||
const submitValue = useCallback(() => {
|
const submitValue = useCallback(() => {
|
||||||
const numValue = Number.parseFloat(inputValue)
|
const spec = lingoUnitSpec(unit)
|
||||||
if (Number.isNaN(numValue)) {
|
let stored = spec
|
||||||
setInputValue(value.toFixed(precision))
|
? 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 {
|
} else {
|
||||||
const nextValue = clamp(Number.parseFloat(numValue.toFixed(precision)))
|
const nextValue = clamp(toStored(Number.parseFloat(toDisplay(stored).toFixed(precision))))
|
||||||
onChange(nextValue)
|
onChange(nextValue)
|
||||||
onCommit?.(nextValue)
|
onCommit?.(nextValue)
|
||||||
}
|
}
|
||||||
setIsEditing(false)
|
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(
|
const handleInputKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
submitValue()
|
submitValue()
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
setInputValue(value.toFixed(precision))
|
setInputValue(toDisplay(value).toFixed(precision))
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
const direction = e.key === 'ArrowUp' ? 1 : -1
|
||||||
const adjustedStep = getAdjustedStep(step, e)
|
const adjustedStep = getAdjustedStep(step, e)
|
||||||
const newV = clamp(
|
const newV = applyDisplayDelta(value, direction * adjustedStep, adjustedStep)
|
||||||
Number.parseFloat((value + adjustedStep).toFixed(stepPrecision(adjustedStep))),
|
|
||||||
)
|
|
||||||
onChange(newV)
|
onChange(newV)
|
||||||
setInputValue(newV.toFixed(precision))
|
setInputValue(toDisplay(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))
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[submitValue, value, precision, step, clamp, onChange],
|
[submitValue, value, precision, step, applyDisplayDelta, onChange, toDisplay],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const displayValue = toDisplay(value)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -279,6 +315,11 @@ export function SliderControl({
|
|||||||
<div className="flex items-center text-xs">
|
<div className="flex items-center text-xs">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<>
|
<>
|
||||||
|
{hint && (
|
||||||
|
<span className="mr-1 shrink-0 whitespace-nowrap text-[10px] text-muted-foreground/50 tabular-nums">
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-14 bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
|
className="w-14 bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
|
||||||
@@ -288,7 +329,7 @@ export function SliderControl({
|
|||||||
type="text"
|
type="text"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
/>
|
/>
|
||||||
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
|
{displayUnit && <span className="ml-[1px] text-muted-foreground">{displayUnit}</span>}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
@@ -296,9 +337,9 @@ export function SliderControl({
|
|||||||
onClick={handleValueClick}
|
onClick={handleValueClick}
|
||||||
>
|
>
|
||||||
<span className="font-mono tabular-nums tracking-tight" suppressHydrationWarning>
|
<span className="font-mono tabular-nums tracking-tight" suppressHydrationWarning>
|
||||||
{Number(value.toFixed(precision)).toFixed(precision)}
|
{Number(displayValue.toFixed(precision)).toFixed(precision)}
|
||||||
</span>
|
</span>
|
||||||
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
|
{displayUnit && <span className="ml-[1px] text-muted-foreground">{displayUnit}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ import {
|
|||||||
} from './../../../../../lib/level-duplication'
|
} from './../../../../../lib/level-duplication'
|
||||||
import { getDefaultLevelName } from '@pascal-app/core'
|
import { getDefaultLevelName } from '@pascal-app/core'
|
||||||
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
|
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
|
||||||
|
import {
|
||||||
|
getLinearUnitLabel,
|
||||||
|
linearUnitToMeters,
|
||||||
|
metersToLinearUnit,
|
||||||
|
} from './../../../../../lib/measurements'
|
||||||
import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
|
import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
|
||||||
import { cn } from './../../../../../lib/utils'
|
import { cn } from './../../../../../lib/utils'
|
||||||
import useEditor from './../../../../../store/use-editor'
|
import useEditor from './../../../../../store/use-editor'
|
||||||
@@ -93,6 +98,7 @@ const PropertyLineSection = memo(function PropertyLineSection() {
|
|||||||
const updateNode = useScene((state) => state.updateNode)
|
const updateNode = useScene((state) => state.updateNode)
|
||||||
const mode = useEditor((state) => state.mode)
|
const mode = useEditor((state) => state.mode)
|
||||||
const setMode = useEditor((state) => state.setMode)
|
const setMode = useEditor((state) => state.setMode)
|
||||||
|
const viewerUnit = useViewer((state) => state.unit)
|
||||||
|
|
||||||
if (!siteNode) return null
|
if (!siteNode) return null
|
||||||
|
|
||||||
@@ -101,6 +107,14 @@ const PropertyLineSection = memo(function PropertyLineSection() {
|
|||||||
const perimeter = calculatePerimeter(points)
|
const perimeter = calculatePerimeter(points)
|
||||||
const isEditing = mode === 'edit'
|
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 = () => {
|
const handleToggleEdit = () => {
|
||||||
setMode(isEditing ? 'select' : 'edit')
|
setMode(isEditing ? 'select' : 'edit')
|
||||||
}
|
}
|
||||||
@@ -166,10 +180,16 @@ const PropertyLineSection = memo(function PropertyLineSection() {
|
|||||||
{/* Measurements */}
|
{/* Measurements */}
|
||||||
<div className="relative flex gap-3 pr-3 pb-2 pl-10">
|
<div className="relative flex gap-3 pr-3 pb-2 pl-10">
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
Area: <span className="text-foreground">{area.toFixed(1)} m²</span>
|
Area:{' '}
|
||||||
|
<span className="text-foreground">
|
||||||
|
{displayArea.toFixed(1)} {isImperial ? 'ft²' : 'm²'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
Perimeter: <span className="text-foreground">{perimeter.toFixed(1)} m</span>
|
Perimeter:{' '}
|
||||||
|
<span className="text-foreground">
|
||||||
|
{displayPerimeter.toFixed(1)} {linearLabel}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -184,21 +204,21 @@ const PropertyLineSection = memo(function PropertyLineSection() {
|
|||||||
<input
|
<input
|
||||||
className="w-16 rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 text-foreground text-xs focus:border-primary focus:outline-none"
|
className="w-16 rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 text-foreground text-xs focus:border-primary focus:outline-none"
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handlePointChange(index, 0, Number.parseFloat(e.target.value) || 0)
|
handlePointChange(index, 0, toStoredLinear(Number.parseFloat(e.target.value) || 0))
|
||||||
}
|
}
|
||||||
step={0.5}
|
step={0.5}
|
||||||
type="number"
|
type="number"
|
||||||
value={point[0]}
|
value={Number(toDisplayLinear(point[0]).toFixed(2))}
|
||||||
/>
|
/>
|
||||||
<label className="shrink-0 text-muted-foreground">Z</label>
|
<label className="shrink-0 text-muted-foreground">Z</label>
|
||||||
<input
|
<input
|
||||||
className="w-16 rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 text-foreground text-xs focus:border-primary focus:outline-none"
|
className="w-16 rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 text-foreground text-xs focus:border-primary focus:outline-none"
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handlePointChange(index, 1, Number.parseFloat(e.target.value) || 0)
|
handlePointChange(index, 1, toStoredLinear(Number.parseFloat(e.target.value) || 0))
|
||||||
}
|
}
|
||||||
step={0.5}
|
step={0.5}
|
||||||
type="number"
|
type="number"
|
||||||
value={point[1]}
|
value={Number(toDisplayLinear(point[1]).toFixed(2))}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { type Kind, parseQuantity, quantity } from '@pascal-app/lingo'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Natural-language measurement parsing for editor property fields, backed by
|
||||||
|
* `@pascal-app/lingo`. Lets a user type `6ft`, `180cm`, `1m80`, `5'11"`, `45°`
|
||||||
|
* or `1.57rad` into any measurement field and have it canonicalized to the
|
||||||
|
* unit the field stores its value in — independent of the metric/imperial
|
||||||
|
* display toggle.
|
||||||
|
*
|
||||||
|
* The editor stores linear values in meters and angular values in radians (a
|
||||||
|
* few fields store inches or degrees); lingo's `length` base is meters and its
|
||||||
|
* `angle` base is radians, so a field's `unit` prop already names the stored /
|
||||||
|
* canonical unit and doubles as the parse target.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LingoUnitSpec {
|
||||||
|
kind: Kind
|
||||||
|
/** Unit id the field's stored `value` is expressed in — the parse target. */
|
||||||
|
unitId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map an editor field `unit` prop to a lingo kind + canonical unit. Returns
|
||||||
|
* `null` for units we deliberately do NOT natural-language-parse (plain
|
||||||
|
* numbers, percentages, angular rates, counts) so those keep exact
|
||||||
|
* `Number.parseFloat` behavior.
|
||||||
|
*/
|
||||||
|
export function lingoUnitSpec(unit: string | undefined): LingoUnitSpec | null {
|
||||||
|
switch (unit) {
|
||||||
|
case 'm':
|
||||||
|
case 'cm':
|
||||||
|
case 'mm':
|
||||||
|
case 'in':
|
||||||
|
case 'ft':
|
||||||
|
return { kind: 'length', unitId: unit }
|
||||||
|
case '°':
|
||||||
|
case 'deg':
|
||||||
|
case 'degrees':
|
||||||
|
return { kind: 'angle', unitId: 'deg' }
|
||||||
|
case 'rad':
|
||||||
|
case 'radians':
|
||||||
|
return { kind: 'angle', unitId: 'rad' }
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParseMeasurementOptions {
|
||||||
|
/**
|
||||||
|
* Implied unit for a BARE number (e.g. `6` → `6 ft`). Defaults to the field's
|
||||||
|
* own `unitId`. A typed unit (`180cm`) is always honored regardless.
|
||||||
|
*/
|
||||||
|
bareUnit?: string
|
||||||
|
/** Disambiguates gal/ton/cup families; harmless for length/angle. */
|
||||||
|
system?: 'metric' | 'us' | 'imperial'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse typed field text into the field's stored numeric unit. Returns `null`
|
||||||
|
* when the text can't be read as a quantity, so the caller can fall back to a
|
||||||
|
* plain number parse or revert to the previous value.
|
||||||
|
*/
|
||||||
|
export function parseMeasurement(
|
||||||
|
raw: string,
|
||||||
|
spec: LingoUnitSpec,
|
||||||
|
options: ParseMeasurementOptions = {},
|
||||||
|
): number | null {
|
||||||
|
const result = parseQuantity(raw, {
|
||||||
|
kind: spec.kind,
|
||||||
|
unit: options.bareUnit ?? spec.unitId,
|
||||||
|
system: options.system,
|
||||||
|
strictness: 'forgiving',
|
||||||
|
})
|
||||||
|
if (!result.ok) return null
|
||||||
|
const value = result.quantity.to(spec.unitId).value
|
||||||
|
return Number.isFinite(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeasurementHintOptions extends ParseMeasurementOptions {
|
||||||
|
/** Unit id the preview is rendered in (the field's displayed unit). */
|
||||||
|
displayUnit?: string
|
||||||
|
/** Max fraction digits in the preview. */
|
||||||
|
precision?: number
|
||||||
|
/**
|
||||||
|
* Clamp applied to the parsed stored value before previewing, so the hint
|
||||||
|
* reflects what a commit would actually store on a bounded field.
|
||||||
|
*/
|
||||||
|
clamp?: (stored: number) => number
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAIN_NUMBER = /^[+-]?\d*\.?\d*$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A faint "= 1.83 m" preview of the value currently being typed, shown only
|
||||||
|
* when the user typed something beyond a plain decimal in the field's own unit
|
||||||
|
* (an explicit unit, a compound like `1m80`, or a number word) and it parses.
|
||||||
|
* Returns `null` when there's nothing useful to preview.
|
||||||
|
*/
|
||||||
|
export function measurementHint(
|
||||||
|
raw: string,
|
||||||
|
spec: LingoUnitSpec,
|
||||||
|
options: MeasurementHintOptions = {},
|
||||||
|
): string | null {
|
||||||
|
const trimmed = raw.trim()
|
||||||
|
if (!trimmed || PLAIN_NUMBER.test(trimmed)) return null
|
||||||
|
|
||||||
|
const parsed = parseMeasurement(trimmed, spec, options)
|
||||||
|
if (parsed === null) return null
|
||||||
|
const stored = options.clamp ? options.clamp(parsed) : parsed
|
||||||
|
|
||||||
|
const displayUnit = options.displayUnit ?? spec.unitId
|
||||||
|
const precision = options.precision ?? 2
|
||||||
|
const q = quantity(stored, spec.unitId)
|
||||||
|
const shown = displayUnit === spec.unitId ? q : q.to(displayUnit)
|
||||||
|
return `= ${shown.format({ precision })}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { getLinearUnitLabel, linearUnitToMeters, metersToLinearUnit } from './measurements'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared display/storage conversion for numeric property controls so that
|
||||||
|
* every length input honors the metric/imperial toggle identically.
|
||||||
|
*
|
||||||
|
* Values are always STORED in the field's own unit (meters for `unit === 'm'`).
|
||||||
|
* When the viewer preference is imperial AND the field is a meter length, the
|
||||||
|
* value is DISPLAYED (and edited) in feet; otherwise the conversions are the
|
||||||
|
* identity, so metric fields and non-length units (`'°'`, `'%'`, `'in'`, `''`,
|
||||||
|
* …) behave exactly as before.
|
||||||
|
*
|
||||||
|
* Used by both `SliderControl` and `MetricControl` — keep the two in sync via
|
||||||
|
* this single source of truth.
|
||||||
|
*/
|
||||||
|
export function useLinearDisplay(unit: string, precision: number) {
|
||||||
|
const viewerUnit = useViewer((state) => state.unit)
|
||||||
|
const isImperial = viewerUnit === 'imperial' && unit === 'm'
|
||||||
|
const displayUnit = isImperial ? getLinearUnitLabel('imperial') : unit
|
||||||
|
|
||||||
|
const toDisplay = useCallback(
|
||||||
|
(stored: number) => (isImperial ? metersToLinearUnit(stored, 'imperial') : stored),
|
||||||
|
[isImperial],
|
||||||
|
)
|
||||||
|
const toStored = useCallback(
|
||||||
|
(display: number) => (isImperial ? linearUnitToMeters(display, 'imperial') : display),
|
||||||
|
[isImperial],
|
||||||
|
)
|
||||||
|
// Round a stored value so it lands on a clean number of DISPLAY-unit digits.
|
||||||
|
const roundStored = useCallback(
|
||||||
|
(stored: number) => toStored(Number.parseFloat(toDisplay(stored).toFixed(precision))),
|
||||||
|
[toDisplay, toStored, precision],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { isImperial, displayUnit, toDisplay, toStored, roundStored }
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"@pascal-app/lingo": "^0.1.0",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { SceneOperations } from '../operations'
|
import type { SceneOperations } from '../operations'
|
||||||
import { publishLiveSceneSnapshot } from './live-sync'
|
import { publishLiveSceneSnapshot } from './live-sync'
|
||||||
|
import { measurement } from './measurement'
|
||||||
import { NodeIdSchema, Vec2Schema, Vec3Schema } from './schemas'
|
import { NodeIdSchema, Vec2Schema, Vec3Schema } from './schemas'
|
||||||
|
|
||||||
const ROOF_TYPES = ['hip', 'gable', 'shed', 'gambrel', 'dutch', 'mansard', 'flat'] as const
|
const ROOF_TYPES = ['hip', 'gable', 'shed', 'gambrel', 'dutch', 'mansard', 'flat'] as const
|
||||||
@@ -22,12 +23,20 @@ const RAILING_MODES = ['none', 'left', 'right', 'both'] as const
|
|||||||
export const createStoryShellInput = {
|
export const createStoryShellInput = {
|
||||||
levelId: NodeIdSchema,
|
levelId: NodeIdSchema,
|
||||||
footprint: z.array(Vec2Schema).min(3),
|
footprint: z.array(Vec2Schema).min(3),
|
||||||
wallHeight: z.number().positive().default(2.8),
|
wallHeight: measurement('length', 'm', { positive: true, description: 'Wall height.' }).default(
|
||||||
wallThickness: z.number().positive().default(0.16),
|
2.8,
|
||||||
|
),
|
||||||
|
wallThickness: measurement('length', 'm', {
|
||||||
|
positive: true,
|
||||||
|
description: 'Wall thickness.',
|
||||||
|
}).default(0.16),
|
||||||
createSlab: z.boolean().default(true),
|
createSlab: z.boolean().default(true),
|
||||||
createCeiling: z.boolean().default(true),
|
createCeiling: z.boolean().default(true),
|
||||||
slabElevation: z.number().default(0.1),
|
slabElevation: measurement('length', 'm', { description: 'Slab elevation.' }).default(0.1),
|
||||||
ceilingHeight: z.number().positive().optional(),
|
ceilingHeight: measurement('length', 'm', {
|
||||||
|
positive: true,
|
||||||
|
description: 'Ceiling height.',
|
||||||
|
}).optional(),
|
||||||
namePrefix: z.string().optional(),
|
namePrefix: z.string().optional(),
|
||||||
wallMaterialPreset: z.string().optional(),
|
wallMaterialPreset: z.string().optional(),
|
||||||
slabMaterialPreset: z.string().optional(),
|
slabMaterialPreset: z.string().optional(),
|
||||||
@@ -47,16 +56,25 @@ export const createRoofInput = {
|
|||||||
roofLevelId: NodeIdSchema.optional(),
|
roofLevelId: NodeIdSchema.optional(),
|
||||||
useDedicatedRoofLevel: z.boolean().default(true),
|
useDedicatedRoofLevel: z.boolean().default(true),
|
||||||
roofLevelLabel: z.string().default('Roof'),
|
roofLevelLabel: z.string().default('Roof'),
|
||||||
|
// A level ordinal (story index), not a length — kept numeric.
|
||||||
roofLevelElevation: z.number().optional(),
|
roofLevelElevation: z.number().optional(),
|
||||||
roofLevelHeight: z.number().positive().optional(),
|
roofLevelHeight: measurement('length', 'm', {
|
||||||
|
positive: true,
|
||||||
|
description: 'Roof level height.',
|
||||||
|
}).optional(),
|
||||||
center: Vec3Schema.optional(),
|
center: Vec3Schema.optional(),
|
||||||
width: z.number().positive(),
|
width: measurement('length', 'm', { positive: true, description: 'Roof width.' }),
|
||||||
depth: z.number().positive(),
|
depth: measurement('length', 'm', { positive: true, description: 'Roof depth.' }),
|
||||||
roofType: z.enum(ROOF_TYPES).default('hip'),
|
roofType: z.enum(ROOF_TYPES).default('hip'),
|
||||||
pitch: z.number().min(0).max(85).default(35),
|
pitch: measurement('angle', 'deg', { min: 0, max: 85, description: 'Roof pitch.' }).default(35),
|
||||||
wallHeight: z.number().min(0).default(0.35),
|
wallHeight: measurement('length', 'm', { min: 0, description: 'Knee-wall height.' }).default(
|
||||||
wallThickness: z.number().positive().default(0.16),
|
0.35,
|
||||||
overhang: z.number().min(0).default(0.45),
|
),
|
||||||
|
wallThickness: measurement('length', 'm', {
|
||||||
|
positive: true,
|
||||||
|
description: 'Wall thickness.',
|
||||||
|
}).default(0.16),
|
||||||
|
overhang: measurement('length', 'm', { min: 0, description: 'Eave overhang.' }).default(0.45),
|
||||||
materialPreset: z.string().optional(),
|
materialPreset: z.string().optional(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
}
|
}
|
||||||
@@ -73,21 +91,33 @@ export const createStairBetweenLevelsInput = {
|
|||||||
fromLevelId: NodeIdSchema,
|
fromLevelId: NodeIdSchema,
|
||||||
toLevelId: NodeIdSchema,
|
toLevelId: NodeIdSchema,
|
||||||
position: Vec3Schema,
|
position: Vec3Schema,
|
||||||
rotation: z.number().default(0),
|
rotation: measurement('angle', 'rad', { description: 'Y-axis rotation.' }).default(0),
|
||||||
width: z.number().positive().default(1),
|
width: measurement('length', 'm', { positive: true, description: 'Stair width.' }).default(1),
|
||||||
runLength: z.number().positive().default(3),
|
runLength: measurement('length', 'm', {
|
||||||
totalRise: z.number().positive().default(2.8),
|
positive: true,
|
||||||
|
description: 'Horizontal run length.',
|
||||||
|
}).default(3),
|
||||||
|
totalRise: measurement('length', 'm', {
|
||||||
|
positive: true,
|
||||||
|
description: 'Total vertical rise.',
|
||||||
|
}).default(2.8),
|
||||||
stepCount: z.number().int().positive().default(14),
|
stepCount: z.number().int().positive().default(14),
|
||||||
railingMode: z.enum(RAILING_MODES).default('both'),
|
railingMode: z.enum(RAILING_MODES).default('both'),
|
||||||
destinationSlabId: NodeIdSchema.optional(),
|
destinationSlabId: NodeIdSchema.optional(),
|
||||||
sourceCeilingId: NodeIdSchema.optional(),
|
sourceCeilingId: NodeIdSchema.optional(),
|
||||||
createDestinationSlabOpening: z.boolean().default(true),
|
createDestinationSlabOpening: z.boolean().default(true),
|
||||||
createSourceCeilingOpening: z.boolean().default(true),
|
createSourceCeilingOpening: z.boolean().default(true),
|
||||||
openingWidth: z.number().positive().optional(),
|
openingWidth: measurement('length', 'm', {
|
||||||
openingLength: z.number().positive().optional(),
|
positive: true,
|
||||||
openingOffset: z.number().min(0).default(0),
|
description: 'Floor opening width.',
|
||||||
|
}).optional(),
|
||||||
|
openingLength: measurement('length', 'm', {
|
||||||
|
positive: true,
|
||||||
|
description: 'Floor opening length.',
|
||||||
|
}).optional(),
|
||||||
|
openingOffset: measurement('length', 'm', { min: 0, description: 'Opening offset.' }).default(0),
|
||||||
openingCenter: Vec2Schema.optional(),
|
openingCenter: Vec2Schema.optional(),
|
||||||
openingRotation: z.number().optional(),
|
openingRotation: measurement('angle', 'rad', { description: 'Opening rotation.' }).optional(),
|
||||||
materialPreset: z.string().optional(),
|
materialPreset: z.string().optional(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ import { z } from 'zod'
|
|||||||
import type { SceneOperations } from '../operations'
|
import type { SceneOperations } from '../operations'
|
||||||
import { ErrorCode, throwMcpError } from './errors'
|
import { ErrorCode, throwMcpError } from './errors'
|
||||||
import { publishLiveSceneSnapshot } from './live-sync'
|
import { publishLiveSceneSnapshot } from './live-sync'
|
||||||
|
import { measurement } from './measurement'
|
||||||
import { NodeIdSchema } from './schemas'
|
import { NodeIdSchema } from './schemas'
|
||||||
|
|
||||||
export const createLevelInput = {
|
export const createLevelInput = {
|
||||||
buildingId: NodeIdSchema,
|
buildingId: NodeIdSchema,
|
||||||
elevation: z.number().optional(),
|
elevation: z.number().optional(),
|
||||||
height: z.number().optional(),
|
height: measurement('length', 'm', {
|
||||||
|
min: 0,
|
||||||
|
description: 'Level height (stored in metadata).',
|
||||||
|
}).optional(),
|
||||||
label: z.string().optional(),
|
label: z.string().optional(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,34 @@ describe('create_wall', () => {
|
|||||||
expect((created as { thickness?: number }).thickness).toBe(0.15)
|
expect((created as { thickness?: number }).thickness).toBe(0.15)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('accepts a natural-language thickness and canonicalizes to meters', async () => {
|
||||||
|
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: 'create_wall',
|
||||||
|
arguments: {
|
||||||
|
levelId: level.id,
|
||||||
|
start: [0, 0],
|
||||||
|
end: [4, 0],
|
||||||
|
thickness: '6 in',
|
||||||
|
height: '2.5m',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(result.isError).toBeFalsy()
|
||||||
|
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
|
||||||
|
const created = bridge.getNode(parsed.wallId) as { thickness?: number; height?: number }
|
||||||
|
expect(created.thickness).toBeCloseTo(0.1524, 6)
|
||||||
|
expect(created.height).toBeCloseTo(2.5, 6)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects an out-of-unit-family value', async () => {
|
||||||
|
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: 'create_wall',
|
||||||
|
arguments: { levelId: level.id, start: [0, 0], end: [4, 0], thickness: 'banana' },
|
||||||
|
})
|
||||||
|
expect(result.isError).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
test('publishes a live scene snapshot when bound to a saved scene', async () => {
|
test('publishes a live scene snapshot when bound to a saved scene', async () => {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const savedMeta: SceneMeta = {
|
const savedMeta: SceneMeta = {
|
||||||
|
|||||||
@@ -5,14 +5,18 @@ import { z } from 'zod'
|
|||||||
import type { SceneOperations } from '../operations'
|
import type { SceneOperations } from '../operations'
|
||||||
import { ErrorCode, throwMcpError } from './errors'
|
import { ErrorCode, throwMcpError } from './errors'
|
||||||
import { publishLiveSceneSnapshot } from './live-sync'
|
import { publishLiveSceneSnapshot } from './live-sync'
|
||||||
|
import { measurement } from './measurement'
|
||||||
import { NodeIdSchema, Vec2Schema } from './schemas'
|
import { NodeIdSchema, Vec2Schema } from './schemas'
|
||||||
|
|
||||||
export const createWallInput = {
|
export const createWallInput = {
|
||||||
levelId: NodeIdSchema,
|
levelId: NodeIdSchema,
|
||||||
start: Vec2Schema,
|
start: Vec2Schema,
|
||||||
end: Vec2Schema,
|
end: Vec2Schema,
|
||||||
thickness: z.number().positive().optional(),
|
thickness: measurement('length', 'm', {
|
||||||
height: z.number().positive().optional(),
|
positive: true,
|
||||||
|
description: 'Wall thickness.',
|
||||||
|
}).optional(),
|
||||||
|
height: measurement('length', 'm', { positive: true, description: 'Wall height.' }).optional(),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createWallOutput = {
|
export const createWallOutput = {
|
||||||
|
|||||||
@@ -6,14 +6,15 @@ import type { SceneOperations } from '../operations'
|
|||||||
import { ErrorCode, throwMcpError } from './errors'
|
import { ErrorCode, throwMcpError } from './errors'
|
||||||
import { wallLength, wallLocalXFromT } from './geometry'
|
import { wallLength, wallLocalXFromT } from './geometry'
|
||||||
import { publishLiveSceneSnapshot } from './live-sync'
|
import { publishLiveSceneSnapshot } from './live-sync'
|
||||||
|
import { measurement } from './measurement'
|
||||||
import { NodeIdSchema } from './schemas'
|
import { NodeIdSchema } from './schemas'
|
||||||
|
|
||||||
export const cutOpeningInput = {
|
export const cutOpeningInput = {
|
||||||
wallId: NodeIdSchema,
|
wallId: NodeIdSchema,
|
||||||
type: z.enum(['door', 'window']),
|
type: z.enum(['door', 'window']),
|
||||||
position: z.number().min(0).max(1),
|
position: z.number().min(0).max(1),
|
||||||
width: z.number().positive(),
|
width: measurement('length', 'm', { positive: true, description: 'Opening width.' }),
|
||||||
height: z.number().positive(),
|
height: measurement('length', 'm', { positive: true, description: 'Opening height.' }),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cutOpeningOutput = {
|
export const cutOpeningOutput = {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { measurement } from './measurement'
|
||||||
|
|
||||||
|
describe('measurement()', () => {
|
||||||
|
test('parses natural-language length to meters', () => {
|
||||||
|
const m = measurement('length', 'm', { min: 0 })
|
||||||
|
expect(m.parse('6 in')).toBeCloseTo(0.1524, 6)
|
||||||
|
expect(m.parse('180cm')).toBeCloseTo(1.8, 6)
|
||||||
|
expect(m.parse('1m80')).toBeCloseTo(1.8, 6)
|
||||||
|
expect(m.parse(`5'11"`)).toBeCloseTo(1.8034, 4)
|
||||||
|
expect(m.parse('2 ft 3 in')).toBeCloseTo(0.6858, 6)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('passes numbers through unchanged (backward compatible)', () => {
|
||||||
|
const m = measurement('length', 'm', { min: 0 })
|
||||||
|
expect(m.parse(0.15)).toBe(0.15)
|
||||||
|
expect(m.parse(3)).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reads a bare numeric string as the field unit', () => {
|
||||||
|
expect(measurement('length', 'm').parse('6')).toBe(6)
|
||||||
|
expect(measurement('angle', 'deg').parse('45')).toBe(45)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('canonicalizes angles to the field unit', () => {
|
||||||
|
expect(measurement('angle', 'deg').parse('45°')).toBe(45)
|
||||||
|
expect(measurement('angle', 'deg').parse('1.57rad')).toBeCloseTo(89.954, 3)
|
||||||
|
expect(measurement('angle', 'rad').parse('90deg')).toBeCloseTo(Math.PI / 2, 6)
|
||||||
|
expect(measurement('angle', 'rad').parse('0.25 turn')).toBeCloseTo(Math.PI / 2, 6)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('enforces min/max bounds in the field unit', () => {
|
||||||
|
const m = measurement('length', 'm', { min: 0, max: 3 })
|
||||||
|
expect(m.safeParse('-1').success).toBe(false)
|
||||||
|
expect(m.safeParse('900ft').success).toBe(false)
|
||||||
|
expect(m.safeParse('2m').success).toBe(true)
|
||||||
|
const tooBig = m.safeParse('900ft')
|
||||||
|
expect(tooBig.success).toBe(false)
|
||||||
|
if (!tooBig.success) expect(tooBig.error.issues[0]?.message).toContain('at most 3 m')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects unparseable input with a model-readable message', () => {
|
||||||
|
const r = measurement('length', 'm').safeParse('banana')
|
||||||
|
expect(r.success).toBe(false)
|
||||||
|
if (!r.success) expect(r.error.issues[0]?.message.toLowerCase()).toContain('number')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('positive rejects zero and negatives (restores .positive() behavior)', () => {
|
||||||
|
const m = measurement('length', 'm', { positive: true })
|
||||||
|
expect(m.safeParse(0).success).toBe(false)
|
||||||
|
expect(m.safeParse('0m').success).toBe(false)
|
||||||
|
expect(m.safeParse(-1).success).toBe(false)
|
||||||
|
expect(m.parse(0.1)).toBe(0.1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects ambiguous separators instead of a silent 1000x reading', () => {
|
||||||
|
const r = measurement('length', 'm').safeParse('1,234')
|
||||||
|
expect(r.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('emits a number|string JSON schema advertising natural language', () => {
|
||||||
|
const schema = z.toJSONSchema(
|
||||||
|
measurement('length', 'm', { min: 0, description: 'Wall thickness.' }),
|
||||||
|
{
|
||||||
|
io: 'input',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const json = JSON.stringify(schema)
|
||||||
|
expect(json).toContain('anyOf')
|
||||||
|
expect(json).toContain('number')
|
||||||
|
expect(json).toContain('string')
|
||||||
|
expect((schema as { description?: string }).description).toContain('natural-language')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { type Kind, parseQuantity } from '@pascal-app/lingo'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A zod field for a measurement tool argument that a model may emit as a bare
|
||||||
|
* number OR as natural language. `measurement('length', 'm')` accepts `0.15`,
|
||||||
|
* `"6 in"`, `"180cm"`, `"2 ft 3 in"`; `measurement('angle', 'deg')` accepts
|
||||||
|
* `45`, `"45°"`, `"1.57rad"`, `"0.25 turn"`. The value is canonicalized (via
|
||||||
|
* `@pascal-app/lingo`) to a number in `unit` — the exact unit the tool handler
|
||||||
|
* already expects — so no handler change is needed. `min`/`max` (in `unit`)
|
||||||
|
* reject out-of-range values with a model-readable message.
|
||||||
|
*
|
||||||
|
* The emitted JSON Schema is `number | string`, so the model is free to answer
|
||||||
|
* in whatever unit it is thinking in; AI SDK v6 applies the transform and
|
||||||
|
* forwards the canonical number to the tool executor.
|
||||||
|
*
|
||||||
|
* Tool-boundary safety: genuinely ambiguous separators (`"1,234"`, which could
|
||||||
|
* mean 1234 or 1.234) are rejected rather than silently absorbed, so a European
|
||||||
|
* decimal never becomes a 1000× value.
|
||||||
|
*
|
||||||
|
* NOTE: intentionally duplicated as `editor/packages/mcp/src/tools/measurement.ts`
|
||||||
|
* (MCP tools) and `packages/ai/src/tools/scene/measurement.ts` (AI-chat tools).
|
||||||
|
* The two tool stacks live in different packages — one inside the `editor`
|
||||||
|
* submodule — so they can't share a module. Keep the two copies identical; the
|
||||||
|
* clean long-term dedup is a zod-field adapter exported from `@pascal-app/lingo`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MeasurementOptions {
|
||||||
|
/** Semantic description (e.g. "Wall thickness"). The natural-language note is appended. */
|
||||||
|
description?: string
|
||||||
|
/** Upper bound, in `unit`. */
|
||||||
|
max?: number
|
||||||
|
/** Inclusive lower bound, in `unit`. */
|
||||||
|
min?: number
|
||||||
|
/** Require the value to be strictly greater than 0 (for non-zero dimensions). */
|
||||||
|
positive?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitNoun(unit: string): string {
|
||||||
|
switch (unit) {
|
||||||
|
case 'm':
|
||||||
|
return 'meters'
|
||||||
|
case 'deg':
|
||||||
|
return 'degrees'
|
||||||
|
case 'rad':
|
||||||
|
return 'radians'
|
||||||
|
default:
|
||||||
|
return unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function naturalLanguageNote(kind: Kind, unit: string): string {
|
||||||
|
let examples: string
|
||||||
|
if (kind === 'angle') {
|
||||||
|
// Lead with an example in the field's own unit so a bare number is read
|
||||||
|
// the way the model intends (a bare "45" in a radians field is 45 rad).
|
||||||
|
examples =
|
||||||
|
unit === 'rad' ? '1.5708, "90°", "1.57rad", "0.25 turn"' : '45, "45°", "1.57rad", "0.25 turn"'
|
||||||
|
} else {
|
||||||
|
examples = '0.9, "6 ft", "180cm", "2 ft 3 in"'
|
||||||
|
}
|
||||||
|
return `Accepts a number (${unitNoun(unit)}) or a natural-language string; other units are converted. e.g. ${examples}.`
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundsNote(
|
||||||
|
unit: string,
|
||||||
|
min: number | undefined,
|
||||||
|
max: number | undefined,
|
||||||
|
positive: boolean,
|
||||||
|
): string {
|
||||||
|
if (positive) {
|
||||||
|
return max === undefined
|
||||||
|
? ` Must be greater than 0 ${unit}.`
|
||||||
|
: ` Greater than 0, up to ${max} ${unit}.`
|
||||||
|
}
|
||||||
|
if (min !== undefined && max !== undefined) return ` Range ${min}–${max} ${unit}.`
|
||||||
|
if (min !== undefined) return ` Minimum ${min} ${unit}.`
|
||||||
|
if (max !== undefined) return ` Maximum ${max} ${unit}.`
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function measurement(kind: Kind, unit: string, opts: MeasurementOptions = {}) {
|
||||||
|
const { min, max, positive = false } = opts
|
||||||
|
const description = [
|
||||||
|
opts.description,
|
||||||
|
naturalLanguageNote(kind, unit) + boundsNote(unit, min, max, positive),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
return z
|
||||||
|
.union([z.number(), z.string()])
|
||||||
|
.transform((val, ctx) => {
|
||||||
|
let value: number
|
||||||
|
if (typeof val === 'number') {
|
||||||
|
value = val
|
||||||
|
} else {
|
||||||
|
const result = parseQuantity(val, {
|
||||||
|
kind,
|
||||||
|
unit,
|
||||||
|
strictness: 'forgiving',
|
||||||
|
// A genuinely ambiguous separator ("1,234") must not silently become
|
||||||
|
// a 1000× value at a tool boundary — fail so the model self-corrects.
|
||||||
|
escalate: { AMBIGUOUS_NUMBER: 'error' },
|
||||||
|
})
|
||||||
|
if (!result.ok) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: result.issues[0]?.message ?? `Could not read "${val}" as a ${kind}.`,
|
||||||
|
})
|
||||||
|
return z.NEVER
|
||||||
|
}
|
||||||
|
value = result.quantity.to(unit).value
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
ctx.addIssue({ code: 'custom', message: `Value must be a finite ${kind} in ${unit}.` })
|
||||||
|
return z.NEVER
|
||||||
|
}
|
||||||
|
if (positive && value <= 0) {
|
||||||
|
ctx.addIssue({ code: 'custom', message: `Must be greater than 0 ${unit} (got ${value}).` })
|
||||||
|
return z.NEVER
|
||||||
|
}
|
||||||
|
if (min !== undefined && value < min) {
|
||||||
|
ctx.addIssue({ code: 'custom', message: `Must be at least ${min} ${unit} (got ${value}).` })
|
||||||
|
return z.NEVER
|
||||||
|
}
|
||||||
|
if (max !== undefined && value > max) {
|
||||||
|
ctx.addIssue({ code: 'custom', message: `Must be at most ${max} ${unit} (got ${value}).` })
|
||||||
|
return z.NEVER
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
.describe(description)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { SceneOperations } from '../../operations'
|
import type { SceneOperations } from '../../operations'
|
||||||
import { appendLiveSceneEvent } from '../live-sync'
|
import { appendLiveSceneEvent } from '../live-sync'
|
||||||
|
import { measurement } from '../measurement'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Input shape for the `photo_to_scene` orchestrator. `image` matches the
|
* Input shape for the `photo_to_scene` orchestrator. `image` matches the
|
||||||
@@ -23,8 +24,14 @@ export const photoToSceneInput = {
|
|||||||
scaleHint: z.string().optional().describe('e.g. "1 cm = 1 m" or "approx 80 m²"'),
|
scaleHint: z.string().optional().describe('e.g. "1 cm = 1 m" or "approx 80 m²"'),
|
||||||
name: z.string().default('Scene from photo'),
|
name: z.string().default('Scene from photo'),
|
||||||
save: z.boolean().default(true),
|
save: z.boolean().default(true),
|
||||||
defaultWallThickness: z.number().default(0.2),
|
defaultWallThickness: measurement('length', 'm', {
|
||||||
defaultWallHeight: z.number().default(2.6),
|
positive: true,
|
||||||
|
description: 'Default wall thickness.',
|
||||||
|
}).default(0.2),
|
||||||
|
defaultWallHeight: measurement('length', 'm', {
|
||||||
|
positive: true,
|
||||||
|
description: 'Default wall height.',
|
||||||
|
}).default(2.6),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const photoToSceneOutput = {
|
export const photoToSceneOutput = {
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import { findCatalogItem } from './asset-catalog'
|
|||||||
import { ErrorCode, throwMcpError } from './errors'
|
import { ErrorCode, throwMcpError } from './errors'
|
||||||
import { projectWorldPointToWallLocalX, wallLength } from './geometry'
|
import { projectWorldPointToWallLocalX, wallLength } from './geometry'
|
||||||
import { publishLiveSceneSnapshot } from './live-sync'
|
import { publishLiveSceneSnapshot } from './live-sync'
|
||||||
|
import { measurement } from './measurement'
|
||||||
import { NodeIdSchema, Vec3Schema } from './schemas'
|
import { NodeIdSchema, Vec3Schema } from './schemas'
|
||||||
|
|
||||||
export const placeItemInput = {
|
export const placeItemInput = {
|
||||||
catalogItemId: z.string().min(1),
|
catalogItemId: z.string().min(1),
|
||||||
targetNodeId: NodeIdSchema,
|
targetNodeId: NodeIdSchema,
|
||||||
position: Vec3Schema,
|
position: Vec3Schema,
|
||||||
rotation: z.number().optional(),
|
rotation: measurement('angle', 'rad', { description: 'Y-axis rotation.' }).optional(),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const placeItemOutput = {
|
export const placeItemOutput = {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
wallLocalXFromT,
|
wallLocalXFromT,
|
||||||
} from './geometry'
|
} from './geometry'
|
||||||
import { publishLiveSceneSnapshot } from './live-sync'
|
import { publishLiveSceneSnapshot } from './live-sync'
|
||||||
|
import { measurement } from './measurement'
|
||||||
import { NodeIdSchema, Vec2Schema } from './schemas'
|
import { NodeIdSchema, Vec2Schema } from './schemas'
|
||||||
|
|
||||||
const ROOM_TYPES = [
|
const ROOM_TYPES = [
|
||||||
@@ -51,8 +52,14 @@ export const createRoomInput = {
|
|||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
polygon: z.array(Vec2Schema).min(3),
|
polygon: z.array(Vec2Schema).min(3),
|
||||||
color: z.string().optional(),
|
color: z.string().optional(),
|
||||||
wallHeight: z.number().positive().optional(),
|
wallHeight: measurement('length', 'm', {
|
||||||
wallThickness: z.number().positive().optional(),
|
positive: true,
|
||||||
|
description: 'Wall height.',
|
||||||
|
}).optional(),
|
||||||
|
wallThickness: measurement('length', 'm', {
|
||||||
|
positive: true,
|
||||||
|
description: 'Wall thickness.',
|
||||||
|
}).optional(),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createRoomOutput = {
|
export const createRoomOutput = {
|
||||||
@@ -67,8 +74,8 @@ export const addDoorInput = {
|
|||||||
wallId: NodeIdSchema,
|
wallId: NodeIdSchema,
|
||||||
t: z.number().min(0).max(1).optional(),
|
t: z.number().min(0).max(1).optional(),
|
||||||
position: z.number().min(0).max(1).optional(),
|
position: z.number().min(0).max(1).optional(),
|
||||||
width: z.number().positive().optional(),
|
width: measurement('length', 'm', { positive: true, description: 'Door width.' }).optional(),
|
||||||
height: z.number().positive().optional(),
|
height: measurement('length', 'm', { positive: true, description: 'Door height.' }).optional(),
|
||||||
hingesSide: z.enum(['left', 'right']).optional(),
|
hingesSide: z.enum(['left', 'right']).optional(),
|
||||||
swingDirection: z.enum(['inward', 'outward']).optional(),
|
swingDirection: z.enum(['inward', 'outward']).optional(),
|
||||||
}
|
}
|
||||||
@@ -87,9 +94,12 @@ export const addWindowInput = {
|
|||||||
wallId: NodeIdSchema,
|
wallId: NodeIdSchema,
|
||||||
t: z.number().min(0).max(1).optional(),
|
t: z.number().min(0).max(1).optional(),
|
||||||
position: z.number().min(0).max(1).optional(),
|
position: z.number().min(0).max(1).optional(),
|
||||||
width: z.number().positive().optional(),
|
width: measurement('length', 'm', { positive: true, description: 'Window width.' }).optional(),
|
||||||
height: z.number().positive().optional(),
|
height: measurement('length', 'm', { positive: true, description: 'Window height.' }).optional(),
|
||||||
sillHeight: z.number().min(0).optional(),
|
sillHeight: measurement('length', 'm', {
|
||||||
|
min: 0,
|
||||||
|
description: 'Sill height above floor.',
|
||||||
|
}).optional(),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addWindowOutput = {
|
export const addWindowOutput = {
|
||||||
|
|||||||
Reference in New Issue
Block a user