From 98a14ea41d517479ff84fb5d3e5ed138ddd21ee2 Mon Sep 17 00:00:00 2001 From: Phinehas Adams Date: Wed, 1 Apr 2026 11:31:15 -0500 Subject: [PATCH 1/3] fix: point vercel output to root .next --- vercel.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 vercel.json diff --git a/vercel.json b/vercel.json new file mode 100644 index 00000000..38e43598 --- /dev/null +++ b/vercel.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "outputDirectory": ".next" +} From 08cdf2034fb39baf374e3b83ca91dab5ac4e61e4 Mon Sep 17 00:00:00 2001 From: Phinehas Adams Date: Wed, 1 Apr 2026 13:11:28 -0500 Subject: [PATCH 2/3] feat: improve floorplan segment drafting --- .../src/components/editor/floorplan-panel.tsx | 400 +++++++++++++++++- .../components/tools/wall/wall-drafting.ts | 3 +- 2 files changed, 398 insertions(+), 5 deletions(-) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 57c2b4d7..fd1d8385 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -42,6 +42,7 @@ import { isWallLongEnough, snapWallDraftPoint, WALL_GRID_STEP, + WALL_MIN_LENGTH, type WallPlanPoint, } from '../tools/wall/wall-drafting' import { furnishTools } from '../ui/action-menu/furnish-tools' @@ -122,6 +123,9 @@ const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_Y = 48 const FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES = 45 const FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES = 1 const FLOORPLAN_SITE_COLOR = '#10b981' +const FLOORPLAN_DRAFT_OVERLAY_OFFSET_PX = 22 +const FLOORPLAN_DRAFT_OVERLAY_PADDING_X = 96 +const FLOORPLAN_DRAFT_OVERLAY_PADDING_Y = 56 type FloorplanViewport = { centerX: number @@ -185,6 +189,21 @@ type FloorplanCursorIndicator = icon: string } +type DraftSegment = { + end: WallPlanPoint + kind: 'polygon' | 'wall' + length: number + start: WallPlanPoint +} + +type DraftLengthInputState = { + end: WallPlanPoint + error: string | null + kind: DraftSegment['kind'] + start: WallPlanPoint + value: string +} + const FLOORPLAN_QUICK_BUILD_TOOL_IDS = ['wall', 'door', 'window', 'slab', 'zone'] as const type FloorplanQuickBuildTool = (typeof FLOORPLAN_QUICK_BUILD_TOOL_IDS)[number] @@ -1152,6 +1171,14 @@ function calculatePolygonSnapPoint( return [x1, y] } +function snapPlanDraftValue(value: number) { + return Math.round(value / WALL_GRID_STEP) * WALL_GRID_STEP +} + +function snapPlanDraftPoint(point: WallPlanPoint): WallPlanPoint { + return [snapPlanDraftValue(point[0]), snapPlanDraftValue(point[1])] +} + function snapPolygonDraftPoint({ point, start, @@ -1161,7 +1188,7 @@ function snapPolygonDraftPoint({ start?: WallPlanPoint angleSnap: boolean }): WallPlanPoint { - const snappedPoint: WallPlanPoint = [snapToHalf(point[0]), snapToHalf(point[1])] + const snappedPoint = snapPlanDraftPoint(point) if (!(start && angleSnap)) { return snappedPoint @@ -1313,6 +1340,77 @@ function formatMeasurement(value: number, unit: 'metric' | 'imperial') { return `${Number.parseFloat(value.toFixed(2))}m` } +function formatDraftLengthInputValue(value: number, unit: 'metric' | 'imperial') { + if (unit === 'imperial') { + return formatMeasurement(value, unit) + } + + return Number.parseFloat(value.toFixed(2)).toString() +} + +function parseDraftLengthInput(input: string, unit: 'metric' | 'imperial') { + const trimmed = input.trim().toLowerCase() + if (!trimmed) { + return null + } + + if (unit === 'metric') { + const normalized = trimmed.endsWith('m') ? trimmed.slice(0, -1).trim() : trimmed + const value = Number.parseFloat(normalized) + return Number.isFinite(value) && value > 0 ? value : null + } + + const feetInchesMatch = trimmed.match( + /^(-?\d+(?:\.\d+)?)\s*(?:ft|feet|')\s*(\d+(?:\.\d+)?)?\s*(?:(?:in|inch|inches|")\s*)?$/, + ) + if (feetInchesMatch) { + const feet = Number.parseFloat(feetInchesMatch[1] ?? '0') + const inches = Number.parseFloat(feetInchesMatch[2] ?? '0') + const meters = feet * 0.3048 + inches * 0.0254 + return Number.isFinite(meters) && meters > 0 ? meters : null + } + + const feetAndInchesWordsMatch = trimmed.match( + /^(-?\d+(?:\.\d+)?)\s*(?:ft|feet)\s+(\d+(?:\.\d+)?)\s*(?:in|inch|inches)$/, + ) + if (feetAndInchesWordsMatch) { + const feet = Number.parseFloat(feetAndInchesWordsMatch[1] ?? '0') + const inches = Number.parseFloat(feetAndInchesWordsMatch[2] ?? '0') + const meters = feet * 0.3048 + inches * 0.0254 + return Number.isFinite(meters) && meters > 0 ? meters : null + } + + const inchesOnlyMatch = trimmed.match(/^(-?\d+(?:\.\d+)?)\s*(?:in|inch|inches|")$/) + if (inchesOnlyMatch) { + const meters = Number.parseFloat(inchesOnlyMatch[1] ?? '0') * 0.0254 + return Number.isFinite(meters) && meters > 0 ? meters : null + } + + const plainValue = Number.parseFloat(trimmed) + if (Number.isFinite(plainValue) && plainValue > 0) { + return plainValue * 0.3048 + } + + return null +} + +function projectDraftPointToLength( + start: WallPlanPoint, + end: WallPlanPoint, + length: number, +): WallPlanPoint | null { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const directionLength = Math.hypot(dx, dz) + + if (!(Number.isFinite(directionLength) && directionLength > 1e-6 && Number.isFinite(length))) { + return null + } + + const scale = length / directionLength + return [start[0] + dx * scale, start[1] + dz * scale] +} + function getPolygonAreaAndCentroid(polygon: Point2D[]) { let cx = 0 let cy = 0 @@ -3021,6 +3119,7 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({ export function FloorplanPanel() { const viewportHostRef = useRef(null) const svgRef = useRef(null) + const draftLengthInputRef = useRef(null) const panStateRef = useRef(null) const guideInteractionRef = useRef(null) const guideTransformDraftRef = useRef(null) @@ -3195,6 +3294,7 @@ export function FloorplanPanel() { const [zoneBoundaryDraft, setZoneBoundaryDraft] = useState(null) const [zoneVertexDragState, setZoneVertexDragState] = useState(null) const [guideTransformDraft, setGuideTransformDraft] = useState(null) + const [draftLengthInput, setDraftLengthInput] = useState(null) const [cursorPoint, setCursorPoint] = useState(null) const [floorplanCursorPosition, setFloorplanCursorPosition] = useState(null) const [wallEndpointDraft, setWallEndpointDraft] = useState(null) @@ -3821,6 +3921,43 @@ export function FloorplanPanel() { y2: toSvgY(firstPoint[1]), } }, [activePolygonDraftPoints, cursorPoint, isPolygonBuildActive]) + const activeDraftSegment = useMemo(() => { + if (isWallBuildActive && draftStart && draftEnd) { + const length = Math.hypot(draftEnd[0] - draftStart[0], draftEnd[1] - draftStart[1]) + if (length > 1e-6) { + return { + end: draftEnd, + kind: 'wall', + length, + start: draftStart, + } + } + } + + if (isPolygonBuildActive && cursorPoint && activePolygonDraftPoints.length > 0) { + const start = activePolygonDraftPoints[activePolygonDraftPoints.length - 1] + if (start) { + const length = Math.hypot(cursorPoint[0] - start[0], cursorPoint[1] - start[1]) + if (length > 1e-6) { + return { + end: cursorPoint, + kind: 'polygon', + length, + start, + } + } + } + } + + return null + }, [ + activePolygonDraftPoints, + cursorPoint, + draftEnd, + draftStart, + isPolygonBuildActive, + isWallBuildActive, + ]) const svgAspectRatio = surfaceSize.width / surfaceSize.height || 1 @@ -4029,6 +4166,63 @@ export function FloorplanPanel() { y: Math.max(anchorY, FLOORPLAN_ACTION_MENU_MIN_ANCHOR_Y), } }, [selectedOpeningEntry, surfaceSize.height, surfaceSize.width, viewBox]) + const draftSegmentOverlay = useMemo(() => { + const segment = draftLengthInput ?? activeDraftSegment + if (!(segment && surfaceSize.width > 0 && surfaceSize.height > 0)) { + return null + } + + const startSvg = toSvgPlanPoint(segment.start) + const endSvg = toSvgPlanPoint(segment.end) + const startX = ((startSvg.x - viewBox.minX) / viewBox.width) * surfaceSize.width + const startY = ((startSvg.y - viewBox.minY) / viewBox.height) * surfaceSize.height + const endX = ((endSvg.x - viewBox.minX) / viewBox.width) * surfaceSize.width + const endY = ((endSvg.y - viewBox.minY) / viewBox.height) * surfaceSize.height + + if ( + !( + Number.isFinite(startX) && + Number.isFinite(startY) && + Number.isFinite(endX) && + Number.isFinite(endY) + ) + ) { + return null + } + + const midpointX = (startX + endX) / 2 + const midpointY = (startY + endY) / 2 + const dx = endX - startX + const dy = endY - startY + const distance = Math.hypot(dx, dy) + const perpX = distance > 1e-6 ? -dy / distance : 0 + const perpY = distance > 1e-6 ? dx / distance : -1 + + const minX = Math.min(FLOORPLAN_DRAFT_OVERLAY_PADDING_X, surfaceSize.width / 2) + const maxX = Math.max(surfaceSize.width - FLOORPLAN_DRAFT_OVERLAY_PADDING_X, minX) + const minY = Math.min(FLOORPLAN_DRAFT_OVERLAY_PADDING_Y, surfaceSize.height / 2) + const maxY = Math.max(surfaceSize.height - FLOORPLAN_DRAFT_OVERLAY_PADDING_Y, minY) + + return { + kind: segment.kind, + length: Math.hypot(segment.end[0] - segment.start[0], segment.end[1] - segment.start[1]), + x: clamp(midpointX + perpX * FLOORPLAN_DRAFT_OVERLAY_OFFSET_PX, minX, maxX), + y: clamp(midpointY + perpY * FLOORPLAN_DRAFT_OVERLAY_OFFSET_PX, minY, maxY), + } + }, [activeDraftSegment, draftLengthInput, surfaceSize.height, surfaceSize.width, viewBox]) + + useEffect(() => { + if (!draftLengthInput) { + return + } + + const focusInput = window.requestAnimationFrame(() => { + draftLengthInputRef.current?.focus() + draftLengthInputRef.current?.select() + }) + + return () => window.cancelAnimationFrame(focusInput) + }, [draftLengthInput]) // biome-ignore lint/correctness/useExhaustiveDependencies: reset hovered corner when selected guide changes useEffect(() => { @@ -4548,6 +4742,7 @@ export function FloorplanPanel() { clearSiteBoundaryInteraction() clearSlabBoundaryInteraction() clearZoneBoundaryInteraction() + setDraftLengthInput(null) setCursorPoint(null) }, [ clearSiteBoundaryInteraction, @@ -4567,6 +4762,24 @@ export function FloorplanPanel() { clearDraft() }, [clearDraft, isPolygonBuildActive, isWallBuildActive]) + const closeDraftLengthInput = useCallback(() => { + setDraftLengthInput(null) + }, []) + + const openDraftLengthInput = useCallback(() => { + if (!activeDraftSegment) { + return + } + + setDraftLengthInput({ + end: [...activeDraftSegment.end] as WallPlanPoint, + error: null, + kind: activeDraftSegment.kind, + start: [...activeDraftSegment.start] as WallPlanPoint, + value: formatDraftLengthInputValue(activeDraftSegment.length, unit), + }) + }, [activeDraftSegment, unit]) + useEffect(() => { const handleCancel = () => { clearDraft() @@ -4653,6 +4866,53 @@ export function FloorplanPanel() { } }, []) + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null + const isEditableTarget = + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + Boolean(target?.isContentEditable) + + if ( + isEditableTarget || + !isFloorplanHovered || + event.metaKey || + event.ctrlKey || + event.altKey + ) { + return + } + + if (event.key === 'Tab') { + if (!activeDraftSegment) { + return + } + + event.preventDefault() + openDraftLengthInput() + return + } + + if (event.key === 'Escape' && draftLengthInput) { + event.preventDefault() + closeDraftLengthInput() + } + } + + window.addEventListener('keydown', handleKeyDown, true) + + return () => { + window.removeEventListener('keydown', handleKeyDown, true) + } + }, [ + activeDraftSegment, + closeDraftLengthInput, + draftLengthInput, + isFloorplanHovered, + openDraftLengthInput, + ]) + useEffect(() => { const handleWindowPointerMove = (event: PointerEvent) => { const guideInteraction = guideInteractionRef.current @@ -4892,7 +5152,7 @@ export function FloorplanPanel() { return } - const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] + const snappedPoint = snapPlanDraftPoint(planPoint) setCursorPoint(snappedPoint) setSiteBoundaryDraft((currentDraft) => { @@ -4996,7 +5256,7 @@ export function FloorplanPanel() { return } - const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] + const snappedPoint = snapPlanDraftPoint(planPoint) setCursorPoint(snappedPoint) setSlabBoundaryDraft((currentDraft) => { @@ -5093,7 +5353,7 @@ export function FloorplanPanel() { return } - const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] + const snappedPoint = snapPlanDraftPoint(planPoint) setCursorPoint(snappedPoint) setZoneBoundaryDraft((currentDraft) => { @@ -5484,6 +5744,67 @@ export function FloorplanPanel() { }, [clearDraft, draftStart], ) + const submitDraftLengthInput = useCallback(() => { + if (!draftLengthInput) { + return + } + + const parsedLength = parseDraftLengthInput(draftLengthInput.value, unit) + if (!(parsedLength && parsedLength > 0)) { + setDraftLengthInput((currentState) => + currentState ? { ...currentState, error: 'Enter a valid length.' } : currentState, + ) + return + } + + if (draftLengthInput.kind === 'wall' && parsedLength < WALL_MIN_LENGTH) { + setDraftLengthInput((currentState) => + currentState + ? { + ...currentState, + error: `Walls must be at least ${formatMeasurement(WALL_MIN_LENGTH, unit)}.`, + } + : currentState, + ) + return + } + + const nextPoint = projectDraftPointToLength( + draftLengthInput.start, + draftLengthInput.end, + parsedLength, + ) + if (!nextPoint) { + setDraftLengthInput((currentState) => + currentState + ? { ...currentState, error: 'Move the cursor to set a direction first.' } + : null, + ) + return + } + + if (draftLengthInput.kind === 'wall') { + createWallOnCurrentLevel(draftLengthInput.start, nextPoint) + clearDraft() + return + } + + if (isZoneBuildActive) { + handleZonePlacementPoint(nextPoint) + } else { + handleSlabPlacementPoint(nextPoint) + } + + closeDraftLengthInput() + }, [ + clearDraft, + closeDraftLengthInput, + draftLengthInput, + handleSlabPlacementPoint, + handleZonePlacementPoint, + isZoneBuildActive, + unit, + ]) const handleBackgroundClick = useCallback( (event: ReactMouseEvent) => { @@ -7264,6 +7585,77 @@ export function FloorplanPanel() { /> )} + {draftSegmentOverlay && ( +
+ {draftLengthInput ? ( +
event.stopPropagation()} + > +
+ + setDraftLengthInput((currentState) => + currentState + ? { + ...currentState, + error: null, + value: event.target.value, + } + : currentState, + ) + } + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + submitDraftLengthInput() + } else if (event.key === 'Escape') { + event.preventDefault() + closeDraftLengthInput() + } + }} + placeholder={unit === 'imperial' ? `8'0"` : '2.40'} + ref={draftLengthInputRef} + value={draftLengthInput.value} + /> + +
+
+ {unit === 'imperial' ? 'Feet/inches or inches' : 'Meters'} + Enter to place +
+ {draftLengthInput.error ? ( +
{draftLengthInput.error}
+ ) : null} +
+ ) : ( +
+ + {formatMeasurement(draftSegmentOverlay.length, unit)} + + + Tab + +
+ )} +
+ )} {!levelNode || levelNode.type !== 'level' ? (
diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index c8b28a00..9013abdb 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -2,7 +2,8 @@ import { useScene, type WallNode, WallNode as WallSchema } from '@pascal-app/cor import { useViewer } from '@pascal-app/viewer' import { sfxEmitter } from '../../../lib/sfx-bus' export type WallPlanPoint = [number, number] -export const WALL_GRID_STEP = 0.5 +// Drafting snaps at 1 inch in world-meter units for finer floorplan precision. +export const WALL_GRID_STEP = 0.0254 export const WALL_JOIN_SNAP_RADIUS = 0.35 export const WALL_MIN_LENGTH = 0.5 function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number { From 24783bf719a930d6ad9d78d2dc3df719e83a7565 Mon Sep 17 00:00:00 2001 From: Phinehas Adams Date: Wed, 1 Apr 2026 13:25:48 -0500 Subject: [PATCH 3/3] fix: show live wall segment length while drawing --- .../src/components/tools/wall/wall-tool.tsx | 292 +++++++++++++++++- 1 file changed, 286 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/components/tools/wall/wall-tool.tsx b/packages/editor/src/components/tools/wall/wall-tool.tsx index 1ecf6576..6b2a015d 100644 --- a/packages/editor/src/components/tools/wall/wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/wall-tool.tsx @@ -1,6 +1,7 @@ import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef } from 'react' +import { Html } from '@react-three/drei' +import { useCallback, useEffect, useMemo, 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' @@ -15,6 +16,90 @@ import { const WALL_HEIGHT = 2.5 +type DraftWallState = { + end: WallPlanPoint + start: WallPlanPoint + y: number +} + +type DraftLengthInputState = { + error: string | null + value: string +} + +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 formatDraftLengthInputValue(value: number, unit: 'metric' | 'imperial') { + if (unit === 'imperial') { + return formatMeasurement(value, unit) + } + + return Number.parseFloat(value.toFixed(2)).toString() +} + +function parseDraftLengthInput(input: string, unit: 'metric' | 'imperial') { + const trimmed = input.trim().toLowerCase() + if (!trimmed) { + return null + } + + if (unit === 'metric') { + const normalized = trimmed.endsWith('m') ? trimmed.slice(0, -1).trim() : trimmed + const value = Number.parseFloat(normalized) + return Number.isFinite(value) && value > 0 ? value : null + } + + const feetInchesMatch = trimmed.match( + /^(-?\d+(?:\.\d+)?)\s*(?:ft|feet|')\s*(\d+(?:\.\d+)?)?\s*(?:(?:in|inch|inches|")\s*)?$/, + ) + if (feetInchesMatch) { + const feet = Number.parseFloat(feetInchesMatch[1] ?? '0') + const inches = Number.parseFloat(feetInchesMatch[2] ?? '0') + const meters = feet * 0.3048 + inches * 0.0254 + return Number.isFinite(meters) && meters > 0 ? meters : null + } + + const inchesOnlyMatch = trimmed.match(/^(-?\d+(?:\.\d+)?)\s*(?:in|inch|inches|")$/) + if (inchesOnlyMatch) { + const meters = Number.parseFloat(inchesOnlyMatch[1] ?? '0') * 0.0254 + return Number.isFinite(meters) && meters > 0 ? meters : null + } + + const plainValue = Number.parseFloat(trimmed) + if (Number.isFinite(plainValue) && plainValue > 0) { + return plainValue * 0.3048 + } + + return null +} + +function projectDraftPointToLength( + start: WallPlanPoint, + end: WallPlanPoint, + length: number, +): WallPlanPoint | null { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const directionLength = Math.hypot(dx, dz) + + if (!(Number.isFinite(directionLength) && directionLength > 1e-6 && Number.isFinite(length))) { + return null + } + + const scale = length / directionLength + return [start[0] + dx * scale, start[1] + dz * scale] +} + /** * Update wall preview mesh geometry to create a vertical plane between two points */ @@ -72,12 +157,104 @@ const getCurrentLevelWalls = (): WallNode[] => { } export const WallTool: React.FC = () => { + const unit = useViewer((state) => state.unit) const cursorRef = useRef(null) const wallPreviewRef = useRef(null!) + const draftLengthInputRef = useRef(null) const startingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0)) + const draftWallRef = useRef(null) const buildingState = useRef(0) const shiftPressed = useRef(false) + const [draftWall, setDraftWall] = useState(null) + const [draftLengthInput, setDraftLengthInput] = useState(null) + + const setDraftWallState = useCallback((nextDraft: DraftWallState | null) => { + draftWallRef.current = nextDraft + setDraftWall(nextDraft) + }, []) + + const clearDraftState = useCallback(() => { + buildingState.current = 0 + setDraftLengthInput(null) + setDraftWallState(null) + if (wallPreviewRef.current) { + wallPreviewRef.current.visible = false + } + }, [setDraftWallState]) + + const draftLength = useMemo(() => { + if (!draftWall) { + return 0 + } + + return Math.hypot(draftWall.end[0] - draftWall.start[0], draftWall.end[1] - draftWall.start[1]) + }, [draftWall]) + + const draftLabelPosition = useMemo<[number, number, number] | null>(() => { + if (!(draftWall && draftLength >= 1e-6)) { + return null + } + + return [ + (draftWall.start[0] + draftWall.end[0]) / 2, + draftWall.y + 0.35, + (draftWall.start[1] + draftWall.end[1]) / 2, + ] + }, [draftLength, draftWall]) + + const submitDraftLengthInput = useCallback(() => { + const currentDraft = draftWallRef.current + if (!(currentDraft && draftLengthInput)) { + return + } + + const parsedLength = parseDraftLengthInput(draftLengthInput.value, unit) + if (!(parsedLength && parsedLength > 0)) { + setDraftLengthInput((currentState) => + currentState ? { ...currentState, error: 'Enter a valid length.' } : currentState, + ) + return + } + + if (parsedLength < WALL_MIN_LENGTH) { + setDraftLengthInput((currentState) => + currentState + ? { + ...currentState, + error: `Walls must be at least ${formatMeasurement(WALL_MIN_LENGTH, unit)}.`, + } + : currentState, + ) + return + } + + const nextPoint = projectDraftPointToLength(currentDraft.start, currentDraft.end, parsedLength) + if (!nextPoint) { + setDraftLengthInput((currentState) => + currentState + ? { ...currentState, error: 'Move the cursor to set a direction first.' } + : null, + ) + return + } + + createWallOnCurrentLevel(currentDraft.start, nextPoint) + clearDraftState() + }, [clearDraftState, draftLengthInput, unit]) + + useEffect(() => { + if (!draftLengthInput) { + return + } + + const focusInput = window.requestAnimationFrame(() => { + draftLengthInputRef.current?.focus() + draftLengthInputRef.current?.select() + }) + + return () => window.cancelAnimationFrame(focusInput) + }, [draftLengthInput]) useEffect(() => { let gridPosition: WallPlanPoint = [0, 0] @@ -118,6 +295,11 @@ export const WallTool: React.FC = () => { // Update wall preview geometry updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current) + setDraftWallState({ + end: [snappedPoint[0], snappedPoint[1]], + start: [startingPoint.current.x, startingPoint.current.z], + y: event.position[1], + }) } else { // Not drawing a wall yet, show the snapped anchor point. cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1]) @@ -138,6 +320,11 @@ export const WallTool: React.FC = () => { endingPoint.current.copy(startingPoint.current) buildingState.current = 1 wallPreviewRef.current.visible = true + setDraftWallState({ + end: [snappedStart[0], snappedStart[1]], + start: [snappedStart[0], snappedStart[1]], + y: event.position[1], + }) } else if (buildingState.current === 1) { const snappedEnd = snapWallDraftPoint({ point: clickPoint, @@ -153,15 +340,39 @@ export const WallTool: React.FC = () => { [startingPoint.current.x, startingPoint.current.z], [endingPoint.current.x, endingPoint.current.z], ) - wallPreviewRef.current.visible = false - buildingState.current = 0 + clearDraftState() } } const onKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null + const isEditableTarget = + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + Boolean(target?.isContentEditable) + if (e.key === 'Shift') { shiftPressed.current = true } + + if ( + !isEditableTarget && + e.key === 'Tab' && + buildingState.current === 1 && + draftWallRef.current + ) { + e.preventDefault() + const currentDraft = draftWallRef.current + const currentLength = Math.hypot( + currentDraft.end[0] - currentDraft.start[0], + currentDraft.end[1] - currentDraft.start[1], + ) + + setDraftLengthInput({ + error: null, + value: formatDraftLengthInputValue(currentLength, unit), + }) + } } const onKeyUp = (e: KeyboardEvent) => { @@ -173,8 +384,7 @@ export const WallTool: React.FC = () => { const onCancel = () => { if (buildingState.current === 1) { markToolCancelConsumed() - buildingState.current = 0 - wallPreviewRef.current.visible = false + clearDraftState() } } @@ -191,13 +401,83 @@ export const WallTool: React.FC = () => { window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) } - }, []) + }, [clearDraftState, setDraftWallState, unit]) return ( {/* Cursor indicator */} + {draftLabelPosition ? ( + + {draftLengthInput ? ( +
event.stopPropagation()} + > +
+ setDraftLengthInput(null)} + onChange={(event) => + setDraftLengthInput((currentState) => + currentState + ? { + ...currentState, + error: null, + value: event.target.value, + } + : currentState, + ) + } + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + submitDraftLengthInput() + } else if (event.key === 'Escape') { + event.preventDefault() + setDraftLengthInput(null) + } + }} + placeholder={unit === 'imperial' ? `8'0"` : '2.40'} + ref={draftLengthInputRef} + value={draftLengthInput.value} + /> + +
+
+ {unit === 'imperial' ? 'Feet/inches or inches' : 'Meters'} + Enter to place +
+ {draftLengthInput.error ? ( +
{draftLengthInput.error}
+ ) : null} +
+ ) : ( +
+ + {formatMeasurement(draftLength, unit)} + + + Tab + +
+ )} + + ) : null} + {/* Wall preview */}