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
12 changes: 3 additions & 9 deletions packages/editor/src/components/editor/floorplan-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
type FloorplanNodeTransform as SharedFloorplanNodeTransform,
} from '../../lib/floorplan'
import { guideEmitter } from '../../lib/guide-events'
import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements'
import { sfxEmitter } from '../../lib/sfx-bus'
import { cn } from '../../lib/utils'
import type { GuideUiState } from '../../store/use-editor'
Expand Down Expand Up @@ -2154,14 +2155,7 @@ function formatMeasurement(
metersPerUnit: number | null = null,
) {
const measuredValue = metersPerUnit && metersPerUnit > 0 ? value * metersPerUnit : value
if (unit === 'imperial') {
const feet = measuredValue * 3.280_84
const wholeFeet = Math.floor(feet)
const inches = Math.round((feet - wholeFeet) * 12)
if (inches === 12) return `${wholeFeet + 1}'0"`
return `${wholeFeet}'${inches}"`
}
return `${Number.parseFloat(measuredValue.toFixed(2))}m`
return formatLinearMeasurement(measuredValue, unit)
}

function formatNumber(value: number, fractionDigits = 2) {
Expand All @@ -2173,7 +2167,7 @@ function convertReferenceLengthToMeters(value: number, unit: ReferenceScaleUnit)
case 'centimeters':
return value / 100
case 'feet':
return value * 0.3048
return linearUnitToMeters(value, 'imperial')
case 'inches':
return value * 0.0254
default:
Expand Down
14 changes: 2 additions & 12 deletions packages/editor/src/components/editor/site-edge-labels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,7 @@ import { Html } from '@react-three/drei'
import { createPortal, useFrame } from '@react-three/fiber'
import { useMemo, useRef, useState } from 'react'
import type { Object3D } from 'three'

function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
if (unit === 'imperial') {
const feet = value * 3.280_84
const wholeFeet = Math.floor(feet)
const inches = Math.round((feet - wholeFeet) * 12)
if (inches === 12) return `${wholeFeet + 1}'0"`
return `${wholeFeet}'${inches}"`
}
return `${Number.parseFloat(value.toFixed(2))}m`
}
import { formatLinearMeasurement } from '../../lib/measurements'

export function SiteEdgeLabels() {
// Narrow subscription to just the site node — subscribing to the full
Expand Down Expand Up @@ -86,7 +76,7 @@ export function SiteEdgeLabels() {
textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,
}}
>
{formatMeasurement(edge.dist, unit)}
{formatLinearMeasurement(edge.dist, unit)}
</div>
</Html>
))}
Expand Down
16 changes: 3 additions & 13 deletions packages/editor/src/components/editor/wall-measurement-label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { Html } from '@react-three/drei'
import { createPortal, useFrame } from '@react-three/fiber'
import { useMemo, useState } from 'react'
import * as THREE from 'three'
import { formatLinearMeasurement } from '../../lib/measurements'

const GUIDE_Y_OFFSET = 0.08
const LABEL_LIFT = 0.08
Expand Down Expand Up @@ -56,17 +57,6 @@ type WallFaceLine = {
end: Point2D
}

function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
if (unit === 'imperial') {
const feet = value * 3.280_84
const wholeFeet = Math.floor(feet)
const inches = Math.round((feet - wholeFeet) * 12)
if (inches === 12) return `${wholeFeet + 1}'0"`
return `${wholeFeet}'${inches}"`
}
return `${Number.parseFloat(value.toFixed(2))}m`
}

export function WallMeasurementLabel() {
const selectedIds = useViewer((state) => state.selection.selectedIds)
const nodes = useScene((state) => state.nodes)
Expand Down Expand Up @@ -542,8 +532,8 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
}
return total
}, [guide, wall])
const label = formatMeasurement(length, unit)
const heightLabel = `H ${formatMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}`
const label = formatLinearMeasurement(length, unit)
const heightLabel = `H ${formatLinearMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}`

if (!(guide && Number.isFinite(length) && length >= 0.01)) return null

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants'
import { formatLinearMeasurement } from '../../../lib/measurements'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { getGridAlignedDimensions, snapToGrid, snapUpToGridStep } from './placement-math'
Expand All @@ -50,17 +51,6 @@ import type { DraftNodeHandle } from './use-draft-node'

const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]

function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
if (unit === 'imperial') {
const feet = value * 3.280_84
const wholeFeet = Math.floor(feet)
const inches = Math.round((feet - wholeFeet) * 12)
if (inches === 12) return `${wholeFeet + 1}'0"`
return `${wholeFeet}'${inches}"`
}
return `${Number.parseFloat(value.toFixed(2))}m`
}

type PreviewBounds = {
min: [number, number, number]
max: [number, number, number]
Expand Down Expand Up @@ -1610,9 +1600,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), [])
const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), [])
const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds
const widthLabel = formatMeasurement(currentDimensionBounds.dimensions[0], unit)
const depthLabel = formatMeasurement(currentDimensionBounds.dimensions[2], unit)
const heightLabel = formatMeasurement(currentDimensionBounds.dimensions[1], unit)
const widthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[0], unit)
const depthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[2], unit)
const heightLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[1], unit)
const widthLabelPosition: [number, number, number] = [
currentDimensionBounds.center[0],
0.04,
Expand Down
97 changes: 66 additions & 31 deletions packages/editor/src/components/ui/controls/metric-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
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'
import { cn } from '../../../lib/utils'

interface MetricControlProps {
Expand Down Expand Up @@ -34,10 +39,24 @@ export function MetricControl({
}: MetricControlProps) {
const viewerUnit = useViewer((state) => state.unit)
const isImperial = viewerUnit === 'imperial' && unit === 'm'
const multiplier = isImperial ? 3.280_84 : 1
const displayUnit = isImperial ? 'ft' : unit
const displayUnit = isImperial ? getLinearUnitLabel('imperial') : unit

const displayValue = value * multiplier
const toDisplayValue = useCallback(
(storedValue: number) => (isImperial ? metersToLinearUnit(storedValue, 'imperial') : storedValue),
[isImperial],
)
const toStoredValue = useCallback(
(displayValue: number) =>
isImperial ? linearUnitToMeters(displayValue, 'imperial') : displayValue,
[isImperial],
)
const roundStoredValueForDisplayPrecision = useCallback(
(storedValue: number) =>
toStoredValue(Number.parseFloat(toDisplayValue(storedValue).toFixed(precision))),
[precision, toDisplayValue, toStoredValue],
)

const displayValue = toDisplayValue(value)

const [isEditing, setIsEditing] = useState(false)
const [isDragging, setIsDragging] = useState(false)
Expand Down Expand Up @@ -84,12 +103,12 @@ export function MetricControl({
e.preventDefault()

const direction = e.deltaY < 0 ? 1 : -1
let scrollStep = step / multiplier
if (e.shiftKey) scrollStep = (step * 10) / multiplier
else if (e.altKey) scrollStep = (step * 0.1) / multiplier
let scrollStep = toStoredValue(step)
if (e.shiftKey) scrollStep = toStoredValue(step * 10)
else if (e.altKey) scrollStep = toStoredValue(step * 0.1)

const newValue = clamp(valueRef.current + direction * scrollStep)
const finalValue = Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
const finalValue = roundStoredValueForDisplayPrecision(newValue)

if (Math.abs(finalValue - valueRef.current) > 1e-6) {
applyCommittedValue(finalValue)
Expand All @@ -98,7 +117,7 @@ export function MetricControl({

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

useEffect(() => {
if (!isHovered || isEditing) return
Expand All @@ -110,13 +129,12 @@ export function MetricControl({

if (direction !== 0) {
e.preventDefault()
let scrollStep = step / multiplier
if (e.shiftKey) scrollStep = (step * 10) / multiplier
else if (e.altKey) scrollStep = (step * 0.1) / multiplier
let scrollStep = toStoredValue(step)
if (e.shiftKey) scrollStep = toStoredValue(step * 10)
else if (e.altKey) scrollStep = toStoredValue(step * 0.1)

const newValue = clamp(valueRef.current + direction * scrollStep)
const finalValue =
Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
const finalValue = roundStoredValueForDisplayPrecision(newValue)

if (Math.abs(finalValue - valueRef.current) > 1e-6) {
applyCommittedValue(finalValue)
Expand All @@ -126,7 +144,15 @@ export function MetricControl({

window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isHovered, isEditing, step, clamp, applyCommittedValue, precision, multiplier])
}, [
isHovered,
isEditing,
step,
clamp,
applyCommittedValue,
toStoredValue,
roundStoredValueForDisplayPrecision,
])

const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
Expand All @@ -143,14 +169,13 @@ export function MetricControl({
const handlePointerMove = (moveEvent: PointerEvent) => {
const deltaX = moveEvent.clientX - startXRef.current

let dragStep = step / multiplier
if (moveEvent.shiftKey) dragStep = (step * 10) / multiplier
else if (moveEvent.altKey) dragStep = (step * 0.1) / multiplier
let dragStep = toStoredValue(step)
if (moveEvent.shiftKey) dragStep = toStoredValue(step * 10)
else if (moveEvent.altKey) dragStep = toStoredValue(step * 0.1)

const deltaValue = deltaX * dragStep
const newValue = clamp(startValueRef.current + deltaValue)
const newFinalValue =
Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
const newFinalValue = roundStoredValueForDisplayPrecision(newValue)

if (Math.abs(newFinalValue - finalValue) > 1e-6) {
finalValue = newFinalValue
Expand Down Expand Up @@ -182,13 +207,23 @@ export function MetricControl({
document.addEventListener('pointermove', handlePointerMove)
document.addEventListener('pointerup', handlePointerUp)
},
[isEditing, value, onChange, onCommit, restoreOnCommit, clamp, precision, step, multiplier],
[
isEditing,
value,
onChange,
onCommit,
restoreOnCommit,
clamp,
step,
toStoredValue,
roundStoredValueForDisplayPrecision,
],
)

const handleValueClick = useCallback(() => {
setIsEditing(true)
setInputValue((value * multiplier).toFixed(precision))
}, [value, multiplier, precision])
setInputValue(toDisplayValue(value).toFixed(precision))
}, [value, toDisplayValue, precision])

const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
Expand All @@ -197,12 +232,12 @@ export function MetricControl({
const submitValue = useCallback(() => {
const numValue = Number.parseFloat(inputValue)
if (Number.isNaN(numValue)) {
setInputValue((value * multiplier).toFixed(precision))
setInputValue(toDisplayValue(value).toFixed(precision))
} else {
applyCommittedValue(clamp(numValue / multiplier))
applyCommittedValue(clamp(toStoredValue(numValue)))
}
setIsEditing(false)
}, [inputValue, applyCommittedValue, clamp, multiplier, value, precision])
}, [inputValue, applyCommittedValue, clamp, toStoredValue, value, precision, toDisplayValue])

const handleInputBlur = useCallback(() => {
submitValue()
Expand All @@ -213,21 +248,21 @@ export function MetricControl({
if (e.key === 'Enter') {
submitValue()
} else if (e.key === 'Escape') {
setInputValue((value * multiplier).toFixed(precision))
setInputValue(toDisplayValue(value).toFixed(precision))
setIsEditing(false)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const newV = clamp(value + step / multiplier)
const newV = clamp(value + toStoredValue(step))
applyCommittedValue(newV)
setInputValue((newV * multiplier).toFixed(precision))
setInputValue(toDisplayValue(newV).toFixed(precision))
} else if (e.key === 'ArrowDown') {
e.preventDefault()
const newV = clamp(value - step / multiplier)
const newV = clamp(value - toStoredValue(step))
applyCommittedValue(newV)
setInputValue((newV * multiplier).toFixed(precision))
setInputValue(toDisplayValue(newV).toFixed(precision))
}
},
[submitValue, value, multiplier, precision, step, clamp, applyCommittedValue],
[submitValue, value, toDisplayValue, precision, step, clamp, applyCommittedValue, toStoredValue],
)

return (
Expand Down
8 changes: 8 additions & 0 deletions packages/editor/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,14 @@ export {
getActivePaintMaterialLabel,
hasActivePaintMaterial,
} from './lib/material-paint'
export {
formatLinearMeasurement,
getLinearUnitLabel,
type LinearUnit,
linearControlValueToMeters,
linearUnitToMeters,
metersToLinearUnit,
} from './lib/measurements'
export { duplicateRoofSubtree } from './lib/roof-duplication'
export type { SceneGraph } from './lib/scene'
export { applySceneGraphToEditor } from './lib/scene'
Expand Down
49 changes: 49 additions & 0 deletions packages/editor/src/lib/measurements.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, test } from 'bun:test'
import {
formatLinearMeasurement,
getLinearUnitLabel,
linearControlValueToMeters,
linearUnitToMeters,
metersToLinearUnit,
} from './measurements'

describe('linear measurements', () => {
test('formats metric measurements in meters', () => {
expect(formatLinearMeasurement(3, 'metric')).toBe('3m')
expect(formatLinearMeasurement(3.456, 'metric')).toBe('3.46m')
})

test('formats imperial measurements as feet and inches', () => {
expect(formatLinearMeasurement(3.048, 'imperial')).toBe(`10'0"`)
expect(formatLinearMeasurement(3.2004, 'imperial')).toBe(`10'6"`)
})

test('carries rounded 12 inches into the next foot', () => {
expect(formatLinearMeasurement(3.047, 'imperial')).toBe(`10'0"`)
})

test('converts between meters and the active linear unit', () => {
expect(metersToLinearUnit(1, 'metric')).toBe(1)
expect(linearUnitToMeters(1, 'metric')).toBe(1)

expect(metersToLinearUnit(0.3048, 'imperial')).toBeCloseTo(1)
expect(linearUnitToMeters(1, 'imperial')).toBeCloseTo(0.3048)
})

test('converts numeric control input back to meters for wall panel edits', () => {
expect(linearControlValueToMeters(10, 'imperial')).toBeCloseTo(3.048)
expect(linearControlValueToMeters(0.5, 'imperial')).toBeCloseTo(0.1524)
expect(linearControlValueToMeters(-1, 'imperial')).toBeCloseTo(-0.3048)
expect(linearControlValueToMeters(3.5, 'metric')).toBe(3.5)
})

test('clamps numeric control input after converting to meters', () => {
expect(linearControlValueToMeters(0.1, 'imperial', { minMeters: 0.1 })).toBe(0.1)
expect(linearControlValueToMeters(0.2, 'metric', { minMeters: 0.1 })).toBe(0.2)
})

test('returns the display label for numeric controls', () => {
expect(getLinearUnitLabel('metric')).toBe('m')
expect(getLinearUnitLabel('imperial')).toBe('ft')
})
})
Loading