diff --git a/.env.example b/.env.example deleted file mode 100644 index daa84046e..000000000 --- a/.env.example +++ /dev/null @@ -1,7 +0,0 @@ -# Optional environment variables — the editor works without any of these - -# Google Maps API key (enables address search) -# NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_google_maps_api_key - -# Dev server port (default: 3000) -# PORT=3000 diff --git a/packages/editor/src/components/tools/wall/wall-tool.tsx b/packages/editor/src/components/tools/wall/wall-tool.tsx index 0cec767f1..538c0245e 100644 --- a/packages/editor/src/components/tools/wall/wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/wall-tool.tsx @@ -5,7 +5,9 @@ import { useEffect, useRef, useState } from 'react' import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { EDITOR_LAYER } from '../../../lib/constants' +import { formatMeasurement, parseMeasurement } from '../../../lib/measurements' import { sfxEmitter } from '../../../lib/sfx-bus' +import { cn } from '../../../lib/utils' import { CursorSphere } from '../shared/cursor-sphere' import { formatAngleRadians, @@ -30,18 +32,6 @@ type DraftMeasurementState = { angleLabels: DraftAngleLabel[] } | null -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 getDraftAngleLabels( start: WallPlanPoint, end: WallPlanPoint, @@ -162,6 +152,8 @@ export const WallTool: React.FC = () => { const endingPoint = useRef(new Vector3(0, 0, 0)) const buildingState = useRef(0) const shiftPressed = useRef(false) + const draftInputRef = useRef(null) + const [draftInput, setDraftInput] = useState(null) const [draftMeasurement, setDraftMeasurement] = useState(null) useEffect(() => { @@ -246,6 +238,45 @@ export const WallTool: React.FC = () => { if (e.key === 'Shift') { shiftPressed.current = true } + + if (buildingState.current === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + if (/^[\d.'"-]$/.test(e.key)) { + const next = (draftInputRef.current || '') + e.key + draftInputRef.current = next + setDraftInput(next) + } else if (e.key === 'Backspace') { + if (draftInputRef.current) { + const next = draftInputRef.current.slice(0, -1) + draftInputRef.current = next || null + setDraftInput(draftInputRef.current) + } + } else if (e.key === 'Enter' && draftInputRef.current) { + const parsed = parseMeasurement( + draftInputRef.current, + unit === 'imperial' ? 'imperial' : 'metric', + ) + if (parsed !== null && parsed > 0.01) { + const start = startingPoint.current + const end = endingPoint.current + const dx = end.x - start.x + const dz = end.z - start.z + const currentLength = Math.hypot(dx, dz) + + if (currentLength > 0.001) { + const newEnd: WallPlanPoint = [ + start.x + (dx / currentLength) * parsed, + start.z + (dz / currentLength) * parsed, + ] + createWallOnCurrentLevel([start.x, start.z], newEnd) + buildingState.current = 0 + wallPreviewRef.current.visible = false + draftInputRef.current = null + setDraftInput(null) + setDraftMeasurement(null) + } + } + } + } } const onKeyUp = (e: KeyboardEvent) => { @@ -255,7 +286,11 @@ export const WallTool: React.FC = () => { } const onCancel = () => { - if (buildingState.current === 1) { + if (draftInputRef.current !== null) { + markToolCancelConsumed() + draftInputRef.current = null + setDraftInput(null) + } else if (buildingState.current === 1) { markToolCancelConsumed() buildingState.current = 0 wallPreviewRef.current.visible = false @@ -299,8 +334,9 @@ export const WallTool: React.FC = () => { {draftMeasurement && ( <> {draftMeasurement.angleLabels.map((angleLabel) => ( { function DraftMeasurementLabel({ label, position, + isTyping, }: { label: string position: [number, number, number] + isTyping?: boolean }) { return ( -
+
{label} + {isTyping && _}
) diff --git a/packages/editor/src/components/ui/controls/slider-control.tsx b/packages/editor/src/components/ui/controls/slider-control.tsx index 12a1f9548..a25aa8e02 100644 --- a/packages/editor/src/components/ui/controls/slider-control.tsx +++ b/packages/editor/src/components/ui/controls/slider-control.tsx @@ -1,7 +1,9 @@ 'use client' import { useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' +import { formatMeasurement, parseMeasurement } from '../../../lib/measurements' import { cn } from '../../../lib/utils' interface SliderControlProps { @@ -59,10 +61,25 @@ export function SliderControl({ unit = '', restoreOnCommit = true, }: SliderControlProps) { + const viewerUnit = useViewer((state) => state.unit) + const isLengthMeasurement = unit === 'm' + const activeUnit = isLengthMeasurement ? viewerUnit : 'metric' + const multiplier = isLengthMeasurement && activeUnit === 'imperial' ? 3.28084 : 1 + const displayUnit = isLengthMeasurement && activeUnit === 'imperial' ? '' : unit + + const getDisplayStr = useCallback( + (v: number) => { + return isLengthMeasurement && activeUnit === 'imperial' + ? formatMeasurement(v, 'imperial', precision) + : v.toFixed(precision) + }, + [isLengthMeasurement, activeUnit, precision], + ) + const [isEditing, setIsEditing] = useState(false) const [isDragging, setIsDragging] = useState(false) const [isHovered, setIsHovered] = useState(false) - const [inputValue, setInputValue] = useState(value.toFixed(precision)) + const [inputValue, setInputValue] = useState(getDisplayStr(value)) const dragRef = useRef<{ // Original value at drag start — preserved across modifier re-anchors so @@ -83,9 +100,9 @@ export function SliderControl({ useEffect(() => { if (!isEditing) { - setInputValue(value.toFixed(precision)) + setInputValue(getDisplayStr(value)) } - }, [value, precision, isEditing]) + }, [value, getDisplayStr, isEditing]) // Wheel support on the label useEffect(() => { @@ -95,7 +112,7 @@ export function SliderControl({ if (isEditing) return e.preventDefault() const direction = e.deltaY < 0 ? 1 : -1 - const s = getAdjustedStep(step, e) + const s = getAdjustedStep(step / multiplier, e) const newValue = clamp(valueRef.current + direction * s) const final = Number.parseFloat(newValue.toFixed(stepPrecision(s))) if (final !== valueRef.current) onChange(final) @@ -114,7 +131,7 @@ export function SliderControl({ else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1 if (direction !== 0) { e.preventDefault() - const s = getAdjustedStep(step, e) + const s = getAdjustedStep(step / multiplier, e) const newValue = clamp(valueRef.current + direction * s) const final = Number.parseFloat(newValue.toFixed(stepPrecision(s))) if (final !== valueRef.current) onChange(final) @@ -145,20 +162,20 @@ export function SliderControl({ const handleLabelPointerMove = useCallback( (e: React.PointerEvent) => { if (!dragRef.current) return - const multiplier = getStepMultiplier(e) + const multiplier_val = getStepMultiplier(e) // If modifier keys changed mid-drag, re-anchor from the current pointer // position and value — otherwise the accumulated dx would be applied // with a new step size and jump the value (e.g. pressing Cmd while // already far from the starting point would snap back toward it). - if (multiplier !== dragRef.current.stepMultiplier) { + if (multiplier_val !== dragRef.current.stepMultiplier) { dragRef.current.anchorX = e.clientX dragRef.current.anchorValue = valueRef.current - dragRef.current.stepMultiplier = multiplier + dragRef.current.stepMultiplier = multiplier_val return } const { anchorX, anchorValue } = dragRef.current const dx = e.clientX - anchorX - const s = step * multiplier + const s = (step / multiplier) * multiplier_val // 4 px per step at default sensitivity const newValue = clamp( Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))), @@ -195,47 +212,54 @@ export function SliderControl({ const handleValueClick = useCallback(() => { setIsEditing(true) - setInputValue(value.toFixed(precision)) - }, [value, precision]) + setInputValue(getDisplayStr(value)) + }, [value, getDisplayStr]) const submitValue = useCallback(() => { - const numValue = Number.parseFloat(inputValue) - if (Number.isNaN(numValue)) { - setInputValue(value.toFixed(precision)) + let nextValue: number | null = null + if (isLengthMeasurement && activeUnit === 'imperial') { + nextValue = parseMeasurement(inputValue, 'imperial') + } else { + const numValue = Number.parseFloat(inputValue) + nextValue = Number.isNaN(numValue) ? null : numValue + } + + if (nextValue === null) { + setInputValue(getDisplayStr(value)) } else { - const nextValue = clamp(Number.parseFloat(numValue.toFixed(precision))) - onChange(nextValue) - onCommit?.(nextValue) + const clamped = clamp(Number.parseFloat(nextValue.toFixed(precision + 2))) + onChange(clamped) + onCommit?.(clamped) } setIsEditing(false) - }, [inputValue, onChange, onCommit, clamp, precision, value]) + }, [inputValue, onChange, onCommit, clamp, precision, value, isLengthMeasurement, activeUnit, getDisplayStr]) const handleInputKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter') { submitValue() } else if (e.key === 'Escape') { - setInputValue(value.toFixed(precision)) + setInputValue(getDisplayStr(value)) setIsEditing(false) } else if (e.key === 'ArrowUp') { e.preventDefault() - const adjustedStep = getAdjustedStep(step, e) + const adjustedStep = getAdjustedStep(step / multiplier, e) const newV = clamp( Number.parseFloat((value + adjustedStep).toFixed(stepPrecision(adjustedStep))), ) onChange(newV) - setInputValue(newV.toFixed(precision)) + setInputValue(getDisplayStr(newV)) } else if (e.key === 'ArrowDown') { e.preventDefault() - const adjustedStep = getAdjustedStep(step, e) + const adjustedStep = getAdjustedStep(step / multiplier, e) const newV = clamp( Number.parseFloat((value - adjustedStep).toFixed(stepPrecision(adjustedStep))), ) onChange(newV) - setInputValue(newV.toFixed(precision)) + setInputValue(getDisplayStr(newV)) } }, - [submitValue, value, precision, step, clamp, onChange], + [submitValue, value, getDisplayStr, step, multiplier, clamp, onChange], ) return ( @@ -288,7 +312,7 @@ export function SliderControl({ type="text" value={inputValue} /> - {unit && {unit}} + {displayUnit && {displayUnit}} ) : (
- {Number(value.toFixed(precision)).toFixed(precision)} + {isLengthMeasurement && activeUnit === 'imperial' + ? formatMeasurement(value, 'imperial', precision) + : Number(value.toFixed(precision)).toFixed(precision)} - {unit && {unit}} + {displayUnit && {displayUnit}}
)}
diff --git a/packages/editor/src/lib/measurements.ts b/packages/editor/src/lib/measurements.ts new file mode 100644 index 000000000..a53685bd2 --- /dev/null +++ b/packages/editor/src/lib/measurements.ts @@ -0,0 +1,46 @@ +export function parseMeasurement(input: string, unit: 'metric' | 'imperial'): number | null { + if (unit === 'metric') { + const val = Number.parseFloat(input) + return Number.isNaN(val) ? null : val + } + + // Handle imperial format: e.g., 10, 10', 10' 6", 6" + const feetInchesRegex = /^\s*(?:(\d+(?:\.\d+)?)\s*')?\s*(?:(\d+(?:\.\d+)?)\s*")?\s*$/ + const match = input.match(feetInchesRegex) + + if (match) { + if (!match[1] && !match[2]) { + // It's just a raw number like "10" or "5.5" + const val = Number.parseFloat(input) + if (Number.isNaN(val)) return null + return val / 3.28084 + } + + const feet = match[1] ? Number.parseFloat(match[1]) : 0 + const inches = match[2] ? Number.parseFloat(match[2]) : 0 + + const totalFeet = feet + inches / 12 + return totalFeet / 3.28084 + } + + return null +} + +export function formatMeasurement( + valueInMeters: number, + unit: 'metric' | 'imperial', + precision = 2, +): string { + if (unit === 'metric') { + return Number.parseFloat(valueInMeters.toFixed(precision)).toFixed(precision) + } + + const feet = valueInMeters * 3.28084 + const wholeFeet = Math.floor(feet) + const inches = Math.round((feet - wholeFeet) * 12) + + if (inches === 12) return `${wholeFeet + 1}'0"` + if (wholeFeet === 0) return `${inches}"` + if (inches === 0) return `${wholeFeet}'` + return `${wholeFeet}'${inches}"` +}