Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 29 additions & 13 deletions packages/editor/src/components/ui/controls/metric-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@

import { useScene } from '@pascal-app/core'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
lingoUnitSpec,
measurementHint,
parseMeasurement,
} from '../../../lib/measurement-parser'
import { lingoUnitSpec, measurementHint, parseMeasurement } from '../../../lib/measurement-parser'
import { useLinearDisplay } from '../../../lib/use-linear-display'
import { cn } from '../../../lib/utils'

Expand All @@ -31,18 +27,21 @@ export function MetricControl({
onCommit,
min = Number.NEGATIVE_INFINITY,
max = Number.POSITIVE_INFINITY,
precision = 2,
step = 1,
precision: storedPrecision = 2,
step: storedStep = 1,
className,
unit = '',
restoreOnCommit = true,
}: MetricControlProps) {
const {
isImperial,
displayUnit,
parseUnit,
precision,
step,
toDisplay: toDisplayValue,
toStored: toStoredValue,
} = useLinearDisplay(unit, precision)
} = useLinearDisplay(unit, storedPrecision, storedStep)

const clamp = useCallback(
(val: number) => {
Expand Down Expand Up @@ -110,7 +109,14 @@ export function MetricControl({

container.addEventListener('wheel', handleWheel, { passive: false })
return () => container.removeEventListener('wheel', handleWheel)
}, [isEditing, step, clamp, applyCommittedValue, toStoredValue, roundStoredValueForDisplayPrecision])
}, [
isEditing,
step,
clamp,
applyCommittedValue,
toStoredValue,
roundStoredValueForDisplayPrecision,
])

useEffect(() => {
if (!isHovered || isEditing) return
Expand Down Expand Up @@ -226,7 +232,7 @@ export function MetricControl({
const spec = lingoUnitSpec(unit)
let stored = spec
? parseMeasurement(inputValue, spec, {
bareUnit: isImperial ? 'ft' : spec.unitId,
bareUnit: parseUnit ?? spec.unitId,
system: isImperial ? 'us' : 'metric',
})
: null
Expand All @@ -244,6 +250,7 @@ export function MetricControl({
inputValue,
unit,
isImperial,
parseUnit,
applyCommittedValue,
clamp,
toStoredValue,
Expand All @@ -256,9 +263,9 @@ export function MetricControl({
const hint =
isEditing && spec
? measurementHint(inputValue, spec, {
bareUnit: isImperial ? 'ft' : spec.unitId,
bareUnit: parseUnit ?? spec.unitId,
system: isImperial ? 'us' : 'metric',
displayUnit: isImperial ? 'ft' : spec.unitId,
displayUnit: parseUnit ?? spec.unitId,
precision,
clamp,
})
Expand Down Expand Up @@ -287,7 +294,16 @@ export function MetricControl({
setInputValue(toDisplayValue(newV).toFixed(precision))
}
},
[submitValue, value, toDisplayValue, precision, step, clamp, applyCommittedValue, toStoredValue],
[
submitValue,
value,
toDisplayValue,
precision,
step,
clamp,
applyCommittedValue,
toStoredValue,
],
)

return (
Expand Down
43 changes: 25 additions & 18 deletions packages/editor/src/components/ui/controls/slider-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@

import { useScene } from '@pascal-app/core'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
lingoUnitSpec,
measurementHint,
parseMeasurement,
} from '../../../lib/measurement-parser'
import { lingoUnitSpec, measurementHint, parseMeasurement } from '../../../lib/measurement-parser'
import { useLinearDisplay } from '../../../lib/use-linear-display'
import { cn } from '../../../lib/utils'

Expand Down Expand Up @@ -60,19 +56,16 @@ export function SliderControl({
onCommit,
min = Number.NEGATIVE_INFINITY,
max = Number.POSITIVE_INFINITY,
precision = 0,
step = 1,
precision: storedPrecision = 0,
step: storedStep = 1,
className,
unit = '',
restoreOnCommit = true,
mixed = false,
}: 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)
// Values and bounds stay in meters; gestures and input use the displayed unit.
const { isImperial, displayUnit, parseUnit, precision, step, toDisplay, toStored } =
useLinearDisplay(unit, storedPrecision, storedStep)

const [isEditing, setIsEditing] = useState(false)
const [isDragging, setIsDragging] = useState(false)
Expand Down Expand Up @@ -106,7 +99,9 @@ export function SliderControl({
(storedValue: number, displayDelta: number, displayStep: number) =>
clamp(
toStored(
Number.parseFloat((toDisplay(storedValue) + displayDelta).toFixed(stepPrecision(displayStep))),
Number.parseFloat(
(toDisplay(storedValue) + displayDelta).toFixed(stepPrecision(displayStep)),
),
),
),
[clamp, toDisplay, toStored],
Expand Down Expand Up @@ -231,7 +226,7 @@ export function SliderControl({
const spec = lingoUnitSpec(unit)
let stored = spec
? parseMeasurement(inputValue, spec, {
bareUnit: isImperial ? 'ft' : spec.unitId,
bareUnit: parseUnit ?? spec.unitId,
system: isImperial ? 'us' : 'metric',
})
: null
Expand All @@ -248,15 +243,27 @@ export function SliderControl({
onCommit?.(nextValue)
}
setIsEditing(false)
}, [inputValue, unit, isImperial, onChange, onCommit, clamp, precision, value, toDisplay, toStored])
}, [
inputValue,
unit,
isImperial,
parseUnit,
onChange,
onCommit,
clamp,
precision,
value,
toDisplay,
toStored,
])

const spec = lingoUnitSpec(unit)
const hint =
isEditing && spec
? measurementHint(inputValue, spec, {
bareUnit: isImperial ? 'ft' : spec.unitId,
bareUnit: parseUnit ?? spec.unitId,
system: isImperial ? 'us' : 'metric',
displayUnit: isImperial ? 'ft' : spec.unitId,
displayUnit: parseUnit ?? spec.unitId,
precision,
clamp,
})
Expand Down
11 changes: 7 additions & 4 deletions packages/editor/src/components/ui/floating-level-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,14 @@ function LevelRow({
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const updateNode = useScene((s) => s.updateNode)
const { isImperial, toDisplay, displayUnit } = useLinearDisplay('m', 2)
const { isImperial, toDisplay, displayUnit, precision: displayPrecision } = useLinearDisplay('m', 2)

const storeyHeight = getStoredLevelHeight(level)
// toFixed(2) + strip one trailing zero: "2.50" → "2.5", "2.75" stays.
const storeyHeightLabel = `${toDisplay(storeyHeight).toFixed(2).replace(/0$/, '')} ${displayUnit}`
// Decimal units keep the compact readout; integer millimeters must retain trailing zeroes.
const formattedStoreyHeight = toDisplay(storeyHeight).toFixed(displayPrecision)
const storeyHeightLabel = `${
displayPrecision > 0 ? formattedStoreyHeight.replace(/0$/, '') : formattedStoreyHeight
} ${displayUnit}`
// Same rule as the site panel and command palette: the ordinal-0 ground
// floor is the vertical model's zero anchor and must never be deletable.
const canDeleteLevel = level.level !== 0
Expand Down Expand Up @@ -244,7 +247,7 @@ function LevelRow({
precision={3}
step={0.1}
unit="m"
value={Math.round(storeyHeight * 1000) / 1000}
value={storeyHeight}
Comment thread
cursor[bot] marked this conversation as resolved.
/>
<div className="mt-1.5 grid grid-cols-3 gap-1.5">
{heightPresets.map((preset) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ function MultiVec3Field({
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round(axisValue * 100) / 100}
value={axisValue}
/>
)
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export function ParametricFieldControl({
restoreOnCommit={!onCommit}
step={0.05}
unit="m"
value={Math.round(axisValue * 100) / 100}
value={axisValue}
/>
)
})}
Expand Down
6 changes: 3 additions & 3 deletions packages/editor/src/components/ui/panels/reference-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ export function ReferencePanel() {
precision={2}
step={0.1}
unit="m"
value={Math.round(node.position[0] * 100) / 100}
value={node.position[0]}
/>
<SliderControl
label={
Expand All @@ -394,7 +394,7 @@ export function ReferencePanel() {
precision={2}
step={0.1}
unit="m"
value={Math.round(node.position[1] * 100) / 100}
value={node.position[1]}
/>
<SliderControl
label={
Expand All @@ -412,7 +412,7 @@ export function ReferencePanel() {
precision={2}
step={0.1}
unit="m"
value={Math.round(node.position[2] * 100) / 100}
value={node.position[2]}
/>
</PanelSection>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -949,7 +949,7 @@ const LevelItem = memo(function LevelItem({
precision={2}
step={0.05}
unit="m"
value={Math.round((level.baseElevation ?? 0) * 100) / 100}
value={(level.baseElevation ?? 0)}
/>
</div>
<LevelReferences
Expand Down
59 changes: 59 additions & 0 deletions packages/editor/src/lib/linear-display.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, test } from 'bun:test'
import { getLinearDisplay } from './linear-display'

describe('linear property control units', () => {
test('keeps meter storage while displaying and accepting millimeters', () => {
const display = getLinearDisplay('m', 'metric', 'millimeters', 2, 0.05)
expect(display.displayUnit).toBe('mm')
expect(display.parseUnit).toBe('mm')
expect(display.toDisplay(2.5)).toBe(2500)
expect(display.toStored(1800)).toBe(1.8)
expect(display.toStored(-250)).toBe(-0.25)
expect(display.precision).toBe(0)
expect(display.step).toBe(50)
expect(display.toStored(display.step)).toBe(0.05)
})

test('retains sub-millimeter precision when the field supports it', () => {
const display = getLinearDisplay('m', 'metric', 'millimeters', 4, 0.0001)
expect(display.precision).toBe(1)
expect(display.step).toBe(0.1)
expect(display.toDisplay(0.0125)).toBe(12.5)
expect(display.roundStored(0.01254)).toBe(0.0125)
})

test('imperial preference takes precedence over the saved metric notation', () => {
const display = getLinearDisplay('m', 'imperial', 'millimeters', 2, 0.05)
expect(display.displayUnit).toBe('ft')
expect(display.parseUnit).toBe('ft')
expect(display.toDisplay(0.9144)).toBeCloseTo(3, 10)
expect(display.toStored(7)).toBeCloseTo(2.1336, 10)
expect(display.precision).toBe(2)
expect(display.step).toBe(0.05)
})

test('switching notation round-trips the same stored dimensions', () => {
for (const stored of [0, 0.01, 0.125, 0.9, 2.1, -0.25]) {
for (const [unit, notation] of [
['metric', 'meters'],
['metric', 'millimeters'],
['imperial', 'meters'],
] as const) {
const display = getLinearDisplay('m', unit, notation, 2, 0.01)
expect(display.toStored(display.toDisplay(stored))).toBeCloseTo(stored, 12)
}
}
})

test('leaves non-meter fields and their gesture steps unchanged', () => {
for (const unit of ['°', 'rad', '%', '', 'in', 'cm']) {
const display = getLinearDisplay(unit, 'metric', 'millimeters', 2, 0.05)
expect(display.displayUnit).toBe(unit)
expect(display.parseUnit).toBeUndefined()
expect(display.toDisplay(12.5)).toBe(12.5)
expect(display.toStored(12.5)).toBe(12.5)
expect(display.precision).toBe(2)
expect(display.step).toBe(0.05)
}
})
})
39 changes: 39 additions & 0 deletions packages/editor/src/lib/linear-display.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {
getLinearUnitLabel,
type LinearUnit,
linearUnitToMeters,
type MetricNotation,
metersToLinearUnit,
} from './measurements'

export function getLinearDisplay(
unit: string,
viewerUnit: LinearUnit,
metricNotation: MetricNotation,
precision: number,
step: number,
) {
const isImperial = unit === 'm' && viewerUnit === 'imperial'
const isMillimeters = unit === 'm' && viewerUnit === 'metric' && metricNotation === 'millimeters'
const displayUnit = isImperial ? getLinearUnitLabel('imperial') : isMillimeters ? 'mm' : unit
const displayPrecision = isMillimeters ? Math.max(0, precision - 3) : precision
const displayStep = isMillimeters ? step * 1000 : step
const parseUnit = isImperial ? 'ft' : isMillimeters ? 'mm' : undefined
const toDisplay = (stored: number) =>
isImperial ? metersToLinearUnit(stored, 'imperial') : isMillimeters ? stored * 1000 : stored
const toStored = (display: number) =>
isImperial ? linearUnitToMeters(display, 'imperial') : isMillimeters ? display / 1000 : display
const roundStored = (stored: number) =>
toStored(Number.parseFloat(toDisplay(stored).toFixed(displayPrecision)))

return {
isImperial,
displayUnit,
parseUnit,
precision: displayPrecision,
step: displayStep,
toDisplay,
toStored,
roundStored,
}
}
Loading