fix: respect imperial units in wall panel (#321)
This commit is contained in:
@@ -74,6 +74,7 @@ import {
|
|||||||
type FloorplanNodeTransform as SharedFloorplanNodeTransform,
|
type FloorplanNodeTransform as SharedFloorplanNodeTransform,
|
||||||
} from '../../lib/floorplan'
|
} from '../../lib/floorplan'
|
||||||
import { guideEmitter } from '../../lib/guide-events'
|
import { guideEmitter } from '../../lib/guide-events'
|
||||||
|
import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements'
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
|
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
@@ -2614,14 +2615,7 @@ function formatMeasurement(
|
|||||||
metersPerUnit: number | null = null,
|
metersPerUnit: number | null = null,
|
||||||
) {
|
) {
|
||||||
const measuredValue = metersPerUnit && metersPerUnit > 0 ? value * metersPerUnit : value
|
const measuredValue = metersPerUnit && metersPerUnit > 0 ? value * metersPerUnit : value
|
||||||
if (unit === 'imperial') {
|
return formatLinearMeasurement(measuredValue, unit)
|
||||||
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`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNumber(value: number, fractionDigits = 2) {
|
function formatNumber(value: number, fractionDigits = 2) {
|
||||||
@@ -2633,7 +2627,7 @@ function convertReferenceLengthToMeters(value: number, unit: ReferenceScaleUnit)
|
|||||||
case 'centimeters':
|
case 'centimeters':
|
||||||
return value / 100
|
return value / 100
|
||||||
case 'feet':
|
case 'feet':
|
||||||
return value * 0.3048
|
return linearUnitToMeters(value, 'imperial')
|
||||||
case 'inches':
|
case 'inches':
|
||||||
return value * 0.0254
|
return value * 0.0254
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { Html } from '@react-three/drei'
|
|||||||
import { createPortal, useFrame, useThree } from '@react-three/fiber'
|
import { createPortal, useFrame, useThree } from '@react-three/fiber'
|
||||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
import { type Camera, type Object3D, Vector3 } from 'three'
|
import { type Camera, type Object3D, Vector3 } from 'three'
|
||||||
|
import { formatLinearMeasurement } from '../../lib/measurements'
|
||||||
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
|
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
import { formatMeasurement } from './measurement-pill'
|
|
||||||
|
|
||||||
type ViewportSize = {
|
type ViewportSize = {
|
||||||
width: number
|
width: number
|
||||||
@@ -110,7 +110,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}`,
|
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>
|
</div>
|
||||||
</Html>
|
</Html>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { Html } from '@react-three/drei'
|
|||||||
import { createPortal, useFrame } from '@react-three/fiber'
|
import { createPortal, useFrame } from '@react-three/fiber'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { formatLinearMeasurement } from '../../lib/measurements'
|
||||||
|
|
||||||
const GUIDE_Y_OFFSET = 0.08
|
const GUIDE_Y_OFFSET = 0.08
|
||||||
const LABEL_LIFT = 0.08
|
const LABEL_LIFT = 0.08
|
||||||
@@ -62,17 +63,6 @@ type WallFaceLine = {
|
|||||||
end: Point2D
|
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() {
|
export function WallMeasurementLabel() {
|
||||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||||
const nodes = useScene((state) => state.nodes)
|
const nodes = useScene((state) => state.nodes)
|
||||||
@@ -547,8 +537,8 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
|
|||||||
}
|
}
|
||||||
return total
|
return total
|
||||||
}, [guide, wall])
|
}, [guide, wall])
|
||||||
const label = formatMeasurement(length, unit)
|
const label = formatLinearMeasurement(length, unit)
|
||||||
const heightLabel = `H ${formatMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}`
|
const heightLabel = `H ${formatLinearMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}`
|
||||||
|
|
||||||
if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
|
if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
||||||
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||||
|
import { formatLinearMeasurement } from '../../../lib/measurements'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
|
import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
@@ -68,17 +69,6 @@ const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
|||||||
* floor-plan overlay and the 3D registry move tool. */
|
* floor-plan overlay and the 3D registry move tool. */
|
||||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||||
|
|
||||||
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`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expand `bounds` outward so each axis is rounded up to the active grid step.
|
* Expand `bounds` outward so each axis is rounded up to the active grid step.
|
||||||
* The wireframe stays centered on the original bounds centre on each axis we
|
* The wireframe stays centered on the original bounds centre on each axis we
|
||||||
@@ -1869,9 +1859,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), [])
|
const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), [])
|
||||||
const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), [])
|
const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), [])
|
||||||
const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds
|
const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds
|
||||||
const widthLabel = formatMeasurement(currentDimensionBounds.dimensions[0], unit)
|
const widthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[0], unit)
|
||||||
const depthLabel = formatMeasurement(currentDimensionBounds.dimensions[2], unit)
|
const depthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[2], unit)
|
||||||
const heightLabel = formatMeasurement(currentDimensionBounds.dimensions[1], unit)
|
const heightLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[1], unit)
|
||||||
const widthLabelPosition: [number, number, number] = [
|
const widthLabelPosition: [number, number, number] = [
|
||||||
currentDimensionBounds.center[0],
|
currentDimensionBounds.center[0],
|
||||||
0.04,
|
0.04,
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
import { useScene } from '@pascal-app/core'
|
import { useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
getLinearUnitLabel,
|
||||||
|
linearUnitToMeters,
|
||||||
|
metersToLinearUnit,
|
||||||
|
} from '../../../lib/measurements'
|
||||||
import { cn } from '../../../lib/utils'
|
import { cn } from '../../../lib/utils'
|
||||||
|
|
||||||
interface MetricControlProps {
|
interface MetricControlProps {
|
||||||
@@ -34,10 +39,30 @@ export function MetricControl({
|
|||||||
}: MetricControlProps) {
|
}: MetricControlProps) {
|
||||||
const viewerUnit = useViewer((state) => state.unit)
|
const viewerUnit = useViewer((state) => state.unit)
|
||||||
const isImperial = viewerUnit === 'imperial' && unit === 'm'
|
const isImperial = viewerUnit === 'imperial' && unit === 'm'
|
||||||
const multiplier = isImperial ? 3.280_84 : 1
|
const displayUnit = isImperial ? getLinearUnitLabel('imperial') : unit
|
||||||
const displayUnit = isImperial ? 'ft' : 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 clamp = useCallback(
|
||||||
|
(val: number) => {
|
||||||
|
return Math.min(Math.max(val, min), max)
|
||||||
|
},
|
||||||
|
[min, max],
|
||||||
|
)
|
||||||
|
const roundStoredValueForDisplayPrecision = useCallback(
|
||||||
|
(storedValue: number) =>
|
||||||
|
clamp(toStoredValue(Number.parseFloat(toDisplayValue(storedValue).toFixed(precision)))),
|
||||||
|
[clamp, precision, toDisplayValue, toStoredValue],
|
||||||
|
)
|
||||||
|
|
||||||
|
const displayValue = toDisplayValue(value)
|
||||||
|
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
@@ -50,13 +75,6 @@ export function MetricControl({
|
|||||||
const valueRef = useRef(value)
|
const valueRef = useRef(value)
|
||||||
valueRef.current = value
|
valueRef.current = value
|
||||||
|
|
||||||
const clamp = useCallback(
|
|
||||||
(val: number) => {
|
|
||||||
return Math.min(Math.max(val, min), max)
|
|
||||||
},
|
|
||||||
[min, max],
|
|
||||||
)
|
|
||||||
|
|
||||||
const applyCommittedValue = useCallback(
|
const applyCommittedValue = useCallback(
|
||||||
(nextValue: number) => {
|
(nextValue: number) => {
|
||||||
if (onCommit) {
|
if (onCommit) {
|
||||||
@@ -84,12 +102,12 @@ export function MetricControl({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
const direction = e.deltaY < 0 ? 1 : -1
|
const direction = e.deltaY < 0 ? 1 : -1
|
||||||
let scrollStep = step / multiplier
|
let scrollStep = toStoredValue(step)
|
||||||
if (e.shiftKey) scrollStep = (step * 10) / multiplier
|
if (e.shiftKey) scrollStep = toStoredValue(step * 10)
|
||||||
else if (e.altKey) scrollStep = (step * 0.1) / multiplier
|
else if (e.altKey) scrollStep = toStoredValue(step * 0.1)
|
||||||
|
|
||||||
const newValue = clamp(valueRef.current + direction * scrollStep)
|
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) {
|
if (Math.abs(finalValue - valueRef.current) > 1e-6) {
|
||||||
applyCommittedValue(finalValue)
|
applyCommittedValue(finalValue)
|
||||||
@@ -98,7 +116,7 @@ export function MetricControl({
|
|||||||
|
|
||||||
container.addEventListener('wheel', handleWheel, { passive: false })
|
container.addEventListener('wheel', handleWheel, { passive: false })
|
||||||
return () => container.removeEventListener('wheel', handleWheel)
|
return () => container.removeEventListener('wheel', handleWheel)
|
||||||
}, [isEditing, step, clamp, applyCommittedValue, precision, multiplier])
|
}, [isEditing, step, clamp, applyCommittedValue, toStoredValue, roundStoredValueForDisplayPrecision])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isHovered || isEditing) return
|
if (!isHovered || isEditing) return
|
||||||
@@ -110,13 +128,12 @@ export function MetricControl({
|
|||||||
|
|
||||||
if (direction !== 0) {
|
if (direction !== 0) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
let scrollStep = step / multiplier
|
let scrollStep = toStoredValue(step)
|
||||||
if (e.shiftKey) scrollStep = (step * 10) / multiplier
|
if (e.shiftKey) scrollStep = toStoredValue(step * 10)
|
||||||
else if (e.altKey) scrollStep = (step * 0.1) / multiplier
|
else if (e.altKey) scrollStep = toStoredValue(step * 0.1)
|
||||||
|
|
||||||
const newValue = clamp(valueRef.current + direction * scrollStep)
|
const newValue = clamp(valueRef.current + direction * scrollStep)
|
||||||
const finalValue =
|
const finalValue = roundStoredValueForDisplayPrecision(newValue)
|
||||||
Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
|
|
||||||
|
|
||||||
if (Math.abs(finalValue - valueRef.current) > 1e-6) {
|
if (Math.abs(finalValue - valueRef.current) > 1e-6) {
|
||||||
applyCommittedValue(finalValue)
|
applyCommittedValue(finalValue)
|
||||||
@@ -126,7 +143,15 @@ export function MetricControl({
|
|||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [isHovered, isEditing, step, clamp, applyCommittedValue, precision, multiplier])
|
}, [
|
||||||
|
isHovered,
|
||||||
|
isEditing,
|
||||||
|
step,
|
||||||
|
clamp,
|
||||||
|
applyCommittedValue,
|
||||||
|
toStoredValue,
|
||||||
|
roundStoredValueForDisplayPrecision,
|
||||||
|
])
|
||||||
|
|
||||||
const handlePointerDown = useCallback(
|
const handlePointerDown = useCallback(
|
||||||
(e: React.PointerEvent) => {
|
(e: React.PointerEvent) => {
|
||||||
@@ -143,14 +168,13 @@ export function MetricControl({
|
|||||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||||
const deltaX = moveEvent.clientX - startXRef.current
|
const deltaX = moveEvent.clientX - startXRef.current
|
||||||
|
|
||||||
let dragStep = step / multiplier
|
let dragStep = toStoredValue(step)
|
||||||
if (moveEvent.shiftKey) dragStep = (step * 10) / multiplier
|
if (moveEvent.shiftKey) dragStep = toStoredValue(step * 10)
|
||||||
else if (moveEvent.altKey) dragStep = (step * 0.1) / multiplier
|
else if (moveEvent.altKey) dragStep = toStoredValue(step * 0.1)
|
||||||
|
|
||||||
const deltaValue = deltaX * dragStep
|
const deltaValue = deltaX * dragStep
|
||||||
const newValue = clamp(startValueRef.current + deltaValue)
|
const newValue = clamp(startValueRef.current + deltaValue)
|
||||||
const newFinalValue =
|
const newFinalValue = roundStoredValueForDisplayPrecision(newValue)
|
||||||
Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
|
|
||||||
|
|
||||||
if (Math.abs(newFinalValue - finalValue) > 1e-6) {
|
if (Math.abs(newFinalValue - finalValue) > 1e-6) {
|
||||||
finalValue = newFinalValue
|
finalValue = newFinalValue
|
||||||
@@ -182,13 +206,23 @@ export function MetricControl({
|
|||||||
document.addEventListener('pointermove', handlePointerMove)
|
document.addEventListener('pointermove', handlePointerMove)
|
||||||
document.addEventListener('pointerup', handlePointerUp)
|
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(() => {
|
const handleValueClick = useCallback(() => {
|
||||||
setIsEditing(true)
|
setIsEditing(true)
|
||||||
setInputValue((value * multiplier).toFixed(precision))
|
setInputValue(toDisplayValue(value).toFixed(precision))
|
||||||
}, [value, multiplier, precision])
|
}, [value, toDisplayValue, precision])
|
||||||
|
|
||||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setInputValue(e.target.value)
|
setInputValue(e.target.value)
|
||||||
@@ -197,12 +231,12 @@ export function MetricControl({
|
|||||||
const submitValue = useCallback(() => {
|
const submitValue = useCallback(() => {
|
||||||
const numValue = Number.parseFloat(inputValue)
|
const numValue = Number.parseFloat(inputValue)
|
||||||
if (Number.isNaN(numValue)) {
|
if (Number.isNaN(numValue)) {
|
||||||
setInputValue((value * multiplier).toFixed(precision))
|
setInputValue(toDisplayValue(value).toFixed(precision))
|
||||||
} else {
|
} else {
|
||||||
applyCommittedValue(clamp(numValue / multiplier))
|
applyCommittedValue(clamp(toStoredValue(numValue)))
|
||||||
}
|
}
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
}, [inputValue, applyCommittedValue, clamp, multiplier, value, precision])
|
}, [inputValue, applyCommittedValue, clamp, toStoredValue, value, precision, toDisplayValue])
|
||||||
|
|
||||||
const handleInputBlur = useCallback(() => {
|
const handleInputBlur = useCallback(() => {
|
||||||
submitValue()
|
submitValue()
|
||||||
@@ -213,21 +247,21 @@ export function MetricControl({
|
|||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
submitValue()
|
submitValue()
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
setInputValue((value * multiplier).toFixed(precision))
|
setInputValue(toDisplayValue(value).toFixed(precision))
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const newV = clamp(value + step / multiplier)
|
const newV = clamp(value + toStoredValue(step))
|
||||||
applyCommittedValue(newV)
|
applyCommittedValue(newV)
|
||||||
setInputValue((newV * multiplier).toFixed(precision))
|
setInputValue(toDisplayValue(newV).toFixed(precision))
|
||||||
} else if (e.key === 'ArrowDown') {
|
} else if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const newV = clamp(value - step / multiplier)
|
const newV = clamp(value - toStoredValue(step))
|
||||||
applyCommittedValue(newV)
|
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 (
|
return (
|
||||||
|
|||||||
@@ -210,6 +210,14 @@ export {
|
|||||||
getActivePaintMaterialLabel,
|
getActivePaintMaterialLabel,
|
||||||
hasActivePaintMaterial,
|
hasActivePaintMaterial,
|
||||||
} from './lib/material-paint'
|
} from './lib/material-paint'
|
||||||
|
export {
|
||||||
|
formatLinearMeasurement,
|
||||||
|
getLinearUnitLabel,
|
||||||
|
type LinearUnit,
|
||||||
|
linearControlValueToMeters,
|
||||||
|
linearUnitToMeters,
|
||||||
|
metersToLinearUnit,
|
||||||
|
} from './lib/measurements'
|
||||||
export {
|
export {
|
||||||
addFreshPlacementMetadata,
|
addFreshPlacementMetadata,
|
||||||
getPlacementMetadataRecord,
|
getPlacementMetadataRecord,
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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('returns a placeholder for non-finite measurements', () => {
|
||||||
|
expect(formatLinearMeasurement(NaN, 'imperial')).toBe('--')
|
||||||
|
expect(formatLinearMeasurement(Infinity, 'imperial')).toBe('--')
|
||||||
|
expect(formatLinearMeasurement(NaN, 'metric')).toBe('--')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('formats zero measurements', () => {
|
||||||
|
expect(formatLinearMeasurement(0, 'imperial')).toBe(`0'0"`)
|
||||||
|
expect(formatLinearMeasurement(0, 'metric')).toBe('0m')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('formats sub-foot imperial measurements', () => {
|
||||||
|
expect(formatLinearMeasurement(0.1524, 'imperial')).toBe(`0'6"`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('formats negative measurements with a sign', () => {
|
||||||
|
expect(formatLinearMeasurement(-0.1524, 'imperial')).toBe(`-0'6"`)
|
||||||
|
expect(formatLinearMeasurement(-0.1524, 'metric')).toBe('-0.15m')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('converts between meters and the active linear unit', () => {
|
||||||
|
expect(metersToLinearUnit(0, 'imperial')).toBe(0)
|
||||||
|
expect(linearUnitToMeters(0, 'imperial')).toBe(0)
|
||||||
|
|
||||||
|
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.3, 'imperial', { minMeters: 0.1 })).toBe(0.1)
|
||||||
|
expect(linearControlValueToMeters(19.7, 'imperial', { maxMeters: 6 })).toBe(6)
|
||||||
|
expect(linearControlValueToMeters(0.2, 'metric', { minMeters: 0.1 })).toBe(0.2)
|
||||||
|
expect(linearControlValueToMeters(0.2, 'metric', { maxMeters: 0.15 })).toBe(0.15)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns the display label for numeric controls', () => {
|
||||||
|
expect(getLinearUnitLabel('metric')).toBe('m')
|
||||||
|
expect(getLinearUnitLabel('imperial')).toBe('ft')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
export type LinearUnit = 'metric' | 'imperial'
|
||||||
|
|
||||||
|
const METERS_PER_FOOT = 0.3048
|
||||||
|
const FEET_PER_METER = 1 / METERS_PER_FOOT
|
||||||
|
|
||||||
|
type LinearControlValueOptions = {
|
||||||
|
minMeters?: number
|
||||||
|
maxMeters?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function metersToLinearUnit(meters: number, unit: LinearUnit): number {
|
||||||
|
return unit === 'imperial' ? meters * FEET_PER_METER : meters
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linearUnitToMeters(value: number, unit: LinearUnit): number {
|
||||||
|
return unit === 'imperial' ? value * METERS_PER_FOOT : value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linearControlValueToMeters(
|
||||||
|
value: number,
|
||||||
|
unit: LinearUnit,
|
||||||
|
options: LinearControlValueOptions = {},
|
||||||
|
): number {
|
||||||
|
const meters = linearUnitToMeters(value, unit)
|
||||||
|
const minMeters = options.minMeters ?? Number.NEGATIVE_INFINITY
|
||||||
|
const maxMeters = options.maxMeters ?? Number.POSITIVE_INFINITY
|
||||||
|
|
||||||
|
return Math.min(Math.max(meters, minMeters), maxMeters)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLinearUnitLabel(unit: LinearUnit): string {
|
||||||
|
return unit === 'imperial' ? 'ft' : 'm'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLinearMeasurement(meters: number, unit: LinearUnit): string {
|
||||||
|
if (!Number.isFinite(meters)) return '--'
|
||||||
|
|
||||||
|
const absoluteMeters = Math.abs(meters)
|
||||||
|
|
||||||
|
if (unit === 'imperial') {
|
||||||
|
const feet = metersToLinearUnit(absoluteMeters, unit)
|
||||||
|
let wholeFeet = Math.floor(feet)
|
||||||
|
let inches = Math.round((feet - wholeFeet) * 12)
|
||||||
|
if (inches === 12) {
|
||||||
|
wholeFeet += 1
|
||||||
|
inches = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const sign = meters < 0 && (wholeFeet !== 0 || inches !== 0) ? '-' : ''
|
||||||
|
|
||||||
|
return `${sign}${wholeFeet}'${inches}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundedMeters = Number.parseFloat(absoluteMeters.toFixed(2))
|
||||||
|
const sign = meters < 0 && roundedMeters !== 0 ? '-' : ''
|
||||||
|
|
||||||
|
return `${sign}${roundedMeters}m`
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
EDITOR_LAYER,
|
EDITOR_LAYER,
|
||||||
type FencePlanPoint,
|
type FencePlanPoint,
|
||||||
formatAngleRadians,
|
formatAngleRadians,
|
||||||
|
formatLinearMeasurement,
|
||||||
getAngleArcToSegmentReference,
|
getAngleArcToSegmentReference,
|
||||||
getAngleToSegmentReference,
|
getAngleToSegmentReference,
|
||||||
getSegmentAngleReferenceAtPoint,
|
getSegmentAngleReferenceAtPoint,
|
||||||
@@ -94,17 +95,6 @@ type AngleSource = {
|
|||||||
draftVector: FencePlanPoint
|
draftVector: FencePlanPoint
|
||||||
}
|
}
|
||||||
|
|
||||||
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`
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number) {
|
function clamp(value: number, min: number, max: number) {
|
||||||
return Math.min(max, Math.max(min, value))
|
return Math.min(max, Math.max(min, value))
|
||||||
}
|
}
|
||||||
@@ -365,7 +355,7 @@ function getDraftMeasurementState(
|
|||||||
const length = Math.hypot(dx, dz)
|
const length = Math.hypot(dx, dz)
|
||||||
if (length < 0.01) return null
|
if (length < 0.01) return null
|
||||||
return {
|
return {
|
||||||
lengthLabel: formatMeasurement(length, unit),
|
lengthLabel: formatLinearMeasurement(length, unit),
|
||||||
lengthPosition: [
|
lengthPosition: [
|
||||||
(start[0] + end[0]) / 2,
|
(start[0] + end[0]) / 2,
|
||||||
baseY + previewHeight + DRAFT_LABEL_Y_OFFSET,
|
baseY + previewHeight + DRAFT_LABEL_Y_OFFSET,
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
ActionButton,
|
ActionButton,
|
||||||
ActionGroup,
|
ActionGroup,
|
||||||
|
getLinearUnitLabel,
|
||||||
|
linearControlValueToMeters,
|
||||||
|
metersToLinearUnit,
|
||||||
PanelSection,
|
PanelSection,
|
||||||
PanelWrapper,
|
PanelWrapper,
|
||||||
SliderControl,
|
SliderControl,
|
||||||
@@ -26,6 +29,7 @@ import { useCallback, useMemo, useRef } from 'react'
|
|||||||
|
|
||||||
export default function WallPanel() {
|
export default function WallPanel() {
|
||||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||||
|
const unit = useViewer((s) => s.unit)
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
||||||
|
|
||||||
@@ -117,14 +121,19 @@ export default function WallPanel() {
|
|||||||
|
|
||||||
if (!(node && node.type === 'wall' && selectedId)) return null
|
if (!(node && node.type === 'wall' && selectedId)) return null
|
||||||
|
|
||||||
const dx = node.end[0] - node.start[0]
|
|
||||||
const dz = node.end[1] - node.start[1]
|
|
||||||
const length = getWallCurveLength(node)
|
const length = getWallCurveLength(node)
|
||||||
|
|
||||||
const height = node.height ?? 2.5
|
const height = node.height ?? 2.5
|
||||||
const thickness = node.thickness ?? 0.1
|
const thickness = node.thickness ?? 0.1
|
||||||
const curveOffset = getClampedWallCurveOffset(node)
|
const curveOffset = getClampedWallCurveOffset(node)
|
||||||
const maxCurveOffset = getMaxWallCurveOffset(node)
|
const maxCurveOffset = getMaxWallCurveOffset(node)
|
||||||
|
const unitLabel = getLinearUnitLabel(unit)
|
||||||
|
const displayLength = metersToLinearUnit(length, unit)
|
||||||
|
const displayHeight = metersToLinearUnit(height, unit)
|
||||||
|
const displayThickness = metersToLinearUnit(thickness, unit)
|
||||||
|
const displayCurveOffset = metersToLinearUnit(curveOffset, unit)
|
||||||
|
const displayMaxCurveOffset = metersToLinearUnit(maxCurveOffset, unit)
|
||||||
|
const curveOffsetLimit = Math.max(0.01, maxCurveOffset)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelWrapper
|
<PanelWrapper
|
||||||
@@ -136,44 +145,66 @@ export default function WallPanel() {
|
|||||||
<PanelSection title="Dimensions">
|
<PanelSection title="Dimensions">
|
||||||
<SliderControl
|
<SliderControl
|
||||||
label="Length"
|
label="Length"
|
||||||
max={20}
|
max={metersToLinearUnit(20, unit)}
|
||||||
min={0.1}
|
min={metersToLinearUnit(0.1, unit)}
|
||||||
onChange={handleUpdateLength}
|
onChange={(value) =>
|
||||||
|
handleUpdateLength(
|
||||||
|
linearControlValueToMeters(value, unit, { maxMeters: 20, minMeters: 0.1 }),
|
||||||
|
)
|
||||||
|
}
|
||||||
precision={2}
|
precision={2}
|
||||||
step={0.01}
|
step={unit === 'imperial' ? 0.1 : 0.01}
|
||||||
unit="m"
|
unit={unitLabel}
|
||||||
value={length}
|
value={displayLength}
|
||||||
/>
|
/>
|
||||||
<SliderControl
|
<SliderControl
|
||||||
label="Height"
|
label="Height"
|
||||||
max={6}
|
max={metersToLinearUnit(6, unit)}
|
||||||
min={0.1}
|
min={metersToLinearUnit(0.1, unit)}
|
||||||
onChange={(v) => handleUpdate({ height: Math.max(0.1, v) })}
|
onChange={(v) =>
|
||||||
|
handleUpdate({
|
||||||
|
height: linearControlValueToMeters(v, unit, { maxMeters: 6, minMeters: 0.1 }),
|
||||||
|
})
|
||||||
|
}
|
||||||
precision={2}
|
precision={2}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
unit="m"
|
unit={unitLabel}
|
||||||
value={Math.round(height * 100) / 100}
|
value={Math.round(displayHeight * 100) / 100}
|
||||||
/>
|
/>
|
||||||
<SliderControl
|
<SliderControl
|
||||||
label="Thickness"
|
label="Thickness"
|
||||||
max={1}
|
max={metersToLinearUnit(1, unit)}
|
||||||
min={0.05}
|
min={metersToLinearUnit(0.05, unit)}
|
||||||
onChange={(v) => handleUpdate({ thickness: Math.max(0.05, v) })}
|
onChange={(v) =>
|
||||||
|
handleUpdate({
|
||||||
|
thickness: linearControlValueToMeters(v, unit, { maxMeters: 1, minMeters: 0.05 }),
|
||||||
|
})
|
||||||
|
}
|
||||||
precision={3}
|
precision={3}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
unit="m"
|
unit={unitLabel}
|
||||||
value={Math.round(thickness * 1000) / 1000}
|
value={Math.round(displayThickness * 1000) / 1000}
|
||||||
/>
|
/>
|
||||||
{!hasWallChildrenBlockingCurve && (
|
{!hasWallChildrenBlockingCurve && (
|
||||||
<SliderControl
|
<SliderControl
|
||||||
label="Curve"
|
label="Curve"
|
||||||
max={Math.max(0.01, maxCurveOffset)}
|
max={Math.max(metersToLinearUnit(0.01, unit), displayMaxCurveOffset)}
|
||||||
min={-Math.max(0.01, maxCurveOffset)}
|
min={-Math.max(metersToLinearUnit(0.01, unit), displayMaxCurveOffset)}
|
||||||
onChange={(v) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, v) })}
|
onChange={(v) =>
|
||||||
|
handleUpdate({
|
||||||
|
curveOffset: normalizeWallCurveOffset(
|
||||||
|
node,
|
||||||
|
linearControlValueToMeters(v, unit, {
|
||||||
|
maxMeters: curveOffsetLimit,
|
||||||
|
minMeters: -curveOffsetLimit,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
precision={2}
|
precision={2}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
unit="m"
|
unit={unitLabel}
|
||||||
value={Math.round(curveOffset * 100) / 100}
|
value={Math.round(displayCurveOffset * 100) / 100}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
createWallOnCurrentLevel,
|
createWallOnCurrentLevel,
|
||||||
EDITOR_LAYER,
|
EDITOR_LAYER,
|
||||||
formatAngleRadians,
|
formatAngleRadians,
|
||||||
|
formatLinearMeasurement,
|
||||||
getAngleArcToSegmentReference,
|
getAngleArcToSegmentReference,
|
||||||
getAngleToSegmentReference,
|
getAngleToSegmentReference,
|
||||||
getSegmentAngleReferenceAtPoint,
|
getSegmentAngleReferenceAtPoint,
|
||||||
@@ -124,17 +125,6 @@ type AngleSource = {
|
|||||||
draftVector: WallPlanPoint
|
draftVector: WallPlanPoint
|
||||||
}
|
}
|
||||||
|
|
||||||
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`
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number) {
|
function clamp(value: number, min: number, max: number) {
|
||||||
return Math.min(max, Math.max(min, value))
|
return Math.min(max, Math.max(min, value))
|
||||||
}
|
}
|
||||||
@@ -426,7 +416,7 @@ function getDraftMeasurementState(
|
|||||||
const length = Math.hypot(dx, dz)
|
const length = Math.hypot(dx, dz)
|
||||||
if (length < 0.01) return null
|
if (length < 0.01) return null
|
||||||
return {
|
return {
|
||||||
lengthLabel: formatMeasurement(length, unit),
|
lengthLabel: formatLinearMeasurement(length, unit),
|
||||||
lengthPosition: [
|
lengthPosition: [
|
||||||
(start[0] + end[0]) / 2,
|
(start[0] + end[0]) / 2,
|
||||||
baseY + previewHeight + DRAFT_LABEL_Y_OFFSET,
|
baseY + previewHeight + DRAFT_LABEL_Y_OFFSET,
|
||||||
|
|||||||
Reference in New Issue
Block a user