From be6009e59f7994b39f49f6c3e47beb03d2e59826 Mon Sep 17 00:00:00 2001 From: MateoSaettone Date: Wed, 20 May 2026 14:35:36 -0400 Subject: [PATCH] fix: respect imperial units in wall panel --- .../src/components/editor/floorplan-panel.tsx | 12 +- .../components/editor/site-edge-labels.tsx | 14 +-- .../editor/wall-measurement-label.tsx | 16 +-- .../tools/item/use-placement-coordinator.tsx | 18 +-- .../components/ui/controls/metric-control.tsx | 110 ++++++++++++------ packages/editor/src/index.tsx | 8 ++ packages/editor/src/lib/measurements.test.ts | 75 ++++++++++++ packages/editor/src/lib/measurements.ts | 58 +++++++++ packages/nodes/src/fence/tool.tsx | 14 +-- packages/nodes/src/wall/panel.tsx | 77 ++++++++---- packages/nodes/src/wall/tool.tsx | 14 +-- 11 files changed, 283 insertions(+), 133 deletions(-) create mode 100644 packages/editor/src/lib/measurements.test.ts create mode 100644 packages/editor/src/lib/measurements.ts diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 48b0a80b7..4428675a9 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -72,6 +72,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' @@ -2331,14 +2332,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) { @@ -2350,7 +2344,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: diff --git a/packages/editor/src/components/editor/site-edge-labels.tsx b/packages/editor/src/components/editor/site-edge-labels.tsx index 5dd613b30..33eb69850 100644 --- a/packages/editor/src/components/editor/site-edge-labels.tsx +++ b/packages/editor/src/components/editor/site-edge-labels.tsx @@ -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 @@ -85,7 +75,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)} ))} diff --git a/packages/editor/src/components/editor/wall-measurement-label.tsx b/packages/editor/src/components/editor/wall-measurement-label.tsx index b82cdca78..285b96293 100644 --- a/packages/editor/src/components/editor/wall-measurement-label.tsx +++ b/packages/editor/src/components/editor/wall-measurement-label.tsx @@ -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 diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 4fae6ae1a..f524f0acb 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -42,6 +42,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 { @@ -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 @@ -1793,9 +1783,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, diff --git a/packages/editor/src/components/ui/controls/metric-control.tsx b/packages/editor/src/components/ui/controls/metric-control.tsx index 14e152fa9..1ce1f5fcc 100644 --- a/packages/editor/src/components/ui/controls/metric-control.tsx +++ b/packages/editor/src/components/ui/controls/metric-control.tsx @@ -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) => { 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 ( diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 046f9b5f4..d6ceb0c53 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -203,6 +203,14 @@ export { getActivePaintMaterialLabel, hasActivePaintMaterial, } from './lib/material-paint' +export { + formatLinearMeasurement, + getLinearUnitLabel, + type LinearUnit, + linearControlValueToMeters, + linearUnitToMeters, + metersToLinearUnit, +} from './lib/measurements' export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-duplication' export type { SceneGraph } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene' diff --git a/packages/editor/src/lib/measurements.test.ts b/packages/editor/src/lib/measurements.test.ts new file mode 100644 index 000000000..4dd01dfa1 --- /dev/null +++ b/packages/editor/src/lib/measurements.test.ts @@ -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') + }) +}) diff --git a/packages/editor/src/lib/measurements.ts b/packages/editor/src/lib/measurements.ts new file mode 100644 index 000000000..d53e61f4c --- /dev/null +++ b/packages/editor/src/lib/measurements.ts @@ -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` +} diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index 6d0716641..827630f14 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -21,6 +21,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, diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index cf5927aa6..5750eefb2 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -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 ( + 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} /> 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} /> 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 && ( 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} /> )} diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 083ce0a29..cf4278178 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -17,6 +17,7 @@ import { createWallOnCurrentLevel, EDITOR_LAYER, formatAngleRadians, + formatLinearMeasurement, getAngleArcToSegmentReference, getAngleToSegmentReference, getSegmentAngleReferenceAtPoint, @@ -96,17 +97,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)) } @@ -351,7 +341,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,