fix: respect imperial units in wall panel (#321)
This commit is contained in:
@@ -74,6 +74,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 { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
|
||||
import { cn } from '../../lib/utils'
|
||||
@@ -2614,14 +2615,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) {
|
||||
@@ -2633,7 +2627,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:
|
||||
|
||||
@@ -7,9 +7,9 @@ import { Html } from '@react-three/drei'
|
||||
import { createPortal, useFrame, useThree } from '@react-three/fiber'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { type Camera, type Object3D, Vector3 } from 'three'
|
||||
import { formatLinearMeasurement } from '../../lib/measurements'
|
||||
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { formatMeasurement } from './measurement-pill'
|
||||
|
||||
type ViewportSize = {
|
||||
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}`,
|
||||
}}
|
||||
>
|
||||
{formatMeasurement(edge.dist, unit)}
|
||||
{formatLinearMeasurement(edge.dist, unit)}
|
||||
</div>
|
||||
</Html>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
@@ -62,17 +63,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)
|
||||
@@ -547,8 +537,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
|
||||
|
||||
|
||||
@@ -40,6 +40,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 { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
|
||||
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. */
|
||||
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.
|
||||
* 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 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,
|
||||
|
||||
@@ -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 {
|
||||
@@ -34,10 +39,30 @@ 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 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 [isDragging, setIsDragging] = useState(false)
|
||||
@@ -50,13 +75,6 @@ export function MetricControl({
|
||||
const valueRef = useRef(value)
|
||||
valueRef.current = value
|
||||
|
||||
const clamp = useCallback(
|
||||
(val: number) => {
|
||||
return Math.min(Math.max(val, min), max)
|
||||
},
|
||||
[min, max],
|
||||
)
|
||||
|
||||
const applyCommittedValue = useCallback(
|
||||
(nextValue: number) => {
|
||||
if (onCommit) {
|
||||
@@ -84,12 +102,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)
|
||||
@@ -98,7 +116,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
|
||||
@@ -110,13 +128,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)
|
||||
@@ -126,7 +143,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) => {
|
||||
@@ -143,14 +168,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
|
||||
@@ -182,13 +206,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)
|
||||
@@ -197,12 +231,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()
|
||||
@@ -213,21 +247,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 (
|
||||
|
||||
@@ -210,6 +210,14 @@ export {
|
||||
getActivePaintMaterialLabel,
|
||||
hasActivePaintMaterial,
|
||||
} from './lib/material-paint'
|
||||
export {
|
||||
formatLinearMeasurement,
|
||||
getLinearUnitLabel,
|
||||
type LinearUnit,
|
||||
linearControlValueToMeters,
|
||||
linearUnitToMeters,
|
||||
metersToLinearUnit,
|
||||
} from './lib/measurements'
|
||||
export {
|
||||
addFreshPlacementMetadata,
|
||||
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,
|
||||
type FencePlanPoint,
|
||||
formatAngleRadians,
|
||||
formatLinearMeasurement,
|
||||
getAngleArcToSegmentReference,
|
||||
getAngleToSegmentReference,
|
||||
getSegmentAngleReferenceAtPoint,
|
||||
@@ -94,17 +95,6 @@ type AngleSource = {
|
||||
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) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
@@ -365,7 +355,7 @@ function getDraftMeasurementState(
|
||||
const length = Math.hypot(dx, dz)
|
||||
if (length < 0.01) return null
|
||||
return {
|
||||
lengthLabel: formatMeasurement(length, unit),
|
||||
lengthLabel: formatLinearMeasurement(length, unit),
|
||||
lengthPosition: [
|
||||
(start[0] + end[0]) / 2,
|
||||
baseY + previewHeight + DRAFT_LABEL_Y_OFFSET,
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
import {
|
||||
ActionButton,
|
||||
ActionGroup,
|
||||
getLinearUnitLabel,
|
||||
linearControlValueToMeters,
|
||||
metersToLinearUnit,
|
||||
PanelSection,
|
||||
PanelWrapper,
|
||||
SliderControl,
|
||||
@@ -26,6 +29,7 @@ import { useCallback, useMemo, useRef } from 'react'
|
||||
|
||||
export default function WallPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const unit = useViewer((s) => s.unit)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
||||
|
||||
@@ -117,14 +121,19 @@ export default function WallPanel() {
|
||||
|
||||
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 height = node.height ?? 2.5
|
||||
const thickness = node.thickness ?? 0.1
|
||||
const curveOffset = getClampedWallCurveOffset(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 (
|
||||
<PanelWrapper
|
||||
@@ -136,44 +145,66 @@ export default function WallPanel() {
|
||||
<PanelSection title="Dimensions">
|
||||
<SliderControl
|
||||
label="Length"
|
||||
max={20}
|
||||
min={0.1}
|
||||
onChange={handleUpdateLength}
|
||||
max={metersToLinearUnit(20, unit)}
|
||||
min={metersToLinearUnit(0.1, unit)}
|
||||
onChange={(value) =>
|
||||
handleUpdateLength(
|
||||
linearControlValueToMeters(value, unit, { maxMeters: 20, minMeters: 0.1 }),
|
||||
)
|
||||
}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={length}
|
||||
step={unit === 'imperial' ? 0.1 : 0.01}
|
||||
unit={unitLabel}
|
||||
value={displayLength}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={6}
|
||||
min={0.1}
|
||||
onChange={(v) => handleUpdate({ height: Math.max(0.1, v) })}
|
||||
max={metersToLinearUnit(6, unit)}
|
||||
min={metersToLinearUnit(0.1, unit)}
|
||||
onChange={(v) =>
|
||||
handleUpdate({
|
||||
height: linearControlValueToMeters(v, unit, { maxMeters: 6, minMeters: 0.1 }),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(height * 100) / 100}
|
||||
unit={unitLabel}
|
||||
value={Math.round(displayHeight * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
max={1}
|
||||
min={0.05}
|
||||
onChange={(v) => handleUpdate({ thickness: Math.max(0.05, v) })}
|
||||
max={metersToLinearUnit(1, unit)}
|
||||
min={metersToLinearUnit(0.05, unit)}
|
||||
onChange={(v) =>
|
||||
handleUpdate({
|
||||
thickness: linearControlValueToMeters(v, unit, { maxMeters: 1, minMeters: 0.05 }),
|
||||
})
|
||||
}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(thickness * 1000) / 1000}
|
||||
unit={unitLabel}
|
||||
value={Math.round(displayThickness * 1000) / 1000}
|
||||
/>
|
||||
{!hasWallChildrenBlockingCurve && (
|
||||
<SliderControl
|
||||
label="Curve"
|
||||
max={Math.max(0.01, maxCurveOffset)}
|
||||
min={-Math.max(0.01, maxCurveOffset)}
|
||||
onChange={(v) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, v) })}
|
||||
max={Math.max(metersToLinearUnit(0.01, unit), displayMaxCurveOffset)}
|
||||
min={-Math.max(metersToLinearUnit(0.01, unit), displayMaxCurveOffset)}
|
||||
onChange={(v) =>
|
||||
handleUpdate({
|
||||
curveOffset: normalizeWallCurveOffset(
|
||||
node,
|
||||
linearControlValueToMeters(v, unit, {
|
||||
maxMeters: curveOffsetLimit,
|
||||
minMeters: -curveOffsetLimit,
|
||||
}),
|
||||
),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(curveOffset * 100) / 100}
|
||||
unit={unitLabel}
|
||||
value={Math.round(displayCurveOffset * 100) / 100}
|
||||
/>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
createWallOnCurrentLevel,
|
||||
EDITOR_LAYER,
|
||||
formatAngleRadians,
|
||||
formatLinearMeasurement,
|
||||
getAngleArcToSegmentReference,
|
||||
getAngleToSegmentReference,
|
||||
getSegmentAngleReferenceAtPoint,
|
||||
@@ -124,17 +125,6 @@ type AngleSource = {
|
||||
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) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
@@ -426,7 +416,7 @@ function getDraftMeasurementState(
|
||||
const length = Math.hypot(dx, dz)
|
||||
if (length < 0.01) return null
|
||||
return {
|
||||
lengthLabel: formatMeasurement(length, unit),
|
||||
lengthLabel: formatLinearMeasurement(length, unit),
|
||||
lengthPosition: [
|
||||
(start[0] + end[0]) / 2,
|
||||
baseY + previewHeight + DRAFT_LABEL_Y_OFFSET,
|
||||
|
||||
Reference in New Issue
Block a user